authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-04-01 16:13:19-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-01 16:13:19-07:00
log801dfe5b413905553ef1a8ba7ee8b4d941c3fc24
treea520b62c9b28de9d389a7932dcca6045603be2cd
parent823d69f6215d80ce4e75ac4fb9a8f763d5d27b87
signature Signed by PGP key B5690EEEBB952194

[codex] fix git remote parsing, PR resolution, and sidebar PR state (#1619)

Co-authored-by: codex <codex@users.noreply.github.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com>

16 files changed, 2098 insertions(+), 617 deletions(-)

apps/server/src/git/Layers/GitCore.test.ts+216-68
...@@ -59,6 +59,23 @@ function git(...@@ -59,6 +59,23 @@ function git(
59 });59 });
60}60}
6161
62function configureRemote(
63 cwd: string,
64 remoteName: string,
65 remotePath: string,
66 fetchNamespace: string,
67): Effect.Effect<string, GitCommandError, GitCore> {
68 return Effect.gen(function* () {
69 yield* git(cwd, ["config", `remote.${remoteName}.url`, remotePath]);
70 return yield* git(cwd, [
71 "config",
72 "--replace-all",
73 `remote.${remoteName}.fetch`,
74 `+refs/heads/*:refs/remotes/${fetchNamespace}/*`,
75 ]);
76 });
77}
78
62function runShellCommand(input: {79function runShellCommand(input: {
63 command: string;80 command: string;
64 cwd: string;81 cwd: string;
...@@ -587,7 +604,7 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -587,7 +604,7 @@ it.layer(TestLayer)("git integration", (it) => {
587 }),604 }),
588 );605 );
589606
590 it.effect("keeps checkout successful when upstream refresh fails", () =>607 it.effect("statusDetails remains successful when upstream refresh fails after checkout", () =>
591 Effect.gen(function* () {608 Effect.gen(function* () {
592 const remote = yield* makeTmpDir();609 const remote = yield* makeTmpDir();
593 const source = yield* makeTmpDir();610 const source = yield* makeTmpDir();
...@@ -612,7 +629,7 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -612,7 +629,7 @@ it.layer(TestLayer)("git integration", (it) => {
612 const realGitCore = yield* GitCore;629 const realGitCore = yield* GitCore;
613 let refreshFetchAttempts = 0;630 let refreshFetchAttempts = 0;
614 const core = yield* makeIsolatedGitCore((input) => {631 const core = yield* makeIsolatedGitCore((input) => {
615 if (input.args[0] === "fetch") {632 if (input.args[0] === "--git-dir" && input.args[2] === "fetch") {
616 refreshFetchAttempts += 1;633 refreshFetchAttempts += 1;
617 return Effect.fail(634 return Effect.fail(
618 new GitCommandError({635 new GitCommandError({
...@@ -626,16 +643,15 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -626,16 +643,15 @@ it.layer(TestLayer)("git integration", (it) => {
626 return realGitCore.execute(input);643 return realGitCore.execute(input);
627 });644 });
628 yield* core.checkoutBranch({ cwd: source, branch: featureBranch });645 yield* core.checkoutBranch({ cwd: source, branch: featureBranch });
629 yield* Effect.promise(() =>646 const status = yield* core.statusDetails(source);
630 vi.waitFor(() => {647 expect(refreshFetchAttempts).toBe(1);
631 expect(refreshFetchAttempts).toBe(1);648 expect(status.branch).toBe(featureBranch);
632 }),649 expect(status.upstreamRef).toBe(`origin/${featureBranch}`);
633 );
634 expect(yield* git(source, ["branch", "--show-current"])).toBe(featureBranch);650 expect(yield* git(source, ["branch", "--show-current"])).toBe(featureBranch);
635 }),651 }),
636 );652 );
637653
638 it.effect("refresh fetch is scoped to the checked out branch upstream refspec", () =>654 it.effect("defers upstream refresh until statusDetails is requested", () =>
639 Effect.gen(function* () {655 Effect.gen(function* () {
640 const remote = yield* makeTmpDir();656 const remote = yield* makeTmpDir();
641 const source = yield* makeTmpDir();657 const source = yield* makeTmpDir();
...@@ -657,10 +673,10 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -657,10 +673,10 @@ it.layer(TestLayer)("git integration", (it) => {
657 yield* git(source, ["checkout", defaultBranch]);673 yield* git(source, ["checkout", defaultBranch]);
658674
659 const realGitCore = yield* GitCore;675 const realGitCore = yield* GitCore;
660 let fetchArgs: readonly string[] | null = null;676 let refreshFetchAttempts = 0;
661 const core = yield* makeIsolatedGitCore((input) => {677 const core = yield* makeIsolatedGitCore((input) => {
662 if (input.args[0] === "fetch") {678 if (input.args[0] === "--git-dir" && input.args[2] === "fetch") {
663 fetchArgs = [...input.args];679 refreshFetchAttempts += 1;
664 return Effect.succeed({680 return Effect.succeed({
665 code: 0,681 code: 0,
666 stdout: "",682 stdout: "",
...@@ -672,73 +688,131 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -672,73 +688,131 @@ it.layer(TestLayer)("git integration", (it) => {
672 return realGitCore.execute(input);688 return realGitCore.execute(input);
673 });689 });
674 yield* core.checkoutBranch({ cwd: source, branch: featureBranch });690 yield* core.checkoutBranch({ cwd: source, branch: featureBranch });
675 yield* Effect.promise(() =>691 yield* Effect.promise(() => new Promise<void>((resolve) => setTimeout(resolve, 50)));
676 vi.waitFor(() => {692 expect(refreshFetchAttempts).toBe(0);
677 expect(fetchArgs).not.toBeNull();693 const status = yield* core.statusDetails(source);
678 }),694 expect(status.branch).toBe(featureBranch);
679 );695 expect(refreshFetchAttempts).toBe(1);
680
681 expect(yield* git(source, ["branch", "--show-current"])).toBe(featureBranch);
682 expect(fetchArgs).toEqual([
683 "fetch",
684 "--quiet",
685 "--no-tags",
686 "origin",
687 `+refs/heads/${featureBranch}:refs/remotes/origin/${featureBranch}`,
688 ]);
689 }),696 }),
690 );697 );
691698
692 it.effect("returns checkout result before background upstream refresh completes", () =>699 it.effect("shares upstream refreshes across worktrees that use the same git common dir", () =>
693 Effect.gen(function* () {700 Effect.gen(function* () {
694 const remote = yield* makeTmpDir();701 const ok = (stdout = "") =>
695 const source = yield* makeTmpDir();702 Effect.succeed({
696 yield* git(remote, ["init", "--bare"]);703 code: 0,
704 stdout,
705 stderr: "",
706 stdoutTruncated: false,
707 stderrTruncated: false,
708 });
697709
698 yield* initRepoWithCommit(source);710 let fetchCount = 0;
699 const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find(711 const core = yield* makeIsolatedGitCore((input) => {
700 (branch) => branch.current,712 if (
701 )!.name;713 input.args[0] === "rev-parse" &&
702 yield* git(source, ["remote", "add", "origin", remote]);714 input.args[1] === "--abbrev-ref" &&
703 yield* git(source, ["push", "-u", "origin", defaultBranch]);715 input.args[2] === "--symbolic-full-name" &&
716 input.args[3] === "@{upstream}"
717 ) {
718 return ok("origin/main\n");
719 }
720 if (input.args[0] === "remote") {
721 return ok("origin\n");
722 }
723 if (input.args[0] === "rev-parse" && input.args[1] === "--git-common-dir") {
724 return ok("/repo/.git\n");
725 }
726 if (input.args[0] === "--git-dir" && input.args[2] === "fetch") {
727 fetchCount += 1;
728 expect(input.cwd).toBe("/repo");
729 return ok();
730 }
731 if (input.operation === "GitCore.statusDetails.status") {
732 return ok("# branch.head main\n# branch.upstream origin/main\n# branch.ab +0 -0\n");
733 }
734 if (
735 input.operation === "GitCore.statusDetails.unstagedNumstat" ||
736 input.operation === "GitCore.statusDetails.stagedNumstat"
737 ) {
738 return ok();
739 }
740 return Effect.fail(
741 new GitCommandError({
742 operation: input.operation,
743 command: `git ${input.args.join(" ")}`,
744 cwd: input.cwd,
745 detail: "Unexpected git command in shared refresh cache test.",
746 }),
747 );
748 });
704749
705 const featureBranch = "feature/background-refresh";750 yield* core.statusDetails("/repo/worktrees/main");
706 yield* git(source, ["checkout", "-b", featureBranch]);751 yield* core.statusDetails("/repo/worktrees/pr-123");
707 yield* writeTextFile(path.join(source, "feature.txt"), "feature base\n");752 expect(fetchCount).toBe(1);
708 yield* git(source, ["add", "feature.txt"]);753 }),
709 yield* git(source, ["commit", "-m", "feature base"]);754 );
710 yield* git(source, ["push", "-u", "origin", featureBranch]);
711 yield* git(source, ["checkout", defaultBranch]);
712755
713 const realGitCore = yield* GitCore;756 it.effect("briefly backs off failed upstream refreshes across sibling worktrees", () =>
714 let fetchStarted = false;757 Effect.gen(function* () {
715 let releaseFetch!: () => void;758 const ok = (stdout = "") =>
716 const waitForReleasePromise = new Promise<void>((resolve) => {759 Effect.succeed({
717 releaseFetch = resolve;760 code: 0,
718 });761 stdout,
762 stderr: "",
763 stdoutTruncated: false,
764 stderrTruncated: false,
765 });
766
767 let fetchCount = 0;
719 const core = yield* makeIsolatedGitCore((input) => {768 const core = yield* makeIsolatedGitCore((input) => {
720 if (input.args[0] === "fetch") {769 if (
721 fetchStarted = true;770 input.args[0] === "rev-parse" &&
722 return Effect.promise(() =>771 input.args[1] === "--abbrev-ref" &&
723 waitForReleasePromise.then(() => ({772 input.args[2] === "--symbolic-full-name" &&
724 code: 0,773 input.args[3] === "@{upstream}"
725 stdout: "",774 ) {
726 stderr: "",775 return ok("origin/main\n");
727 stdoutTruncated: false,776 }
728 stderrTruncated: false,777 if (input.args[0] === "remote") {
729 })),778 return ok("origin\n");
779 }
780 if (input.args[0] === "rev-parse" && input.args[1] === "--git-common-dir") {
781 return ok("/repo/.git\n");
782 }
783 if (input.args[0] === "--git-dir" && input.args[2] === "fetch") {
784 fetchCount += 1;
785 return Effect.fail(
786 new GitCommandError({
787 operation: input.operation,
788 command: `git ${input.args.join(" ")}`,
789 cwd: input.cwd,
790 detail: "simulated fetch timeout",
791 }),
730 );792 );
731 }793 }
732 return realGitCore.execute(input);794 if (input.operation === "GitCore.statusDetails.status") {
795 return ok("# branch.head main\n# branch.upstream origin/main\n# branch.ab +0 -0\n");
796 }
797 if (
798 input.operation === "GitCore.statusDetails.unstagedNumstat" ||
799 input.operation === "GitCore.statusDetails.stagedNumstat"
800 ) {
801 return ok();
802 }
803 return Effect.fail(
804 new GitCommandError({
805 operation: input.operation,
806 command: `git ${input.args.join(" ")}`,
807 cwd: input.cwd,
808 detail: "Unexpected git command in refresh failure cooldown test.",
809 }),
810 );
733 });811 });
734 yield* core.checkoutBranch({ cwd: source, branch: featureBranch });812
735 yield* Effect.promise(() =>813 yield* core.statusDetails("/repo/worktrees/main");
736 vi.waitFor(() => {814 yield* core.statusDetails("/repo/worktrees/pr-123");
737 expect(fetchStarted).toBe(true);815 expect(fetchCount).toBe(1);
738 }),
739 );
740 expect(yield* git(source, ["branch", "--show-current"])).toBe(featureBranch);
741 releaseFetch();
742 }),816 }),
743 );817 );
744818
...@@ -779,16 +853,21 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -779,16 +853,21 @@ it.layer(TestLayer)("git integration", (it) => {
779 it.effect("checks out a remote tracking branch when remote name contains slashes", () =>853 it.effect("checks out a remote tracking branch when remote name contains slashes", () =>
780 Effect.gen(function* () {854 Effect.gen(function* () {
781 const remote = yield* makeTmpDir();855 const remote = yield* makeTmpDir();
856 const prefixRemote = yield* makeTmpDir();
782 const source = yield* makeTmpDir();857 const source = yield* makeTmpDir();
858 const prefixFetchNamespace = "prefix-my-org";
859 const prefixRemoteName = "my-org";
783 const remoteName = "my-org/upstream";860 const remoteName = "my-org/upstream";
784 const featureBranch = "feature";861 const featureBranch = "feature";
785 yield* git(remote, ["init", "--bare"]);862 yield* git(remote, ["init", "--bare"]);
863 yield* git(prefixRemote, ["init", "--bare"]);
786864
787 yield* initRepoWithCommit(source);865 yield* initRepoWithCommit(source);
788 const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find(866 const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find(
789 (branch) => branch.current,867 (branch) => branch.current,
790 )!.name;868 )!.name;
791 yield* git(source, ["remote", "add", remoteName, remote]);869 yield* configureRemote(source, prefixRemoteName, prefixRemote, prefixFetchNamespace);
870 yield* configureRemote(source, remoteName, remote, remoteName);
792 yield* git(source, ["push", "-u", remoteName, defaultBranch]);871 yield* git(source, ["push", "-u", remoteName, defaultBranch]);
793872
794 yield* git(source, ["checkout", "-b", featureBranch]);873 yield* git(source, ["checkout", "-b", featureBranch]);
...@@ -805,6 +884,34 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -805,6 +884,34 @@ it.layer(TestLayer)("git integration", (it) => {
805 });884 });
806885
807 expect(yield* git(source, ["branch", "--show-current"])).toBe("upstream/feature");886 expect(yield* git(source, ["branch", "--show-current"])).toBe("upstream/feature");
887 const realGitCore = yield* GitCore;
888 let fetchArgs: readonly string[] | null = null;
889 const core = yield* makeIsolatedGitCore((input) => {
890 if (input.args[0] === "--git-dir" && input.args[2] === "fetch") {
891 fetchArgs = [...input.args];
892 return Effect.succeed({
893 code: 0,
894 stdout: "",
895 stderr: "",
896 stdoutTruncated: false,
897 stderrTruncated: false,
898 });
899 }
900 return realGitCore.execute(input);
901 });
902
903 const status = yield* core.statusDetails(source);
904 expect(status.branch).toBe("upstream/feature");
905 expect(status.upstreamRef).toBe(`${remoteName}/${featureBranch}`);
906 expect(fetchArgs).toEqual([
907 "--git-dir",
908 path.join(source, ".git"),
909 "fetch",
910 "--quiet",
911 "--no-tags",
912 remoteName,
913 `+refs/heads/${featureBranch}:refs/remotes/${remoteName}/${featureBranch}`,
914 ]);
808 }),915 }),
809 );916 );
810917
...@@ -1691,6 +1798,47 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -1691,6 +1798,47 @@ it.layer(TestLayer)("git integration", (it) => {
1691 }),1798 }),
1692 );1799 );
16931800
1801 it.effect("pushes to the tracked upstream when the remote name contains slashes", () =>
1802 Effect.gen(function* () {
1803 const tmp = yield* makeTmpDir();
1804 const remote = yield* makeTmpDir();
1805 const prefixRemote = yield* makeTmpDir();
1806 const prefixFetchNamespace = "prefix-my-org";
1807 const prefixRemoteName = "my-org";
1808 const remoteName = "my-org/upstream";
1809 const featureBranch = "feature/slash-remote-push";
1810 yield* git(remote, ["init", "--bare"]);
1811 yield* git(prefixRemote, ["init", "--bare"]);
1812
1813 const { initialBranch } = yield* initRepoWithCommit(tmp);
1814 yield* configureRemote(tmp, prefixRemoteName, prefixRemote, prefixFetchNamespace);
1815 yield* configureRemote(tmp, remoteName, remote, remoteName);
1816 yield* git(tmp, ["push", "-u", remoteName, initialBranch]);
1817
1818 yield* git(tmp, ["checkout", "-b", featureBranch]);
1819 yield* writeTextFile(path.join(tmp, "feature.txt"), "first revision\n");
1820 yield* git(tmp, ["add", "feature.txt"]);
1821 yield* git(tmp, ["commit", "-m", "feature base"]);
1822 yield* git(tmp, ["push", "-u", remoteName, featureBranch]);
1823
1824 yield* writeTextFile(path.join(tmp, "feature.txt"), "second revision\n");
1825 yield* git(tmp, ["add", "feature.txt"]);
1826 yield* git(tmp, ["commit", "-m", "feature update"]);
1827
1828 const core = yield* GitCore;
1829 const pushed = yield* core.pushCurrentBranch(tmp, null);
1830 expect(pushed.status).toBe("pushed");
1831 expect(pushed.setUpstream).toBe(false);
1832 expect(pushed.upstreamBranch).toBe(`${remoteName}/${featureBranch}`);
1833 expect(yield* git(tmp, ["rev-parse", "--abbrev-ref", "@{upstream}"])).toBe(
1834 `${remoteName}/${featureBranch}`,
1835 );
1836 expect(yield* git(tmp, ["ls-remote", "--heads", remoteName, featureBranch])).toContain(
1837 featureBranch,
1838 );
1839 }),
1840 );
1841
1694 it.effect("includes command context when worktree removal fails", () =>1842 it.effect("includes command context when worktree removal fails", () =>
1695 Effect.gen(function* () {1843 Effect.gen(function* () {
1696 const tmp = yield* makeTmpDir();1844 const tmp = yield* makeTmpDir();
apps/server/src/git/Layers/GitCore.ts+68-79
...@@ -27,6 +27,11 @@ import {...@@ -27,6 +27,11 @@ import {
27 type ExecuteGitInput,27 type ExecuteGitInput,
28 type ExecuteGitResult,28 type ExecuteGitResult,
29} from "../Services/GitCore.ts";29} from "../Services/GitCore.ts";
30import {
31 parseRemoteNames,
32 parseRemoteNamesInGitOrder,
33 parseRemoteRefWithRemoteNames,
34} from "../remoteRefs.ts";
30import { ServerConfig } from "../../config.ts";35import { ServerConfig } from "../../config.ts";
31import { decodeJsonResult } from "@t3tools/shared/schemaJson";36import { decodeJsonResult } from "@t3tools/shared/schemaJson";
3237
...@@ -41,6 +46,7 @@ const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 16 * 1024 * 1024;...@@ -41,6 +46,7 @@ const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 16 * 1024 * 1024;
41const GIT_CHECK_IGNORE_MAX_STDIN_BYTES = 256 * 1024;46const GIT_CHECK_IGNORE_MAX_STDIN_BYTES = 256 * 1024;
42const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15);47const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15);
43const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5);48const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5);
49const STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN = Duration.seconds(5);
44const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048;50const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048;
45const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const;51const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const;
4652
...@@ -50,7 +56,7 @@ type TraceTailState = {...@@ -50,7 +56,7 @@ type TraceTailState = {
50};56};
5157
52class StatusUpstreamRefreshCacheKey extends Data.Class<{58class StatusUpstreamRefreshCacheKey extends Data.Class<{
53 cwd: string;59 gitCommonDir: string;
54 upstreamRef: string;60 upstreamRef: string;
55 remoteName: string;61 remoteName: string;
56 upstreamBranch: string;62 upstreamBranch: string;
...@@ -177,14 +183,6 @@ function parseBranchLine(line: string): { name: string; current: boolean } | nul...@@ -177,14 +183,6 @@ function parseBranchLine(line: string): { name: string; current: boolean } | nul
177 };183 };
178}184}
179185
180function parseRemoteNames(stdout: string): ReadonlyArray<string> {
181 return stdout
182 .split("\n")
183 .map((line) => line.trim())
184 .filter((line) => line.length > 0)
185 .toSorted((a, b) => b.length - a.length);
186}
187
188function sanitizeRemoteName(value: string): string {186function sanitizeRemoteName(value: string): string {
189 const sanitized = value187 const sanitized = value
190 .trim()188 .trim()
...@@ -217,30 +215,41 @@ function parseRemoteFetchUrls(stdout: string): Map<string, string> {...@@ -217,30 +215,41 @@ function parseRemoteFetchUrls(stdout: string): Map<string, string> {
217 return remotes;215 return remotes;
218}216}
219217
220function parseRemoteRefWithRemoteNames(218function parseUpstreamRefWithRemoteNames(
221 branchName: string,219 upstreamRef: string,
222 remoteNames: ReadonlyArray<string>,220 remoteNames: ReadonlyArray<string>,
223): { remoteRef: string; remoteName: string; localBranch: string } | null {221): { upstreamRef: string; remoteName: string; upstreamBranch: string } | null {
224 const trimmedBranchName = branchName.trim();222 const parsed = parseRemoteRefWithRemoteNames(upstreamRef, remoteNames);
225 if (trimmedBranchName.length === 0) return null;223 if (!parsed) {
224 return null;
225 }
226226
227 for (const remoteName of remoteNames) {227 return {
228 const remotePrefix = `${remoteName}/`;228 upstreamRef,
229 if (!trimmedBranchName.startsWith(remotePrefix)) {229 remoteName: parsed.remoteName,
230 continue;230 upstreamBranch: parsed.branchName,
231 }231 };
232 const localBranch = trimmedBranchName.slice(remotePrefix.length).trim();232}
233 if (localBranch.length === 0) {233
234 return null;234function parseUpstreamRefByFirstSeparator(
235 }235 upstreamRef: string,
236 return {236): { upstreamRef: string; remoteName: string; upstreamBranch: string } | null {
237 remoteRef: trimmedBranchName,237 const separatorIndex = upstreamRef.indexOf("/");
238 remoteName,238 if (separatorIndex <= 0 || separatorIndex === upstreamRef.length - 1) {
239 localBranch,239 return null;
240 };
241 }240 }
242241
243 return null;242 const remoteName = upstreamRef.slice(0, separatorIndex).trim();
243 const upstreamBranch = upstreamRef.slice(separatorIndex + 1).trim();
244 if (remoteName.length === 0 || upstreamBranch.length === 0) {
245 return null;
246 }
247
248 return {
249 upstreamRef,
250 remoteName,
251 upstreamBranch,
252 };
244}253}
245254
246function parseTrackingBranchByUpstreamRef(stdout: string, upstreamRef: string): string | null {255function parseTrackingBranchByUpstreamRef(stdout: string, upstreamRef: string): string | null {
...@@ -792,45 +801,27 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {...@@ -792,45 +801,27 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
792 return null;801 return null;
793 }802 }
794803
795 const separatorIndex = upstreamRef.indexOf("/");804 const remoteNames = yield* runGitStdout("GitCore.listRemoteNames", cwd, ["remote"]).pipe(
796 if (separatorIndex <= 0) {805 Effect.map(parseRemoteNames),
797 return null;806 Effect.catch(() => Effect.succeed<ReadonlyArray<string>>([])),
798 }
799 const remoteName = upstreamRef.slice(0, separatorIndex);
800 const upstreamBranch = upstreamRef.slice(separatorIndex + 1);
801 if (remoteName.length === 0 || upstreamBranch.length === 0) {
802 return null;
803 }
804
805 return {
806 upstreamRef,
807 remoteName,
808 upstreamBranch,
809 };
810 });
811
812 const fetchUpstreamRef = (
813 cwd: string,
814 upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string },
815 ): Effect.Effect<void, GitCommandError> => {
816 const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`;
817 return runGit(
818 "GitCore.fetchUpstreamRef",
819 cwd,
820 ["fetch", "--quiet", "--no-tags", upstream.remoteName, refspec],
821 true,
822 );807 );
823 };808 return (
809 parseUpstreamRefWithRemoteNames(upstreamRef, remoteNames) ??
810 parseUpstreamRefByFirstSeparator(upstreamRef)
811 );
812 });
824813
825 const fetchUpstreamRefForStatus = (814 const fetchUpstreamRefForStatus = (
826 cwd: string,815 gitCommonDir: string,
827 upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string },816 upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string },
828 ): Effect.Effect<void, GitCommandError> => {817 ): Effect.Effect<void, GitCommandError> => {
829 const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`;818 const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`;
819 const fetchCwd =
820 path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir;
830 return executeGit(821 return executeGit(
831 "GitCore.fetchUpstreamRefForStatus",822 "GitCore.fetchUpstreamRefForStatus",
832 cwd,823 fetchCwd,
833 ["fetch", "--quiet", "--no-tags", upstream.remoteName, refspec],824 ["--git-dir", gitCommonDir, "fetch", "--quiet", "--no-tags", upstream.remoteName, refspec],
834 {825 {
835 allowNonZeroExit: true,826 allowNonZeroExit: true,
836 timeoutMs: Duration.toMillis(STATUS_UPSTREAM_REFRESH_TIMEOUT),827 timeoutMs: Duration.toMillis(STATUS_UPSTREAM_REFRESH_TIMEOUT),
...@@ -838,10 +829,18 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {...@@ -838,10 +829,18 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
838 ).pipe(Effect.asVoid);829 ).pipe(Effect.asVoid);
839 };830 };
840831
832 const resolveGitCommonDir = Effect.fn("resolveGitCommonDir")(function* (cwd: string) {
833 const gitCommonDir = yield* runGitStdout("GitCore.resolveGitCommonDir", cwd, [
834 "rev-parse",
835 "--git-common-dir",
836 ]).pipe(Effect.map((stdout) => stdout.trim()));
837 return path.isAbsolute(gitCommonDir) ? gitCommonDir : path.resolve(cwd, gitCommonDir);
838 });
839
841 const refreshStatusUpstreamCacheEntry = Effect.fn("refreshStatusUpstreamCacheEntry")(function* (840 const refreshStatusUpstreamCacheEntry = Effect.fn("refreshStatusUpstreamCacheEntry")(function* (
842 cacheKey: StatusUpstreamRefreshCacheKey,841 cacheKey: StatusUpstreamRefreshCacheKey,
843 ) {842 ) {
844 yield* fetchUpstreamRefForStatus(cacheKey.cwd, {843 yield* fetchUpstreamRefForStatus(cacheKey.gitCommonDir, {
845 upstreamRef: cacheKey.upstreamRef,844 upstreamRef: cacheKey.upstreamRef,
846 remoteName: cacheKey.remoteName,845 remoteName: cacheKey.remoteName,
847 upstreamBranch: cacheKey.upstreamBranch,846 upstreamBranch: cacheKey.upstreamBranch,
...@@ -852,8 +851,11 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {...@@ -852,8 +851,11 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
852 const statusUpstreamRefreshCache = yield* Cache.makeWith({851 const statusUpstreamRefreshCache = yield* Cache.makeWith({
853 capacity: STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY,852 capacity: STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY,
854 lookup: refreshStatusUpstreamCacheEntry,853 lookup: refreshStatusUpstreamCacheEntry,
855 // Keep successful refreshes warm; drop failures immediately so next request can retry.854 // Keep successful refreshes warm and briefly back off failed refreshes to avoid retry storms.
856 timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_UPSTREAM_REFRESH_INTERVAL : Duration.zero),855 timeToLive: (exit) =>
856 Exit.isSuccess(exit)
857 ? STATUS_UPSTREAM_REFRESH_INTERVAL
858 : STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN,
857 });859 });
858860
859 const refreshStatusUpstreamIfStale = Effect.fn("refreshStatusUpstreamIfStale")(function* (861 const refreshStatusUpstreamIfStale = Effect.fn("refreshStatusUpstreamIfStale")(function* (
...@@ -861,10 +863,11 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {...@@ -861,10 +863,11 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
861 ) {863 ) {
862 const upstream = yield* resolveCurrentUpstream(cwd);864 const upstream = yield* resolveCurrentUpstream(cwd);
863 if (!upstream) return;865 if (!upstream) return;
866 const gitCommonDir = yield* resolveGitCommonDir(cwd);
864 yield* Cache.get(867 yield* Cache.get(
865 statusUpstreamRefreshCache,868 statusUpstreamRefreshCache,
866 new StatusUpstreamRefreshCacheKey({869 new StatusUpstreamRefreshCacheKey({
867 cwd,870 gitCommonDir,
868 upstreamRef: upstream.upstreamRef,871 upstreamRef: upstream.upstreamRef,
869 remoteName: upstream.remoteName,872 remoteName: upstream.remoteName,
870 upstreamBranch: upstream.upstreamBranch,873 upstreamBranch: upstream.upstreamBranch,
...@@ -872,14 +875,6 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {...@@ -872,14 +875,6 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
872 );875 );
873 });876 });
874877
875 const refreshCheckedOutBranchUpstream = Effect.fn("refreshCheckedOutBranchUpstream")(function* (
876 cwd: string,
877 ) {
878 const upstream = yield* resolveCurrentUpstream(cwd);
879 if (!upstream) return;
880 yield* fetchUpstreamRef(cwd, upstream);
881 });
882
883 const resolveDefaultBranchName = (878 const resolveDefaultBranchName = (
884 cwd: string,879 cwd: string,
885 remoteName: string,880 remoteName: string,
...@@ -919,7 +914,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {...@@ -919,7 +914,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
919914
920 const listRemoteNames = (cwd: string): Effect.Effect<ReadonlyArray<string>, GitCommandError> =>915 const listRemoteNames = (cwd: string): Effect.Effect<ReadonlyArray<string>, GitCommandError> =>
921 runGitStdout("GitCore.listRemoteNames", cwd, ["remote"]).pipe(916 runGitStdout("GitCore.listRemoteNames", cwd, ["remote"]).pipe(
922 Effect.map((stdout) => parseRemoteNames(stdout).toReversed()),917 Effect.map(parseRemoteNamesInGitOrder),
923 );918 );
924919
925 const resolvePrimaryRemoteName = Effect.fn("resolvePrimaryRemoteName")(function* (cwd: string) {920 const resolvePrimaryRemoteName = Effect.fn("resolvePrimaryRemoteName")(function* (cwd: string) {
...@@ -1939,12 +1934,6 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {...@@ -1939,12 +1934,6 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
1939 timeoutMs: 10_000,1934 timeoutMs: 10_000,
1940 fallbackErrorMessage: "git checkout failed",1935 fallbackErrorMessage: "git checkout failed",
1941 });1936 });
1942
1943 // Refresh upstream refs in the background so checkout remains responsive.
1944 yield* refreshCheckedOutBranchUpstream(input.cwd).pipe(
1945 Effect.ignoreCause({ log: true }),
1946 Effect.forkDetach({ startImmediately: true }),
1947 );
1948 },1937 },
1949 );1938 );
19501939
apps/server/src/git/Layers/GitHubCli.ts+1-1
...@@ -187,7 +187,7 @@ const makeGitHubCli = Effect.sync(() => {...@@ -187,7 +187,7 @@ const makeGitHubCli = Effect.sync(() => {
187 "--limit",187 "--limit",
188 String(input.limit ?? 1),188 String(input.limit ?? 1),
189 "--json",189 "--json",
190 "number,title,url,baseRefName,headRefName",190 "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner",
191 ],191 ],
192 }).pipe(192 }).pipe(
193 Effect.map((result) => result.stdout.trim()),193 Effect.map((result) => result.stdout.trim()),
apps/server/src/git/Layers/GitManager.test.ts+680-26
...@@ -25,6 +25,7 @@ import { ServerSettingsService } from "../../serverSettings.ts";...@@ -25,6 +25,7 @@ import { ServerSettingsService } from "../../serverSettings.ts";
25interface FakeGhScenario {25interface FakeGhScenario {
26 prListSequence?: string[];26 prListSequence?: string[];
27 prListByHeadSelector?: Record<string, string>;27 prListByHeadSelector?: Record<string, string>;
28 prListSequenceByHeadSelector?: Record<string, string[]>;
28 createdPrUrl?: string;29 createdPrUrl?: string;
29 defaultBranch?: string;30 defaultBranch?: string;
30 pullRequest?: {31 pullRequest?: {
...@@ -77,6 +78,72 @@ interface FakeGitTextGeneration {...@@ -77,6 +78,72 @@ interface FakeGitTextGeneration {
7778
78type FakePullRequest = NonNullable<FakeGhScenario["pullRequest"]>;79type FakePullRequest = NonNullable<FakeGhScenario["pullRequest"]>;
7980
81function normalizeFakePullRequestSummary(raw: unknown): GitHubPullRequestSummary | null {
82 if (!raw || typeof raw !== "object") {
83 return null;
84 }
85
86 const record = raw as Record<string, unknown>;
87 const number = record.number;
88 const title = record.title;
89 const url = record.url;
90 const baseRefName = record.baseRefName;
91 const headRefName = record.headRefName;
92 const headRepository =
93 typeof record.headRepository === "object" && record.headRepository !== null
94 ? (record.headRepository as Record<string, unknown>)
95 : null;
96 const headRepositoryOwner =
97 typeof record.headRepositoryOwner === "object" && record.headRepositoryOwner !== null
98 ? (record.headRepositoryOwner as Record<string, unknown>)
99 : null;
100
101 if (
102 typeof number !== "number" ||
103 typeof title !== "string" ||
104 typeof url !== "string" ||
105 typeof baseRefName !== "string" ||
106 typeof headRefName !== "string"
107 ) {
108 return null;
109 }
110
111 const state =
112 typeof record.state === "string"
113 ? record.state === "OPEN" || record.state === "open"
114 ? "open"
115 : record.state === "CLOSED" || record.state === "closed"
116 ? "closed"
117 : "merged"
118 : undefined;
119 const isCrossRepository =
120 typeof record.isCrossRepository === "boolean" ? record.isCrossRepository : undefined;
121 const headRepositoryNameWithOwner =
122 typeof record.headRepositoryNameWithOwner === "string"
123 ? record.headRepositoryNameWithOwner
124 : typeof headRepository?.nameWithOwner === "string"
125 ? headRepository.nameWithOwner
126 : undefined;
127 const headRepositoryOwnerLogin =
128 typeof record.headRepositoryOwnerLogin === "string"
129 ? record.headRepositoryOwnerLogin
130 : typeof headRepositoryOwner?.login === "string"
131 ? headRepositoryOwner.login
132 : undefined;
133
134 return {
135 number,
136 title,
137 url,
138 baseRefName,
139 headRefName,
140 ...(state ? { state } : {}),
141 ...(isCrossRepository !== undefined ? { isCrossRepository } : {}),
142 ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}),
143 ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}),
144 };
145}
146
80function runGitSyncForFakeGh(cwd: string, args: readonly string[]): void {147function runGitSyncForFakeGh(cwd: string, args: readonly string[]): void {
81 const result = spawnSync("git", args, {148 const result = spawnSync("git", args, {
82 cwd,149 cwd,
...@@ -159,6 +226,23 @@ function createBareRemote(): Effect.Effect<...@@ -159,6 +226,23 @@ function createBareRemote(): Effect.Effect<
159 });226 });
160}227}
161228
229function configureRemote(
230 cwd: string,
231 remoteName: string,
232 remotePath: string,
233 fetchNamespace: string,
234): Effect.Effect<void, GitCommandError, GitCore> {
235 return Effect.gen(function* () {
236 yield* runGit(cwd, ["config", `remote.${remoteName}.url`, remotePath]);
237 yield* runGit(cwd, [
238 "config",
239 "--replace-all",
240 `remote.${remoteName}.fetch`,
241 `+refs/heads/*:refs/remotes/${fetchNamespace}/*`,
242 ]);
243 });
244}
245
162function createTextGeneration(overrides: Partial<FakeGitTextGeneration> = {}): TextGenerationShape {246function createTextGeneration(overrides: Partial<FakeGitTextGeneration> = {}): TextGenerationShape {
163 const implementation: FakeGitTextGeneration = {247 const implementation: FakeGitTextGeneration = {
164 generateCommitMessage: (input) =>248 generateCommitMessage: (input) =>
...@@ -236,6 +320,12 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {...@@ -236,6 +320,12 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
236 ghCalls: string[];320 ghCalls: string[];
237} {321} {
238 const prListQueue = [...(scenario.prListSequence ?? [])];322 const prListQueue = [...(scenario.prListSequence ?? [])];
323 const prListQueueByHeadSelector = new Map(
324 Object.entries(scenario.prListSequenceByHeadSelector ?? {}).map(([headSelector, values]) => [
325 headSelector,
326 [...values],
327 ]),
328 );
239 const ghCalls: string[] = [];329 const ghCalls: string[] = [];
240330
241 const execute: GitHubCliShape["execute"] = (input) => {331 const execute: GitHubCliShape["execute"] = (input) => {
...@@ -252,11 +342,15 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {...@@ -252,11 +342,15 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
252 headSelectorIndex >= 0 && headSelectorIndex < args.length - 1342 headSelectorIndex >= 0 && headSelectorIndex < args.length - 1
253 ? args[headSelectorIndex + 1]343 ? args[headSelectorIndex + 1]
254 : undefined;344 : undefined;
345 const mappedQueue =
346 typeof headSelector === "string"
347 ? prListQueueByHeadSelector.get(headSelector)?.shift()
348 : undefined;
255 const mappedStdout =349 const mappedStdout =
256 typeof headSelector === "string"350 typeof headSelector === "string"
257 ? scenario.prListByHeadSelector?.[headSelector]351 ? scenario.prListByHeadSelector?.[headSelector]
258 : undefined;352 : undefined;
259 const stdout = (mappedStdout ?? prListQueue.shift() ?? "[]") + "\n";353 const stdout = (mappedQueue ?? mappedStdout ?? prListQueue.shift() ?? "[]") + "\n";
260 return Effect.succeed({354 return Effect.succeed({
261 stdout,355 stdout,
262 stderr: "",356 stderr: "",
...@@ -410,11 +504,14 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {...@@ -410,11 +504,14 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
410 "--limit",504 "--limit",
411 String(input.limit ?? 1),505 String(input.limit ?? 1),
412 "--json",506 "--json",
413 "number,title,url,baseRefName,headRefName",507 "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner",
414 ],508 ],
415 }).pipe(509 }).pipe(
416 Effect.map(510 Effect.map((result) => JSON.parse(result.stdout) as unknown[]),
417 (result) => JSON.parse(result.stdout) as ReadonlyArray<GitHubPullRequestSummary>,511 Effect.map((raw) =>
512 raw
513 .map((entry) => normalizeFakePullRequestSummary(entry))
514 .filter((entry): entry is GitHubPullRequestSummary => entry !== null),
418 ),515 ),
419 ),516 ),
420 createPullRequest: (input) =>517 createPullRequest: (input) =>
...@@ -473,7 +570,7 @@ function runStackedAction(...@@ -473,7 +570,7 @@ function runStackedAction(
473 manager: GitManagerShape,570 manager: GitManagerShape,
474 input: {571 input: {
475 cwd: string;572 cwd: string;
476 action: "commit" | "commit_push" | "commit_push_pr";573 action: "commit" | "push" | "create_pr" | "commit_push" | "commit_push_pr";
477 actionId?: string;574 actionId?: string;
478 commitMessage?: string;575 commitMessage?: string;
479 featureBranch?: boolean;576 featureBranch?: boolean;
...@@ -575,6 +672,78 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -575,6 +672,78 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
575 }),672 }),
576 );673 );
577674
675 it.effect("status briefly caches repeated lookups for the same cwd", () =>
676 Effect.gen(function* () {
677 const repoDir = yield* makeTempDir("t3code-git-manager-");
678 yield* initRepo(repoDir);
679 yield* runGit(repoDir, ["checkout", "-b", "feature/status-cache"]);
680 const remoteDir = yield* createBareRemote();
681 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
682 yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-cache"]);
683
684 const existingPr = {
685 number: 113,
686 title: "Cached PR",
687 url: "https://github.com/pingdotgg/codething-mvp/pull/113",
688 baseRefName: "main",
689 headRefName: "feature/status-cache",
690 };
691 const { manager, ghCalls } = yield* makeManager({
692 ghScenario: {
693 prListSequence: [JSON.stringify([existingPr]), JSON.stringify([existingPr])],
694 },
695 });
696
697 const first = yield* manager.status({ cwd: repoDir });
698 const second = yield* manager.status({ cwd: repoDir });
699
700 expect(first.pr?.number).toBe(113);
701 expect(second.pr?.number).toBe(113);
702 expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(1);
703 }),
704 );
705
706 it.effect(
707 "status ignores unrelated fork PRs when the current branch tracks the same repository",
708 () =>
709 Effect.gen(function* () {
710 const repoDir = yield* makeTempDir("t3code-git-manager-");
711 yield* initRepo(repoDir);
712 const remoteDir = yield* createBareRemote();
713 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
714 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
715
716 const { manager } = yield* makeManager({
717 ghScenario: {
718 prListSequence: [
719 JSON.stringify([
720 {
721 number: 1661,
722 title: "Fork PR from main",
723 url: "https://github.com/pingdotgg/t3code/pull/1661",
724 baseRefName: "main",
725 headRefName: "main",
726 state: "OPEN",
727 updatedAt: "2026-04-01T15:00:00Z",
728 isCrossRepository: true,
729 headRepository: {
730 nameWithOwner: "lnieuwenhuis/t3code",
731 },
732 headRepositoryOwner: {
733 login: "lnieuwenhuis",
734 },
735 },
736 ]),
737 ],
738 },
739 });
740
741 const status = yield* manager.status({ cwd: repoDir });
742 expect(status.branch).toBe("main");
743 expect(status.pr).toBeNull();
744 }),
745 );
746
578 it.effect(747 it.effect(
579 "status detects cross-repo PRs from the upstream remote URL owner",748 "status detects cross-repo PRs from the upstream remote URL owner",
580 () =>749 () =>
...@@ -610,6 +779,13 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -610,6 +779,13 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
610 headRefName: "statemachine",779 headRefName: "statemachine",
611 state: "OPEN",780 state: "OPEN",
612 updatedAt: "2026-03-10T07:00:00Z",781 updatedAt: "2026-03-10T07:00:00Z",
782 isCrossRepository: true,
783 headRepository: {
784 nameWithOwner: "jasonLaster/codething-mvp",
785 },
786 headRepositoryOwner: {
787 login: "jasonLaster",
788 },
613 },789 },
614 ]),790 ]),
615 ],791 ],
...@@ -627,8 +803,116 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -627,8 +803,116 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
627 state: "open",803 state: "open",
628 });804 });
629 expect(ghCalls).toContain(805 expect(ghCalls).toContain(
630 "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt",806 "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",
807 );
808 }),
809 12_000,
810 );
811
812 it.effect(
813 "status ignores synthetic local branch aliases when the upstream remote name contains slashes",
814 () =>
815 Effect.gen(function* () {
816 const repoDir = yield* makeTempDir("t3code-git-manager-");
817 yield* initRepo(repoDir);
818 const originDir = yield* createBareRemote();
819 const upstreamDir = yield* createBareRemote();
820 yield* configureRemote(repoDir, "origin", originDir, "origin");
821 yield* configureRemote(repoDir, "my-org/upstream", upstreamDir, "my-org/upstream");
822
823 yield* runGit(repoDir, ["checkout", "-b", "effect-atom"]);
824 yield* runGit(repoDir, ["push", "-u", "origin", "effect-atom"]);
825 yield* runGit(repoDir, ["push", "-u", "my-org/upstream", "effect-atom"]);
826 yield* runGit(repoDir, [
827 "config",
828 "remote.origin.url",
829 "git@github.com:pingdotgg/codething-mvp.git",
830 ]);
831 yield* runGit(repoDir, ["config", "remote.origin.pushurl", originDir]);
832 yield* runGit(repoDir, [
833 "config",
834 "remote.my-org/upstream.url",
835 "git@github.com:pingdotgg/codething-mvp.git",
836 ]);
837 yield* runGit(repoDir, ["config", "remote.my-org/upstream.pushurl", upstreamDir]);
838 yield* runGit(repoDir, ["checkout", "main"]);
839 yield* runGit(repoDir, ["branch", "-D", "effect-atom"]);
840 yield* runGit(repoDir, ["checkout", "--track", "my-org/upstream/effect-atom"]);
841
842 const { manager, ghCalls } = yield* makeManager({
843 ghScenario: {
844 prListByHeadSelector: {
845 "effect-atom": JSON.stringify([
846 {
847 number: 1618,
848 title: "Correct PR",
849 url: "https://github.com/pingdotgg/t3code/pull/1618",
850 baseRefName: "main",
851 headRefName: "effect-atom",
852 state: "OPEN",
853 updatedAt: "2026-03-01T10:00:00Z",
854 },
855 ]),
856 "upstream/effect-atom": JSON.stringify([
857 {
858 number: 1518,
859 title: "Wrong PR",
860 url: "https://github.com/pingdotgg/t3code/pull/1518",
861 baseRefName: "main",
862 headRefName: "upstream/effect-atom",
863 state: "OPEN",
864 updatedAt: "2026-04-01T10:00:00Z",
865 },
866 ]),
867 "pingdotgg:effect-atom": JSON.stringify([]),
868 "my-org/upstream:effect-atom": JSON.stringify([]),
869 "pingdotgg:upstream/effect-atom": JSON.stringify([
870 {
871 number: 1518,
872 title: "Wrong PR",
873 url: "https://github.com/pingdotgg/t3code/pull/1518",
874 baseRefName: "main",
875 headRefName: "upstream/effect-atom",
876 state: "OPEN",
877 updatedAt: "2026-04-01T10:00:00Z",
878 },
879 ]),
880 "my-org/upstream:upstream/effect-atom": JSON.stringify([
881 {
882 number: 1518,
883 title: "Wrong PR",
884 url: "https://github.com/pingdotgg/t3code/pull/1518",
885 baseRefName: "main",
886 headRefName: "upstream/effect-atom",
887 state: "OPEN",
888 updatedAt: "2026-04-01T10:00:00Z",
889 },
890 ]),
891 },
892 },
893 });
894
895 const status = yield* manager.status({ cwd: repoDir });
896 expect(status.branch).toBe("upstream/effect-atom");
897 expect(status.pr).toEqual({
898 number: 1618,
899 title: "Correct PR",
900 url: "https://github.com/pingdotgg/t3code/pull/1618",
901 baseBranch: "main",
902 headBranch: "effect-atom",
903 state: "open",
904 });
905 expect(ghCalls.some((call) => call.includes("pr list --head upstream/effect-atom "))).toBe(
906 false,
631 );907 );
908 expect(
909 ghCalls.some((call) => call.includes("pr list --head pingdotgg:upstream/effect-atom ")),
910 ).toBe(false);
911 expect(
912 ghCalls.some((call) =>
913 call.includes("pr list --head my-org/upstream:upstream/effect-atom "),
914 ),
915 ).toBe(false);
632 }),916 }),
633 12_000,917 12_000,
634 );918 );
...@@ -758,6 +1042,17 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -758,6 +1042,17 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
758 expect(result.commit.status).toBe("created");1042 expect(result.commit.status).toBe("created");
759 expect(result.push.status).toBe("skipped_not_requested");1043 expect(result.push.status).toBe("skipped_not_requested");
760 expect(result.pr.status).toBe("skipped_not_requested");1044 expect(result.pr.status).toBe("skipped_not_requested");
1045 expect(result.toast).toMatchObject({
1046 description: "Implement stacked git actions",
1047 cta: {
1048 kind: "run_action",
1049 label: "Push",
1050 action: {
1051 kind: "push",
1052 },
1053 },
1054 });
1055 expect(result.toast.title).toMatch(/^Committed [0-9a-f]{7}$/);
761 expect(1056 expect(
762 yield* runGit(repoDir, ["log", "-1", "--pretty=%s"]).pipe(1057 yield* runGit(repoDir, ["log", "-1", "--pretty=%s"]).pipe(
763 Effect.map((result) => result.stdout.trim()),1058 Effect.map((result) => result.stdout.trim()),
...@@ -867,6 +1162,19 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -867,6 +1162,19 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
867 expect(result.branch.name).toBe("feature/implement-stacked-git-actions");1162 expect(result.branch.name).toBe("feature/implement-stacked-git-actions");
868 expect(result.commit.status).toBe("created");1163 expect(result.commit.status).toBe("created");
869 expect(result.push.status).toBe("pushed");1164 expect(result.push.status).toBe("pushed");
1165 expect(result.toast).toMatchObject({
1166 description: "Implement stacked git actions",
1167 cta: {
1168 kind: "run_action",
1169 label: "Create PR",
1170 action: {
1171 kind: "create_pr",
1172 },
1173 },
1174 });
1175 expect(result.toast.title).toMatch(
1176 /^Pushed [0-9a-f]{7} to origin\/feature\/implement-stacked-git-actions$/,
1177 );
870 expect(1178 expect(
871 yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "HEAD"]).pipe(1179 yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "HEAD"]).pipe(
872 Effect.map((result) => result.stdout.trim()),1180 Effect.map((result) => result.stdout.trim()),
...@@ -1063,6 +1371,80 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -1063,6 +1371,80 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
1063 }),1371 }),
1064 );1372 );
10651373
1374 it.effect("pushes existing clean commits without rerunning commit logic", () =>
1375 Effect.gen(function* () {
1376 const repoDir = yield* makeTempDir("t3code-git-manager-");
1377 yield* initRepo(repoDir);
1378 yield* runGit(repoDir, ["checkout", "-b", "feature/push-only"]);
1379 const remoteDir = yield* createBareRemote();
1380 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
1381 fs.writeFileSync(path.join(repoDir, "push-only.txt"), "push only\n");
1382 yield* runGit(repoDir, ["add", "push-only.txt"]);
1383 yield* runGit(repoDir, ["commit", "-m", "Push only branch"]);
1384
1385 const { manager } = yield* makeManager();
1386 const result = yield* runStackedAction(manager, {
1387 cwd: repoDir,
1388 action: "push",
1389 });
1390
1391 expect(result.commit.status).toBe("skipped_not_requested");
1392 expect(result.push.status).toBe("pushed");
1393 expect(result.pr.status).toBe("skipped_not_requested");
1394 expect(
1395 yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"]).pipe(
1396 Effect.map((output) => output.stdout.trim()),
1397 ),
1398 ).toBe("origin/feature/push-only");
1399 }),
1400 );
1401
1402 it.effect("create_pr pushes a clean branch before creating the PR when needed", () =>
1403 Effect.gen(function* () {
1404 const repoDir = yield* makeTempDir("t3code-git-manager-");
1405 yield* initRepo(repoDir);
1406 yield* runGit(repoDir, ["checkout", "-b", "feature/create-pr-only"]);
1407 const remoteDir = yield* createBareRemote();
1408 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
1409 fs.writeFileSync(path.join(repoDir, "create-pr-only.txt"), "create pr\n");
1410 yield* runGit(repoDir, ["add", "create-pr-only.txt"]);
1411 yield* runGit(repoDir, ["commit", "-m", "Create PR only branch"]);
1412
1413 const { manager, ghCalls } = yield* makeManager({
1414 ghScenario: {
1415 prListSequence: [
1416 "[]",
1417 JSON.stringify([
1418 {
1419 number: 303,
1420 title: "Create PR only branch",
1421 url: "https://github.com/pingdotgg/codething-mvp/pull/303",
1422 baseRefName: "main",
1423 headRefName: "feature/create-pr-only",
1424 },
1425 ]),
1426 ],
1427 },
1428 });
1429
1430 const result = yield* runStackedAction(manager, {
1431 cwd: repoDir,
1432 action: "create_pr",
1433 });
1434
1435 expect(result.commit.status).toBe("skipped_not_requested");
1436 expect(result.push.status).toBe("pushed");
1437 expect(result.push.setUpstream).toBe(true);
1438 expect(result.pr.status).toBe("created");
1439 expect(result.pr.number).toBe(303);
1440 expect(
1441 ghCalls.some((call) =>
1442 call.includes("pr create --base main --head feature/create-pr-only"),
1443 ),
1444 ).toBe(true);
1445 }),
1446 );
1447
1066 it.effect("returns existing PR metadata for commit/push/pr action", () =>1448 it.effect("returns existing PR metadata for commit/push/pr action", () =>
1067 Effect.gen(function* () {1449 Effect.gen(function* () {
1068 const repoDir = yield* makeTempDir("t3code-git-manager-");1450 const repoDir = yield* makeTempDir("t3code-git-manager-");
...@@ -1095,6 +1477,15 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -1095,6 +1477,15 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
1095 expect(result.branch.status).toBe("skipped_not_requested");1477 expect(result.branch.status).toBe("skipped_not_requested");
1096 expect(result.pr.status).toBe("opened_existing");1478 expect(result.pr.status).toBe("opened_existing");
1097 expect(result.pr.number).toBe(42);1479 expect(result.pr.number).toBe(42);
1480 expect(result.toast).toEqual({
1481 title: "Opened PR #42",
1482 description: "Existing PR",
1483 cta: {
1484 kind: "open_pr",
1485 label: "View PR",
1486 url: "https://github.com/pingdotgg/codething-mvp/pull/42",
1487 },
1488 });
1098 expect(ghCalls.some((call) => call.startsWith("pr view "))).toBe(false);1489 expect(ghCalls.some((call) => call.startsWith("pr view "))).toBe(false);
1099 }),1490 }),
1100 );1491 );
...@@ -1126,6 +1517,14 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -1126,6 +1517,14 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
1126 url: "https://github.com/pingdotgg/codething-mvp/pull/142",1517 url: "https://github.com/pingdotgg/codething-mvp/pull/142",
1127 baseRefName: "main",1518 baseRefName: "main",
1128 headRefName: "statemachine",1519 headRefName: "statemachine",
1520 state: "OPEN",
1521 isCrossRepository: true,
1522 headRepository: {
1523 nameWithOwner: "octocat/codething-mvp",
1524 },
1525 headRepositoryOwner: {
1526 login: "octocat",
1527 },
1129 },1528 },
1130 ]),1529 ]),
1131 ],1530 ],
...@@ -1149,6 +1548,98 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -1149,6 +1548,98 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
1149 12_000,1548 12_000,
1150 );1549 );
11511550
1551 it.effect(
1552 "returns the correct existing PR when a slash remote checks out to a synthetic local alias",
1553 () =>
1554 Effect.gen(function* () {
1555 const repoDir = yield* makeTempDir("t3code-git-manager-");
1556 yield* initRepo(repoDir);
1557 const originDir = yield* createBareRemote();
1558 const upstreamDir = yield* createBareRemote();
1559 yield* configureRemote(repoDir, "origin", originDir, "origin");
1560 yield* configureRemote(repoDir, "my-org/upstream", upstreamDir, "my-org/upstream");
1561
1562 yield* runGit(repoDir, ["checkout", "-b", "effect-atom"]);
1563 yield* runGit(repoDir, ["push", "-u", "origin", "effect-atom"]);
1564 yield* runGit(repoDir, ["push", "-u", "my-org/upstream", "effect-atom"]);
1565 yield* runGit(repoDir, [
1566 "config",
1567 "remote.origin.url",
1568 "git@github.com:pingdotgg/codething-mvp.git",
1569 ]);
1570 yield* runGit(repoDir, ["config", "remote.origin.pushurl", originDir]);
1571 yield* runGit(repoDir, [
1572 "config",
1573 "remote.my-org/upstream.url",
1574 "git@github.com:pingdotgg/codething-mvp.git",
1575 ]);
1576 yield* runGit(repoDir, ["config", "remote.my-org/upstream.pushurl", upstreamDir]);
1577 yield* runGit(repoDir, ["checkout", "main"]);
1578 yield* runGit(repoDir, ["branch", "-D", "effect-atom"]);
1579 yield* runGit(repoDir, ["checkout", "--track", "my-org/upstream/effect-atom"]);
1580 fs.writeFileSync(path.join(repoDir, "changes.txt"), "change\n");
1581 yield* runGit(repoDir, ["add", "changes.txt"]);
1582 yield* runGit(repoDir, ["commit", "-m", "Feature commit"]);
1583
1584 const { manager, ghCalls } = yield* makeManager({
1585 ghScenario: {
1586 prListByHeadSelector: {
1587 "effect-atom": JSON.stringify([
1588 {
1589 number: 1618,
1590 title: "Correct PR",
1591 url: "https://github.com/pingdotgg/t3code/pull/1618",
1592 baseRefName: "main",
1593 headRefName: "effect-atom",
1594 },
1595 ]),
1596 "upstream/effect-atom": JSON.stringify([
1597 {
1598 number: 1518,
1599 title: "Wrong PR",
1600 url: "https://github.com/pingdotgg/t3code/pull/1518",
1601 baseRefName: "main",
1602 headRefName: "upstream/effect-atom",
1603 },
1604 ]),
1605 "pingdotgg:effect-atom": JSON.stringify([]),
1606 "my-org/upstream:effect-atom": JSON.stringify([]),
1607 "pingdotgg:upstream/effect-atom": JSON.stringify([
1608 {
1609 number: 1518,
1610 title: "Wrong PR",
1611 url: "https://github.com/pingdotgg/t3code/pull/1518",
1612 baseRefName: "main",
1613 headRefName: "upstream/effect-atom",
1614 },
1615 ]),
1616 "my-org/upstream:upstream/effect-atom": JSON.stringify([
1617 {
1618 number: 1518,
1619 title: "Wrong PR",
1620 url: "https://github.com/pingdotgg/t3code/pull/1518",
1621 baseRefName: "main",
1622 headRefName: "upstream/effect-atom",
1623 },
1624 ]),
1625 },
1626 },
1627 });
1628
1629 const result = yield* runStackedAction(manager, {
1630 cwd: repoDir,
1631 action: "commit_push_pr",
1632 });
1633
1634 expect(result.pr.status).toBe("opened_existing");
1635 expect(result.pr.number).toBe(1618);
1636 expect(ghCalls.some((call) => call.includes("pr list --head upstream/effect-atom "))).toBe(
1637 false,
1638 );
1639 }),
1640 12_000,
1641 );
1642
1152 it.effect(1643 it.effect(
1153 "prefers owner-qualified selectors before bare branch names for cross-repo PRs",1644 "prefers owner-qualified selectors before bare branch names for cross-repo PRs",
1154 () =>1645 () =>
...@@ -1187,6 +1678,14 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -1187,6 +1678,14 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
1187 url: "https://github.com/pingdotgg/codething-mvp/pull/142",1678 url: "https://github.com/pingdotgg/codething-mvp/pull/142",
1188 baseRefName: "main",1679 baseRefName: "main",
1189 headRefName: "statemachine",1680 headRefName: "statemachine",
1681 state: "OPEN",
1682 isCrossRepository: true,
1683 headRepository: {
1684 nameWithOwner: "octocat/codething-mvp",
1685 },
1686 headRepositoryOwner: {
1687 login: "octocat",
1688 },
1190 },1689 },
1191 ]),1690 ]),
1192 "fork-seed:statemachine": JSON.stringify([]),1691 "fork-seed:statemachine": JSON.stringify([]),
...@@ -1239,6 +1738,14 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -1239,6 +1738,14 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
1239 url: "https://github.com/pingdotgg/codething-mvp/pull/142",1738 url: "https://github.com/pingdotgg/codething-mvp/pull/142",
1240 baseRefName: "main",1739 baseRefName: "main",
1241 headRefName: "statemachine",1740 headRefName: "statemachine",
1741 state: "OPEN",
1742 isCrossRepository: true,
1743 headRepository: {
1744 nameWithOwner: "octocat/codething-mvp",
1745 },
1746 headRepositoryOwner: {
1747 login: "octocat",
1748 },
1242 },1749 },
1243 ]),1750 ]),
1244 "fork-seed:statemachine": JSON.stringify([]),1751 "fork-seed:statemachine": JSON.stringify([]),
...@@ -1256,9 +1763,9 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -1256,9 +1763,9 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
1256 expect(result.pr.status).toBe("opened_existing");1763 expect(result.pr.status).toBe("opened_existing");
1257 expect(result.pr.number).toBe(142);1764 expect(result.pr.number).toBe(142);
12581765
1259 const prListCalls = ghCalls.filter((call) => call.startsWith("pr list "));1766 const openLookupCalls = ghCalls.filter((call) => call.includes("--state open --limit 1"));
1260 expect(prListCalls).toHaveLength(1);1767 expect(openLookupCalls).toHaveLength(1);
1261 expect(prListCalls[0]).toContain(1768 expect(openLookupCalls[0]).toContain(
1262 "pr list --head octocat:statemachine --state open --limit 1",1769 "pr list --head octocat:statemachine --state open --limit 1",
1263 );1770 );
1264 }),1771 }),
...@@ -1302,6 +1809,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -1302,6 +1809,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
1302 expect(result.branch.status).toBe("skipped_not_requested");1809 expect(result.branch.status).toBe("skipped_not_requested");
1303 expect(result.pr.status).toBe("created");1810 expect(result.pr.status).toBe("created");
1304 expect(result.pr.number).toBe(88);1811 expect(result.pr.number).toBe(88);
1812 expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(2);
1305 expect(1813 expect(
1306 ghCalls.some((call) => call.includes("pr create --base main --head feature-create-pr")),1814 ghCalls.some((call) => call.includes("pr create --base main --head feature-create-pr")),
1307 ).toBe(true);1815 ).toBe(true);
...@@ -1309,6 +1817,78 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -1309,6 +1817,78 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
1309 }),1817 }),
1310 );1818 );
13111819
1820 it.effect(
1821 "creates a new PR instead of reusing an unrelated fork PR with the same head branch",
1822 () =>
1823 Effect.gen(function* () {
1824 const repoDir = yield* makeTempDir("t3code-git-manager-");
1825 yield* initRepo(repoDir);
1826 yield* runGit(repoDir, ["checkout", "-b", "feature/no-fork-match"]);
1827 const remoteDir = yield* createBareRemote();
1828 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
1829 fs.writeFileSync(path.join(repoDir, "changes.txt"), "change\n");
1830 yield* runGit(repoDir, ["add", "changes.txt"]);
1831 yield* runGit(repoDir, ["commit", "-m", "Feature commit"]);
1832 yield* runGit(repoDir, ["push", "-u", "origin", "feature/no-fork-match"]);
1833
1834 const { manager, ghCalls } = yield* makeManager({
1835 ghScenario: {
1836 prListSequence: [
1837 JSON.stringify([
1838 {
1839 number: 1661,
1840 title: "Fork PR with same branch name",
1841 url: "https://github.com/pingdotgg/t3code/pull/1661",
1842 baseRefName: "main",
1843 headRefName: "feature/no-fork-match",
1844 state: "OPEN",
1845 isCrossRepository: true,
1846 headRepository: {
1847 nameWithOwner: "lnieuwenhuis/t3code",
1848 },
1849 headRepositoryOwner: {
1850 login: "lnieuwenhuis",
1851 },
1852 },
1853 ]),
1854 JSON.stringify([
1855 {
1856 number: 188,
1857 title: "Add stacked git actions",
1858 url: "https://github.com/pingdotgg/codething-mvp/pull/188",
1859 baseRefName: "main",
1860 headRefName: "feature/no-fork-match",
1861 state: "OPEN",
1862 isCrossRepository: false,
1863 },
1864 ]),
1865 ],
1866 },
1867 });
1868 const result = yield* runStackedAction(manager, {
1869 cwd: repoDir,
1870 action: "commit_push_pr",
1871 });
1872
1873 expect(result.pr.status).toBe("created");
1874 expect(result.pr.number).toBe(188);
1875 expect(result.toast).toEqual({
1876 title: "Created PR #188",
1877 description: "Add stacked git actions",
1878 cta: {
1879 kind: "open_pr",
1880 label: "View PR",
1881 url: "https://github.com/pingdotgg/codething-mvp/pull/188",
1882 },
1883 });
1884 expect(
1885 ghCalls.some((call) =>
1886 call.includes("pr create --base main --head feature/no-fork-match"),
1887 ),
1888 ).toBe(true);
1889 }),
1890 );
1891
1312 it.effect("creates cross-repo PRs with the fork owner selector and default base branch", () =>1892 it.effect("creates cross-repo PRs with the fork owner selector and default base branch", () =>
1313 Effect.gen(function* () {1893 Effect.gen(function* () {
1314 const repoDir = yield* makeTempDir("t3code-git-manager-");1894 const repoDir = yield* makeTempDir("t3code-git-manager-");
...@@ -1330,23 +1910,30 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -1330,23 +1910,30 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
13301910
1331 const { manager, ghCalls } = yield* makeManager({1911 const { manager, ghCalls } = yield* makeManager({
1332 ghScenario: {1912 ghScenario: {
1333 prListSequence: [1913 prListSequenceByHeadSelector: {
1334 JSON.stringify([]),1914 "octocat:statemachine": [
1335 JSON.stringify([]),1915 JSON.stringify([]),
1336 JSON.stringify([]),1916 JSON.stringify([
1337 JSON.stringify([]),1917 {
1338 JSON.stringify([]),1918 number: 188,
1339 JSON.stringify([]),1919 title: "Add stacked git actions",
1340 JSON.stringify([1920 url: "https://github.com/pingdotgg/codething-mvp/pull/188",
1341 {1921 baseRefName: "main",
1342 number: 188,1922 headRefName: "statemachine",
1343 title: "Add stacked git actions",1923 state: "OPEN",
1344 url: "https://github.com/pingdotgg/codething-mvp/pull/188",1924 isCrossRepository: true,
1345 baseRefName: "main",1925 headRepository: {
1346 headRefName: "statemachine",1926 nameWithOwner: "octocat/codething-mvp",
1347 },1927 },
1348 ]),1928 headRepositoryOwner: {
1349 ],1929 login: "octocat",
1930 },
1931 },
1932 ]),
1933 ],
1934 "fork-seed:statemachine": [JSON.stringify([])],
1935 statemachine: [JSON.stringify([])],
1936 },
1350 },1937 },
1351 });1938 });
13521939
...@@ -2090,4 +2677,71 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -2090,4 +2677,71 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
2090 );2677 );
2091 }),2678 }),
2092 );2679 );
2680
2681 it.effect("create_pr emits only the PR phase when the branch is already pushed", () =>
2682 Effect.gen(function* () {
2683 const repoDir = yield* makeTempDir("t3code-git-manager-");
2684 yield* initRepo(repoDir);
2685 yield* runGit(repoDir, ["checkout", "-b", "feature/pr-only-follow-up"]);
2686 const remoteDir = yield* createBareRemote();
2687 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
2688 fs.writeFileSync(path.join(repoDir, "pr-only.txt"), "pr only\n");
2689 yield* runGit(repoDir, ["add", "pr-only.txt"]);
2690 yield* runGit(repoDir, ["commit", "-m", "PR only branch"]);
2691 yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-only-follow-up"]);
2692
2693 const { manager } = yield* makeManager({
2694 ghScenario: {
2695 prListSequence: [
2696 JSON.stringify([]),
2697 JSON.stringify([
2698 {
2699 number: 201,
2700 title: "PR only branch",
2701 url: "https://github.com/pingdotgg/codething-mvp/pull/201",
2702 baseRefName: "main",
2703 headRefName: "feature/pr-only-follow-up",
2704 state: "OPEN",
2705 isCrossRepository: false,
2706 },
2707 ]),
2708 ],
2709 },
2710 });
2711 const events: GitActionProgressEvent[] = [];
2712
2713 const result = yield* runStackedAction(
2714 manager,
2715 {
2716 cwd: repoDir,
2717 action: "create_pr",
2718 },
2719 {
2720 actionId: "action-pr-only",
2721 progressReporter: {
2722 publish: (event) =>
2723 Effect.sync(() => {
2724 events.push(event);
2725 }),
2726 },
2727 },
2728 );
2729
2730 expect(result.commit.status).toBe("skipped_not_requested");
2731 expect(result.push.status).toBe("skipped_not_requested");
2732 expect(result.pr.status).toBe("created");
2733 expect(
2734 events.filter(
2735 (event): event is Extract<GitActionProgressEvent, { kind: "phase_started" }> =>
2736 event.kind === "phase_started",
2737 ),
2738 ).toEqual([
2739 expect.objectContaining({
2740 kind: "phase_started",
2741 phase: "pr",
2742 label: "Creating PR...",
2743 }),
2744 ]);
2745 }),
2746 );
2093});2747});
apps/server/src/git/Layers/GitManager.ts+561-206
...@@ -1,11 +1,12 @@...@@ -1,11 +1,12 @@
1import { randomUUID } from "node:crypto";1import { randomUUID } from "node:crypto";
2import { realpathSync } from "node:fs";2import { realpathSync } from "node:fs";
33
4import { Effect, FileSystem, Layer, Option, Path, Ref } from "effect";4import { Cache, Duration, Effect, Exit, FileSystem, Layer, Option, Path, Ref } from "effect";
5import {5import {
6 GitActionProgressEvent,6 GitActionProgressEvent,
7 GitActionProgressPhase,7 GitActionProgressPhase,
8 GitRunStackedActionResult,8 GitRunStackedActionResult,
9 GitStackedAction,
9 ModelSelection,10 ModelSelection,
10} from "@t3tools/contracts";11} from "@t3tools/contracts";
11import {12import {
...@@ -22,13 +23,18 @@ import {...@@ -22,13 +23,18 @@ import {
22 type GitRunStackedActionOptions,23 type GitRunStackedActionOptions,
23} from "../Services/GitManager.ts";24} from "../Services/GitManager.ts";
24import { GitCore } from "../Services/GitCore.ts";25import { GitCore } from "../Services/GitCore.ts";
25import { GitHubCli } from "../Services/GitHubCli.ts";26import { GitHubCli, type GitHubPullRequestSummary } from "../Services/GitHubCli.ts";
26import { TextGeneration } from "../Services/TextGeneration.ts";27import { TextGeneration } from "../Services/TextGeneration.ts";
28import { extractBranchNameFromRemoteRef } from "../remoteRefs.ts";
27import { ServerSettingsService } from "../../serverSettings.ts";29import { ServerSettingsService } from "../../serverSettings.ts";
28import type { GitManagerServiceError } from "@t3tools/contracts";30import type { GitManagerServiceError } from "@t3tools/contracts";
2931
30const COMMIT_TIMEOUT_MS = 10 * 60_000;32const COMMIT_TIMEOUT_MS = 10 * 60_000;
31const MAX_PROGRESS_TEXT_LENGTH = 500;33const MAX_PROGRESS_TEXT_LENGTH = 500;
34const SHORT_SHA_LENGTH = 7;
35const TOAST_DESCRIPTION_MAX = 72;
36const STATUS_RESULT_CACHE_TTL = Duration.seconds(1);
37const STATUS_RESULT_CACHE_CAPACITY = 2_048;
32type StripProgressContext<T> = T extends any ? Omit<T, "actionId" | "cwd" | "action"> : never;38type StripProgressContext<T> = T extends any ? Omit<T, "actionId" | "cwd" | "action"> : never;
33type GitActionProgressPayload = StripProgressContext<GitActionProgressEvent>;39type GitActionProgressPayload = StripProgressContext<GitActionProgressEvent>;
3440
...@@ -40,7 +46,7 @@ interface OpenPrInfo {...@@ -40,7 +46,7 @@ interface OpenPrInfo {
40 headRefName: string;46 headRefName: string;
41}47}
4248
43interface PullRequestInfo extends OpenPrInfo {49interface PullRequestInfo extends OpenPrInfo, PullRequestHeadRemoteInfo {
44 state: "open" | "closed" | "merged";50 state: "open" | "closed" | "merged";
45 updatedAt: string | null;51 updatedAt: string | null;
46}52}
...@@ -135,6 +141,94 @@ function parseRepositoryOwnerLogin(nameWithOwner: string | null): string | null...@@ -135,6 +141,94 @@ function parseRepositoryOwnerLogin(nameWithOwner: string | null): string | null
135 return normalizedOwnerLogin.length > 0 ? normalizedOwnerLogin : null;141 return normalizedOwnerLogin.length > 0 ? normalizedOwnerLogin : null;
136}142}
137143
144function normalizeOptionalString(value: string | null | undefined): string | null {
145 const trimmed = value?.trim() ?? "";
146 return trimmed.length > 0 ? trimmed : null;
147}
148
149function normalizeOptionalRepositoryNameWithOwner(value: string | null | undefined): string | null {
150 const normalized = normalizeOptionalString(value);
151 return normalized ? normalized.toLowerCase() : null;
152}
153
154function normalizeOptionalOwnerLogin(value: string | null | undefined): string | null {
155 const normalized = normalizeOptionalString(value);
156 return normalized ? normalized.toLowerCase() : null;
157}
158
159function resolvePullRequestHeadRepositoryNameWithOwner(
160 pr: PullRequestHeadRemoteInfo & { url: string },
161) {
162 const explicitRepository = normalizeOptionalString(pr.headRepositoryNameWithOwner);
163 if (explicitRepository) {
164 return explicitRepository;
165 }
166
167 if (!pr.isCrossRepository) {
168 return null;
169 }
170
171 const ownerLogin = normalizeOptionalString(pr.headRepositoryOwnerLogin);
172 const repositoryName = parseRepositoryNameFromPullRequestUrl(pr.url);
173 if (!ownerLogin || !repositoryName) {
174 return null;
175 }
176
177 return `${ownerLogin}/${repositoryName}`;
178}
179
180function matchesBranchHeadContext(
181 pr: PullRequestInfo,
182 headContext: Pick<
183 BranchHeadContext,
184 "headBranch" | "headRepositoryNameWithOwner" | "headRepositoryOwnerLogin" | "isCrossRepository"
185 >,
186): boolean {
187 if (pr.headRefName !== headContext.headBranch) {
188 return false;
189 }
190
191 const expectedHeadRepository = normalizeOptionalRepositoryNameWithOwner(
192 headContext.headRepositoryNameWithOwner,
193 );
194 const expectedHeadOwner =
195 normalizeOptionalOwnerLogin(headContext.headRepositoryOwnerLogin) ??
196 parseRepositoryOwnerLogin(expectedHeadRepository);
197 const prHeadRepository = normalizeOptionalRepositoryNameWithOwner(
198 resolvePullRequestHeadRepositoryNameWithOwner(pr),
199 );
200 const prHeadOwner =
201 normalizeOptionalOwnerLogin(pr.headRepositoryOwnerLogin) ??
202 parseRepositoryOwnerLogin(prHeadRepository);
203
204 if (headContext.isCrossRepository) {
205 if (pr.isCrossRepository === false) {
206 return false;
207 }
208 if ((expectedHeadRepository || expectedHeadOwner) && !prHeadRepository && !prHeadOwner) {
209 return false;
210 }
211 if (expectedHeadRepository && prHeadRepository && expectedHeadRepository !== prHeadRepository) {
212 return false;
213 }
214 if (expectedHeadOwner && prHeadOwner && expectedHeadOwner !== prHeadOwner) {
215 return false;
216 }
217 return true;
218 }
219
220 if (pr.isCrossRepository === true) {
221 return false;
222 }
223 if (expectedHeadRepository && prHeadRepository && expectedHeadRepository !== prHeadRepository) {
224 return false;
225 }
226 if (expectedHeadOwner && prHeadOwner && expectedHeadOwner !== prHeadOwner) {
227 return false;
228 }
229 return true;
230}
231
138function parsePullRequestList(raw: unknown): PullRequestInfo[] {232function parsePullRequestList(raw: unknown): PullRequestInfo[] {
139 if (!Array.isArray(raw)) return [];233 if (!Array.isArray(raw)) return [];
140234
...@@ -150,6 +244,27 @@ function parsePullRequestList(raw: unknown): PullRequestInfo[] {...@@ -150,6 +244,27 @@ function parsePullRequestList(raw: unknown): PullRequestInfo[] {
150 const state = record.state;244 const state = record.state;
151 const mergedAt = record.mergedAt;245 const mergedAt = record.mergedAt;
152 const updatedAt = record.updatedAt;246 const updatedAt = record.updatedAt;
247 const isCrossRepository = record.isCrossRepository;
248 const headRepositoryRecord =
249 typeof record.headRepository === "object" && record.headRepository !== null
250 ? (record.headRepository as Record<string, unknown>)
251 : null;
252 const headRepositoryOwnerRecord =
253 typeof record.headRepositoryOwner === "object" && record.headRepositoryOwner !== null
254 ? (record.headRepositoryOwner as Record<string, unknown>)
255 : null;
256 const headRepositoryNameWithOwner =
257 typeof record.headRepositoryNameWithOwner === "string"
258 ? record.headRepositoryNameWithOwner
259 : typeof headRepositoryRecord?.nameWithOwner === "string"
260 ? headRepositoryRecord.nameWithOwner
261 : null;
262 const headRepositoryOwnerLogin =
263 typeof record.headRepositoryOwnerLogin === "string"
264 ? record.headRepositoryOwnerLogin
265 : typeof headRepositoryOwnerRecord?.login === "string"
266 ? headRepositoryOwnerRecord.login
267 : null;
153 if (typeof number !== "number" || !Number.isInteger(number) || number <= 0) {268 if (typeof number !== "number" || !Number.isInteger(number) || number <= 0) {
154 continue;269 continue;
155 }270 }
...@@ -163,11 +278,15 @@ function parsePullRequestList(raw: unknown): PullRequestInfo[] {...@@ -163,11 +278,15 @@ function parsePullRequestList(raw: unknown): PullRequestInfo[] {
163 }278 }
164279
165 let normalizedState: "open" | "closed" | "merged";280 let normalizedState: "open" | "closed" | "merged";
166 if ((typeof mergedAt === "string" && mergedAt.trim().length > 0) || state === "MERGED") {281 if (
282 (typeof mergedAt === "string" && mergedAt.trim().length > 0) ||
283 state === "MERGED" ||
284 state === "merged"
285 ) {
167 normalizedState = "merged";286 normalizedState = "merged";
168 } else if (state === "OPEN" || state === undefined || state === null) {287 } else if (state === "OPEN" || state === "open" || state === undefined || state === null) {
169 normalizedState = "open";288 normalizedState = "open";
170 } else if (state === "CLOSED") {289 } else if (state === "CLOSED" || state === "closed") {
171 normalizedState = "closed";290 normalizedState = "closed";
172 } else {291 } else {
173 continue;292 continue;
...@@ -181,11 +300,35 @@ function parsePullRequestList(raw: unknown): PullRequestInfo[] {...@@ -181,11 +300,35 @@ function parsePullRequestList(raw: unknown): PullRequestInfo[] {
181 headRefName,300 headRefName,
182 state: normalizedState,301 state: normalizedState,
183 updatedAt: typeof updatedAt === "string" && updatedAt.trim().length > 0 ? updatedAt : null,302 updatedAt: typeof updatedAt === "string" && updatedAt.trim().length > 0 ? updatedAt : null,
303 ...(typeof isCrossRepository === "boolean" ? { isCrossRepository } : {}),
304 ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}),
305 ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}),
184 });306 });
185 }307 }
186 return parsed;308 return parsed;
187}309}
188310
311function toPullRequestInfo(summary: GitHubPullRequestSummary): PullRequestInfo {
312 return {
313 number: summary.number,
314 title: summary.title,
315 url: summary.url,
316 baseRefName: summary.baseRefName,
317 headRefName: summary.headRefName,
318 state: summary.state ?? "open",
319 updatedAt: null,
320 ...(summary.isCrossRepository !== undefined
321 ? { isCrossRepository: summary.isCrossRepository }
322 : {}),
323 ...(summary.headRepositoryNameWithOwner !== undefined
324 ? { headRepositoryNameWithOwner: summary.headRepositoryNameWithOwner }
325 : {}),
326 ...(summary.headRepositoryOwnerLogin !== undefined
327 ? { headRepositoryOwnerLogin: summary.headRepositoryOwnerLogin }
328 : {}),
329 };
330}
331
189function gitManagerError(operation: string, detail: string, cause?: unknown): GitManagerError {332function gitManagerError(operation: string, detail: string, cause?: unknown): GitManagerError {
190 return new GitManagerError({333 return new GitManagerError({
191 operation,334 operation,
...@@ -199,6 +342,57 @@ function limitContext(value: string, maxChars: number): string {...@@ -199,6 +342,57 @@ function limitContext(value: string, maxChars: number): string {
199 return `${value.slice(0, maxChars)}\n\n[truncated]`;342 return `${value.slice(0, maxChars)}\n\n[truncated]`;
200}343}
201344
345function shortenSha(sha: string | undefined): string | null {
346 if (!sha) return null;
347 return sha.slice(0, SHORT_SHA_LENGTH);
348}
349
350function truncateText(
351 value: string | undefined,
352 maxLength = TOAST_DESCRIPTION_MAX,
353): string | undefined {
354 if (!value) return undefined;
355 if (value.length <= maxLength) return value;
356 if (maxLength <= 3) return "...".slice(0, maxLength);
357 return `${value.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`;
358}
359
360function withDescription(title: string, description: string | undefined) {
361 return description ? { title, description } : { title };
362}
363
364function summarizeGitActionResult(
365 result: Pick<GitRunStackedActionResult, "commit" | "push" | "pr">,
366): {
367 title: string;
368 description?: string;
369} {
370 if (result.pr.status === "created" || result.pr.status === "opened_existing") {
371 const prNumber = result.pr.number ? ` #${result.pr.number}` : "";
372 const title = `${result.pr.status === "created" ? "Created PR" : "Opened PR"}${prNumber}`;
373 return withDescription(title, truncateText(result.pr.title));
374 }
375
376 if (result.push.status === "pushed") {
377 const shortSha = shortenSha(result.commit.commitSha);
378 const branch = result.push.upstreamBranch ?? result.push.branch;
379 const pushedCommitPart = shortSha ? ` ${shortSha}` : "";
380 const branchPart = branch ? ` to ${branch}` : "";
381 return withDescription(
382 `Pushed${pushedCommitPart}${branchPart}`,
383 truncateText(result.commit.subject),
384 );
385 }
386
387 if (result.commit.status === "created") {
388 const shortSha = shortenSha(result.commit.commitSha);
389 const title = shortSha ? `Committed ${shortSha}` : "Committed changes";
390 return withDescription(title, truncateText(result.commit.subject));
391 }
392
393 return { title: "Done" };
394}
395
202function sanitizeCommitMessage(generated: {396function sanitizeCommitMessage(generated: {
203 subject: string;397 subject: string;
204 body: string;398 body: string;
...@@ -236,6 +430,12 @@ interface CommitAndBranchSuggestion {...@@ -236,6 +430,12 @@ interface CommitAndBranchSuggestion {
236 commitMessage: string;430 commitMessage: string;
237}431}
238432
433function isCommitAction(
434 action: GitStackedAction,
435): action is "commit" | "commit_push" | "commit_push_pr" {
436 return action === "commit" || action === "commit_push" || action === "commit_push_pr";
437}
438
239function formatCommitMessage(subject: string, body: string): string {439function formatCommitMessage(subject: string, body: string): string {
240 const trimmedBody = body.trim();440 const trimmedBody = body.trim();
241 if (trimmedBody.length === 0) {441 if (trimmedBody.length === 0) {
...@@ -262,25 +462,6 @@ function parseCustomCommitMessage(raw: string): { subject: string; body: string...@@ -262,25 +462,6 @@ function parseCustomCommitMessage(raw: string): { subject: string; body: string
262 };462 };
263}463}
264464
265function extractBranchFromRef(ref: string): string {
266 const normalized = ref.trim();
267
268 if (normalized.startsWith("refs/remotes/")) {
269 const withoutPrefix = normalized.slice("refs/remotes/".length);
270 const firstSlash = withoutPrefix.indexOf("/");
271 if (firstSlash === -1) {
272 return withoutPrefix.trim();
273 }
274 return withoutPrefix.slice(firstSlash + 1).trim();
275 }
276
277 const firstSlash = normalized.indexOf("/");
278 if (firstSlash === -1) {
279 return normalized;
280 }
281 return normalized.slice(firstSlash + 1).trim();
282}
283
284function appendUnique(values: string[], next: string | null | undefined): void {465function appendUnique(values: string[], next: string | null | undefined): void {
285 const trimmed = next?.trim() ?? "";466 const trimmed = next?.trim() ?? "";
286 if (trimmed.length === 0 || values.includes(trimmed)) {467 if (trimmed.length === 0 || values.includes(trimmed)) {
...@@ -368,7 +549,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {...@@ -368,7 +549,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
368 const serverSettingsService = yield* ServerSettingsService;549 const serverSettingsService = yield* ServerSettingsService;
369550
370 const createProgressEmitter = (551 const createProgressEmitter = (
371 input: { cwd: string; action: "commit" | "commit_push" | "commit_push_pr" },552 input: { cwd: string; action: GitStackedAction },
372 options?: GitRunStackedActionOptions,553 options?: GitRunStackedActionOptions,
373 ) => {554 ) => {
374 const actionId = options?.actionId ?? randomUUID();555 const actionId = options?.actionId ?? randomUUID();
...@@ -505,6 +686,38 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {...@@ -505,6 +686,38 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
505 const path = yield* Path.Path;686 const path = yield* Path.Path;
506687
507 const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp";688 const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp";
689 const normalizeStatusCacheKey = (cwd: string) => canonicalizeExistingPath(cwd);
690 const readStatus = Effect.fn("readStatus")(function* (cwd: string) {
691 const details = yield* gitCore.statusDetails(cwd);
692
693 const pr =
694 details.branch !== null
695 ? yield* findLatestPr(cwd, {
696 branch: details.branch,
697 upstreamRef: details.upstreamRef,
698 }).pipe(
699 Effect.map((latest) => (latest ? toStatusPr(latest) : null)),
700 Effect.catch(() => Effect.succeed(null)),
701 )
702 : null;
703
704 return {
705 branch: details.branch,
706 hasWorkingTreeChanges: details.hasWorkingTreeChanges,
707 workingTree: details.workingTree,
708 hasUpstream: details.hasUpstream,
709 aheadCount: details.aheadCount,
710 behindCount: details.behindCount,
711 pr,
712 };
713 });
714 const statusResultCache = yield* Cache.makeWith({
715 capacity: STATUS_RESULT_CACHE_CAPACITY,
716 lookup: readStatus,
717 timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_RESULT_CACHE_TTL : Duration.zero),
718 });
719 const invalidateStatusResultCache = (cwd: string) =>
720 Cache.invalidate(statusResultCache, normalizeStatusCacheKey(cwd));
508721
509 const readConfigValueNullable = (cwd: string, key: string) =>722 const readConfigValueNullable = (cwd: string, key: string) =>
510 gitCore.readConfigValue(cwd, key).pipe(Effect.catch(() => Effect.succeed(null)));723 gitCore.readConfigValue(cwd, key).pipe(Effect.catch(() => Effect.succeed(null)));
...@@ -534,9 +747,11 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {...@@ -534,9 +747,11 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
534 ) {747 ) {
535 const remoteName = yield* readConfigValueNullable(cwd, `branch.${details.branch}.remote`);748 const remoteName = yield* readConfigValueNullable(cwd, `branch.${details.branch}.remote`);
536 const headBranchFromUpstream = details.upstreamRef749 const headBranchFromUpstream = details.upstreamRef
537 ? extractBranchFromRef(details.upstreamRef)750 ? extractBranchNameFromRemoteRef(details.upstreamRef, { remoteName })
538 : "";751 : "";
539 const headBranch = headBranchFromUpstream.length > 0 ? headBranchFromUpstream : details.branch;752 const headBranch = headBranchFromUpstream.length > 0 ? headBranchFromUpstream : details.branch;
753 const shouldProbeLocalBranchSelector =
754 headBranchFromUpstream.length === 0 || headBranch === details.branch;
540755
541 const [remoteRepository, originRepository] = yield* Effect.all(756 const [remoteRepository, originRepository] = yield* Effect.all(
542 [757 [
...@@ -572,7 +787,9 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {...@@ -572,7 +787,9 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
572 remoteAliasHeadSelector !== ownerHeadSelector ? remoteAliasHeadSelector : null,787 remoteAliasHeadSelector !== ownerHeadSelector ? remoteAliasHeadSelector : null,
573 );788 );
574 }789 }
575 appendUnique(headSelectors, details.branch);790 if (shouldProbeLocalBranchSelector) {
791 appendUnique(headSelectors, details.branch);
792 }
576 appendUnique(headSelectors, headBranch !== details.branch ? headBranch : null);793 appendUnique(headSelectors, headBranch !== details.branch ? headBranch : null);
577 if (!isCrossRepository && shouldProbeRemoteOwnedSelectors) {794 if (!isCrossRepository && shouldProbeRemoteOwnedSelectors) {
578 appendUnique(headSelectors, ownerHeadSelector);795 appendUnique(headSelectors, ownerHeadSelector);
...@@ -597,16 +814,26 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {...@@ -597,16 +814,26 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
597814
598 const findOpenPr = Effect.fn("findOpenPr")(function* (815 const findOpenPr = Effect.fn("findOpenPr")(function* (
599 cwd: string,816 cwd: string,
600 headSelectors: ReadonlyArray<string>,817 headContext: Pick<
818 BranchHeadContext,
819 | "headBranch"
820 | "headSelectors"
821 | "headRepositoryNameWithOwner"
822 | "headRepositoryOwnerLogin"
823 | "isCrossRepository"
824 >,
601 ) {825 ) {
602 for (const headSelector of headSelectors) {826 for (const headSelector of headContext.headSelectors) {
603 const pullRequests = yield* gitHubCli.listOpenPullRequests({827 const pullRequests = yield* gitHubCli.listOpenPullRequests({
604 cwd,828 cwd,
605 headSelector,829 headSelector,
606 limit: 1,830 limit: 1,
607 });831 });
832 const normalizedPullRequests = pullRequests.map(toPullRequestInfo);
608833
609 const [firstPullRequest] = pullRequests;834 const firstPullRequest = normalizedPullRequests.find((pullRequest) =>
835 matchesBranchHeadContext(pullRequest, headContext),
836 );
610 if (firstPullRequest) {837 if (firstPullRequest) {
611 return {838 return {
612 number: firstPullRequest.number,839 number: firstPullRequest.number,
...@@ -644,7 +871,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {...@@ -644,7 +871,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
644 "--limit",871 "--limit",
645 "20",872 "20",
646 "--json",873 "--json",
647 "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt",874 "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",
648 ],875 ],
649 })876 })
650 .pipe(Effect.map((result) => result.stdout));877 .pipe(Effect.map((result) => result.stdout));
...@@ -661,6 +888,9 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {...@@ -661,6 +888,9 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
661 });888 });
662889
663 for (const pr of parsePullRequestList(parsedJson)) {890 for (const pr of parsePullRequestList(parsedJson)) {
891 if (!matchesBranchHeadContext(pr, headContext)) {
892 continue;
893 }
664 parsedByNumber.set(pr.number, pr);894 parsedByNumber.set(pr.number, pr);
665 }895 }
666 }896 }
...@@ -678,17 +908,119 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {...@@ -678,17 +908,119 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
678 return parsed[0] ?? null;908 return parsed[0] ?? null;
679 });909 });
680910
911 const isDefaultBranch = Effect.fn("isDefaultBranch")(function* (cwd: string, branch: string) {
912 const branches = yield* gitCore.listBranches({ cwd });
913 const currentBranch = branches.branches.find((candidate) => candidate.name === branch);
914 return currentBranch?.isDefault ?? (branch === "main" || branch === "master");
915 });
916
917 const buildCompletionToast = Effect.fn("buildCompletionToast")(function* (
918 cwd: string,
919 result: Pick<GitRunStackedActionResult, "action" | "branch" | "commit" | "push" | "pr">,
920 ) {
921 const summary = summarizeGitActionResult(result);
922 let latestOpenPr: PullRequestInfo | null = null;
923 let currentBranchIsDefault = false;
924 let finalBranchContext: {
925 branch: string;
926 upstreamRef: string | null;
927 hasUpstream: boolean;
928 } | null = null;
929
930 if (result.action !== "commit") {
931 const finalStatus = yield* gitCore.statusDetails(cwd);
932 if (finalStatus.branch) {
933 finalBranchContext = {
934 branch: finalStatus.branch,
935 upstreamRef: finalStatus.upstreamRef,
936 hasUpstream: finalStatus.hasUpstream,
937 };
938 currentBranchIsDefault = yield* isDefaultBranch(cwd, finalStatus.branch).pipe(
939 Effect.catch(() =>
940 Effect.succeed(finalStatus.branch === "main" || finalStatus.branch === "master"),
941 ),
942 );
943 }
944 }
945
946 const explicitResultPr =
947 (result.pr.status === "created" || result.pr.status === "opened_existing") && result.pr.url
948 ? {
949 url: result.pr.url,
950 state: "open" as const,
951 }
952 : null;
953 const shouldLookupExistingOpenPr =
954 (result.action === "commit_push" || result.action === "push") &&
955 result.push.status === "pushed" &&
956 result.branch.status !== "created" &&
957 !currentBranchIsDefault &&
958 explicitResultPr === null &&
959 finalBranchContext?.hasUpstream === true;
960
961 if (shouldLookupExistingOpenPr && finalBranchContext) {
962 latestOpenPr = yield* resolveBranchHeadContext(cwd, {
963 branch: finalBranchContext.branch,
964 upstreamRef: finalBranchContext.upstreamRef,
965 }).pipe(
966 Effect.flatMap((headContext) => findOpenPr(cwd, headContext)),
967 Effect.catch(() => Effect.succeed(null)),
968 );
969 }
970
971 const openPr = latestOpenPr ?? explicitResultPr;
972
973 const cta =
974 result.action === "commit" && result.commit.status === "created"
975 ? {
976 kind: "run_action" as const,
977 label: "Push",
978 action: { kind: "push" as const },
979 }
980 : (result.action === "push" ||
981 result.action === "create_pr" ||
982 result.action === "commit_push" ||
983 result.action === "commit_push_pr") &&
984 openPr?.url &&
985 (!currentBranchIsDefault ||
986 result.pr.status === "created" ||
987 result.pr.status === "opened_existing")
988 ? {
989 kind: "open_pr" as const,
990 label: "View PR",
991 url: openPr.url,
992 }
993 : (result.action === "push" || result.action === "commit_push") &&
994 result.push.status === "pushed" &&
995 !currentBranchIsDefault
996 ? {
997 kind: "run_action" as const,
998 label: "Create PR",
999 action: { kind: "create_pr" as const },
1000 }
1001 : {
1002 kind: "none" as const,
1003 };
1004
1005 return {
1006 ...summary,
1007 cta,
1008 };
1009 });
1010
681 const resolveBaseBranch = Effect.fn("resolveBaseBranch")(function* (1011 const resolveBaseBranch = Effect.fn("resolveBaseBranch")(function* (
682 cwd: string,1012 cwd: string,
683 branch: string,1013 branch: string,
684 upstreamRef: string | null,1014 upstreamRef: string | null,
685 headContext: Pick<BranchHeadContext, "isCrossRepository">,1015 headContext: Pick<BranchHeadContext, "isCrossRepository" | "remoteName">,
686 ) {1016 ) {
687 const configured = yield* gitCore.readConfigValue(cwd, `branch.${branch}.gh-merge-base`);1017 const configured = yield* gitCore.readConfigValue(cwd, `branch.${branch}.gh-merge-base`);
688 if (configured) return configured;1018 if (configured) return configured;
6891019
690 if (upstreamRef && !headContext.isCrossRepository) {1020 if (upstreamRef && !headContext.isCrossRepository) {
691 const upstreamBranch = extractBranchFromRef(upstreamRef);1021 const upstreamBranch = extractBranchNameFromRemoteRef(upstreamRef, {
1022 remoteName: headContext.remoteName,
1023 });
692 if (upstreamBranch.length > 0 && upstreamBranch !== branch) {1024 if (upstreamBranch.length > 0 && upstreamBranch !== branch) {
693 return upstreamBranch;1025 return upstreamBranch;
694 }1026 }
...@@ -889,7 +1221,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {...@@ -889,7 +1221,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
889 upstreamRef: details.upstreamRef,1221 upstreamRef: details.upstreamRef,
890 });1222 });
8911223
892 const existing = yield* findOpenPr(cwd, headContext.headSelectors);1224 const existing = yield* findOpenPr(cwd, headContext);
893 if (existing) {1225 if (existing) {
894 return {1226 return {
895 status: "opened_existing" as const,1227 status: "opened_existing" as const,
...@@ -932,7 +1264,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {...@@ -932,7 +1264,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
932 })1264 })
933 .pipe(Effect.ensuring(fileSystem.remove(bodyFile).pipe(Effect.catch(() => Effect.void))));1265 .pipe(Effect.ensuring(fileSystem.remove(bodyFile).pipe(Effect.catch(() => Effect.void))));
9341266
935 const created = yield* findOpenPr(cwd, headContext.headSelectors);1267 const created = yield* findOpenPr(cwd, headContext);
936 if (!created) {1268 if (!created) {
937 return {1269 return {
938 status: "created" as const,1270 status: "created" as const,
...@@ -953,28 +1285,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {...@@ -953,28 +1285,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
953 });1285 });
9541286
955 const status: GitManagerShape["status"] = Effect.fn("status")(function* (input) {1287 const status: GitManagerShape["status"] = Effect.fn("status")(function* (input) {
956 const details = yield* gitCore.statusDetails(input.cwd);1288 return yield* Cache.get(statusResultCache, normalizeStatusCacheKey(input.cwd));
957
958 const pr =
959 details.branch !== null
960 ? yield* findLatestPr(input.cwd, {
961 branch: details.branch,
962 upstreamRef: details.upstreamRef,
963 }).pipe(
964 Effect.map((latest) => (latest ? toStatusPr(latest) : null)),
965 Effect.catch(() => Effect.succeed(null)),
966 )
967 : null;
968
969 return {
970 branch: details.branch,
971 hasWorkingTreeChanges: details.hasWorkingTreeChanges,
972 workingTree: details.workingTree,
973 hasUpstream: details.hasUpstream,
974 aheadCount: details.aheadCount,
975 behindCount: details.behindCount,
976 pr,
977 };
978 });1289 });
9791290
980 const resolvePullRequest: GitManagerShape["resolvePullRequest"] = Effect.fn("resolvePullRequest")(1291 const resolvePullRequest: GitManagerShape["resolvePullRequest"] = Effect.fn("resolvePullRequest")(
...@@ -993,143 +1304,145 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {...@@ -993,143 +1304,145 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
993 const preparePullRequestThread: GitManagerShape["preparePullRequestThread"] = Effect.fn(1304 const preparePullRequestThread: GitManagerShape["preparePullRequestThread"] = Effect.fn(
994 "preparePullRequestThread",1305 "preparePullRequestThread",
995 )(function* (input) {1306 )(function* (input) {
996 const normalizedReference = normalizePullRequestReference(input.reference);1307 return yield* Effect.gen(function* () {
997 const rootWorktreePath = canonicalizeExistingPath(input.cwd);1308 const normalizedReference = normalizePullRequestReference(input.reference);
998 const pullRequestSummary = yield* gitHubCli.getPullRequest({1309 const rootWorktreePath = canonicalizeExistingPath(input.cwd);
999 cwd: input.cwd,1310 const pullRequestSummary = yield* gitHubCli.getPullRequest({
1000 reference: normalizedReference,
1001 });
1002 const pullRequest = toResolvedPullRequest(pullRequestSummary);
1003
1004 if (input.mode === "local") {
1005 yield* gitHubCli.checkoutPullRequest({
1006 cwd: input.cwd,1311 cwd: input.cwd,
1007 reference: normalizedReference,1312 reference: normalizedReference,
1008 force: true,
1009 });1313 });
1010 const details = yield* gitCore.statusDetails(input.cwd);1314 const pullRequest = toResolvedPullRequest(pullRequestSummary);
1011 yield* configurePullRequestHeadUpstream(
1012 input.cwd,
1013 {
1014 ...pullRequest,
1015 ...toPullRequestHeadRemoteInfo(pullRequestSummary),
1016 },
1017 details.branch ?? pullRequest.headBranch,
1018 );
1019 return {
1020 pullRequest,
1021 branch: details.branch ?? pullRequest.headBranch,
1022 worktreePath: null,
1023 };
1024 }
10251315
1026 const ensureExistingWorktreeUpstream = Effect.fn("ensureExistingWorktreeUpstream")(function* (1316 if (input.mode === "local") {
1027 worktreePath: string,1317 yield* gitHubCli.checkoutPullRequest({
1028 ) {1318 cwd: input.cwd,
1029 const details = yield* gitCore.statusDetails(worktreePath);1319 reference: normalizedReference,
1030 yield* configurePullRequestHeadUpstream(1320 force: true,
1031 worktreePath,1321 });
1032 {1322 const details = yield* gitCore.statusDetails(input.cwd);
1033 ...pullRequest,1323 yield* configurePullRequestHeadUpstream(
1034 ...toPullRequestHeadRemoteInfo(pullRequestSummary),1324 input.cwd,
1035 },1325 {
1036 details.branch ?? pullRequest.headBranch,1326 ...pullRequest,
1037 );1327 ...toPullRequestHeadRemoteInfo(pullRequestSummary),
1038 });1328 },
1329 details.branch ?? pullRequest.headBranch,
1330 );
1331 return {
1332 pullRequest,
1333 branch: details.branch ?? pullRequest.headBranch,
1334 worktreePath: null,
1335 };
1336 }
10391337
1040 const pullRequestWithRemoteInfo = {1338 const ensureExistingWorktreeUpstream = Effect.fn("ensureExistingWorktreeUpstream")(function* (
1041 ...pullRequest,1339 worktreePath: string,
1042 ...toPullRequestHeadRemoteInfo(pullRequestSummary),1340 ) {
1043 } as const;1341 const details = yield* gitCore.statusDetails(worktreePath);
1044 const localPullRequestBranch =1342 yield* configurePullRequestHeadUpstream(
1045 resolvePullRequestWorktreeLocalBranchName(pullRequestWithRemoteInfo);1343 worktreePath,
10461344 {
1047 const findLocalHeadBranch = (cwd: string) =>1345 ...pullRequest,
1048 gitCore.listBranches({ cwd }).pipe(1346 ...toPullRequestHeadRemoteInfo(pullRequestSummary),
1049 Effect.map((result) => {1347 },
1050 const localBranch = result.branches.find(1348 details.branch ?? pullRequest.headBranch,
1051 (branch) => !branch.isRemote && branch.name === localPullRequestBranch,1349 );
1052 );1350 });
1053 if (localBranch) {
1054 return localBranch;
1055 }
1056 if (localPullRequestBranch === pullRequest.headBranch) {
1057 return null;
1058 }
1059 return (
1060 result.branches.find(
1061 (branch) =>
1062 !branch.isRemote &&
1063 branch.name === pullRequest.headBranch &&
1064 branch.worktreePath !== null &&
1065 canonicalizeExistingPath(branch.worktreePath) !== rootWorktreePath,
1066 ) ?? null
1067 );
1068 }),
1069 );
10701351
1071 const existingBranchBeforeFetch = yield* findLocalHeadBranch(input.cwd);1352 const pullRequestWithRemoteInfo = {
1072 const existingBranchBeforeFetchPath = existingBranchBeforeFetch?.worktreePath1353 ...pullRequest,
1073 ? canonicalizeExistingPath(existingBranchBeforeFetch.worktreePath)1354 ...toPullRequestHeadRemoteInfo(pullRequestSummary),
1074 : null;1355 } as const;
1075 if (1356 const localPullRequestBranch =
1076 existingBranchBeforeFetch?.worktreePath &&1357 resolvePullRequestWorktreeLocalBranchName(pullRequestWithRemoteInfo);
1077 existingBranchBeforeFetchPath !== rootWorktreePath1358
1078 ) {1359 const findLocalHeadBranch = (cwd: string) =>
1079 yield* ensureExistingWorktreeUpstream(existingBranchBeforeFetch.worktreePath);1360 gitCore.listBranches({ cwd }).pipe(
1080 return {1361 Effect.map((result) => {
1081 pullRequest,1362 const localBranch = result.branches.find(
1082 branch: localPullRequestBranch,1363 (branch) => !branch.isRemote && branch.name === localPullRequestBranch,
1083 worktreePath: existingBranchBeforeFetch.worktreePath,1364 );
1084 };1365 if (localBranch) {
1085 }1366 return localBranch;
1086 if (existingBranchBeforeFetchPath === rootWorktreePath) {1367 }
1087 return yield* gitManagerError(1368 if (localPullRequestBranch === pullRequest.headBranch) {
1088 "preparePullRequestThread",1369 return null;
1089 "This PR branch is already checked out in the main repo. Use Local, or switch the main repo off that branch before creating a worktree thread.",1370 }
1371 return (
1372 result.branches.find(
1373 (branch) =>
1374 !branch.isRemote &&
1375 branch.name === pullRequest.headBranch &&
1376 branch.worktreePath !== null &&
1377 canonicalizeExistingPath(branch.worktreePath) !== rootWorktreePath,
1378 ) ?? null
1379 );
1380 }),
1381 );
1382
1383 const existingBranchBeforeFetch = yield* findLocalHeadBranch(input.cwd);
1384 const existingBranchBeforeFetchPath = existingBranchBeforeFetch?.worktreePath
1385 ? canonicalizeExistingPath(existingBranchBeforeFetch.worktreePath)
1386 : null;
1387 if (
1388 existingBranchBeforeFetch?.worktreePath &&
1389 existingBranchBeforeFetchPath !== rootWorktreePath
1390 ) {
1391 yield* ensureExistingWorktreeUpstream(existingBranchBeforeFetch.worktreePath);
1392 return {
1393 pullRequest,
1394 branch: localPullRequestBranch,
1395 worktreePath: existingBranchBeforeFetch.worktreePath,
1396 };
1397 }
1398 if (existingBranchBeforeFetchPath === rootWorktreePath) {
1399 return yield* gitManagerError(
1400 "preparePullRequestThread",
1401 "This PR branch is already checked out in the main repo. Use Local, or switch the main repo off that branch before creating a worktree thread.",
1402 );
1403 }
1404
1405 yield* materializePullRequestHeadBranch(
1406 input.cwd,
1407 pullRequestWithRemoteInfo,
1408 localPullRequestBranch,
1090 );1409 );
1091 }
10921410
1093 yield* materializePullRequestHeadBranch(1411 const existingBranchAfterFetch = yield* findLocalHeadBranch(input.cwd);
1094 input.cwd,1412 const existingBranchAfterFetchPath = existingBranchAfterFetch?.worktreePath
1095 pullRequestWithRemoteInfo,1413 ? canonicalizeExistingPath(existingBranchAfterFetch.worktreePath)
1096 localPullRequestBranch,1414 : null;
1097 );1415 if (
1416 existingBranchAfterFetch?.worktreePath &&
1417 existingBranchAfterFetchPath !== rootWorktreePath
1418 ) {
1419 yield* ensureExistingWorktreeUpstream(existingBranchAfterFetch.worktreePath);
1420 return {
1421 pullRequest,
1422 branch: localPullRequestBranch,
1423 worktreePath: existingBranchAfterFetch.worktreePath,
1424 };
1425 }
1426 if (existingBranchAfterFetchPath === rootWorktreePath) {
1427 return yield* gitManagerError(
1428 "preparePullRequestThread",
1429 "This PR branch is already checked out in the main repo. Use Local, or switch the main repo off that branch before creating a worktree thread.",
1430 );
1431 }
1432
1433 const worktree = yield* gitCore.createWorktree({
1434 cwd: input.cwd,
1435 branch: localPullRequestBranch,
1436 path: null,
1437 });
1438 yield* ensureExistingWorktreeUpstream(worktree.worktree.path);
10981439
1099 const existingBranchAfterFetch = yield* findLocalHeadBranch(input.cwd);
1100 const existingBranchAfterFetchPath = existingBranchAfterFetch?.worktreePath
1101 ? canonicalizeExistingPath(existingBranchAfterFetch.worktreePath)
1102 : null;
1103 if (
1104 existingBranchAfterFetch?.worktreePath &&
1105 existingBranchAfterFetchPath !== rootWorktreePath
1106 ) {
1107 yield* ensureExistingWorktreeUpstream(existingBranchAfterFetch.worktreePath);
1108 return {1440 return {
1109 pullRequest,1441 pullRequest,
1110 branch: localPullRequestBranch,1442 branch: worktree.worktree.branch,
1111 worktreePath: existingBranchAfterFetch.worktreePath,1443 worktreePath: worktree.worktree.path,
1112 };1444 };
1113 }1445 }).pipe(Effect.ensuring(invalidateStatusResultCache(input.cwd)));
1114 if (existingBranchAfterFetchPath === rootWorktreePath) {
1115 return yield* gitManagerError(
1116 "preparePullRequestThread",
1117 "This PR branch is already checked out in the main repo. Use Local, or switch the main repo off that branch before creating a worktree thread.",
1118 );
1119 }
1120
1121 const worktree = yield* gitCore.createWorktree({
1122 cwd: input.cwd,
1123 branch: localPullRequestBranch,
1124 path: null,
1125 });
1126 yield* ensureExistingWorktreeUpstream(worktree.worktree.path);
1127
1128 return {
1129 pullRequest,
1130 branch: worktree.worktree.branch,
1131 worktreePath: worktree.worktree.path,
1132 };
1133 });1446 });
11341447
1135 const runFeatureBranchStep = Effect.fn("runFeatureBranchStep")(function* (1448 const runFeatureBranchStep = Effect.fn("runFeatureBranchStep")(function* (
...@@ -1171,27 +1484,53 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {...@@ -1171,27 +1484,53 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
1171 const runStackedAction: GitManagerShape["runStackedAction"] = Effect.fn("runStackedAction")(1484 const runStackedAction: GitManagerShape["runStackedAction"] = Effect.fn("runStackedAction")(
1172 function* (input, options) {1485 function* (input, options) {
1173 const progress = createProgressEmitter(input, options);1486 const progress = createProgressEmitter(input, options);
1174 const phases: GitActionProgressPhase[] = [
1175 ...(input.featureBranch ? (["branch"] as const) : []),
1176 "commit",
1177 ...(input.action !== "commit" ? (["push"] as const) : []),
1178 ...(input.action === "commit_push_pr" ? (["pr"] as const) : []),
1179 ];
1180 const currentPhase = yield* Ref.make<Option.Option<GitActionProgressPhase>>(Option.none());1487 const currentPhase = yield* Ref.make<Option.Option<GitActionProgressPhase>>(Option.none());
11811488
1182 const runAction = Effect.fn("runStackedAction.runAction")(function* (): Effect.fn.Return<1489 const runAction = Effect.fn("runStackedAction.runAction")(function* (): Effect.fn.Return<
1183 GitRunStackedActionResult,1490 GitRunStackedActionResult,
1184 GitManagerServiceError1491 GitManagerServiceError
1185 > {1492 > {
1493 const initialStatus = yield* gitCore.statusDetails(input.cwd);
1494 const wantsCommit = isCommitAction(input.action);
1495 const wantsPush =
1496 input.action === "push" ||
1497 input.action === "commit_push" ||
1498 input.action === "commit_push_pr" ||
1499 (input.action === "create_pr" &&
1500 (!initialStatus.hasUpstream || initialStatus.aheadCount > 0));
1501 const wantsPr = input.action === "create_pr" || input.action === "commit_push_pr";
1502
1503 if (input.featureBranch && !wantsCommit) {
1504 return yield* gitManagerError(
1505 "runStackedAction",
1506 "Feature-branch checkout is only supported for commit actions.",
1507 );
1508 }
1509 if (input.action === "push" && initialStatus.hasWorkingTreeChanges) {
1510 return yield* gitManagerError(
1511 "runStackedAction",
1512 "Commit or stash local changes before pushing.",
1513 );
1514 }
1515 if (input.action === "create_pr" && initialStatus.hasWorkingTreeChanges) {
1516 return yield* gitManagerError(
1517 "runStackedAction",
1518 "Commit local changes before creating a PR.",
1519 );
1520 }
1521
1522 const phases: GitActionProgressPhase[] = [
1523 ...(input.featureBranch ? (["branch"] as const) : []),
1524 ...(wantsCommit ? (["commit"] as const) : []),
1525 ...(wantsPush ? (["push"] as const) : []),
1526 ...(wantsPr ? (["pr"] as const) : []),
1527 ];
1528
1186 yield* progress.emit({1529 yield* progress.emit({
1187 kind: "action_started",1530 kind: "action_started",
1188 phases,1531 phases,
1189 });1532 });
11901533
1191 const wantsPush = input.action !== "commit";
1192 const wantsPr = input.action === "commit_push_pr";
1193
1194 const initialStatus = yield* gitCore.statusDetails(input.cwd);
1195 if (!input.featureBranch && wantsPush && !initialStatus.branch) {1534 if (!input.featureBranch && wantsPush && !initialStatus.branch) {
1196 return yield* gitManagerError("runStackedAction", "Cannot push from detached HEAD.");1535 return yield* gitManagerError("runStackedAction", "Cannot push from detached HEAD.");
1197 }1536 }
...@@ -1235,19 +1574,25 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {...@@ -1235,19 +1574,25 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
1235 }1574 }
12361575
1237 const currentBranch = branchStep.name ?? initialStatus.branch;1576 const currentBranch = branchStep.name ?? initialStatus.branch;
12381577 const commitAction = isCommitAction(input.action) ? input.action : null;
1239 yield* Ref.set(currentPhase, Option.some("commit"));1578
1240 const commit = yield* runCommitStep(1579 const commit = commitAction
1241 modelSelection,1580 ? yield* Ref.set(currentPhase, Option.some("commit")).pipe(
1242 input.cwd,1581 Effect.flatMap(() =>
1243 input.action,1582 runCommitStep(
1244 currentBranch,1583 modelSelection,
1245 commitMessageForStep,1584 input.cwd,
1246 preResolvedCommitSuggestion,1585 commitAction,
1247 input.filePaths,1586 currentBranch,
1248 options?.progressReporter,1587 commitMessageForStep,
1249 progress.actionId,1588 preResolvedCommitSuggestion,
1250 );1589 input.filePaths,
1590 options?.progressReporter,
1591 progress.actionId,
1592 ),
1593 ),
1594 )
1595 : { status: "skipped_not_requested" as const };
12511596
1252 const push = wantsPush1597 const push = wantsPush
1253 ? yield* progress1598 ? yield* progress
...@@ -1275,12 +1620,21 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {...@@ -1275,12 +1620,21 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
1275 )1620 )
1276 : { status: "skipped_not_requested" as const };1621 : { status: "skipped_not_requested" as const };
12771622
1623 const toast = yield* buildCompletionToast(input.cwd, {
1624 action: input.action,
1625 branch: branchStep,
1626 commit,
1627 push,
1628 pr,
1629 });
1630
1278 const result = {1631 const result = {
1279 action: input.action,1632 action: input.action,
1280 branch: branchStep,1633 branch: branchStep,
1281 commit,1634 commit,
1282 push,1635 push,
1283 pr,1636 pr,
1637 toast,
1284 };1638 };
1285 yield* progress.emit({1639 yield* progress.emit({
1286 kind: "action_finished",1640 kind: "action_finished",
...@@ -1290,6 +1644,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {...@@ -1290,6 +1644,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
1290 });1644 });
12911645
1292 return yield* runAction().pipe(1646 return yield* runAction().pipe(
1647 Effect.ensuring(invalidateStatusResultCache(input.cwd)),
1293 Effect.tapError((error) =>1648 Effect.tapError((error) =>
1294 Effect.flatMap(Ref.get(currentPhase), (phase) =>1649 Effect.flatMap(Ref.get(currentPhase), (phase) =>
1295 progress.emit({1650 progress.emit({
apps/server/src/git/Services/GitManager.ts+1-1
...@@ -56,7 +56,7 @@ export interface GitManagerShape {...@@ -56,7 +56,7 @@ export interface GitManagerShape {
56 ) => Effect.Effect<GitPreparePullRequestThreadResult, GitManagerServiceError>;56 ) => Effect.Effect<GitPreparePullRequestThreadResult, GitManagerServiceError>;
5757
58 /**58 /**
59 * Run a stacked Git action (`commit`, `commit_push`, `commit_push_pr`).59 * Run a Git action (`commit`, `push`, `create_pr`, `commit_push`, `commit_push_pr`).
60 * When `featureBranch` is set, creates and checks out a feature branch first.60 * When `featureBranch` is set, creates and checks out a feature branch first.
61 */61 */
62 readonly runStackedAction: (62 readonly runStackedAction: (
apps/server/src/git/remoteRefs.ts created+67
...@@ -0,0 +1,67 @@
1export function parseRemoteNamesInGitOrder(stdout: string): ReadonlyArray<string> {
2 return stdout
3 .split("\n")
4 .map((line) => line.trim())
5 .filter((line) => line.length > 0);
6}
7
8export function parseRemoteNames(stdout: string): ReadonlyArray<string> {
9 return parseRemoteNamesInGitOrder(stdout).toSorted((a, b) => b.length - a.length);
10}
11
12export function parseRemoteRefWithRemoteNames(
13 ref: string,
14 remoteNames: ReadonlyArray<string>,
15): { remoteRef: string; remoteName: string; branchName: string } | null {
16 const trimmedRef = ref.trim();
17 if (trimmedRef.length === 0) {
18 return null;
19 }
20
21 for (const remoteName of remoteNames) {
22 const remotePrefix = `${remoteName}/`;
23 if (!trimmedRef.startsWith(remotePrefix)) {
24 continue;
25 }
26 const branchName = trimmedRef.slice(remotePrefix.length).trim();
27 if (branchName.length === 0) {
28 return null;
29 }
30 return {
31 remoteRef: trimmedRef,
32 remoteName,
33 branchName,
34 };
35 }
36
37 return null;
38}
39
40export function extractBranchNameFromRemoteRef(
41 ref: string,
42 options?: {
43 remoteName?: string | null;
44 remoteNames?: ReadonlyArray<string>;
45 },
46): string {
47 const normalized = ref.trim();
48 if (normalized.length === 0) {
49 return "";
50 }
51
52 if (normalized.startsWith("refs/remotes/")) {
53 return extractBranchNameFromRemoteRef(normalized.slice("refs/remotes/".length), options);
54 }
55
56 const remoteNames = options?.remoteName ? [options.remoteName] : (options?.remoteNames ?? []);
57 const parsedRemoteRef = parseRemoteRefWithRemoteNames(normalized, remoteNames);
58 if (parsedRemoteRef) {
59 return parsedRemoteRef.branchName;
60 }
61
62 const firstSlash = normalized.indexOf("/");
63 if (firstSlash === -1) {
64 return normalized;
65 }
66 return normalized.slice(firstSlash + 1).trim();
67}
apps/server/src/server.test.ts+11
...@@ -842,6 +842,17 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -842,6 +842,17 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
842 },842 },
843 push: { status: "skipped_not_requested" as const },843 push: { status: "skipped_not_requested" as const },
844 pr: { status: "skipped_not_requested" as const },844 pr: { status: "skipped_not_requested" as const },
845 toast: {
846 title: "Committed abc123",
847 description: "feat: demo",
848 cta: {
849 kind: "run_action" as const,
850 label: "Push",
851 action: {
852 kind: "push" as const,
853 },
854 },
855 },
845 };856 };
846857
847 yield* (858 yield* (
apps/web/src/components/GitActionsControl.logic.test.ts+86-78
...@@ -6,8 +6,9 @@ import {...@@ -6,8 +6,9 @@ import {
6 requiresDefaultBranchConfirmation,6 requiresDefaultBranchConfirmation,
7 resolveAutoFeatureBranchName,7 resolveAutoFeatureBranchName,
8 resolveDefaultBranchActionDialogCopy,8 resolveDefaultBranchActionDialogCopy,
9 resolveLiveThreadBranchUpdate,
9 resolveQuickAction,10 resolveQuickAction,
10 summarizeGitResult,11 resolveThreadBranchUpdate,
11} from "./GitActionsControl.logic";12} from "./GitActionsControl.logic";
1213
13function status(overrides: Partial<GitStatusResult> = {}): GitStatusResult {14function status(overrides: Partial<GitStatusResult> = {}): GitStatusResult {
...@@ -162,7 +163,7 @@ describe("when: branch is clean, ahead, and has an open PR", () => {...@@ -162,7 +163,7 @@ describe("when: branch is clean, ahead, and has an open PR", () => {
162 }),163 }),
163 false,164 false,
164 );165 );
165 assert.deepInclude(quick, { kind: "run_action", action: "commit_push", label: "Push" });166 assert.deepInclude(quick, { kind: "run_action", action: "push", label: "Push" });
166 });167 });
167168
168 it("buildMenuItems enables push and keeps open PR available", () => {169 it("buildMenuItems enables push and keeps open PR available", () => {
...@@ -213,7 +214,7 @@ describe("when: branch is clean, ahead, and has no open PR", () => {...@@ -213,7 +214,7 @@ describe("when: branch is clean, ahead, and has no open PR", () => {
213 const quick = resolveQuickAction(status({ aheadCount: 2, pr: null }), false);214 const quick = resolveQuickAction(status({ aheadCount: 2, pr: null }), false);
214 assert.deepInclude(quick, {215 assert.deepInclude(quick, {
215 kind: "run_action",216 kind: "run_action",
216 action: "commit_push_pr",217 action: "create_pr",
217 label: "Push & create PR",218 label: "Push & create PR",
218 });219 });
219 });220 });
...@@ -585,7 +586,7 @@ describe("when: branch has no upstream configured", () => {...@@ -585,7 +586,7 @@ describe("when: branch has no upstream configured", () => {
585 );586 );
586 assert.deepInclude(quick, {587 assert.deepInclude(quick, {
587 kind: "run_action",588 kind: "run_action",
588 action: "commit_push",589 action: "push",
589 label: "Push",590 label: "Push",
590 disabled: false,591 disabled: false,
591 });592 });
...@@ -632,7 +633,7 @@ describe("when: branch has no upstream configured", () => {...@@ -632,7 +633,7 @@ describe("when: branch has no upstream configured", () => {
632 );633 );
633 assert.deepInclude(quick, {634 assert.deepInclude(quick, {
634 kind: "run_action",635 kind: "run_action",
635 action: "commit_push_pr",636 action: "create_pr",
636 label: "Push & create PR",637 label: "Push & create PR",
637 disabled: false,638 disabled: false,
638 });639 });
...@@ -801,9 +802,12 @@ describe("when: branch has no upstream configured", () => {...@@ -801,9 +802,12 @@ describe("when: branch has no upstream configured", () => {
801describe("requiresDefaultBranchConfirmation", () => {802describe("requiresDefaultBranchConfirmation", () => {
802 it("requires confirmation for push actions on default branch", () => {803 it("requires confirmation for push actions on default branch", () => {
803 assert.isFalse(requiresDefaultBranchConfirmation("commit", true));804 assert.isFalse(requiresDefaultBranchConfirmation("commit", true));
805 assert.isTrue(requiresDefaultBranchConfirmation("push", true));
806 assert.isTrue(requiresDefaultBranchConfirmation("create_pr", true));
804 assert.isTrue(requiresDefaultBranchConfirmation("commit_push", true));807 assert.isTrue(requiresDefaultBranchConfirmation("commit_push", true));
805 assert.isTrue(requiresDefaultBranchConfirmation("commit_push_pr", true));808 assert.isTrue(requiresDefaultBranchConfirmation("commit_push_pr", true));
806 assert.isFalse(requiresDefaultBranchConfirmation("commit_push", false));809 assert.isFalse(requiresDefaultBranchConfirmation("commit_push", false));
810 assert.isFalse(requiresDefaultBranchConfirmation("push", false));
807 });811 });
808});812});
809813
...@@ -855,28 +859,37 @@ describe("resolveDefaultBranchActionDialogCopy", () => {...@@ -855,28 +859,37 @@ describe("resolveDefaultBranchActionDialogCopy", () => {
855});859});
856860
857describe("buildGitActionProgressStages", () => {861describe("buildGitActionProgressStages", () => {
858 it("shows only push progress when push-only is forced", () => {862 it("shows only push progress for explicit push actions", () => {
859 const stages = buildGitActionProgressStages({863 const stages = buildGitActionProgressStages({
860 action: "commit_push",864 action: "push",
861 hasCustomCommitMessage: false,865 hasCustomCommitMessage: false,
862 hasWorkingTreeChanges: true,866 hasWorkingTreeChanges: false,
863 forcePushOnly: true,
864 pushTarget: "origin/feature/test",867 pushTarget: "origin/feature/test",
865 });868 });
866 assert.deepEqual(stages, ["Pushing to origin/feature/test..."]);869 assert.deepEqual(stages, ["Pushing to origin/feature/test..."]);
867 });870 });
868871
869 it("skips commit stages for create-pr flow when push-only is forced", () => {872 it("shows push and PR progress for create-pr actions that still need a push", () => {
870 const stages = buildGitActionProgressStages({873 const stages = buildGitActionProgressStages({
871 action: "commit_push_pr",874 action: "create_pr",
872 hasCustomCommitMessage: false,875 hasCustomCommitMessage: false,
873 hasWorkingTreeChanges: true,876 hasWorkingTreeChanges: false,
874 forcePushOnly: true,
875 pushTarget: "origin/feature/test",877 pushTarget: "origin/feature/test",
878 shouldPushBeforePr: true,
876 });879 });
877 assert.deepEqual(stages, ["Pushing to origin/feature/test...", "Creating PR..."]);880 assert.deepEqual(stages, ["Pushing to origin/feature/test...", "Creating PR..."]);
878 });881 });
879882
883 it("shows only PR progress when create-pr can skip the push", () => {
884 const stages = buildGitActionProgressStages({
885 action: "create_pr",
886 hasCustomCommitMessage: false,
887 hasWorkingTreeChanges: false,
888 shouldPushBeforePr: false,
889 });
890 assert.deepEqual(stages, ["Creating PR..."]);
891 });
892
880 it("includes commit stages for commit+push when working tree is dirty", () => {893 it("includes commit stages for commit+push when working tree is dirty", () => {
881 const stages = buildGitActionProgressStages({894 const stages = buildGitActionProgressStages({
882 action: "commit_push",895 action: "commit_push",
...@@ -892,97 +905,92 @@ describe("buildGitActionProgressStages", () => {...@@ -892,97 +905,92 @@ describe("buildGitActionProgressStages", () => {
892 });905 });
893});906});
894907
895describe("summarizeGitResult", () => {908describe("resolveThreadBranchUpdate", () => {
896 it("returns commit-focused toast for commit action", () => {909 it("returns a branch update when the action created a new branch", () => {
897 const result = summarizeGitResult({910 const update = resolveThreadBranchUpdate({
898 action: "commit",911 action: "commit_push_pr",
899 branch: { status: "skipped_not_requested" },912 branch: {
913 status: "created",
914 name: "feature/fix-toast-copy",
915 },
900 commit: {916 commit: {
901 status: "created",917 status: "created",
902 commitSha: "0123456789abcdef",918 commitSha: "89abcdef01234567",
903 subject: "feat: add optimistic UI for git action button",919 subject: "feat: add branch sync",
904 },920 },
905 push: { status: "skipped_not_requested" },921 push: { status: "pushed", branch: "feature/fix-toast-copy" },
906 pr: { status: "skipped_not_requested" },922 pr: { status: "skipped_not_requested" },
923 toast: {
924 title: "Pushed 89abcde to origin/feature/fix-toast-copy",
925 cta: { kind: "none" },
926 },
907 });927 });
908928
909 assert.deepEqual(result, {929 assert.deepEqual(update, {
910 title: "Committed 0123456",930 branch: "feature/fix-toast-copy",
911 description: "feat: add optimistic UI for git action button",
912 });931 });
913 });932 });
914933
915 it("returns push-focused toast for push action", () => {934 it("returns null when the action stayed on the existing branch", () => {
916 const result = summarizeGitResult({935 const update = resolveThreadBranchUpdate({
917 action: "commit_push",936 action: "commit_push",
918 branch: { status: "skipped_not_requested" },937 branch: {
938 status: "skipped_not_requested",
939 },
919 commit: {940 commit: {
920 status: "created",941 status: "created",
921 commitSha: "abcdef0123456789",942 commitSha: "89abcdef01234567",
922 subject: "fix: tighten quick action tooltip hover handling",943 subject: "feat: add branch sync",
923 },
924 push: {
925 status: "pushed",
926 branch: "foo",
927 upstreamBranch: "origin/foo",
928 },944 },
945 push: { status: "pushed", branch: "feature/fix-toast-copy" },
929 pr: { status: "skipped_not_requested" },946 pr: { status: "skipped_not_requested" },
947 toast: {
948 title: "Pushed 89abcde to origin/feature/fix-toast-copy",
949 cta: { kind: "none" },
950 },
930 });951 });
931952
932 assert.deepEqual(result, {953 assert.equal(update, null);
933 title: "Pushed abcdef0 to origin/foo",
934 description: "fix: tighten quick action tooltip hover handling",
935 });
936 });954 });
955});
937956
938 it("returns PR-focused toast for created PR action", () => {957describe("resolveLiveThreadBranchUpdate", () => {
939 const result = summarizeGitResult({958 it("returns a branch update when live git status differs from stored thread metadata", () => {
940 action: "commit_push_pr",959 const update = resolveLiveThreadBranchUpdate({
941 branch: { status: "skipped_not_requested" },960 threadBranch: "feature/old-branch",
942 commit: {961 gitStatus: status({ branch: "effect-atom" }),
943 status: "created",
944 commitSha: "89abcdef01234567",
945 subject: "feat: ship github shortcuts",
946 },
947 push: {
948 status: "pushed",
949 branch: "foo",
950 },
951 pr: {
952 status: "created",
953 number: 42,
954 title: "feat: ship github shortcuts and improve PR CTA in success toast",
955 },
956 });962 });
957963
958 assert.deepEqual(result, {964 assert.deepEqual(update, {
959 title: "Created PR #42",965 branch: "effect-atom",
960 description: "feat: ship github shortcuts and improve PR CTA in success toast",
961 });966 });
962 });967 });
963968
964 it("truncates long description text", () => {969 it("returns null when live git status is unavailable", () => {
965 const result = summarizeGitResult({970 const update = resolveLiveThreadBranchUpdate({
966 action: "commit_push_pr",971 threadBranch: "feature/old-branch",
967 branch: { status: "skipped_not_requested" },972 gitStatus: null,
968 commit: {
969 status: "created",
970 commitSha: "89abcdef01234567",
971 subject: "short subject",
972 },
973 push: { status: "pushed", branch: "foo" },
974 pr: {
975 status: "created",
976 number: 99,
977 title:
978 "feat: this title is intentionally extremely long so we can validate that toast descriptions are truncated with an ellipsis suffix",
979 },
980 });973 });
981974
982 assert.deepEqual(result, {975 assert.equal(update, null);
983 title: "Created PR #99",976 });
984 description: "feat: this title is intentionally extremely long so we can validate t...",977
978 it("returns null when the stored thread branch already matches git status", () => {
979 const update = resolveLiveThreadBranchUpdate({
980 threadBranch: "effect-atom",
981 gitStatus: status({ branch: "effect-atom" }),
985 });982 });
983
984 assert.equal(update, null);
985 });
986
987 it("returns null when git status is detached HEAD but the thread already has a branch", () => {
988 const update = resolveLiveThreadBranchUpdate({
989 threadBranch: "effect-atom",
990 gitStatus: status({ branch: null }),
991 });
992
993 assert.equal(update, null);
986 });994 });
987});995});
988996
apps/web/src/components/GitActionsControl.logic.ts+70-62
...@@ -31,43 +31,36 @@ export interface DefaultBranchActionDialogCopy {...@@ -31,43 +31,36 @@ export interface DefaultBranchActionDialogCopy {
31 continueLabel: string;31 continueLabel: string;
32}32}
3333
34export type DefaultBranchConfirmableAction = "commit_push" | "commit_push_pr";34export type DefaultBranchConfirmableAction =
3535 | "push"
36const SHORT_SHA_LENGTH = 7;36 | "create_pr"
37const TOAST_DESCRIPTION_MAX = 72;37 | "commit_push"
3838 | "commit_push_pr";
39function shortenSha(sha: string | undefined): string | null {
40 if (!sha) return null;
41 return sha.slice(0, SHORT_SHA_LENGTH);
42}
43
44function truncateText(
45 value: string | undefined,
46 maxLength = TOAST_DESCRIPTION_MAX,
47): string | undefined {
48 if (!value) return undefined;
49 if (value.length <= maxLength) return value;
50 if (maxLength <= 3) return "...".slice(0, maxLength);
51 return `${value.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`;
52}
5339
54export function buildGitActionProgressStages(input: {40export function buildGitActionProgressStages(input: {
55 action: GitStackedAction;41 action: GitStackedAction;
56 hasCustomCommitMessage: boolean;42 hasCustomCommitMessage: boolean;
57 hasWorkingTreeChanges: boolean;43 hasWorkingTreeChanges: boolean;
58 forcePushOnly?: boolean;
59 pushTarget?: string;44 pushTarget?: string;
60 featureBranch?: boolean;45 featureBranch?: boolean;
46 shouldPushBeforePr?: boolean;
61}): string[] {47}): string[] {
62 const branchStages = input.featureBranch ? ["Preparing feature branch..."] : [];48 const branchStages = input.featureBranch ? ["Preparing feature branch..."] : [];
63 const shouldIncludeCommitStages =49 const pushStage = input.pushTarget ? `Pushing to ${input.pushTarget}...` : "Pushing...";
64 !input.forcePushOnly && (input.action === "commit" || input.hasWorkingTreeChanges);50
51 if (input.action === "push") {
52 return [pushStage];
53 }
54 if (input.action === "create_pr") {
55 return input.shouldPushBeforePr ? [pushStage, "Creating PR..."] : ["Creating PR..."];
56 }
57
58 const shouldIncludeCommitStages = input.action === "commit" || input.hasWorkingTreeChanges;
65 const commitStages = !shouldIncludeCommitStages59 const commitStages = !shouldIncludeCommitStages
66 ? []60 ? []
67 : input.hasCustomCommitMessage61 : input.hasCustomCommitMessage
68 ? ["Committing..."]62 ? ["Committing..."]
69 : ["Generating commit message...", "Committing..."];63 : ["Generating commit message...", "Committing..."];
70 const pushStage = input.pushTarget ? `Pushing to ${input.pushTarget}...` : "Pushing...";
71 if (input.action === "commit") {64 if (input.action === "commit") {
72 return [...branchStages, ...commitStages];65 return [...branchStages, ...commitStages];
73 }66 }
...@@ -77,39 +70,6 @@ export function buildGitActionProgressStages(input: {...@@ -77,39 +70,6 @@ export function buildGitActionProgressStages(input: {
77 return [...branchStages, ...commitStages, pushStage, "Creating PR..."];70 return [...branchStages, ...commitStages, pushStage, "Creating PR..."];
78}71}
7972
80const withDescription = (title: string, description: string | undefined) =>
81 description ? { title, description } : { title };
82
83export function summarizeGitResult(result: GitRunStackedActionResult): {
84 title: string;
85 description?: string;
86} {
87 if (result.pr.status === "created" || result.pr.status === "opened_existing") {
88 const prNumber = result.pr.number ? ` #${result.pr.number}` : "";
89 const title = `${result.pr.status === "created" ? "Created PR" : "Opened PR"}${prNumber}`;
90 return withDescription(title, truncateText(result.pr.title));
91 }
92
93 if (result.push.status === "pushed") {
94 const shortSha = shortenSha(result.commit.commitSha);
95 const branch = result.push.upstreamBranch ?? result.push.branch;
96 const pushedCommitPart = shortSha ? ` ${shortSha}` : "";
97 const branchPart = branch ? ` to ${branch}` : "";
98 return withDescription(
99 `Pushed${pushedCommitPart}${branchPart}`,
100 truncateText(result.commit.subject),
101 );
102 }
103
104 if (result.commit.status === "created") {
105 const shortSha = shortenSha(result.commit.commitSha);
106 const title = shortSha ? `Committed ${shortSha}` : "Committed changes";
107 return withDescription(title, truncateText(result.commit.subject));
108 }
109
110 return { title: "Done" };
111}
112
113export function buildMenuItems(73export function buildMenuItems(
114 gitStatus: GitStatusResult | null,74 gitStatus: GitStatusResult | null,
115 isBusy: boolean,75 isBusy: boolean,
...@@ -250,13 +210,18 @@ export function resolveQuickAction(...@@ -250,13 +210,18 @@ export function resolveQuickAction(
250 };210 };
251 }211 }
252 if (hasOpenPr || isDefaultBranch) {212 if (hasOpenPr || isDefaultBranch) {
253 return { label: "Push", disabled: false, kind: "run_action", action: "commit_push" };213 return {
214 label: "Push",
215 disabled: false,
216 kind: "run_action",
217 action: isDefaultBranch ? "commit_push" : "push",
218 };
254 }219 }
255 return {220 return {
256 label: "Push & create PR",221 label: "Push & create PR",
257 disabled: false,222 disabled: false,
258 kind: "run_action",223 kind: "run_action",
259 action: "commit_push_pr",224 action: "create_pr",
260 };225 };
261 }226 }
262227
...@@ -279,13 +244,18 @@ export function resolveQuickAction(...@@ -279,13 +244,18 @@ export function resolveQuickAction(
279244
280 if (isAhead) {245 if (isAhead) {
281 if (hasOpenPr || isDefaultBranch) {246 if (hasOpenPr || isDefaultBranch) {
282 return { label: "Push", disabled: false, kind: "run_action", action: "commit_push" };247 return {
248 label: "Push",
249 disabled: false,
250 kind: "run_action",
251 action: isDefaultBranch ? "commit_push" : "push",
252 };
283 }253 }
284 return {254 return {
285 label: "Push & create PR",255 label: "Push & create PR",
286 disabled: false,256 disabled: false,
287 kind: "run_action",257 kind: "run_action",
288 action: "commit_push_pr",258 action: "create_pr",
289 };259 };
290 }260 }
291261
...@@ -306,7 +276,12 @@ export function requiresDefaultBranchConfirmation(...@@ -306,7 +276,12 @@ export function requiresDefaultBranchConfirmation(
306 isDefaultBranch: boolean,276 isDefaultBranch: boolean,
307): boolean {277): boolean {
308 if (!isDefaultBranch) return false;278 if (!isDefaultBranch) return false;
309 return action === "commit_push" || action === "commit_push_pr";279 return (
280 action === "push" ||
281 action === "create_pr" ||
282 action === "commit_push" ||
283 action === "commit_push_pr"
284 );
310}285}
311286
312export function resolveDefaultBranchActionDialogCopy(input: {287export function resolveDefaultBranchActionDialogCopy(input: {
...@@ -317,7 +292,7 @@ export function resolveDefaultBranchActionDialogCopy(input: {...@@ -317,7 +292,7 @@ export function resolveDefaultBranchActionDialogCopy(input: {
317 const branchLabel = input.branchName;292 const branchLabel = input.branchName;
318 const suffix = ` on "${branchLabel}". You can continue on this branch or create a feature branch and run the same action there.`;293 const suffix = ` on "${branchLabel}". You can continue on this branch or create a feature branch and run the same action there.`;
319294
320 if (input.action === "commit_push") {295 if (input.action === "push" || input.action === "commit_push") {
321 if (input.includesCommit) {296 if (input.includesCommit) {
322 return {297 return {
323 title: "Commit & push to default branch?",298 title: "Commit & push to default branch?",
...@@ -346,5 +321,38 @@ export function resolveDefaultBranchActionDialogCopy(input: {...@@ -346,5 +321,38 @@ export function resolveDefaultBranchActionDialogCopy(input: {
346 };321 };
347}322}
348323
324export function resolveThreadBranchUpdate(
325 result: GitRunStackedActionResult,
326): { branch: string } | null {
327 if (result.branch.status !== "created" || !result.branch.name) {
328 return null;
329 }
330
331 return {
332 branch: result.branch.name,
333 };
334}
335
336export function resolveLiveThreadBranchUpdate(input: {
337 threadBranch: string | null;
338 gitStatus: GitStatusResult | null;
339}): { branch: string | null } | null {
340 if (!input.gitStatus) {
341 return null;
342 }
343
344 if (input.gitStatus.branch === null && input.threadBranch !== null) {
345 return null;
346 }
347
348 if (input.threadBranch === input.gitStatus.branch) {
349 return null;
350 }
351
352 return {
353 branch: input.gitStatus.branch,
354 };
355}
356
349// Re-export from shared for backwards compatibility in this module's exports357// Re-export from shared for backwards compatibility in this module's exports
350export { resolveAutoFeatureBranchName } from "@t3tools/shared/git";358export { resolveAutoFeatureBranchName } from "@t3tools/shared/git";
apps/web/src/components/GitActionsControl.tsx+120-84
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import type {1import type {
2 GitActionProgressEvent,2 GitActionProgressEvent,
3 GitRunStackedActionResult,
3 GitStackedAction,4 GitStackedAction,
4 GitStatusResult,5 GitStatusResult,
5 ThreadId,6 ThreadId,
...@@ -17,8 +18,9 @@ import {...@@ -17,8 +18,9 @@ import {
17 type DefaultBranchConfirmableAction,18 type DefaultBranchConfirmableAction,
18 requiresDefaultBranchConfirmation,19 requiresDefaultBranchConfirmation,
19 resolveDefaultBranchActionDialogCopy,20 resolveDefaultBranchActionDialogCopy,
21 resolveLiveThreadBranchUpdate,
20 resolveQuickAction,22 resolveQuickAction,
21 summarizeGitResult,23 resolveThreadBranchUpdate,
22} from "./GitActionsControl.logic";24} from "./GitActionsControl.logic";
23import { Button } from "~/components/ui/button";25import { Button } from "~/components/ui/button";
24import { Checkbox } from "~/components/ui/checkbox";26import { Checkbox } from "~/components/ui/checkbox";
...@@ -47,9 +49,10 @@ import {...@@ -47,9 +49,10 @@ import {
47 gitStatusQueryOptions,49 gitStatusQueryOptions,
48 invalidateGitQueries,50 invalidateGitQueries,
49} from "~/lib/gitReactQuery";51} from "~/lib/gitReactQuery";
50import { randomUUID } from "~/lib/utils";52import { newCommandId, randomUUID } from "~/lib/utils";
51import { resolvePathLinkTarget } from "~/terminal-links";53import { resolvePathLinkTarget } from "~/terminal-links";
52import { readNativeApi } from "~/nativeApi";54import { readNativeApi } from "~/nativeApi";
55import { useStore } from "~/store";
5356
54interface GitActionsControlProps {57interface GitActionsControlProps {
55 gitCwd: string | null;58 gitCwd: string | null;
...@@ -61,7 +64,6 @@ interface PendingDefaultBranchAction {...@@ -61,7 +64,6 @@ interface PendingDefaultBranchAction {
61 branchName: string;64 branchName: string;
62 includesCommit: boolean;65 includesCommit: boolean;
63 commitMessage?: string;66 commitMessage?: string;
64 forcePushOnlyProgress: boolean;
65 onConfirmed?: () => void;67 onConfirmed?: () => void;
66 filePaths?: string[];68 filePaths?: string[];
67}69}
...@@ -82,12 +84,10 @@ interface ActiveGitActionProgress {...@@ -82,12 +84,10 @@ interface ActiveGitActionProgress {
82interface RunGitActionWithToastInput {84interface RunGitActionWithToastInput {
83 action: GitStackedAction;85 action: GitStackedAction;
84 commitMessage?: string;86 commitMessage?: string;
85 forcePushOnlyProgress?: boolean;
86 onConfirmed?: () => void;87 onConfirmed?: () => void;
87 skipDefaultBranchPrompt?: boolean;88 skipDefaultBranchPrompt?: boolean;
88 statusOverride?: GitStatusResult | null;89 statusOverride?: GitStatusResult | null;
89 featureBranch?: boolean;90 featureBranch?: boolean;
90 isDefaultBranchOverride?: boolean;
91 progressToastId?: GitActionToastId;91 progressToastId?: GitActionToastId;
92 filePaths?: string[];92 filePaths?: string[];
93}93}
...@@ -196,7 +196,9 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) {...@@ -196,7 +196,9 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) {
196 if (quickAction.kind === "run_pull") return <InfoIcon className={iconClassName} />;196 if (quickAction.kind === "run_pull") return <InfoIcon className={iconClassName} />;
197 if (quickAction.kind === "run_action") {197 if (quickAction.kind === "run_action") {
198 if (quickAction.action === "commit") return <GitCommitIcon className={iconClassName} />;198 if (quickAction.action === "commit") return <GitCommitIcon className={iconClassName} />;
199 if (quickAction.action === "commit_push") return <CloudUploadIcon className={iconClassName} />;199 if (quickAction.action === "push" || quickAction.action === "commit_push") {
200 return <CloudUploadIcon className={iconClassName} />;
201 }
200 return <GitHubIcon className={iconClassName} />;202 return <GitHubIcon className={iconClassName} />;
201 }203 }
202 if (quickAction.label === "Commit") return <GitCommitIcon className={iconClassName} />;204 if (quickAction.label === "Commit") return <GitCommitIcon className={iconClassName} />;
...@@ -208,6 +210,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -208,6 +210,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
208 () => (activeThreadId ? { threadId: activeThreadId } : undefined),210 () => (activeThreadId ? { threadId: activeThreadId } : undefined),
209 [activeThreadId],211 [activeThreadId],
210 );212 );
213 const activeServerThread = useStore((store) =>
214 activeThreadId ? store.threads.find((thread) => thread.id === activeThreadId) : undefined,
215 );
216 const setThreadBranch = useStore((store) => store.setThreadBranch);
211 const queryClient = useQueryClient();217 const queryClient = useQueryClient();
212 const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false);218 const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false);
213 const [dialogCommitMessage, setDialogCommitMessage] = useState("");219 const [dialogCommitMessage, setDialogCommitMessage] = useState("");
...@@ -231,6 +237,43 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -231,6 +237,43 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
231 });237 });
232 }, [threadToastData]);238 }, [threadToastData]);
233239
240 const persistThreadBranchSync = useCallback(
241 (branch: string | null) => {
242 if (!activeThreadId || !activeServerThread || activeServerThread.branch === branch) {
243 return;
244 }
245
246 const worktreePath = activeServerThread.worktreePath;
247 const api = readNativeApi();
248 if (api) {
249 void api.orchestration
250 .dispatchCommand({
251 type: "thread.meta.update",
252 commandId: newCommandId(),
253 threadId: activeThreadId,
254 branch,
255 worktreePath,
256 })
257 .catch(() => undefined);
258 }
259
260 setThreadBranch(activeThreadId, branch, worktreePath);
261 },
262 [activeServerThread, activeThreadId, setThreadBranch],
263 );
264
265 const syncThreadBranchAfterGitAction = useCallback(
266 (result: GitRunStackedActionResult) => {
267 const branchUpdate = resolveThreadBranchUpdate(result);
268 if (!branchUpdate) {
269 return;
270 }
271
272 persistThreadBranchSync(branchUpdate.branch);
273 },
274 [persistThreadBranchSync],
275 );
276
234 const { data: gitStatus = null, error: gitStatusError } = useQuery(gitStatusQueryOptions(gitCwd));277 const { data: gitStatus = null, error: gitStatusError } = useQuery(gitStatusQueryOptions(gitCwd));
235278
236 const { data: branchList = null } = useQuery(gitBranchesQueryOptions(gitCwd));279 const { data: branchList = null } = useQuery(gitBranchesQueryOptions(gitCwd));
...@@ -267,6 +310,28 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -267,6 +310,28 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
267 useIsMutating({ mutationKey: gitMutationKeys.runStackedAction(gitCwd) }) > 0;310 useIsMutating({ mutationKey: gitMutationKeys.runStackedAction(gitCwd) }) > 0;
268 const isPullRunning = useIsMutating({ mutationKey: gitMutationKeys.pull(gitCwd) }) > 0;311 const isPullRunning = useIsMutating({ mutationKey: gitMutationKeys.pull(gitCwd) }) > 0;
269 const isGitActionRunning = isRunStackedActionRunning || isPullRunning;312 const isGitActionRunning = isRunStackedActionRunning || isPullRunning;
313
314 useEffect(() => {
315 if (isGitActionRunning) {
316 return;
317 }
318
319 const branchUpdate = resolveLiveThreadBranchUpdate({
320 threadBranch: activeServerThread?.branch ?? null,
321 gitStatus: gitStatusForActions,
322 });
323 if (!branchUpdate) {
324 return;
325 }
326
327 persistThreadBranchSync(branchUpdate.branch);
328 }, [
329 activeServerThread?.branch,
330 gitStatusForActions,
331 isGitActionRunning,
332 persistThreadBranchSync,
333 ]);
334
270 const isDefaultBranch = useMemo(() => {335 const isDefaultBranch = useMemo(() => {
271 const branchName = gitStatusForActions?.branch;336 const branchName = gitStatusForActions?.branch;
272 if (!branchName) return false;337 if (!branchName) return false;
...@@ -340,27 +405,32 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -340,27 +405,32 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
340 async ({405 async ({
341 action,406 action,
342 commitMessage,407 commitMessage,
343 forcePushOnlyProgress = false,
344 onConfirmed,408 onConfirmed,
345 skipDefaultBranchPrompt = false,409 skipDefaultBranchPrompt = false,
346 statusOverride,410 statusOverride,
347 featureBranch = false,411 featureBranch = false,
348 isDefaultBranchOverride,
349 progressToastId,412 progressToastId,
350 filePaths,413 filePaths,
351 }: RunGitActionWithToastInput) => {414 }: RunGitActionWithToastInput) => {
352 const actionStatus = statusOverride ?? gitStatusForActions;415 const actionStatus = statusOverride ?? gitStatusForActions;
353 const actionBranch = actionStatus?.branch ?? null;416 const actionBranch = actionStatus?.branch ?? null;
354 const actionIsDefaultBranch =417 const actionIsDefaultBranch = featureBranch ? false : isDefaultBranch;
355 isDefaultBranchOverride ?? (featureBranch ? false : isDefaultBranch);418 const actionCanCommit =
419 action === "commit" || action === "commit_push" || action === "commit_push_pr";
356 const includesCommit =420 const includesCommit =
357 !forcePushOnlyProgress && (action === "commit" || !!actionStatus?.hasWorkingTreeChanges);421 actionCanCommit &&
422 (action === "commit" || !!actionStatus?.hasWorkingTreeChanges || featureBranch);
358 if (423 if (
359 !skipDefaultBranchPrompt &&424 !skipDefaultBranchPrompt &&
360 requiresDefaultBranchConfirmation(action, actionIsDefaultBranch) &&425 requiresDefaultBranchConfirmation(action, actionIsDefaultBranch) &&
361 actionBranch426 actionBranch
362 ) {427 ) {
363 if (action !== "commit_push" && action !== "commit_push_pr") {428 if (
429 action !== "push" &&
430 action !== "create_pr" &&
431 action !== "commit_push" &&
432 action !== "commit_push_pr"
433 ) {
364 return;434 return;
365 }435 }
366 setPendingDefaultBranchAction({436 setPendingDefaultBranchAction({
...@@ -368,7 +438,6 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -368,7 +438,6 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
368 branchName: actionBranch,438 branchName: actionBranch,
369 includesCommit,439 includesCommit,
370 ...(commitMessage ? { commitMessage } : {}),440 ...(commitMessage ? { commitMessage } : {}),
371 forcePushOnlyProgress,
372 ...(onConfirmed ? { onConfirmed } : {}),441 ...(onConfirmed ? { onConfirmed } : {}),
373 ...(filePaths ? { filePaths } : {}),442 ...(filePaths ? { filePaths } : {}),
374 });443 });
...@@ -380,8 +449,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -380,8 +449,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
380 action,449 action,
381 hasCustomCommitMessage: !!commitMessage?.trim(),450 hasCustomCommitMessage: !!commitMessage?.trim(),
382 hasWorkingTreeChanges: !!actionStatus?.hasWorkingTreeChanges,451 hasWorkingTreeChanges: !!actionStatus?.hasWorkingTreeChanges,
383 forcePushOnly: forcePushOnlyProgress,
384 featureBranch,452 featureBranch,
453 shouldPushBeforePr:
454 action === "create_pr" &&
455 (!actionStatus?.hasUpstream || (actionStatus?.aheadCount ?? 0) > 0),
385 });456 });
386 const actionId = randomUUID();457 const actionId = randomUUID();
387 const resolvedProgressToastId =458 const resolvedProgressToastId =
...@@ -483,79 +554,48 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -483,79 +554,48 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
483 try {554 try {
484 const result = await promise;555 const result = await promise;
485 activeGitActionProgressRef.current = null;556 activeGitActionProgressRef.current = null;
486 const resultToast = summarizeGitResult(result);557 syncThreadBranchAfterGitAction(result);
487
488 const existingOpenPrUrl =
489 actionStatus?.pr?.state === "open" ? actionStatus.pr.url : undefined;
490 const prUrl = result.pr.url ?? existingOpenPrUrl;
491 const shouldOfferPushCta = action === "commit" && result.commit.status === "created";
492 const shouldOfferOpenPrCta =
493 (action === "commit_push" || action === "commit_push_pr") &&
494 !!prUrl &&
495 (!actionIsDefaultBranch ||
496 result.pr.status === "created" ||
497 result.pr.status === "opened_existing");
498 const shouldOfferCreatePrCta =
499 action === "commit_push" &&
500 !prUrl &&
501 result.push.status === "pushed" &&
502 !actionIsDefaultBranch;
503 const closeResultToast = () => {558 const closeResultToast = () => {
504 toastManager.close(resolvedProgressToastId);559 toastManager.close(resolvedProgressToastId);
505 };560 };
506561
562 const toastCta = result.toast.cta;
563 let toastActionProps: {
564 children: string;
565 onClick: () => void;
566 } | null = null;
567 if (toastCta.kind === "run_action") {
568 toastActionProps = {
569 children: toastCta.label,
570 onClick: () => {
571 closeResultToast();
572 void runGitActionWithToast({
573 action: toastCta.action.kind,
574 });
575 },
576 };
577 } else if (toastCta.kind === "open_pr") {
578 toastActionProps = {
579 children: toastCta.label,
580 onClick: () => {
581 const api = readNativeApi();
582 if (!api) return;
583 closeResultToast();
584 void api.shell.openExternal(toastCta.url);
585 },
586 };
587 }
588
507 toastManager.update(resolvedProgressToastId, {589 toastManager.update(resolvedProgressToastId, {
508 type: "success",590 type: "success",
509 title: resultToast.title,591 title: result.toast.title,
510 description: resultToast.description,592 description: result.toast.description,
511 timeout: 0,593 timeout: 0,
512 data: {594 data: {
513 ...threadToastData,595 ...threadToastData,
514 dismissAfterVisibleMs: 10_000,596 dismissAfterVisibleMs: 10_000,
515 },597 },
516 ...(shouldOfferPushCta598 ...(toastActionProps ? { actionProps: toastActionProps } : {}),
517 ? {
518 actionProps: {
519 children: "Push",
520 onClick: () => {
521 void runGitActionWithToast({
522 action: "commit_push",
523 forcePushOnlyProgress: true,
524 onConfirmed: closeResultToast,
525 statusOverride: actionStatus,
526 isDefaultBranchOverride: actionIsDefaultBranch,
527 });
528 },
529 },
530 }
531 : shouldOfferOpenPrCta
532 ? {
533 actionProps: {
534 children: "View PR",
535 onClick: () => {
536 const api = readNativeApi();
537 if (!api) return;
538 closeResultToast();
539 void api.shell.openExternal(prUrl);
540 },
541 },
542 }
543 : shouldOfferCreatePrCta
544 ? {
545 actionProps: {
546 children: "Create PR",
547 onClick: () => {
548 closeResultToast();
549 void runGitActionWithToast({
550 action: "commit_push_pr",
551 forcePushOnlyProgress: true,
552 statusOverride: actionStatus,
553 isDefaultBranchOverride: actionIsDefaultBranch,
554 });
555 },
556 },
557 }
558 : {}),
559 });599 });
560 } catch (err) {600 } catch (err) {
561 activeGitActionProgressRef.current = null;601 activeGitActionProgressRef.current = null;
...@@ -571,13 +611,11 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -571,13 +611,11 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
571611
572 const continuePendingDefaultBranchAction = useCallback(() => {612 const continuePendingDefaultBranchAction = useCallback(() => {
573 if (!pendingDefaultBranchAction) return;613 if (!pendingDefaultBranchAction) return;
574 const { action, commitMessage, forcePushOnlyProgress, onConfirmed, filePaths } =614 const { action, commitMessage, onConfirmed, filePaths } = pendingDefaultBranchAction;
575 pendingDefaultBranchAction;
576 setPendingDefaultBranchAction(null);615 setPendingDefaultBranchAction(null);
577 void runGitActionWithToast({616 void runGitActionWithToast({
578 action,617 action,
579 ...(commitMessage ? { commitMessage } : {}),618 ...(commitMessage ? { commitMessage } : {}),
580 forcePushOnlyProgress,
581 ...(onConfirmed ? { onConfirmed } : {}),619 ...(onConfirmed ? { onConfirmed } : {}),
582 ...(filePaths ? { filePaths } : {}),620 ...(filePaths ? { filePaths } : {}),
583 skipDefaultBranchPrompt: true,621 skipDefaultBranchPrompt: true,
...@@ -586,13 +624,11 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -586,13 +624,11 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
586624
587 const checkoutFeatureBranchAndContinuePendingAction = useCallback(() => {625 const checkoutFeatureBranchAndContinuePendingAction = useCallback(() => {
588 if (!pendingDefaultBranchAction) return;626 if (!pendingDefaultBranchAction) return;
589 const { action, commitMessage, forcePushOnlyProgress, onConfirmed, filePaths } =627 const { action, commitMessage, onConfirmed, filePaths } = pendingDefaultBranchAction;
590 pendingDefaultBranchAction;
591 setPendingDefaultBranchAction(null);628 setPendingDefaultBranchAction(null);
592 void runGitActionWithToast({629 void runGitActionWithToast({
593 action,630 action,
594 ...(commitMessage ? { commitMessage } : {}),631 ...(commitMessage ? { commitMessage } : {}),
595 forcePushOnlyProgress,
596 ...(onConfirmed ? { onConfirmed } : {}),632 ...(onConfirmed ? { onConfirmed } : {}),
597 ...(filePaths ? { filePaths } : {}),633 ...(filePaths ? { filePaths } : {}),
598 featureBranch: true,634 featureBranch: true,
...@@ -666,11 +702,11 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -666,11 +702,11 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
666 return;702 return;
667 }703 }
668 if (item.dialogAction === "push") {704 if (item.dialogAction === "push") {
669 void runGitActionWithToast({ action: "commit_push", forcePushOnlyProgress: true });705 void runGitActionWithToast({ action: "push" });
670 return;706 return;
671 }707 }
672 if (item.dialogAction === "create_pr") {708 if (item.dialogAction === "create_pr") {
673 void runGitActionWithToast({ action: "commit_push_pr" });709 void runGitActionWithToast({ action: "create_pr" });
674 return;710 return;
675 }711 }
676 setExcludedFiles(new Set());712 setExcludedFiles(new Set());
apps/web/src/components/Sidebar.logic.test.ts+63
...@@ -11,6 +11,7 @@ import {...@@ -11,6 +11,7 @@ import {
11 isContextMenuPointerDown,11 isContextMenuPointerDown,
12 orderItemsByPreferredIds,12 orderItemsByPreferredIds,
13 resolveProjectStatusIndicator,13 resolveProjectStatusIndicator,
14 resolveSidebarNewThreadSeedContext,
14 resolveSidebarNewThreadEnvMode,15 resolveSidebarNewThreadEnvMode,
15 resolveThreadRowClassName,16 resolveThreadRowClassName,
16 resolveThreadStatusPill,17 resolveThreadStatusPill,
...@@ -164,6 +165,68 @@ describe("resolveSidebarNewThreadEnvMode", () => {...@@ -164,6 +165,68 @@ describe("resolveSidebarNewThreadEnvMode", () => {
164 });165 });
165});166});
166167
168describe("resolveSidebarNewThreadSeedContext", () => {
169 it("inherits the active server thread context when creating a new thread in the same project", () => {
170 expect(
171 resolveSidebarNewThreadSeedContext({
172 projectId: "project-1",
173 defaultEnvMode: "local",
174 activeThread: {
175 projectId: "project-1",
176 branch: "effect-atom",
177 worktreePath: null,
178 },
179 activeDraftThread: null,
180 }),
181 ).toEqual({
182 branch: "effect-atom",
183 worktreePath: null,
184 envMode: "local",
185 });
186 });
187
188 it("prefers the active draft thread context when it matches the target project", () => {
189 expect(
190 resolveSidebarNewThreadSeedContext({
191 projectId: "project-1",
192 defaultEnvMode: "local",
193 activeThread: {
194 projectId: "project-1",
195 branch: "effect-atom",
196 worktreePath: null,
197 },
198 activeDraftThread: {
199 projectId: "project-1",
200 branch: "feature/new-draft",
201 worktreePath: "/repo/worktree",
202 envMode: "worktree",
203 },
204 }),
205 ).toEqual({
206 branch: "feature/new-draft",
207 worktreePath: "/repo/worktree",
208 envMode: "worktree",
209 });
210 });
211
212 it("falls back to the default env mode when there is no matching active thread context", () => {
213 expect(
214 resolveSidebarNewThreadSeedContext({
215 projectId: "project-2",
216 defaultEnvMode: "worktree",
217 activeThread: {
218 projectId: "project-1",
219 branch: "effect-atom",
220 worktreePath: null,
221 },
222 activeDraftThread: null,
223 }),
224 ).toEqual({
225 envMode: "worktree",
226 });
227 });
228});
229
167describe("orderItemsByPreferredIds", () => {230describe("orderItemsByPreferredIds", () => {
168 it("keeps preferred ids first, skips stale ids, and preserves the relative order of remaining items", () => {231 it("keeps preferred ids first, skips stale ids, and preserves the relative order of remaining items", () => {
169 const ordered = orderItemsByPreferredIds({232 const ordered = orderItemsByPreferredIds({
apps/web/src/components/Sidebar.logic.ts+40
...@@ -161,6 +161,46 @@ export function resolveSidebarNewThreadEnvMode(input: {...@@ -161,6 +161,46 @@ export function resolveSidebarNewThreadEnvMode(input: {
161 return input.requestedEnvMode ?? input.defaultEnvMode;161 return input.requestedEnvMode ?? input.defaultEnvMode;
162}162}
163163
164export function resolveSidebarNewThreadSeedContext(input: {
165 projectId: string;
166 defaultEnvMode: SidebarNewThreadEnvMode;
167 activeThread?: {
168 projectId: string;
169 branch: string | null;
170 worktreePath: string | null;
171 } | null;
172 activeDraftThread?: {
173 projectId: string;
174 branch: string | null;
175 worktreePath: string | null;
176 envMode: SidebarNewThreadEnvMode;
177 } | null;
178}): {
179 branch?: string | null;
180 worktreePath?: string | null;
181 envMode: SidebarNewThreadEnvMode;
182} {
183 if (input.activeDraftThread?.projectId === input.projectId) {
184 return {
185 branch: input.activeDraftThread.branch,
186 worktreePath: input.activeDraftThread.worktreePath,
187 envMode: input.activeDraftThread.envMode,
188 };
189 }
190
191 if (input.activeThread?.projectId === input.projectId) {
192 return {
193 branch: input.activeThread.branch,
194 worktreePath: input.activeThread.worktreePath,
195 envMode: input.activeThread.worktreePath ? "worktree" : "local",
196 };
197 }
198
199 return {
200 envMode: input.defaultEnvMode,
201 };
202}
203
164export function orderItemsByPreferredIds<TItem, TId>(input: {204export function orderItemsByPreferredIds<TItem, TId>(input: {
165 items: readonly TItem[];205 items: readonly TItem[];
166 preferredIds: readonly TId[];206 preferredIds: readonly TId[];
apps/web/src/components/Sidebar.tsx+29-3
...@@ -111,6 +111,7 @@ import {...@@ -111,6 +111,7 @@ import {
111 resolveAdjacentThreadId,111 resolveAdjacentThreadId,
112 isContextMenuPointerDown,112 isContextMenuPointerDown,
113 resolveProjectStatusIndicator,113 resolveProjectStatusIndicator,
114 resolveSidebarNewThreadSeedContext,
114 resolveSidebarNewThreadEnvMode,115 resolveSidebarNewThreadEnvMode,
115 resolveThreadRowClassName,116 resolveThreadRowClassName,
116 resolveThreadStatusPill,117 resolveThreadStatusPill,
...@@ -460,7 +461,7 @@ export default function Sidebar() {...@@ -460,7 +461,7 @@ export default function Sidebar() {
460 const isOnSettings = pathname.startsWith("/settings");461 const isOnSettings = pathname.startsWith("/settings");
461 const appSettings = useSettings();462 const appSettings = useSettings();
462 const { updateSettings } = useUpdateSettings();463 const { updateSettings } = useUpdateSettings();
463 const { handleNewThread } = useHandleNewThread();464 const { activeDraftThread, activeThread, handleNewThread } = useHandleNewThread();
464 const { archiveThread, deleteThread } = useThreadActions();465 const { archiveThread, deleteThread } = useThreadActions();
465 const routeThreadId = useParams({466 const routeThreadId = useParams({
466 strict: false,467 strict: false,
...@@ -1680,10 +1681,35 @@ export default function Sidebar() {...@@ -1680,10 +1681,35 @@ export default function Sidebar() {
1680 onClick={(event) => {1681 onClick={(event) => {
1681 event.preventDefault();1682 event.preventDefault();
1682 event.stopPropagation();1683 event.stopPropagation();
1683 void handleNewThread(project.id, {1684 const seedContext = resolveSidebarNewThreadSeedContext({
1684 envMode: resolveSidebarNewThreadEnvMode({1685 projectId: project.id,
1686 defaultEnvMode: resolveSidebarNewThreadEnvMode({
1685 defaultEnvMode: appSettings.defaultThreadEnvMode,1687 defaultEnvMode: appSettings.defaultThreadEnvMode,
1686 }),1688 }),
1689 activeThread:
1690 activeThread && activeThread.projectId === project.id
1691 ? {
1692 projectId: activeThread.projectId,
1693 branch: activeThread.branch,
1694 worktreePath: activeThread.worktreePath,
1695 }
1696 : null,
1697 activeDraftThread:
1698 activeDraftThread && activeDraftThread.projectId === project.id
1699 ? {
1700 projectId: activeDraftThread.projectId,
1701 branch: activeDraftThread.branch,
1702 worktreePath: activeDraftThread.worktreePath,
1703 envMode: activeDraftThread.envMode,
1704 }
1705 : null,
1706 });
1707 void handleNewThread(project.id, {
1708 ...(seedContext.branch !== undefined ? { branch: seedContext.branch } : {}),
1709 ...(seedContext.worktreePath !== undefined
1710 ? { worktreePath: seedContext.worktreePath }
1711 : {}),
1712 envMode: seedContext.envMode,
1687 });1713 });
1688 }}1714 }}
1689 >1715 >
packages/contracts/src/git.test.ts+46-7
...@@ -4,6 +4,7 @@ import { Schema } from "effect";...@@ -4,6 +4,7 @@ import { Schema } from "effect";
4import {4import {
5 GitCreateWorktreeInput,5 GitCreateWorktreeInput,
6 GitPreparePullRequestThreadInput,6 GitPreparePullRequestThreadInput,
7 GitRunStackedActionResult,
7 GitRunStackedActionInput,8 GitRunStackedActionInput,
8 GitResolvePullRequestResult,9 GitResolvePullRequestResult,
9} from "./git";10} from "./git";
...@@ -13,6 +14,7 @@ const decodePreparePullRequestThreadInput = Schema.decodeUnknownSync(...@@ -13,6 +14,7 @@ const decodePreparePullRequestThreadInput = Schema.decodeUnknownSync(
13 GitPreparePullRequestThreadInput,14 GitPreparePullRequestThreadInput,
14);15);
15const decodeRunStackedActionInput = Schema.decodeUnknownSync(GitRunStackedActionInput);16const decodeRunStackedActionInput = Schema.decodeUnknownSync(GitRunStackedActionInput);
17const decodeRunStackedActionResult = Schema.decodeUnknownSync(GitRunStackedActionResult);
16const decodeResolvePullRequestResult = Schema.decodeUnknownSync(GitResolvePullRequestResult);18const decodeResolvePullRequestResult = Schema.decodeUnknownSync(GitResolvePullRequestResult);
1719
18describe("GitCreateWorktreeInput", () => {20describe("GitCreateWorktreeInput", () => {
...@@ -60,18 +62,55 @@ describe("GitResolvePullRequestResult", () => {...@@ -60,18 +62,55 @@ describe("GitResolvePullRequestResult", () => {
60});62});
6163
62describe("GitRunStackedActionInput", () => {64describe("GitRunStackedActionInput", () => {
63 it("requires a client-provided actionId for progress correlation", () => {65 it("accepts explicit stacked actions and requires a client-provided actionId", () => {
64 const parsed = decodeRunStackedActionInput({66 const parsed = decodeRunStackedActionInput({
65 actionId: "action-1",67 actionId: "action-1",
66 cwd: "/repo",68 cwd: "/repo",
67 action: "commit",69 action: "create_pr",
68 modelSelection: {
69 provider: "codex",
70 model: "gpt-5.4-mini",
71 },
72 });70 });
7371
74 expect(parsed.actionId).toBe("action-1");72 expect(parsed.actionId).toBe("action-1");
75 expect(parsed.action).toBe("commit");73 expect(parsed.action).toBe("create_pr");
74 });
75});
76
77describe("GitRunStackedActionResult", () => {
78 it("decodes a server-authored completion toast", () => {
79 const parsed = decodeRunStackedActionResult({
80 action: "commit_push",
81 branch: {
82 status: "created",
83 name: "feature/server-owned-toast",
84 },
85 commit: {
86 status: "created",
87 commitSha: "89abcdef01234567",
88 subject: "feat: move toast state into git manager",
89 },
90 push: {
91 status: "pushed",
92 branch: "feature/server-owned-toast",
93 upstreamBranch: "origin/feature/server-owned-toast",
94 },
95 pr: {
96 status: "skipped_not_requested",
97 },
98 toast: {
99 title: "Pushed 89abcde to origin/feature/server-owned-toast",
100 description: "feat: move toast state into git manager",
101 cta: {
102 kind: "run_action",
103 label: "Create PR",
104 action: {
105 kind: "create_pr",
106 },
107 },
108 },
109 });
110
111 expect(parsed.toast.cta.kind).toBe("run_action");
112 if (parsed.toast.cta.kind === "run_action") {
113 expect(parsed.toast.cta.action.kind).toBe("create_pr");
114 }
76 });115 });
77});116});
packages/contracts/src/git.ts+39-2
...@@ -5,7 +5,13 @@ const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;...@@ -5,7 +5,13 @@ const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;
55
6// Domain Types6// Domain Types
77
8export const GitStackedAction = Schema.Literals(["commit", "commit_push", "commit_push_pr"]);8export const GitStackedAction = Schema.Literals([
9 "commit",
10 "push",
11 "create_pr",
12 "commit_push",
13 "commit_push_pr",
14]);
9export type GitStackedAction = typeof GitStackedAction.Type;15export type GitStackedAction = typeof GitStackedAction.Type;
10export const GitActionProgressPhase = Schema.Literals(["branch", "commit", "push", "pr"]);16export const GitActionProgressPhase = Schema.Literals(["branch", "commit", "push", "pr"]);
11export type GitActionProgressPhase = typeof GitActionProgressPhase.Type;17export type GitActionProgressPhase = typeof GitActionProgressPhase.Type;
...@@ -21,7 +27,11 @@ export const GitActionProgressKind = Schema.Literals([...@@ -21,7 +27,11 @@ export const GitActionProgressKind = Schema.Literals([
21export type GitActionProgressKind = typeof GitActionProgressKind.Type;27export type GitActionProgressKind = typeof GitActionProgressKind.Type;
22export const GitActionProgressStream = Schema.Literals(["stdout", "stderr"]);28export const GitActionProgressStream = Schema.Literals(["stdout", "stderr"]);
23export type GitActionProgressStream = typeof GitActionProgressStream.Type;29export type GitActionProgressStream = typeof GitActionProgressStream.Type;
24const GitCommitStepStatus = Schema.Literals(["created", "skipped_no_changes"]);30const GitCommitStepStatus = Schema.Literals([
31 "created",
32 "skipped_no_changes",
33 "skipped_not_requested",
34]);
25const GitPushStepStatus = Schema.Literals([35const GitPushStepStatus = Schema.Literals([
26 "pushed",36 "pushed",
27 "skipped_not_requested",37 "skipped_not_requested",
...@@ -33,6 +43,32 @@ const GitStatusPrState = Schema.Literals(["open", "closed", "merged"]);...@@ -33,6 +43,32 @@ const GitStatusPrState = Schema.Literals(["open", "closed", "merged"]);
33const GitPullRequestReference = TrimmedNonEmptyStringSchema;43const GitPullRequestReference = TrimmedNonEmptyStringSchema;
34const GitPullRequestState = Schema.Literals(["open", "closed", "merged"]);44const GitPullRequestState = Schema.Literals(["open", "closed", "merged"]);
35const GitPreparePullRequestThreadMode = Schema.Literals(["local", "worktree"]);45const GitPreparePullRequestThreadMode = Schema.Literals(["local", "worktree"]);
46export const GitRunStackedActionToastRunAction = Schema.Struct({
47 kind: GitStackedAction,
48});
49export type GitRunStackedActionToastRunAction = typeof GitRunStackedActionToastRunAction.Type;
50const GitRunStackedActionToastCta = Schema.Union([
51 Schema.Struct({
52 kind: Schema.Literal("none"),
53 }),
54 Schema.Struct({
55 kind: Schema.Literal("open_pr"),
56 label: TrimmedNonEmptyStringSchema,
57 url: Schema.String,
58 }),
59 Schema.Struct({
60 kind: Schema.Literal("run_action"),
61 label: TrimmedNonEmptyStringSchema,
62 action: GitRunStackedActionToastRunAction,
63 }),
64]);
65export type GitRunStackedActionToastCta = typeof GitRunStackedActionToastCta.Type;
66const GitRunStackedActionToast = Schema.Struct({
67 title: TrimmedNonEmptyStringSchema,
68 description: Schema.optional(TrimmedNonEmptyStringSchema),
69 cta: GitRunStackedActionToastCta,
70});
71export type GitRunStackedActionToast = typeof GitRunStackedActionToast.Type;
3672
37export const GitBranch = Schema.Struct({73export const GitBranch = Schema.Struct({
38 name: TrimmedNonEmptyStringSchema,74 name: TrimmedNonEmptyStringSchema,
...@@ -213,6 +249,7 @@ export const GitRunStackedActionResult = Schema.Struct({...@@ -213,6 +249,7 @@ export const GitRunStackedActionResult = Schema.Struct({
213 headBranch: Schema.optional(TrimmedNonEmptyStringSchema),249 headBranch: Schema.optional(TrimmedNonEmptyStringSchema),
214 title: Schema.optional(TrimmedNonEmptyStringSchema),250 title: Schema.optional(TrimmedNonEmptyStringSchema),
215 }),251 }),
252 toast: GitRunStackedActionToast,
216});253});
217export type GitRunStackedActionResult = typeof GitRunStackedActionResult.Type;254export type GitRunStackedActionResult = typeof GitRunStackedActionResult.Type;
218255