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(
5959 });
6060}
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
6279function runShellCommand(input: {
6380 command: string;
6481 cwd: string;
......@@ -587,7 +604,7 @@ it.layer(TestLayer)("git integration", (it) => {
587604 }),
588605 );
589606
590 it.effect("keeps checkout successful when upstream refresh fails", () =>
607 it.effect("statusDetails remains successful when upstream refresh fails after checkout", () =>
591608 Effect.gen(function* () {
592609 const remote = yield* makeTmpDir();
593610 const source = yield* makeTmpDir();
......@@ -612,7 +629,7 @@ it.layer(TestLayer)("git integration", (it) => {
612629 const realGitCore = yield* GitCore;
613630 let refreshFetchAttempts = 0;
614631 const core = yield* makeIsolatedGitCore((input) => {
615 if (input.args[0] === "fetch") {
632 if (input.args[0] === "--git-dir" && input.args[2] === "fetch") {
616633 refreshFetchAttempts += 1;
617634 return Effect.fail(
618635 new GitCommandError({
......@@ -626,16 +643,15 @@ it.layer(TestLayer)("git integration", (it) => {
626643 return realGitCore.execute(input);
627644 });
628645 yield* core.checkoutBranch({ cwd: source, branch: featureBranch });
629 yield* Effect.promise(() =>
630 vi.waitFor(() => {
631 expect(refreshFetchAttempts).toBe(1);
632 }),
633 );
646 const status = yield* core.statusDetails(source);
647 expect(refreshFetchAttempts).toBe(1);
648 expect(status.branch).toBe(featureBranch);
649 expect(status.upstreamRef).toBe(`origin/${featureBranch}`);
634650 expect(yield* git(source, ["branch", "--show-current"])).toBe(featureBranch);
635651 }),
636652 );
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", () =>
639655 Effect.gen(function* () {
640656 const remote = yield* makeTmpDir();
641657 const source = yield* makeTmpDir();
......@@ -657,10 +673,10 @@ it.layer(TestLayer)("git integration", (it) => {
657673 yield* git(source, ["checkout", defaultBranch]);
658674
659675 const realGitCore = yield* GitCore;
660 let fetchArgs: readonly string[] | null = null;
676 let refreshFetchAttempts = 0;
661677 const core = yield* makeIsolatedGitCore((input) => {
662 if (input.args[0] === "fetch") {
663 fetchArgs = [...input.args];
678 if (input.args[0] === "--git-dir" && input.args[2] === "fetch") {
679 refreshFetchAttempts += 1;
664680 return Effect.succeed({
665681 code: 0,
666682 stdout: "",
......@@ -672,73 +688,131 @@ it.layer(TestLayer)("git integration", (it) => {
672688 return realGitCore.execute(input);
673689 });
674690 yield* core.checkoutBranch({ cwd: source, branch: featureBranch });
675 yield* Effect.promise(() =>
676 vi.waitFor(() => {
677 expect(fetchArgs).not.toBeNull();
678 }),
679 );
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 ]);
691 yield* Effect.promise(() => new Promise<void>((resolve) => setTimeout(resolve, 50)));
692 expect(refreshFetchAttempts).toBe(0);
693 const status = yield* core.statusDetails(source);
694 expect(status.branch).toBe(featureBranch);
695 expect(refreshFetchAttempts).toBe(1);
689696 }),
690697 );
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", () =>
693700 Effect.gen(function* () {
694 const remote = yield* makeTmpDir();
695 const source = yield* makeTmpDir();
696 yield* git(remote, ["init", "--bare"]);
701 const ok = (stdout = "") =>
702 Effect.succeed({
703 code: 0,
704 stdout,
705 stderr: "",
706 stdoutTruncated: false,
707 stderrTruncated: false,
708 });
697709
698 yield* initRepoWithCommit(source);
699 const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find(
700 (branch) => branch.current,
701 )!.name;
702 yield* git(source, ["remote", "add", "origin", remote]);
703 yield* git(source, ["push", "-u", "origin", defaultBranch]);
710 let fetchCount = 0;
711 const core = yield* makeIsolatedGitCore((input) => {
712 if (
713 input.args[0] === "rev-parse" &&
714 input.args[1] === "--abbrev-ref" &&
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";
706 yield* git(source, ["checkout", "-b", featureBranch]);
707 yield* writeTextFile(path.join(source, "feature.txt"), "feature base\n");
708 yield* git(source, ["add", "feature.txt"]);
709 yield* git(source, ["commit", "-m", "feature base"]);
710 yield* git(source, ["push", "-u", "origin", featureBranch]);
711 yield* git(source, ["checkout", defaultBranch]);
750 yield* core.statusDetails("/repo/worktrees/main");
751 yield* core.statusDetails("/repo/worktrees/pr-123");
752 expect(fetchCount).toBe(1);
753 }),
754 );
712755
713 const realGitCore = yield* GitCore;
714 let fetchStarted = false;
715 let releaseFetch!: () => void;
716 const waitForReleasePromise = new Promise<void>((resolve) => {
717 releaseFetch = resolve;
718 });
756 it.effect("briefly backs off failed upstream refreshes across sibling worktrees", () =>
757 Effect.gen(function* () {
758 const ok = (stdout = "") =>
759 Effect.succeed({
760 code: 0,
761 stdout,
762 stderr: "",
763 stdoutTruncated: false,
764 stderrTruncated: false,
765 });
766
767 let fetchCount = 0;
719768 const core = yield* makeIsolatedGitCore((input) => {
720 if (input.args[0] === "fetch") {
721 fetchStarted = true;
722 return Effect.promise(() =>
723 waitForReleasePromise.then(() => ({
724 code: 0,
725 stdout: "",
726 stderr: "",
727 stdoutTruncated: false,
728 stderrTruncated: false,
729 })),
769 if (
770 input.args[0] === "rev-parse" &&
771 input.args[1] === "--abbrev-ref" &&
772 input.args[2] === "--symbolic-full-name" &&
773 input.args[3] === "@{upstream}"
774 ) {
775 return ok("origin/main\n");
776 }
777 if (input.args[0] === "remote") {
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 }),
730792 );
731793 }
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 );
733811 });
734 yield* core.checkoutBranch({ cwd: source, branch: featureBranch });
735 yield* Effect.promise(() =>
736 vi.waitFor(() => {
737 expect(fetchStarted).toBe(true);
738 }),
739 );
740 expect(yield* git(source, ["branch", "--show-current"])).toBe(featureBranch);
741 releaseFetch();
812
813 yield* core.statusDetails("/repo/worktrees/main");
814 yield* core.statusDetails("/repo/worktrees/pr-123");
815 expect(fetchCount).toBe(1);
742816 }),
743817 );
744818
......@@ -779,16 +853,21 @@ it.layer(TestLayer)("git integration", (it) => {
779853 it.effect("checks out a remote tracking branch when remote name contains slashes", () =>
780854 Effect.gen(function* () {
781855 const remote = yield* makeTmpDir();
856 const prefixRemote = yield* makeTmpDir();
782857 const source = yield* makeTmpDir();
858 const prefixFetchNamespace = "prefix-my-org";
859 const prefixRemoteName = "my-org";
783860 const remoteName = "my-org/upstream";
784861 const featureBranch = "feature";
785862 yield* git(remote, ["init", "--bare"]);
863 yield* git(prefixRemote, ["init", "--bare"]);
786864
787865 yield* initRepoWithCommit(source);
788866 const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find(
789867 (branch) => branch.current,
790868 )!.name;
791 yield* git(source, ["remote", "add", remoteName, remote]);
869 yield* configureRemote(source, prefixRemoteName, prefixRemote, prefixFetchNamespace);
870 yield* configureRemote(source, remoteName, remote, remoteName);
792871 yield* git(source, ["push", "-u", remoteName, defaultBranch]);
793872
794873 yield* git(source, ["checkout", "-b", featureBranch]);
......@@ -805,6 +884,34 @@ it.layer(TestLayer)("git integration", (it) => {
805884 });
806885
807886 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 ]);
808915 }),
809916 );
810917
......@@ -1691,6 +1798,47 @@ it.layer(TestLayer)("git integration", (it) => {
16911798 }),
16921799 );
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
16941842 it.effect("includes command context when worktree removal fails", () =>
16951843 Effect.gen(function* () {
16961844 const tmp = yield* makeTmpDir();
apps/server/src/git/Layers/GitCore.ts+68-79
......@@ -27,6 +27,11 @@ import {
2727 type ExecuteGitInput,
2828 type ExecuteGitResult,
2929} from "../Services/GitCore.ts";
30import {
31 parseRemoteNames,
32 parseRemoteNamesInGitOrder,
33 parseRemoteRefWithRemoteNames,
34} from "../remoteRefs.ts";
3035import { ServerConfig } from "../../config.ts";
3136import { decodeJsonResult } from "@t3tools/shared/schemaJson";
3237
......@@ -41,6 +46,7 @@ const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 16 * 1024 * 1024;
4146const GIT_CHECK_IGNORE_MAX_STDIN_BYTES = 256 * 1024;
4247const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15);
4348const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5);
49const STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN = Duration.seconds(5);
4450const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048;
4551const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const;
4652
......@@ -50,7 +56,7 @@ type TraceTailState = {
5056};
5157
5258class StatusUpstreamRefreshCacheKey extends Data.Class<{
53 cwd: string;
59 gitCommonDir: string;
5460 upstreamRef: string;
5561 remoteName: string;
5662 upstreamBranch: string;
......@@ -177,14 +183,6 @@ function parseBranchLine(line: string): { name: string; current: boolean } | nul
177183 };
178184}
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
188186function sanitizeRemoteName(value: string): string {
189187 const sanitized = value
190188 .trim()
......@@ -217,30 +215,41 @@ function parseRemoteFetchUrls(stdout: string): Map<string, string> {
217215 return remotes;
218216}
219217
220function parseRemoteRefWithRemoteNames(
221 branchName: string,
218function parseUpstreamRefWithRemoteNames(
219 upstreamRef: string,
222220 remoteNames: ReadonlyArray<string>,
223): { remoteRef: string; remoteName: string; localBranch: string } | null {
224 const trimmedBranchName = branchName.trim();
225 if (trimmedBranchName.length === 0) return null;
221): { upstreamRef: string; remoteName: string; upstreamBranch: string } | null {
222 const parsed = parseRemoteRefWithRemoteNames(upstreamRef, remoteNames);
223 if (!parsed) {
224 return null;
225 }
226226
227 for (const remoteName of remoteNames) {
228 const remotePrefix = `${remoteName}/`;
229 if (!trimmedBranchName.startsWith(remotePrefix)) {
230 continue;
231 }
232 const localBranch = trimmedBranchName.slice(remotePrefix.length).trim();
233 if (localBranch.length === 0) {
234 return null;
235 }
236 return {
237 remoteRef: trimmedBranchName,
238 remoteName,
239 localBranch,
240 };
227 return {
228 upstreamRef,
229 remoteName: parsed.remoteName,
230 upstreamBranch: parsed.branchName,
231 };
232}
233
234function parseUpstreamRefByFirstSeparator(
235 upstreamRef: string,
236): { upstreamRef: string; remoteName: string; upstreamBranch: string } | null {
237 const separatorIndex = upstreamRef.indexOf("/");
238 if (separatorIndex <= 0 || separatorIndex === upstreamRef.length - 1) {
239 return null;
241240 }
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 };
244253}
245254
246255function parseTrackingBranchByUpstreamRef(stdout: string, upstreamRef: string): string | null {
......@@ -792,45 +801,27 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
792801 return null;
793802 }
794803
795 const separatorIndex = upstreamRef.indexOf("/");
796 if (separatorIndex <= 0) {
797 return null;
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,
804 const remoteNames = yield* runGitStdout("GitCore.listRemoteNames", cwd, ["remote"]).pipe(
805 Effect.map(parseRemoteNames),
806 Effect.catch(() => Effect.succeed<ReadonlyArray<string>>([])),
822807 );
823 };
808 return (
809 parseUpstreamRefWithRemoteNames(upstreamRef, remoteNames) ??
810 parseUpstreamRefByFirstSeparator(upstreamRef)
811 );
812 });
824813
825814 const fetchUpstreamRefForStatus = (
826 cwd: string,
815 gitCommonDir: string,
827816 upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string },
828817 ): Effect.Effect<void, GitCommandError> => {
829818 const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`;
819 const fetchCwd =
820 path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir;
830821 return executeGit(
831822 "GitCore.fetchUpstreamRefForStatus",
832 cwd,
833 ["fetch", "--quiet", "--no-tags", upstream.remoteName, refspec],
823 fetchCwd,
824 ["--git-dir", gitCommonDir, "fetch", "--quiet", "--no-tags", upstream.remoteName, refspec],
834825 {
835826 allowNonZeroExit: true,
836827 timeoutMs: Duration.toMillis(STATUS_UPSTREAM_REFRESH_TIMEOUT),
......@@ -838,10 +829,18 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
838829 ).pipe(Effect.asVoid);
839830 };
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
841840 const refreshStatusUpstreamCacheEntry = Effect.fn("refreshStatusUpstreamCacheEntry")(function* (
842841 cacheKey: StatusUpstreamRefreshCacheKey,
843842 ) {
844 yield* fetchUpstreamRefForStatus(cacheKey.cwd, {
843 yield* fetchUpstreamRefForStatus(cacheKey.gitCommonDir, {
845844 upstreamRef: cacheKey.upstreamRef,
846845 remoteName: cacheKey.remoteName,
847846 upstreamBranch: cacheKey.upstreamBranch,
......@@ -852,8 +851,11 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
852851 const statusUpstreamRefreshCache = yield* Cache.makeWith({
853852 capacity: STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY,
854853 lookup: refreshStatusUpstreamCacheEntry,
855 // Keep successful refreshes warm; drop failures immediately so next request can retry.
856 timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_UPSTREAM_REFRESH_INTERVAL : Duration.zero),
854 // Keep successful refreshes warm and briefly back off failed refreshes to avoid retry storms.
855 timeToLive: (exit) =>
856 Exit.isSuccess(exit)
857 ? STATUS_UPSTREAM_REFRESH_INTERVAL
858 : STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN,
857859 });
858860
859861 const refreshStatusUpstreamIfStale = Effect.fn("refreshStatusUpstreamIfStale")(function* (
......@@ -861,10 +863,11 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
861863 ) {
862864 const upstream = yield* resolveCurrentUpstream(cwd);
863865 if (!upstream) return;
866 const gitCommonDir = yield* resolveGitCommonDir(cwd);
864867 yield* Cache.get(
865868 statusUpstreamRefreshCache,
866869 new StatusUpstreamRefreshCacheKey({
867 cwd,
870 gitCommonDir,
868871 upstreamRef: upstream.upstreamRef,
869872 remoteName: upstream.remoteName,
870873 upstreamBranch: upstream.upstreamBranch,
......@@ -872,14 +875,6 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
872875 );
873876 });
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
883878 const resolveDefaultBranchName = (
884879 cwd: string,
885880 remoteName: string,
......@@ -919,7 +914,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
919914
920915 const listRemoteNames = (cwd: string): Effect.Effect<ReadonlyArray<string>, GitCommandError> =>
921916 runGitStdout("GitCore.listRemoteNames", cwd, ["remote"]).pipe(
922 Effect.map((stdout) => parseRemoteNames(stdout).toReversed()),
917 Effect.map(parseRemoteNamesInGitOrder),
923918 );
924919
925920 const resolvePrimaryRemoteName = Effect.fn("resolvePrimaryRemoteName")(function* (cwd: string) {
......@@ -1939,12 +1934,6 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
19391934 timeoutMs: 10_000,
19401935 fallbackErrorMessage: "git checkout failed",
19411936 });
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 );
19481937 },
19491938 );
19501939
apps/server/src/git/Layers/GitHubCli.ts+1-1
......@@ -187,7 +187,7 @@ const makeGitHubCli = Effect.sync(() => {
187187 "--limit",
188188 String(input.limit ?? 1),
189189 "--json",
190 "number,title,url,baseRefName,headRefName",
190 "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner",
191191 ],
192192 }).pipe(
193193 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";
2525interface FakeGhScenario {
2626 prListSequence?: string[];
2727 prListByHeadSelector?: Record<string, string>;
28 prListSequenceByHeadSelector?: Record<string, string[]>;
2829 createdPrUrl?: string;
2930 defaultBranch?: string;
3031 pullRequest?: {
......@@ -77,6 +78,72 @@ interface FakeGitTextGeneration {
7778
7879type 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
80147function runGitSyncForFakeGh(cwd: string, args: readonly string[]): void {
81148 const result = spawnSync("git", args, {
82149 cwd,
......@@ -159,6 +226,23 @@ function createBareRemote(): Effect.Effect<
159226 });
160227}
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
162246function createTextGeneration(overrides: Partial<FakeGitTextGeneration> = {}): TextGenerationShape {
163247 const implementation: FakeGitTextGeneration = {
164248 generateCommitMessage: (input) =>
......@@ -236,6 +320,12 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
236320 ghCalls: string[];
237321} {
238322 const prListQueue = [...(scenario.prListSequence ?? [])];
323 const prListQueueByHeadSelector = new Map(
324 Object.entries(scenario.prListSequenceByHeadSelector ?? {}).map(([headSelector, values]) => [
325 headSelector,
326 [...values],
327 ]),
328 );
239329 const ghCalls: string[] = [];
240330
241331 const execute: GitHubCliShape["execute"] = (input) => {
......@@ -252,11 +342,15 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
252342 headSelectorIndex >= 0 && headSelectorIndex < args.length - 1
253343 ? args[headSelectorIndex + 1]
254344 : undefined;
345 const mappedQueue =
346 typeof headSelector === "string"
347 ? prListQueueByHeadSelector.get(headSelector)?.shift()
348 : undefined;
255349 const mappedStdout =
256350 typeof headSelector === "string"
257351 ? scenario.prListByHeadSelector?.[headSelector]
258352 : undefined;
259 const stdout = (mappedStdout ?? prListQueue.shift() ?? "[]") + "\n";
353 const stdout = (mappedQueue ?? mappedStdout ?? prListQueue.shift() ?? "[]") + "\n";
260354 return Effect.succeed({
261355 stdout,
262356 stderr: "",
......@@ -410,11 +504,14 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
410504 "--limit",
411505 String(input.limit ?? 1),
412506 "--json",
413 "number,title,url,baseRefName,headRefName",
507 "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner",
414508 ],
415509 }).pipe(
416 Effect.map(
417 (result) => JSON.parse(result.stdout) as ReadonlyArray<GitHubPullRequestSummary>,
510 Effect.map((result) => JSON.parse(result.stdout) as unknown[]),
511 Effect.map((raw) =>
512 raw
513 .map((entry) => normalizeFakePullRequestSummary(entry))
514 .filter((entry): entry is GitHubPullRequestSummary => entry !== null),
418515 ),
419516 ),
420517 createPullRequest: (input) =>
......@@ -473,7 +570,7 @@ function runStackedAction(
473570 manager: GitManagerShape,
474571 input: {
475572 cwd: string;
476 action: "commit" | "commit_push" | "commit_push_pr";
573 action: "commit" | "push" | "create_pr" | "commit_push" | "commit_push_pr";
477574 actionId?: string;
478575 commitMessage?: string;
479576 featureBranch?: boolean;
......@@ -575,6 +672,78 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
575672 }),
576673 );
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
578747 it.effect(
579748 "status detects cross-repo PRs from the upstream remote URL owner",
580749 () =>
......@@ -610,6 +779,13 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
610779 headRefName: "statemachine",
611780 state: "OPEN",
612781 updatedAt: "2026-03-10T07:00:00Z",
782 isCrossRepository: true,
783 headRepository: {
784 nameWithOwner: "jasonLaster/codething-mvp",
785 },
786 headRepositoryOwner: {
787 login: "jasonLaster",
788 },
613789 },
614790 ]),
615791 ],
......@@ -627,8 +803,116 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
627803 state: "open",
628804 });
629805 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,
631907 );
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);
632916 }),
633917 12_000,
634918 );
......@@ -758,6 +1042,17 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
7581042 expect(result.commit.status).toBe("created");
7591043 expect(result.push.status).toBe("skipped_not_requested");
7601044 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}$/);
7611056 expect(
7621057 yield* runGit(repoDir, ["log", "-1", "--pretty=%s"]).pipe(
7631058 Effect.map((result) => result.stdout.trim()),
......@@ -867,6 +1162,19 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
8671162 expect(result.branch.name).toBe("feature/implement-stacked-git-actions");
8681163 expect(result.commit.status).toBe("created");
8691164 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 );
8701178 expect(
8711179 yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "HEAD"]).pipe(
8721180 Effect.map((result) => result.stdout.trim()),
......@@ -1063,6 +1371,80 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
10631371 }),
10641372 );
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
10661448 it.effect("returns existing PR metadata for commit/push/pr action", () =>
10671449 Effect.gen(function* () {
10681450 const repoDir = yield* makeTempDir("t3code-git-manager-");
......@@ -1095,6 +1477,15 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
10951477 expect(result.branch.status).toBe("skipped_not_requested");
10961478 expect(result.pr.status).toBe("opened_existing");
10971479 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 });
10981489 expect(ghCalls.some((call) => call.startsWith("pr view "))).toBe(false);
10991490 }),
11001491 );
......@@ -1126,6 +1517,14 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
11261517 url: "https://github.com/pingdotgg/codething-mvp/pull/142",
11271518 baseRefName: "main",
11281519 headRefName: "statemachine",
1520 state: "OPEN",
1521 isCrossRepository: true,
1522 headRepository: {
1523 nameWithOwner: "octocat/codething-mvp",
1524 },
1525 headRepositoryOwner: {
1526 login: "octocat",
1527 },
11291528 },
11301529 ]),
11311530 ],
......@@ -1149,6 +1548,98 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
11491548 12_000,
11501549 );
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
11521643 it.effect(
11531644 "prefers owner-qualified selectors before bare branch names for cross-repo PRs",
11541645 () =>
......@@ -1187,6 +1678,14 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
11871678 url: "https://github.com/pingdotgg/codething-mvp/pull/142",
11881679 baseRefName: "main",
11891680 headRefName: "statemachine",
1681 state: "OPEN",
1682 isCrossRepository: true,
1683 headRepository: {
1684 nameWithOwner: "octocat/codething-mvp",
1685 },
1686 headRepositoryOwner: {
1687 login: "octocat",
1688 },
11901689 },
11911690 ]),
11921691 "fork-seed:statemachine": JSON.stringify([]),
......@@ -1239,6 +1738,14 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
12391738 url: "https://github.com/pingdotgg/codething-mvp/pull/142",
12401739 baseRefName: "main",
12411740 headRefName: "statemachine",
1741 state: "OPEN",
1742 isCrossRepository: true,
1743 headRepository: {
1744 nameWithOwner: "octocat/codething-mvp",
1745 },
1746 headRepositoryOwner: {
1747 login: "octocat",
1748 },
12421749 },
12431750 ]),
12441751 "fork-seed:statemachine": JSON.stringify([]),
......@@ -1256,9 +1763,9 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
12561763 expect(result.pr.status).toBe("opened_existing");
12571764 expect(result.pr.number).toBe(142);
12581765
1259 const prListCalls = ghCalls.filter((call) => call.startsWith("pr list "));
1260 expect(prListCalls).toHaveLength(1);
1261 expect(prListCalls[0]).toContain(
1766 const openLookupCalls = ghCalls.filter((call) => call.includes("--state open --limit 1"));
1767 expect(openLookupCalls).toHaveLength(1);
1768 expect(openLookupCalls[0]).toContain(
12621769 "pr list --head octocat:statemachine --state open --limit 1",
12631770 );
12641771 }),
......@@ -1302,6 +1809,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
13021809 expect(result.branch.status).toBe("skipped_not_requested");
13031810 expect(result.pr.status).toBe("created");
13041811 expect(result.pr.number).toBe(88);
1812 expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(2);
13051813 expect(
13061814 ghCalls.some((call) => call.includes("pr create --base main --head feature-create-pr")),
13071815 ).toBe(true);
......@@ -1309,6 +1817,78 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
13091817 }),
13101818 );
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
13121892 it.effect("creates cross-repo PRs with the fork owner selector and default base branch", () =>
13131893 Effect.gen(function* () {
13141894 const repoDir = yield* makeTempDir("t3code-git-manager-");
......@@ -1330,23 +1910,30 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
13301910
13311911 const { manager, ghCalls } = yield* makeManager({
13321912 ghScenario: {
1333 prListSequence: [
1334 JSON.stringify([]),
1335 JSON.stringify([]),
1336 JSON.stringify([]),
1337 JSON.stringify([]),
1338 JSON.stringify([]),
1339 JSON.stringify([]),
1340 JSON.stringify([
1341 {
1342 number: 188,
1343 title: "Add stacked git actions",
1344 url: "https://github.com/pingdotgg/codething-mvp/pull/188",
1345 baseRefName: "main",
1346 headRefName: "statemachine",
1347 },
1348 ]),
1349 ],
1913 prListSequenceByHeadSelector: {
1914 "octocat:statemachine": [
1915 JSON.stringify([]),
1916 JSON.stringify([
1917 {
1918 number: 188,
1919 title: "Add stacked git actions",
1920 url: "https://github.com/pingdotgg/codething-mvp/pull/188",
1921 baseRefName: "main",
1922 headRefName: "statemachine",
1923 state: "OPEN",
1924 isCrossRepository: true,
1925 headRepository: {
1926 nameWithOwner: "octocat/codething-mvp",
1927 },
1928 headRepositoryOwner: {
1929 login: "octocat",
1930 },
1931 },
1932 ]),
1933 ],
1934 "fork-seed:statemachine": [JSON.stringify([])],
1935 statemachine: [JSON.stringify([])],
1936 },
13501937 },
13511938 });
13521939
......@@ -2090,4 +2677,71 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
20902677 );
20912678 }),
20922679 );
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 );
20932747});
apps/server/src/git/Layers/GitManager.ts+561-206
......@@ -1,11 +1,12 @@
11import { randomUUID } from "node:crypto";
22import { 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";
55import {
66 GitActionProgressEvent,
77 GitActionProgressPhase,
88 GitRunStackedActionResult,
9 GitStackedAction,
910 ModelSelection,
1011} from "@t3tools/contracts";
1112import {
......@@ -22,13 +23,18 @@ import {
2223 type GitRunStackedActionOptions,
2324} from "../Services/GitManager.ts";
2425import { GitCore } from "../Services/GitCore.ts";
25import { GitHubCli } from "../Services/GitHubCli.ts";
26import { GitHubCli, type GitHubPullRequestSummary } from "../Services/GitHubCli.ts";
2627import { TextGeneration } from "../Services/TextGeneration.ts";
28import { extractBranchNameFromRemoteRef } from "../remoteRefs.ts";
2729import { ServerSettingsService } from "../../serverSettings.ts";
2830import type { GitManagerServiceError } from "@t3tools/contracts";
2931
3032const COMMIT_TIMEOUT_MS = 10 * 60_000;
3133const 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;
3238type StripProgressContext<T> = T extends any ? Omit<T, "actionId" | "cwd" | "action"> : never;
3339type GitActionProgressPayload = StripProgressContext<GitActionProgressEvent>;
3440
......@@ -40,7 +46,7 @@ interface OpenPrInfo {
4046 headRefName: string;
4147}
4248
43interface PullRequestInfo extends OpenPrInfo {
49interface PullRequestInfo extends OpenPrInfo, PullRequestHeadRemoteInfo {
4450 state: "open" | "closed" | "merged";
4551 updatedAt: string | null;
4652}
......@@ -135,6 +141,94 @@ function parseRepositoryOwnerLogin(nameWithOwner: string | null): string | null
135141 return normalizedOwnerLogin.length > 0 ? normalizedOwnerLogin : null;
136142}
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
138232function parsePullRequestList(raw: unknown): PullRequestInfo[] {
139233 if (!Array.isArray(raw)) return [];
140234
......@@ -150,6 +244,27 @@ function parsePullRequestList(raw: unknown): PullRequestInfo[] {
150244 const state = record.state;
151245 const mergedAt = record.mergedAt;
152246 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;
153268 if (typeof number !== "number" || !Number.isInteger(number) || number <= 0) {
154269 continue;
155270 }
......@@ -163,11 +278,15 @@ function parsePullRequestList(raw: unknown): PullRequestInfo[] {
163278 }
164279
165280 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 ) {
167286 normalizedState = "merged";
168 } else if (state === "OPEN" || state === undefined || state === null) {
287 } else if (state === "OPEN" || state === "open" || state === undefined || state === null) {
169288 normalizedState = "open";
170 } else if (state === "CLOSED") {
289 } else if (state === "CLOSED" || state === "closed") {
171290 normalizedState = "closed";
172291 } else {
173292 continue;
......@@ -181,11 +300,35 @@ function parsePullRequestList(raw: unknown): PullRequestInfo[] {
181300 headRefName,
182301 state: normalizedState,
183302 updatedAt: typeof updatedAt === "string" && updatedAt.trim().length > 0 ? updatedAt : null,
303 ...(typeof isCrossRepository === "boolean" ? { isCrossRepository } : {}),
304 ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}),
305 ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}),
184306 });
185307 }
186308 return parsed;
187309}
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
189332function gitManagerError(operation: string, detail: string, cause?: unknown): GitManagerError {
190333 return new GitManagerError({
191334 operation,
......@@ -199,6 +342,57 @@ function limitContext(value: string, maxChars: number): string {
199342 return `${value.slice(0, maxChars)}\n\n[truncated]`;
200343}
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
202396function sanitizeCommitMessage(generated: {
203397 subject: string;
204398 body: string;
......@@ -236,6 +430,12 @@ interface CommitAndBranchSuggestion {
236430 commitMessage: string;
237431}
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
239439function formatCommitMessage(subject: string, body: string): string {
240440 const trimmedBody = body.trim();
241441 if (trimmedBody.length === 0) {
......@@ -262,25 +462,6 @@ function parseCustomCommitMessage(raw: string): { subject: string; body: string
262462 };
263463}
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
284465function appendUnique(values: string[], next: string | null | undefined): void {
285466 const trimmed = next?.trim() ?? "";
286467 if (trimmed.length === 0 || values.includes(trimmed)) {
......@@ -368,7 +549,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
368549 const serverSettingsService = yield* ServerSettingsService;
369550
370551 const createProgressEmitter = (
371 input: { cwd: string; action: "commit" | "commit_push" | "commit_push_pr" },
552 input: { cwd: string; action: GitStackedAction },
372553 options?: GitRunStackedActionOptions,
373554 ) => {
374555 const actionId = options?.actionId ?? randomUUID();
......@@ -505,6 +686,38 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
505686 const path = yield* Path.Path;
506687
507688 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
509722 const readConfigValueNullable = (cwd: string, key: string) =>
510723 gitCore.readConfigValue(cwd, key).pipe(Effect.catch(() => Effect.succeed(null)));
......@@ -534,9 +747,11 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
534747 ) {
535748 const remoteName = yield* readConfigValueNullable(cwd, `branch.${details.branch}.remote`);
536749 const headBranchFromUpstream = details.upstreamRef
537 ? extractBranchFromRef(details.upstreamRef)
750 ? extractBranchNameFromRemoteRef(details.upstreamRef, { remoteName })
538751 : "";
539752 const headBranch = headBranchFromUpstream.length > 0 ? headBranchFromUpstream : details.branch;
753 const shouldProbeLocalBranchSelector =
754 headBranchFromUpstream.length === 0 || headBranch === details.branch;
540755
541756 const [remoteRepository, originRepository] = yield* Effect.all(
542757 [
......@@ -572,7 +787,9 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
572787 remoteAliasHeadSelector !== ownerHeadSelector ? remoteAliasHeadSelector : null,
573788 );
574789 }
575 appendUnique(headSelectors, details.branch);
790 if (shouldProbeLocalBranchSelector) {
791 appendUnique(headSelectors, details.branch);
792 }
576793 appendUnique(headSelectors, headBranch !== details.branch ? headBranch : null);
577794 if (!isCrossRepository && shouldProbeRemoteOwnedSelectors) {
578795 appendUnique(headSelectors, ownerHeadSelector);
......@@ -597,16 +814,26 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
597814
598815 const findOpenPr = Effect.fn("findOpenPr")(function* (
599816 cwd: string,
600 headSelectors: ReadonlyArray<string>,
817 headContext: Pick<
818 BranchHeadContext,
819 | "headBranch"
820 | "headSelectors"
821 | "headRepositoryNameWithOwner"
822 | "headRepositoryOwnerLogin"
823 | "isCrossRepository"
824 >,
601825 ) {
602 for (const headSelector of headSelectors) {
826 for (const headSelector of headContext.headSelectors) {
603827 const pullRequests = yield* gitHubCli.listOpenPullRequests({
604828 cwd,
605829 headSelector,
606830 limit: 1,
607831 });
832 const normalizedPullRequests = pullRequests.map(toPullRequestInfo);
608833
609 const [firstPullRequest] = pullRequests;
834 const firstPullRequest = normalizedPullRequests.find((pullRequest) =>
835 matchesBranchHeadContext(pullRequest, headContext),
836 );
610837 if (firstPullRequest) {
611838 return {
612839 number: firstPullRequest.number,
......@@ -644,7 +871,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
644871 "--limit",
645872 "20",
646873 "--json",
647 "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt",
874 "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",
648875 ],
649876 })
650877 .pipe(Effect.map((result) => result.stdout));
......@@ -661,6 +888,9 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
661888 });
662889
663890 for (const pr of parsePullRequestList(parsedJson)) {
891 if (!matchesBranchHeadContext(pr, headContext)) {
892 continue;
893 }
664894 parsedByNumber.set(pr.number, pr);
665895 }
666896 }
......@@ -678,17 +908,119 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
678908 return parsed[0] ?? null;
679909 });
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
6811011 const resolveBaseBranch = Effect.fn("resolveBaseBranch")(function* (
6821012 cwd: string,
6831013 branch: string,
6841014 upstreamRef: string | null,
685 headContext: Pick<BranchHeadContext, "isCrossRepository">,
1015 headContext: Pick<BranchHeadContext, "isCrossRepository" | "remoteName">,
6861016 ) {
6871017 const configured = yield* gitCore.readConfigValue(cwd, `branch.${branch}.gh-merge-base`);
6881018 if (configured) return configured;
6891019
6901020 if (upstreamRef && !headContext.isCrossRepository) {
691 const upstreamBranch = extractBranchFromRef(upstreamRef);
1021 const upstreamBranch = extractBranchNameFromRemoteRef(upstreamRef, {
1022 remoteName: headContext.remoteName,
1023 });
6921024 if (upstreamBranch.length > 0 && upstreamBranch !== branch) {
6931025 return upstreamBranch;
6941026 }
......@@ -889,7 +1221,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
8891221 upstreamRef: details.upstreamRef,
8901222 });
8911223
892 const existing = yield* findOpenPr(cwd, headContext.headSelectors);
1224 const existing = yield* findOpenPr(cwd, headContext);
8931225 if (existing) {
8941226 return {
8951227 status: "opened_existing" as const,
......@@ -932,7 +1264,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
9321264 })
9331265 .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);
9361268 if (!created) {
9371269 return {
9381270 status: "created" as const,
......@@ -953,28 +1285,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
9531285 });
9541286
9551287 const status: GitManagerShape["status"] = Effect.fn("status")(function* (input) {
956 const details = yield* gitCore.statusDetails(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 };
1288 return yield* Cache.get(statusResultCache, normalizeStatusCacheKey(input.cwd));
9781289 });
9791290
9801291 const resolvePullRequest: GitManagerShape["resolvePullRequest"] = Effect.fn("resolvePullRequest")(
......@@ -993,143 +1304,145 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
9931304 const preparePullRequestThread: GitManagerShape["preparePullRequestThread"] = Effect.fn(
9941305 "preparePullRequestThread",
9951306 )(function* (input) {
996 const normalizedReference = normalizePullRequestReference(input.reference);
997 const rootWorktreePath = canonicalizeExistingPath(input.cwd);
998 const pullRequestSummary = yield* gitHubCli.getPullRequest({
999 cwd: input.cwd,
1000 reference: normalizedReference,
1001 });
1002 const pullRequest = toResolvedPullRequest(pullRequestSummary);
1003
1004 if (input.mode === "local") {
1005 yield* gitHubCli.checkoutPullRequest({
1307 return yield* Effect.gen(function* () {
1308 const normalizedReference = normalizePullRequestReference(input.reference);
1309 const rootWorktreePath = canonicalizeExistingPath(input.cwd);
1310 const pullRequestSummary = yield* gitHubCli.getPullRequest({
10061311 cwd: input.cwd,
10071312 reference: normalizedReference,
1008 force: true,
10091313 });
1010 const details = yield* gitCore.statusDetails(input.cwd);
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 }
1314 const pullRequest = toResolvedPullRequest(pullRequestSummary);
10251315
1026 const ensureExistingWorktreeUpstream = Effect.fn("ensureExistingWorktreeUpstream")(function* (
1027 worktreePath: string,
1028 ) {
1029 const details = yield* gitCore.statusDetails(worktreePath);
1030 yield* configurePullRequestHeadUpstream(
1031 worktreePath,
1032 {
1033 ...pullRequest,
1034 ...toPullRequestHeadRemoteInfo(pullRequestSummary),
1035 },
1036 details.branch ?? pullRequest.headBranch,
1037 );
1038 });
1316 if (input.mode === "local") {
1317 yield* gitHubCli.checkoutPullRequest({
1318 cwd: input.cwd,
1319 reference: normalizedReference,
1320 force: true,
1321 });
1322 const details = yield* gitCore.statusDetails(input.cwd);
1323 yield* configurePullRequestHeadUpstream(
1324 input.cwd,
1325 {
1326 ...pullRequest,
1327 ...toPullRequestHeadRemoteInfo(pullRequestSummary),
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 = {
1041 ...pullRequest,
1042 ...toPullRequestHeadRemoteInfo(pullRequestSummary),
1043 } as const;
1044 const localPullRequestBranch =
1045 resolvePullRequestWorktreeLocalBranchName(pullRequestWithRemoteInfo);
1046
1047 const findLocalHeadBranch = (cwd: string) =>
1048 gitCore.listBranches({ cwd }).pipe(
1049 Effect.map((result) => {
1050 const localBranch = result.branches.find(
1051 (branch) => !branch.isRemote && branch.name === localPullRequestBranch,
1052 );
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 );
1338 const ensureExistingWorktreeUpstream = Effect.fn("ensureExistingWorktreeUpstream")(function* (
1339 worktreePath: string,
1340 ) {
1341 const details = yield* gitCore.statusDetails(worktreePath);
1342 yield* configurePullRequestHeadUpstream(
1343 worktreePath,
1344 {
1345 ...pullRequest,
1346 ...toPullRequestHeadRemoteInfo(pullRequestSummary),
1347 },
1348 details.branch ?? pullRequest.headBranch,
1349 );
1350 });
10701351
1071 const existingBranchBeforeFetch = yield* findLocalHeadBranch(input.cwd);
1072 const existingBranchBeforeFetchPath = existingBranchBeforeFetch?.worktreePath
1073 ? canonicalizeExistingPath(existingBranchBeforeFetch.worktreePath)
1074 : null;
1075 if (
1076 existingBranchBeforeFetch?.worktreePath &&
1077 existingBranchBeforeFetchPath !== rootWorktreePath
1078 ) {
1079 yield* ensureExistingWorktreeUpstream(existingBranchBeforeFetch.worktreePath);
1080 return {
1081 pullRequest,
1082 branch: localPullRequestBranch,
1083 worktreePath: existingBranchBeforeFetch.worktreePath,
1084 };
1085 }
1086 if (existingBranchBeforeFetchPath === rootWorktreePath) {
1087 return yield* gitManagerError(
1088 "preparePullRequestThread",
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.",
1352 const pullRequestWithRemoteInfo = {
1353 ...pullRequest,
1354 ...toPullRequestHeadRemoteInfo(pullRequestSummary),
1355 } as const;
1356 const localPullRequestBranch =
1357 resolvePullRequestWorktreeLocalBranchName(pullRequestWithRemoteInfo);
1358
1359 const findLocalHeadBranch = (cwd: string) =>
1360 gitCore.listBranches({ cwd }).pipe(
1361 Effect.map((result) => {
1362 const localBranch = result.branches.find(
1363 (branch) => !branch.isRemote && branch.name === localPullRequestBranch,
1364 );
1365 if (localBranch) {
1366 return localBranch;
1367 }
1368 if (localPullRequestBranch === pullRequest.headBranch) {
1369 return null;
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,
10901409 );
1091 }
10921410
1093 yield* materializePullRequestHeadBranch(
1094 input.cwd,
1095 pullRequestWithRemoteInfo,
1096 localPullRequestBranch,
1097 );
1411 const existingBranchAfterFetch = yield* findLocalHeadBranch(input.cwd);
1412 const existingBranchAfterFetchPath = existingBranchAfterFetch?.worktreePath
1413 ? canonicalizeExistingPath(existingBranchAfterFetch.worktreePath)
1414 : null;
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);
11081440 return {
11091441 pullRequest,
1110 branch: localPullRequestBranch,
1111 worktreePath: existingBranchAfterFetch.worktreePath,
1442 branch: worktree.worktree.branch,
1443 worktreePath: worktree.worktree.path,
11121444 };
1113 }
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 };
1445 }).pipe(Effect.ensuring(invalidateStatusResultCache(input.cwd)));
11331446 });
11341447
11351448 const runFeatureBranchStep = Effect.fn("runFeatureBranchStep")(function* (
......@@ -1171,27 +1484,53 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
11711484 const runStackedAction: GitManagerShape["runStackedAction"] = Effect.fn("runStackedAction")(
11721485 function* (input, options) {
11731486 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 ];
11801487 const currentPhase = yield* Ref.make<Option.Option<GitActionProgressPhase>>(Option.none());
11811488
11821489 const runAction = Effect.fn("runStackedAction.runAction")(function* (): Effect.fn.Return<
11831490 GitRunStackedActionResult,
11841491 GitManagerServiceError
11851492 > {
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
11861529 yield* progress.emit({
11871530 kind: "action_started",
11881531 phases,
11891532 });
11901533
1191 const wantsPush = input.action !== "commit";
1192 const wantsPr = input.action === "commit_push_pr";
1193
1194 const initialStatus = yield* gitCore.statusDetails(input.cwd);
11951534 if (!input.featureBranch && wantsPush && !initialStatus.branch) {
11961535 return yield* gitManagerError("runStackedAction", "Cannot push from detached HEAD.");
11971536 }
......@@ -1235,19 +1574,25 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
12351574 }
12361575
12371576 const currentBranch = branchStep.name ?? initialStatus.branch;
1238
1239 yield* Ref.set(currentPhase, Option.some("commit"));
1240 const commit = yield* runCommitStep(
1241 modelSelection,
1242 input.cwd,
1243 input.action,
1244 currentBranch,
1245 commitMessageForStep,
1246 preResolvedCommitSuggestion,
1247 input.filePaths,
1248 options?.progressReporter,
1249 progress.actionId,
1250 );
1577 const commitAction = isCommitAction(input.action) ? input.action : null;
1578
1579 const commit = commitAction
1580 ? yield* Ref.set(currentPhase, Option.some("commit")).pipe(
1581 Effect.flatMap(() =>
1582 runCommitStep(
1583 modelSelection,
1584 input.cwd,
1585 commitAction,
1586 currentBranch,
1587 commitMessageForStep,
1588 preResolvedCommitSuggestion,
1589 input.filePaths,
1590 options?.progressReporter,
1591 progress.actionId,
1592 ),
1593 ),
1594 )
1595 : { status: "skipped_not_requested" as const };
12511596
12521597 const push = wantsPush
12531598 ? yield* progress
......@@ -1275,12 +1620,21 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
12751620 )
12761621 : { 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
12781631 const result = {
12791632 action: input.action,
12801633 branch: branchStep,
12811634 commit,
12821635 push,
12831636 pr,
1637 toast,
12841638 };
12851639 yield* progress.emit({
12861640 kind: "action_finished",
......@@ -1290,6 +1644,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
12901644 });
12911645
12921646 return yield* runAction().pipe(
1647 Effect.ensuring(invalidateStatusResultCache(input.cwd)),
12931648 Effect.tapError((error) =>
12941649 Effect.flatMap(Ref.get(currentPhase), (phase) =>
12951650 progress.emit({
apps/server/src/git/Services/GitManager.ts+1-1
......@@ -56,7 +56,7 @@ export interface GitManagerShape {
5656 ) => Effect.Effect<GitPreparePullRequestThreadResult, GitManagerServiceError>;
5757
5858 /**
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`).
6060 * When `featureBranch` is set, creates and checks out a feature branch first.
6161 */
6262 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) => {
842842 },
843843 push: { status: "skipped_not_requested" as const },
844844 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 },
845856 };
846857
847858 yield* (
apps/web/src/components/GitActionsControl.logic.test.ts+86-78
......@@ -6,8 +6,9 @@ import {
66 requiresDefaultBranchConfirmation,
77 resolveAutoFeatureBranchName,
88 resolveDefaultBranchActionDialogCopy,
9 resolveLiveThreadBranchUpdate,
910 resolveQuickAction,
10 summarizeGitResult,
11 resolveThreadBranchUpdate,
1112} from "./GitActionsControl.logic";
1213
1314function status(overrides: Partial<GitStatusResult> = {}): GitStatusResult {
......@@ -162,7 +163,7 @@ describe("when: branch is clean, ahead, and has an open PR", () => {
162163 }),
163164 false,
164165 );
165 assert.deepInclude(quick, { kind: "run_action", action: "commit_push", label: "Push" });
166 assert.deepInclude(quick, { kind: "run_action", action: "push", label: "Push" });
166167 });
167168
168169 it("buildMenuItems enables push and keeps open PR available", () => {
......@@ -213,7 +214,7 @@ describe("when: branch is clean, ahead, and has no open PR", () => {
213214 const quick = resolveQuickAction(status({ aheadCount: 2, pr: null }), false);
214215 assert.deepInclude(quick, {
215216 kind: "run_action",
216 action: "commit_push_pr",
217 action: "create_pr",
217218 label: "Push & create PR",
218219 });
219220 });
......@@ -585,7 +586,7 @@ describe("when: branch has no upstream configured", () => {
585586 );
586587 assert.deepInclude(quick, {
587588 kind: "run_action",
588 action: "commit_push",
589 action: "push",
589590 label: "Push",
590591 disabled: false,
591592 });
......@@ -632,7 +633,7 @@ describe("when: branch has no upstream configured", () => {
632633 );
633634 assert.deepInclude(quick, {
634635 kind: "run_action",
635 action: "commit_push_pr",
636 action: "create_pr",
636637 label: "Push & create PR",
637638 disabled: false,
638639 });
......@@ -801,9 +802,12 @@ describe("when: branch has no upstream configured", () => {
801802describe("requiresDefaultBranchConfirmation", () => {
802803 it("requires confirmation for push actions on default branch", () => {
803804 assert.isFalse(requiresDefaultBranchConfirmation("commit", true));
805 assert.isTrue(requiresDefaultBranchConfirmation("push", true));
806 assert.isTrue(requiresDefaultBranchConfirmation("create_pr", true));
804807 assert.isTrue(requiresDefaultBranchConfirmation("commit_push", true));
805808 assert.isTrue(requiresDefaultBranchConfirmation("commit_push_pr", true));
806809 assert.isFalse(requiresDefaultBranchConfirmation("commit_push", false));
810 assert.isFalse(requiresDefaultBranchConfirmation("push", false));
807811 });
808812});
809813
......@@ -855,28 +859,37 @@ describe("resolveDefaultBranchActionDialogCopy", () => {
855859});
856860
857861describe("buildGitActionProgressStages", () => {
858 it("shows only push progress when push-only is forced", () => {
862 it("shows only push progress for explicit push actions", () => {
859863 const stages = buildGitActionProgressStages({
860 action: "commit_push",
864 action: "push",
861865 hasCustomCommitMessage: false,
862 hasWorkingTreeChanges: true,
863 forcePushOnly: true,
866 hasWorkingTreeChanges: false,
864867 pushTarget: "origin/feature/test",
865868 });
866869 assert.deepEqual(stages, ["Pushing to origin/feature/test..."]);
867870 });
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", () => {
870873 const stages = buildGitActionProgressStages({
871 action: "commit_push_pr",
874 action: "create_pr",
872875 hasCustomCommitMessage: false,
873 hasWorkingTreeChanges: true,
874 forcePushOnly: true,
876 hasWorkingTreeChanges: false,
875877 pushTarget: "origin/feature/test",
878 shouldPushBeforePr: true,
876879 });
877880 assert.deepEqual(stages, ["Pushing to origin/feature/test...", "Creating PR..."]);
878881 });
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
880893 it("includes commit stages for commit+push when working tree is dirty", () => {
881894 const stages = buildGitActionProgressStages({
882895 action: "commit_push",
......@@ -892,97 +905,92 @@ describe("buildGitActionProgressStages", () => {
892905 });
893906});
894907
895describe("summarizeGitResult", () => {
896 it("returns commit-focused toast for commit action", () => {
897 const result = summarizeGitResult({
898 action: "commit",
899 branch: { status: "skipped_not_requested" },
908describe("resolveThreadBranchUpdate", () => {
909 it("returns a branch update when the action created a new branch", () => {
910 const update = resolveThreadBranchUpdate({
911 action: "commit_push_pr",
912 branch: {
913 status: "created",
914 name: "feature/fix-toast-copy",
915 },
900916 commit: {
901917 status: "created",
902 commitSha: "0123456789abcdef",
903 subject: "feat: add optimistic UI for git action button",
918 commitSha: "89abcdef01234567",
919 subject: "feat: add branch sync",
904920 },
905 push: { status: "skipped_not_requested" },
921 push: { status: "pushed", branch: "feature/fix-toast-copy" },
906922 pr: { status: "skipped_not_requested" },
923 toast: {
924 title: "Pushed 89abcde to origin/feature/fix-toast-copy",
925 cta: { kind: "none" },
926 },
907927 });
908928
909 assert.deepEqual(result, {
910 title: "Committed 0123456",
911 description: "feat: add optimistic UI for git action button",
929 assert.deepEqual(update, {
930 branch: "feature/fix-toast-copy",
912931 });
913932 });
914933
915 it("returns push-focused toast for push action", () => {
916 const result = summarizeGitResult({
934 it("returns null when the action stayed on the existing branch", () => {
935 const update = resolveThreadBranchUpdate({
917936 action: "commit_push",
918 branch: { status: "skipped_not_requested" },
937 branch: {
938 status: "skipped_not_requested",
939 },
919940 commit: {
920941 status: "created",
921 commitSha: "abcdef0123456789",
922 subject: "fix: tighten quick action tooltip hover handling",
923 },
924 push: {
925 status: "pushed",
926 branch: "foo",
927 upstreamBranch: "origin/foo",
942 commitSha: "89abcdef01234567",
943 subject: "feat: add branch sync",
928944 },
945 push: { status: "pushed", branch: "feature/fix-toast-copy" },
929946 pr: { status: "skipped_not_requested" },
947 toast: {
948 title: "Pushed 89abcde to origin/feature/fix-toast-copy",
949 cta: { kind: "none" },
950 },
930951 });
931952
932 assert.deepEqual(result, {
933 title: "Pushed abcdef0 to origin/foo",
934 description: "fix: tighten quick action tooltip hover handling",
935 });
953 assert.equal(update, null);
936954 });
955});
937956
938 it("returns PR-focused toast for created PR action", () => {
939 const result = summarizeGitResult({
940 action: "commit_push_pr",
941 branch: { status: "skipped_not_requested" },
942 commit: {
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 },
957describe("resolveLiveThreadBranchUpdate", () => {
958 it("returns a branch update when live git status differs from stored thread metadata", () => {
959 const update = resolveLiveThreadBranchUpdate({
960 threadBranch: "feature/old-branch",
961 gitStatus: status({ branch: "effect-atom" }),
956962 });
957963
958 assert.deepEqual(result, {
959 title: "Created PR #42",
960 description: "feat: ship github shortcuts and improve PR CTA in success toast",
964 assert.deepEqual(update, {
965 branch: "effect-atom",
961966 });
962967 });
963968
964 it("truncates long description text", () => {
965 const result = summarizeGitResult({
966 action: "commit_push_pr",
967 branch: { status: "skipped_not_requested" },
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 },
969 it("returns null when live git status is unavailable", () => {
970 const update = resolveLiveThreadBranchUpdate({
971 threadBranch: "feature/old-branch",
972 gitStatus: null,
980973 });
981974
982 assert.deepEqual(result, {
983 title: "Created PR #99",
984 description: "feat: this title is intentionally extremely long so we can validate t...",
975 assert.equal(update, null);
976 });
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" }),
985982 });
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);
986994 });
987995});
988996
apps/web/src/components/GitActionsControl.logic.ts+70-62
......@@ -31,43 +31,36 @@ export interface DefaultBranchActionDialogCopy {
3131 continueLabel: string;
3232}
3333
34export type DefaultBranchConfirmableAction = "commit_push" | "commit_push_pr";
35
36const SHORT_SHA_LENGTH = 7;
37const TOAST_DESCRIPTION_MAX = 72;
38
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}
34export type DefaultBranchConfirmableAction =
35 | "push"
36 | "create_pr"
37 | "commit_push"
38 | "commit_push_pr";
5339
5440export function buildGitActionProgressStages(input: {
5541 action: GitStackedAction;
5642 hasCustomCommitMessage: boolean;
5743 hasWorkingTreeChanges: boolean;
58 forcePushOnly?: boolean;
5944 pushTarget?: string;
6045 featureBranch?: boolean;
46 shouldPushBeforePr?: boolean;
6147}): string[] {
6248 const branchStages = input.featureBranch ? ["Preparing feature branch..."] : [];
63 const shouldIncludeCommitStages =
64 !input.forcePushOnly && (input.action === "commit" || input.hasWorkingTreeChanges);
49 const pushStage = input.pushTarget ? `Pushing to ${input.pushTarget}...` : "Pushing...";
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;
6559 const commitStages = !shouldIncludeCommitStages
6660 ? []
6761 : input.hasCustomCommitMessage
6862 ? ["Committing..."]
6963 : ["Generating commit message...", "Committing..."];
70 const pushStage = input.pushTarget ? `Pushing to ${input.pushTarget}...` : "Pushing...";
7164 if (input.action === "commit") {
7265 return [...branchStages, ...commitStages];
7366 }
......@@ -77,39 +70,6 @@ export function buildGitActionProgressStages(input: {
7770 return [...branchStages, ...commitStages, pushStage, "Creating PR..."];
7871}
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
11373export function buildMenuItems(
11474 gitStatus: GitStatusResult | null,
11575 isBusy: boolean,
......@@ -250,13 +210,18 @@ export function resolveQuickAction(
250210 };
251211 }
252212 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 };
254219 }
255220 return {
256221 label: "Push & create PR",
257222 disabled: false,
258223 kind: "run_action",
259 action: "commit_push_pr",
224 action: "create_pr",
260225 };
261226 }
262227
......@@ -279,13 +244,18 @@ export function resolveQuickAction(
279244
280245 if (isAhead) {
281246 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 };
283253 }
284254 return {
285255 label: "Push & create PR",
286256 disabled: false,
287257 kind: "run_action",
288 action: "commit_push_pr",
258 action: "create_pr",
289259 };
290260 }
291261
......@@ -306,7 +276,12 @@ export function requiresDefaultBranchConfirmation(
306276 isDefaultBranch: boolean,
307277): boolean {
308278 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 );
310285}
311286
312287export function resolveDefaultBranchActionDialogCopy(input: {
......@@ -317,7 +292,7 @@ export function resolveDefaultBranchActionDialogCopy(input: {
317292 const branchLabel = input.branchName;
318293 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") {
321296 if (input.includesCommit) {
322297 return {
323298 title: "Commit & push to default branch?",
......@@ -346,5 +321,38 @@ export function resolveDefaultBranchActionDialogCopy(input: {
346321 };
347322}
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
349357// Re-export from shared for backwards compatibility in this module's exports
350358export { resolveAutoFeatureBranchName } from "@t3tools/shared/git";
apps/web/src/components/GitActionsControl.tsx+120-84
......@@ -1,5 +1,6 @@
11import type {
22 GitActionProgressEvent,
3 GitRunStackedActionResult,
34 GitStackedAction,
45 GitStatusResult,
56 ThreadId,
......@@ -17,8 +18,9 @@ import {
1718 type DefaultBranchConfirmableAction,
1819 requiresDefaultBranchConfirmation,
1920 resolveDefaultBranchActionDialogCopy,
21 resolveLiveThreadBranchUpdate,
2022 resolveQuickAction,
21 summarizeGitResult,
23 resolveThreadBranchUpdate,
2224} from "./GitActionsControl.logic";
2325import { Button } from "~/components/ui/button";
2426import { Checkbox } from "~/components/ui/checkbox";
......@@ -47,9 +49,10 @@ import {
4749 gitStatusQueryOptions,
4850 invalidateGitQueries,
4951} from "~/lib/gitReactQuery";
50import { randomUUID } from "~/lib/utils";
52import { newCommandId, randomUUID } from "~/lib/utils";
5153import { resolvePathLinkTarget } from "~/terminal-links";
5254import { readNativeApi } from "~/nativeApi";
55import { useStore } from "~/store";
5356
5457interface GitActionsControlProps {
5558 gitCwd: string | null;
......@@ -61,7 +64,6 @@ interface PendingDefaultBranchAction {
6164 branchName: string;
6265 includesCommit: boolean;
6366 commitMessage?: string;
64 forcePushOnlyProgress: boolean;
6567 onConfirmed?: () => void;
6668 filePaths?: string[];
6769}
......@@ -82,12 +84,10 @@ interface ActiveGitActionProgress {
8284interface RunGitActionWithToastInput {
8385 action: GitStackedAction;
8486 commitMessage?: string;
85 forcePushOnlyProgress?: boolean;
8687 onConfirmed?: () => void;
8788 skipDefaultBranchPrompt?: boolean;
8889 statusOverride?: GitStatusResult | null;
8990 featureBranch?: boolean;
90 isDefaultBranchOverride?: boolean;
9191 progressToastId?: GitActionToastId;
9292 filePaths?: string[];
9393}
......@@ -196,7 +196,9 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) {
196196 if (quickAction.kind === "run_pull") return <InfoIcon className={iconClassName} />;
197197 if (quickAction.kind === "run_action") {
198198 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 }
200202 return <GitHubIcon className={iconClassName} />;
201203 }
202204 if (quickAction.label === "Commit") return <GitCommitIcon className={iconClassName} />;
......@@ -208,6 +210,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
208210 () => (activeThreadId ? { threadId: activeThreadId } : undefined),
209211 [activeThreadId],
210212 );
213 const activeServerThread = useStore((store) =>
214 activeThreadId ? store.threads.find((thread) => thread.id === activeThreadId) : undefined,
215 );
216 const setThreadBranch = useStore((store) => store.setThreadBranch);
211217 const queryClient = useQueryClient();
212218 const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false);
213219 const [dialogCommitMessage, setDialogCommitMessage] = useState("");
......@@ -231,6 +237,43 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
231237 });
232238 }, [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
234277 const { data: gitStatus = null, error: gitStatusError } = useQuery(gitStatusQueryOptions(gitCwd));
235278
236279 const { data: branchList = null } = useQuery(gitBranchesQueryOptions(gitCwd));
......@@ -267,6 +310,28 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
267310 useIsMutating({ mutationKey: gitMutationKeys.runStackedAction(gitCwd) }) > 0;
268311 const isPullRunning = useIsMutating({ mutationKey: gitMutationKeys.pull(gitCwd) }) > 0;
269312 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
270335 const isDefaultBranch = useMemo(() => {
271336 const branchName = gitStatusForActions?.branch;
272337 if (!branchName) return false;
......@@ -340,27 +405,32 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
340405 async ({
341406 action,
342407 commitMessage,
343 forcePushOnlyProgress = false,
344408 onConfirmed,
345409 skipDefaultBranchPrompt = false,
346410 statusOverride,
347411 featureBranch = false,
348 isDefaultBranchOverride,
349412 progressToastId,
350413 filePaths,
351414 }: RunGitActionWithToastInput) => {
352415 const actionStatus = statusOverride ?? gitStatusForActions;
353416 const actionBranch = actionStatus?.branch ?? null;
354 const actionIsDefaultBranch =
355 isDefaultBranchOverride ?? (featureBranch ? false : isDefaultBranch);
417 const actionIsDefaultBranch = featureBranch ? false : isDefaultBranch;
418 const actionCanCommit =
419 action === "commit" || action === "commit_push" || action === "commit_push_pr";
356420 const includesCommit =
357 !forcePushOnlyProgress && (action === "commit" || !!actionStatus?.hasWorkingTreeChanges);
421 actionCanCommit &&
422 (action === "commit" || !!actionStatus?.hasWorkingTreeChanges || featureBranch);
358423 if (
359424 !skipDefaultBranchPrompt &&
360425 requiresDefaultBranchConfirmation(action, actionIsDefaultBranch) &&
361426 actionBranch
362427 ) {
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 ) {
364434 return;
365435 }
366436 setPendingDefaultBranchAction({
......@@ -368,7 +438,6 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
368438 branchName: actionBranch,
369439 includesCommit,
370440 ...(commitMessage ? { commitMessage } : {}),
371 forcePushOnlyProgress,
372441 ...(onConfirmed ? { onConfirmed } : {}),
373442 ...(filePaths ? { filePaths } : {}),
374443 });
......@@ -380,8 +449,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
380449 action,
381450 hasCustomCommitMessage: !!commitMessage?.trim(),
382451 hasWorkingTreeChanges: !!actionStatus?.hasWorkingTreeChanges,
383 forcePushOnly: forcePushOnlyProgress,
384452 featureBranch,
453 shouldPushBeforePr:
454 action === "create_pr" &&
455 (!actionStatus?.hasUpstream || (actionStatus?.aheadCount ?? 0) > 0),
385456 });
386457 const actionId = randomUUID();
387458 const resolvedProgressToastId =
......@@ -483,79 +554,48 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
483554 try {
484555 const result = await promise;
485556 activeGitActionProgressRef.current = null;
486 const resultToast = summarizeGitResult(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;
557 syncThreadBranchAfterGitAction(result);
503558 const closeResultToast = () => {
504559 toastManager.close(resolvedProgressToastId);
505560 };
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
507589 toastManager.update(resolvedProgressToastId, {
508590 type: "success",
509 title: resultToast.title,
510 description: resultToast.description,
591 title: result.toast.title,
592 description: result.toast.description,
511593 timeout: 0,
512594 data: {
513595 ...threadToastData,
514596 dismissAfterVisibleMs: 10_000,
515597 },
516 ...(shouldOfferPushCta
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 : {}),
598 ...(toastActionProps ? { actionProps: toastActionProps } : {}),
559599 });
560600 } catch (err) {
561601 activeGitActionProgressRef.current = null;
......@@ -571,13 +611,11 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
571611
572612 const continuePendingDefaultBranchAction = useCallback(() => {
573613 if (!pendingDefaultBranchAction) return;
574 const { action, commitMessage, forcePushOnlyProgress, onConfirmed, filePaths } =
575 pendingDefaultBranchAction;
614 const { action, commitMessage, onConfirmed, filePaths } = pendingDefaultBranchAction;
576615 setPendingDefaultBranchAction(null);
577616 void runGitActionWithToast({
578617 action,
579618 ...(commitMessage ? { commitMessage } : {}),
580 forcePushOnlyProgress,
581619 ...(onConfirmed ? { onConfirmed } : {}),
582620 ...(filePaths ? { filePaths } : {}),
583621 skipDefaultBranchPrompt: true,
......@@ -586,13 +624,11 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
586624
587625 const checkoutFeatureBranchAndContinuePendingAction = useCallback(() => {
588626 if (!pendingDefaultBranchAction) return;
589 const { action, commitMessage, forcePushOnlyProgress, onConfirmed, filePaths } =
590 pendingDefaultBranchAction;
627 const { action, commitMessage, onConfirmed, filePaths } = pendingDefaultBranchAction;
591628 setPendingDefaultBranchAction(null);
592629 void runGitActionWithToast({
593630 action,
594631 ...(commitMessage ? { commitMessage } : {}),
595 forcePushOnlyProgress,
596632 ...(onConfirmed ? { onConfirmed } : {}),
597633 ...(filePaths ? { filePaths } : {}),
598634 featureBranch: true,
......@@ -666,11 +702,11 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
666702 return;
667703 }
668704 if (item.dialogAction === "push") {
669 void runGitActionWithToast({ action: "commit_push", forcePushOnlyProgress: true });
705 void runGitActionWithToast({ action: "push" });
670706 return;
671707 }
672708 if (item.dialogAction === "create_pr") {
673 void runGitActionWithToast({ action: "commit_push_pr" });
709 void runGitActionWithToast({ action: "create_pr" });
674710 return;
675711 }
676712 setExcludedFiles(new Set());
apps/web/src/components/Sidebar.logic.test.ts+63
......@@ -11,6 +11,7 @@ import {
1111 isContextMenuPointerDown,
1212 orderItemsByPreferredIds,
1313 resolveProjectStatusIndicator,
14 resolveSidebarNewThreadSeedContext,
1415 resolveSidebarNewThreadEnvMode,
1516 resolveThreadRowClassName,
1617 resolveThreadStatusPill,
......@@ -164,6 +165,68 @@ describe("resolveSidebarNewThreadEnvMode", () => {
164165 });
165166});
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
167230describe("orderItemsByPreferredIds", () => {
168231 it("keeps preferred ids first, skips stale ids, and preserves the relative order of remaining items", () => {
169232 const ordered = orderItemsByPreferredIds({
apps/web/src/components/Sidebar.logic.ts+40
......@@ -161,6 +161,46 @@ export function resolveSidebarNewThreadEnvMode(input: {
161161 return input.requestedEnvMode ?? input.defaultEnvMode;
162162}
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
164204export function orderItemsByPreferredIds<TItem, TId>(input: {
165205 items: readonly TItem[];
166206 preferredIds: readonly TId[];
apps/web/src/components/Sidebar.tsx+29-3
......@@ -111,6 +111,7 @@ import {
111111 resolveAdjacentThreadId,
112112 isContextMenuPointerDown,
113113 resolveProjectStatusIndicator,
114 resolveSidebarNewThreadSeedContext,
114115 resolveSidebarNewThreadEnvMode,
115116 resolveThreadRowClassName,
116117 resolveThreadStatusPill,
......@@ -460,7 +461,7 @@ export default function Sidebar() {
460461 const isOnSettings = pathname.startsWith("/settings");
461462 const appSettings = useSettings();
462463 const { updateSettings } = useUpdateSettings();
463 const { handleNewThread } = useHandleNewThread();
464 const { activeDraftThread, activeThread, handleNewThread } = useHandleNewThread();
464465 const { archiveThread, deleteThread } = useThreadActions();
465466 const routeThreadId = useParams({
466467 strict: false,
......@@ -1680,10 +1681,35 @@ export default function Sidebar() {
16801681 onClick={(event) => {
16811682 event.preventDefault();
16821683 event.stopPropagation();
1683 void handleNewThread(project.id, {
1684 envMode: resolveSidebarNewThreadEnvMode({
1684 const seedContext = resolveSidebarNewThreadSeedContext({
1685 projectId: project.id,
1686 defaultEnvMode: resolveSidebarNewThreadEnvMode({
16851687 defaultEnvMode: appSettings.defaultThreadEnvMode,
16861688 }),
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,
16871713 });
16881714 }}
16891715 >
packages/contracts/src/git.test.ts+46-7
......@@ -4,6 +4,7 @@ import { Schema } from "effect";
44import {
55 GitCreateWorktreeInput,
66 GitPreparePullRequestThreadInput,
7 GitRunStackedActionResult,
78 GitRunStackedActionInput,
89 GitResolvePullRequestResult,
910} from "./git";
......@@ -13,6 +14,7 @@ const decodePreparePullRequestThreadInput = Schema.decodeUnknownSync(
1314 GitPreparePullRequestThreadInput,
1415);
1516const decodeRunStackedActionInput = Schema.decodeUnknownSync(GitRunStackedActionInput);
17const decodeRunStackedActionResult = Schema.decodeUnknownSync(GitRunStackedActionResult);
1618const decodeResolvePullRequestResult = Schema.decodeUnknownSync(GitResolvePullRequestResult);
1719
1820describe("GitCreateWorktreeInput", () => {
......@@ -60,18 +62,55 @@ describe("GitResolvePullRequestResult", () => {
6062});
6163
6264describe("GitRunStackedActionInput", () => {
63 it("requires a client-provided actionId for progress correlation", () => {
65 it("accepts explicit stacked actions and requires a client-provided actionId", () => {
6466 const parsed = decodeRunStackedActionInput({
6567 actionId: "action-1",
6668 cwd: "/repo",
67 action: "commit",
68 modelSelection: {
69 provider: "codex",
70 model: "gpt-5.4-mini",
71 },
69 action: "create_pr",
7270 });
7371
7472 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 }
76115 });
77116});
packages/contracts/src/git.ts+39-2
......@@ -5,7 +5,13 @@ const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;
55
66// 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]);
915export type GitStackedAction = typeof GitStackedAction.Type;
1016export const GitActionProgressPhase = Schema.Literals(["branch", "commit", "push", "pr"]);
1117export type GitActionProgressPhase = typeof GitActionProgressPhase.Type;
......@@ -21,7 +27,11 @@ export const GitActionProgressKind = Schema.Literals([
2127export type GitActionProgressKind = typeof GitActionProgressKind.Type;
2228export const GitActionProgressStream = Schema.Literals(["stdout", "stderr"]);
2329export 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]);
2535const GitPushStepStatus = Schema.Literals([
2636 "pushed",
2737 "skipped_not_requested",
......@@ -33,6 +43,32 @@ const GitStatusPrState = Schema.Literals(["open", "closed", "merged"]);
3343const GitPullRequestReference = TrimmedNonEmptyStringSchema;
3444const GitPullRequestState = Schema.Literals(["open", "closed", "merged"]);
3545const 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
3773export const GitBranch = Schema.Struct({
3874 name: TrimmedNonEmptyStringSchema,
......@@ -213,6 +249,7 @@ export const GitRunStackedActionResult = Schema.Struct({
213249 headBranch: Schema.optional(TrimmedNonEmptyStringSchema),
214250 title: Schema.optional(TrimmedNonEmptyStringSchema),
215251 }),
252 toast: GitRunStackedActionToast,
216253});
217254export type GitRunStackedActionResult = typeof GitRunStackedActionResult.Type;
218255