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