| author | |
| committer | |
| log | efcee4b86354fba7457a62d8beafb4011ec3ae52 |
| tree | 31172a0685896a2377111397b74f8e0010dec49f |
| parent | 26edcab8b4bfde31046000a410fbc9ae27ce30ee |
- add `remoteName` to git branch contract metadata
- resolve remote refs using full remote names (including slashes)
- avoid fallback to wrong local checkout when remote ref is missing
- update branch toolbar logic/tests to map and dedupe remote branches correctly6 files changed, 300 insertions(+), 57 deletions(-)
apps/server/src/git/Layers/GitCore.test.ts+86| ... | @@ -414,6 +414,38 @@ it.layer(TestLayer)("git integration", (it) => { | ... | @@ -414,6 +414,38 @@ it.layer(TestLayer)("git integration", (it) => { |
| 414 | ).toBe(true); | 414 | ).toBe(true); |
| 415 | }), | 415 | }), |
| 416 | ); | 416 | ); |
| 417 | |||
| 418 | it.effect("includes remoteName metadata for remotes with slash in the name", () => | ||
| 419 | Effect.gen(function* () { | ||
| 420 | const remote = yield* makeTmpDir(); | ||
| 421 | const tmp = yield* makeTmpDir(); | ||
| 422 | const remoteName = "my-org/upstream"; | ||
| 423 | |||
| 424 | yield* git(remote, ["init", "--bare"]); | ||
| 425 | yield* initRepoWithCommit(tmp); | ||
| 426 | const defaultBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( | ||
| 427 | (branch) => branch.current, | ||
| 428 | )!.name; | ||
| 429 | |||
| 430 | yield* git(tmp, ["remote", "add", remoteName, remote]); | ||
| 431 | yield* git(tmp, ["push", "-u", remoteName, defaultBranch]); | ||
| 432 | |||
| 433 | const remoteOnlyBranch = "feature/remote-with-remote-name"; | ||
| 434 | yield* git(tmp, ["checkout", "-b", remoteOnlyBranch]); | ||
| 435 | yield* git(tmp, ["push", "-u", remoteName, remoteOnlyBranch]); | ||
| 436 | yield* git(tmp, ["checkout", defaultBranch]); | ||
| 437 | yield* git(tmp, ["branch", "-D", remoteOnlyBranch]); | ||
| 438 | |||
| 439 | const result = yield* listGitBranches({ cwd: tmp }); | ||
| 440 | const remoteBranch = result.branches.find( | ||
| 441 | (branch) => branch.name === `${remoteName}/${remoteOnlyBranch}`, | ||
| 442 | ); | ||
| 443 | |||
| 444 | expect(remoteBranch).toBeDefined(); | ||
| 445 | expect(remoteBranch?.isRemote).toBe(true); | ||
| 446 | expect(remoteBranch?.remoteName).toBe(remoteName); | ||
| 447 | }), | ||
| 448 | ); | ||
| 417 | }); | 449 | }); |
| 418 | 450 | ||
| 419 | // ── checkoutGitBranch ── | 451 | // ── checkoutGitBranch ── |
| ... | @@ -636,6 +668,60 @@ it.layer(TestLayer)("git integration", (it) => { | ... | @@ -636,6 +668,60 @@ it.layer(TestLayer)("git integration", (it) => { |
| 636 | }), | 668 | }), |
| 637 | ); | 669 | ); |
| 638 | 670 | ||
| 671 | it.effect( | ||
| 672 | "does not silently checkout a local branch when a remote ref no longer exists", | ||
| 673 | () => | ||
| 674 | Effect.gen(function* () { | ||
| 675 | const remote = yield* makeTmpDir(); | ||
| 676 | const source = yield* makeTmpDir(); | ||
| 677 | yield* git(remote, ["init", "--bare"]); | ||
| 678 | |||
| 679 | yield* initRepoWithCommit(source); | ||
| 680 | const defaultBranch = (yield* listGitBranches({ cwd: source })).branches.find( | ||
| 681 | (branch) => branch.current, | ||
| 682 | )!.name; | ||
| 683 | yield* git(source, ["remote", "add", "origin", remote]); | ||
| 684 | yield* git(source, ["push", "-u", "origin", defaultBranch]); | ||
| 685 | |||
| 686 | yield* createGitBranch({ cwd: source, branch: "feature" }); | ||
| 687 | |||
| 688 | const checkoutResult = yield* Effect.result( | ||
| 689 | checkoutGitBranch({ cwd: source, branch: "origin/feature" }), | ||
| 690 | ); | ||
| 691 | expect(checkoutResult._tag).toBe("Failure"); | ||
| 692 | expect(yield* git(source, ["branch", "--show-current"])).toBe(defaultBranch); | ||
| 693 | }), | ||
| 694 | ); | ||
| 695 | |||
| 696 | it.effect("checks out a remote tracking branch when remote name contains slashes", () => | ||
| 697 | Effect.gen(function* () { | ||
| 698 | const remote = yield* makeTmpDir(); | ||
| 699 | const source = yield* makeTmpDir(); | ||
| 700 | const remoteName = "my-org/upstream"; | ||
| 701 | const featureBranch = "feature"; | ||
| 702 | yield* git(remote, ["init", "--bare"]); | ||
| 703 | |||
| 704 | yield* initRepoWithCommit(source); | ||
| 705 | const defaultBranch = (yield* listGitBranches({ cwd: source })).branches.find( | ||
| 706 | (branch) => branch.current, | ||
| 707 | )!.name; | ||
| 708 | yield* git(source, ["remote", "add", remoteName, remote]); | ||
| 709 | yield* git(source, ["push", "-u", remoteName, defaultBranch]); | ||
| 710 | |||
| 711 | yield* git(source, ["checkout", "-b", featureBranch]); | ||
| 712 | yield* writeTextFile(path.join(source, "feature.txt"), "feature content\n"); | ||
| 713 | yield* git(source, ["add", "feature.txt"]); | ||
| 714 | yield* git(source, ["commit", "-m", "feature commit"]); | ||
| 715 | yield* git(source, ["push", "-u", remoteName, featureBranch]); | ||
| 716 | yield* git(source, ["checkout", defaultBranch]); | ||
| 717 | yield* git(source, ["branch", "-D", featureBranch]); | ||
| 718 | |||
| 719 | yield* checkoutGitBranch({ cwd: source, branch: `${remoteName}/${featureBranch}` }); | ||
| 720 | |||
| 721 | expect(yield* git(source, ["branch", "--show-current"])).toBe("upstream/feature"); | ||
| 722 | }), | ||
| 723 | ); | ||
| 724 | |||
| 639 | it.effect("throws when checkout would overwrite uncommitted changes", () => | 725 | it.effect("throws when checkout would overwrite uncommitted changes", () => |
| 640 | Effect.gen(function* () { | 726 | Effect.gen(function* () { |
| 641 | const tmp = yield* makeTmpDir(); | 727 | const tmp = yield* makeTmpDir(); |
apps/server/src/git/Layers/GitCore.ts+109-48| ... | @@ -90,17 +90,61 @@ function parseBranchLine(line: string): { name: string; current: boolean } | nul | ... | @@ -90,17 +90,61 @@ function parseBranchLine(line: string): { name: string; current: boolean } | nul |
| 90 | }; | 90 | }; |
| 91 | } | 91 | } |
| 92 | 92 | ||
| 93 | function parseRemoteTrackingCandidate( | 93 | function parseRemoteNames(stdout: string): ReadonlyArray<string> { |
| 94 | return stdout | ||
| 95 | .split("\n") | ||
| 96 | .map((line) => line.trim()) | ||
| 97 | .filter((line) => line.length > 0) | ||
| 98 | .toSorted((a, b) => b.length - a.length); | ||
| 99 | } | ||
| 100 | |||
| 101 | function parseRemoteRefWithRemoteNames( | ||
| 94 | branchName: string, | 102 | branchName: string, |
| 95 | ): { remoteRef: string; localBranch: string } | null { | 103 | remoteNames: ReadonlyArray<string>, |
| 96 | const separatorIndex = branchName.indexOf("/"); | 104 | ): { remoteRef: string; remoteName: string; localBranch: string } | null { |
| 97 | if (separatorIndex <= 0 || separatorIndex === branchName.length - 1) return null; | 105 | const trimmedBranchName = branchName.trim(); |
| 98 | const localBranch = branchName.slice(separatorIndex + 1).trim(); | 106 | if (trimmedBranchName.length === 0) return null; |
| 99 | if (localBranch.length === 0) return null; | 107 | |
| 100 | return { | 108 | for (const remoteName of remoteNames) { |
| 101 | remoteRef: branchName.trim(), | 109 | const remotePrefix = `${remoteName}/`; |
| 102 | localBranch, | 110 | if (!trimmedBranchName.startsWith(remotePrefix)) { |
| 103 | }; | 111 | continue; |
| 112 | } | ||
| 113 | const localBranch = trimmedBranchName.slice(remotePrefix.length).trim(); | ||
| 114 | if (localBranch.length === 0) { | ||
| 115 | return null; | ||
| 116 | } | ||
| 117 | return { | ||
| 118 | remoteRef: trimmedBranchName, | ||
| 119 | remoteName, | ||
| 120 | localBranch, | ||
| 121 | }; | ||
| 122 | } | ||
| 123 | |||
| 124 | return null; | ||
| 125 | } | ||
| 126 | |||
| 127 | function parseTrackingBranchByUpstreamRef( | ||
| 128 | stdout: string, | ||
| 129 | upstreamRef: string, | ||
| 130 | ): string | null { | ||
| 131 | for (const line of stdout.split("\n")) { | ||
| 132 | const trimmedLine = line.trim(); | ||
| 133 | if (trimmedLine.length === 0) { | ||
| 134 | continue; | ||
| 135 | } | ||
| 136 | const [branchNameRaw, upstreamBranchRaw = ""] = trimmedLine.split("\t"); | ||
| 137 | const branchName = branchNameRaw?.trim() ?? ""; | ||
| 138 | const upstreamBranch = upstreamBranchRaw.trim(); | ||
| 139 | if (branchName.length === 0 || upstreamBranch.length === 0) { | ||
| 140 | continue; | ||
| 141 | } | ||
| 142 | if (upstreamBranch === upstreamRef) { | ||
| 143 | return branchName; | ||
| 144 | } | ||
| 145 | } | ||
| 146 | |||
| 147 | return null; | ||
| 104 | } | 148 | } |
| 105 | 149 | ||
| 106 | function commandLabel(args: readonly string[]): string { | 150 | function commandLabel(args: readonly string[]): string { |
| ... | @@ -619,7 +663,8 @@ const makeGitCore = Effect.gen(function* () { | ... | @@ -619,7 +663,8 @@ const makeGitCore = Effect.gen(function* () { |
| 619 | ); | 663 | ); |
| 620 | } | 664 | } |
| 621 | 665 | ||
| 622 | const [defaultRef, worktreeList, remoteBranchResult, branchLastCommit] = yield* Effect.all( | 666 | const [defaultRef, worktreeList, remoteBranchResult, remoteNamesResult, branchLastCommit] = |
| 667 | yield* Effect.all( | ||
| 623 | [ | 668 | [ |
| 624 | executeGit( | 669 | executeGit( |
| 625 | "GitCore.listBranches.defaultRef", | 670 | "GitCore.listBranches.defaultRef", |
| ... | @@ -648,11 +693,17 @@ const makeGitCore = Effect.gen(function* () { | ... | @@ -648,11 +693,17 @@ const makeGitCore = Effect.gen(function* () { |
| 648 | allowNonZeroExit: true, | 693 | allowNonZeroExit: true, |
| 649 | }, | 694 | }, |
| 650 | ), | 695 | ), |
| 696 | executeGit("GitCore.listBranches.remoteNames", input.cwd, ["remote"], { | ||
| 697 | timeoutMs: 5_000, | ||
| 698 | allowNonZeroExit: true, | ||
| 699 | }), | ||
| 651 | branchRecencyPromise, | 700 | branchRecencyPromise, |
| 652 | ], | 701 | ], |
| 653 | { concurrency: "unbounded" }, | 702 | { concurrency: "unbounded" }, |
| 654 | ); | 703 | ); |
| 655 | 704 | ||
| 705 | const remoteNames = remoteNamesResult.code === 0 ? parseRemoteNames(remoteNamesResult.stdout) : []; | ||
| 706 | |||
| 656 | const defaultBranch = | 707 | const defaultBranch = |
| 657 | defaultRef.code === 0 | 708 | defaultRef.code === 0 |
| 658 | ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "") | 709 | ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "") |
| ... | @@ -705,13 +756,27 @@ const makeGitCore = Effect.gen(function* () { | ... | @@ -705,13 +756,27 @@ const makeGitCore = Effect.gen(function* () { |
| 705 | .split("\n") | 756 | .split("\n") |
| 706 | .map(parseBranchLine) | 757 | .map(parseBranchLine) |
| 707 | .filter((branch): branch is { name: string; current: boolean } => branch !== null) | 758 | .filter((branch): branch is { name: string; current: boolean } => branch !== null) |
| 708 | .map((branch) => ({ | 759 | .map((branch) => { |
| 709 | name: branch.name, | 760 | const parsedRemoteRef = parseRemoteRefWithRemoteNames(branch.name, remoteNames); |
| 710 | current: false, | 761 | const remoteBranch: { |
| 711 | isRemote: true, | 762 | name: string; |
| 712 | isDefault: false, | 763 | current: boolean; |
| 713 | worktreePath: null, | 764 | isRemote: boolean; |
| 714 | })) | 765 | remoteName?: string; |
| 766 | isDefault: boolean; | ||
| 767 | worktreePath: string | null; | ||
| 768 | } = { | ||
| 769 | name: branch.name, | ||
| 770 | current: false, | ||
| 771 | isRemote: true, | ||
| 772 | isDefault: false, | ||
| 773 | worktreePath: null, | ||
| 774 | }; | ||
| 775 | if (parsedRemoteRef) { | ||
| 776 | remoteBranch.remoteName = parsedRemoteRef.remoteName; | ||
| 777 | } | ||
| 778 | return remoteBranch; | ||
| 779 | }) | ||
| 715 | .toSorted((a, b) => { | 780 | .toSorted((a, b) => { |
| 716 | const aLastCommit = branchLastCommit.get(a.name) ?? 0; | 781 | const aLastCommit = branchLastCommit.get(a.name) ?? 0; |
| 717 | const bLastCommit = branchLastCommit.get(b.name) ?? 0; | 782 | const bLastCommit = branchLastCommit.get(b.name) ?? 0; |
| ... | @@ -781,23 +846,7 @@ const makeGitCore = Effect.gen(function* () { | ... | @@ -781,23 +846,7 @@ const makeGitCore = Effect.gen(function* () { |
| 781 | 846 | ||
| 782 | const checkoutBranch: GitCoreShape["checkoutBranch"] = (input) => | 847 | const checkoutBranch: GitCoreShape["checkoutBranch"] = (input) => |
| 783 | Effect.gen(function* () { | 848 | Effect.gen(function* () { |
| 784 | const trackingCandidate = parseRemoteTrackingCandidate(input.branch); | 849 | const [localInputExists, remoteExists] = yield* Effect.all( |
| 785 | if (!trackingCandidate) { | ||
| 786 | yield* executeGit("GitCore.checkoutBranch.checkout", input.cwd, ["checkout", input.branch], { | ||
| 787 | timeoutMs: 10_000, | ||
| 788 | fallbackErrorMessage: "git checkout failed", | ||
| 789 | }); | ||
| 790 | |||
| 791 | // Refresh upstream refs in the background so checkout remains responsive. | ||
| 792 | yield* Effect.sync(() => { | ||
| 793 | void Effect.runPromise( | ||
| 794 | refreshCheckedOutBranchUpstream(input.cwd).pipe(Effect.catch(() => Effect.void)), | ||
| 795 | ); | ||
| 796 | }); | ||
| 797 | return; | ||
| 798 | } | ||
| 799 | |||
| 800 | const [localInputExists, remoteExists, localTrackingExists] = yield* Effect.all( | ||
| 801 | [ | 850 | [ |
| 802 | executeGit( | 851 | executeGit( |
| 803 | "GitCore.checkoutBranch.localInputExists", | 852 | "GitCore.checkoutBranch.localInputExists", |
| ... | @@ -811,16 +860,7 @@ const makeGitCore = Effect.gen(function* () { | ... | @@ -811,16 +860,7 @@ const makeGitCore = Effect.gen(function* () { |
| 811 | executeGit( | 860 | executeGit( |
| 812 | "GitCore.checkoutBranch.remoteExists", | 861 | "GitCore.checkoutBranch.remoteExists", |
| 813 | input.cwd, | 862 | input.cwd, |
| 814 | ["show-ref", "--verify", "--quiet", `refs/remotes/${trackingCandidate.remoteRef}`], | 863 | ["show-ref", "--verify", "--quiet", `refs/remotes/${input.branch}`], |
| 815 | { | ||
| 816 | timeoutMs: 5_000, | ||
| 817 | allowNonZeroExit: true, | ||
| 818 | }, | ||
| 819 | ).pipe(Effect.map((result) => result.code === 0)), | ||
| 820 | executeGit( | ||
| 821 | "GitCore.checkoutBranch.localTrackingExists", | ||
| 822 | input.cwd, | ||
| 823 | ["show-ref", "--verify", "--quiet", `refs/heads/${trackingCandidate.localBranch}`], | ||
| 824 | { | 864 | { |
| 825 | timeoutMs: 5_000, | 865 | timeoutMs: 5_000, |
| 826 | allowNonZeroExit: true, | 866 | allowNonZeroExit: true, |
| ... | @@ -830,12 +870,33 @@ const makeGitCore = Effect.gen(function* () { | ... | @@ -830,12 +870,33 @@ const makeGitCore = Effect.gen(function* () { |
| 830 | { concurrency: "unbounded" }, | 870 | { concurrency: "unbounded" }, |
| 831 | ); | 871 | ); |
| 832 | 872 | ||
| 873 | const localTrackingBranch = | ||
| 874 | remoteExists | ||
| 875 | ? yield* executeGit( | ||
| 876 | "GitCore.checkoutBranch.localTrackingBranch", | ||
| 877 | input.cwd, | ||
| 878 | ["for-each-ref", "--format=%(refname:short)\t%(upstream:short)", "refs/heads"], | ||
| 879 | { | ||
| 880 | timeoutMs: 5_000, | ||
| 881 | allowNonZeroExit: true, | ||
| 882 | }, | ||
| 883 | ).pipe( | ||
| 884 | Effect.map((result) => | ||
| 885 | result.code === 0 | ||
| 886 | ? parseTrackingBranchByUpstreamRef(result.stdout, input.branch) | ||
| 887 | : null, | ||
| 888 | ), | ||
| 889 | ) | ||
| 890 | : null; | ||
| 891 | |||
| 833 | const checkoutArgs = | 892 | const checkoutArgs = |
| 834 | localInputExists | 893 | localInputExists |
| 835 | ? ["checkout", input.branch] | 894 | ? ["checkout", input.branch] |
| 836 | : remoteExists && !localTrackingExists | 895 | : remoteExists && !localTrackingBranch |
| 837 | ? ["checkout", "--track", trackingCandidate.remoteRef] | 896 | ? ["checkout", "--track", input.branch] |
| 838 | : ["checkout", localTrackingExists ? trackingCandidate.localBranch : input.branch]; | 897 | : remoteExists && localTrackingBranch |
| 898 | ? ["checkout", localTrackingBranch] | ||
| 899 | : ["checkout", input.branch]; | ||
| 839 | 900 | ||
| 840 | yield* executeGit("GitCore.checkoutBranch.checkout", input.cwd, checkoutArgs, { | 901 | yield* executeGit("GitCore.checkoutBranch.checkout", input.cwd, checkoutArgs, { |
| 841 | timeoutMs: 10_000, | 902 | timeoutMs: 10_000, |
apps/web/src/components/BranchToolbar.logic.test.ts+55| ... | @@ -67,6 +67,12 @@ describe("deriveLocalBranchNameFromRemoteRef", () => { | ... | @@ -67,6 +67,12 @@ describe("deriveLocalBranchNameFromRemoteRef", () => { |
| 67 | expect(deriveLocalBranchNameFromRemoteRef("origin/feature/demo")).toBe("feature/demo"); | 67 | expect(deriveLocalBranchNameFromRemoteRef("origin/feature/demo")).toBe("feature/demo"); |
| 68 | }); | 68 | }); |
| 69 | 69 | ||
| 70 | it("supports remote names that contain slashes", () => { | ||
| 71 | expect( | ||
| 72 | deriveLocalBranchNameFromRemoteRef("my-org/upstream/feature/demo", "my-org/upstream"), | ||
| 73 | ).toBe("feature/demo"); | ||
| 74 | }); | ||
| 75 | |||
| 70 | it("returns the original name when ref is malformed", () => { | 76 | it("returns the original name when ref is malformed", () => { |
| 71 | expect(deriveLocalBranchNameFromRemoteRef("origin/")).toBe("origin/"); | 77 | expect(deriveLocalBranchNameFromRemoteRef("origin/")).toBe("origin/"); |
| 72 | expect(deriveLocalBranchNameFromRemoteRef("/feature/demo")).toBe("/feature/demo"); | 78 | expect(deriveLocalBranchNameFromRemoteRef("/feature/demo")).toBe("/feature/demo"); |
| ... | @@ -85,6 +91,7 @@ describe("dedupeRemoteBranchesWithLocalMatches", () => { | ... | @@ -85,6 +91,7 @@ describe("dedupeRemoteBranchesWithLocalMatches", () => { |
| 85 | { | 91 | { |
| 86 | name: "origin/feature/demo", | 92 | name: "origin/feature/demo", |
| 87 | isRemote: true, | 93 | isRemote: true, |
| 94 | remoteName: "origin", | ||
| 88 | current: false, | 95 | current: false, |
| 89 | isDefault: false, | 96 | isDefault: false, |
| 90 | worktreePath: null, | 97 | worktreePath: null, |
| ... | @@ -92,6 +99,7 @@ describe("dedupeRemoteBranchesWithLocalMatches", () => { | ... | @@ -92,6 +99,7 @@ describe("dedupeRemoteBranchesWithLocalMatches", () => { |
| 92 | { | 99 | { |
| 93 | name: "origin/feature/remote-only", | 100 | name: "origin/feature/remote-only", |
| 94 | isRemote: true, | 101 | isRemote: true, |
| 102 | remoteName: "origin", | ||
| 95 | current: false, | 103 | current: false, |
| 96 | isDefault: false, | 104 | isDefault: false, |
| 97 | worktreePath: null, | 105 | worktreePath: null, |
| ... | @@ -115,6 +123,7 @@ describe("dedupeRemoteBranchesWithLocalMatches", () => { | ... | @@ -115,6 +123,7 @@ describe("dedupeRemoteBranchesWithLocalMatches", () => { |
| 115 | { | 123 | { |
| 116 | name: "origin/feature/remote-only", | 124 | name: "origin/feature/remote-only", |
| 117 | isRemote: true, | 125 | isRemote: true, |
| 126 | remoteName: "origin", | ||
| 118 | current: false, | 127 | current: false, |
| 119 | isDefault: false, | 128 | isDefault: false, |
| 120 | worktreePath: null, | 129 | worktreePath: null, |
| ... | @@ -126,4 +135,50 @@ describe("dedupeRemoteBranchesWithLocalMatches", () => { | ... | @@ -126,4 +135,50 @@ describe("dedupeRemoteBranchesWithLocalMatches", () => { |
| 126 | "origin/feature/remote-only", | 135 | "origin/feature/remote-only", |
| 127 | ]); | 136 | ]); |
| 128 | }); | 137 | }); |
| 138 | |||
| 139 | it("dedupes remote refs for remotes whose names contain slashes", () => { | ||
| 140 | const input: GitBranch[] = [ | ||
| 141 | { | ||
| 142 | name: "feature/demo", | ||
| 143 | current: false, | ||
| 144 | isDefault: false, | ||
| 145 | worktreePath: null, | ||
| 146 | }, | ||
| 147 | { | ||
| 148 | name: "my-org/upstream/feature/demo", | ||
| 149 | isRemote: true, | ||
| 150 | remoteName: "my-org/upstream", | ||
| 151 | current: false, | ||
| 152 | isDefault: false, | ||
| 153 | worktreePath: null, | ||
| 154 | }, | ||
| 155 | ]; | ||
| 156 | |||
| 157 | expect(dedupeRemoteBranchesWithLocalMatches(input).map((branch) => branch.name)).toEqual([ | ||
| 158 | "feature/demo", | ||
| 159 | ]); | ||
| 160 | }); | ||
| 161 | |||
| 162 | it("dedupes remote refs when git tracks with first-slash local naming", () => { | ||
| 163 | const input: GitBranch[] = [ | ||
| 164 | { | ||
| 165 | name: "upstream/feature", | ||
| 166 | current: false, | ||
| 167 | isDefault: false, | ||
| 168 | worktreePath: null, | ||
| 169 | }, | ||
| 170 | { | ||
| 171 | name: "my-org/upstream/feature", | ||
| 172 | isRemote: true, | ||
| 173 | remoteName: "my-org/upstream", | ||
| 174 | current: false, | ||
| 175 | isDefault: false, | ||
| 176 | worktreePath: null, | ||
| 177 | }, | ||
| 178 | ]; | ||
| 179 | |||
| 180 | expect(dedupeRemoteBranchesWithLocalMatches(input).map((branch) => branch.name)).toEqual([ | ||
| 181 | "upstream/feature", | ||
| 182 | ]); | ||
| 183 | }); | ||
| 129 | }); | 184 | }); |
apps/web/src/components/BranchToolbar.logic.ts+39-6| ... | @@ -8,12 +8,42 @@ interface DeriveSyncedLocalBranchInput { | ... | @@ -8,12 +8,42 @@ interface DeriveSyncedLocalBranchInput { |
| 8 | queryBranches: ReadonlyArray<GitBranch> | undefined; | 8 | queryBranches: ReadonlyArray<GitBranch> | undefined; |
| 9 | } | 9 | } |
| 10 | 10 | ||
| 11 | export function deriveLocalBranchNameFromRemoteRef(branchName: string): string { | 11 | export function deriveLocalBranchNameFromRemoteRef( |
| 12 | const separatorIndex = branchName.indexOf("/"); | 12 | branchName: string, |
| 13 | if (separatorIndex <= 0 || separatorIndex === branchName.length - 1) { | 13 | remoteName?: string, |
| 14 | ): string { | ||
| 15 | if (remoteName) { | ||
| 16 | const remotePrefix = `${remoteName}/`; | ||
| 17 | if (branchName.startsWith(remotePrefix) && branchName.length > remotePrefix.length) { | ||
| 18 | return branchName.slice(remotePrefix.length); | ||
| 19 | } | ||
| 20 | } | ||
| 21 | |||
| 22 | const firstSeparatorIndex = branchName.indexOf("/"); | ||
| 23 | if (firstSeparatorIndex <= 0 || firstSeparatorIndex === branchName.length - 1) { | ||
| 14 | return branchName; | 24 | return branchName; |
| 15 | } | 25 | } |
| 16 | return branchName.slice(separatorIndex + 1); | 26 | return branchName.slice(firstSeparatorIndex + 1); |
| 27 | } | ||
| 28 | |||
| 29 | function deriveLocalBranchNameCandidatesFromRemoteRef( | ||
| 30 | branchName: string, | ||
| 31 | remoteName?: string, | ||
| 32 | ): ReadonlyArray<string> { | ||
| 33 | const candidates = new Set<string>(); | ||
| 34 | const firstSlashCandidate = deriveLocalBranchNameFromRemoteRef(branchName); | ||
| 35 | if (firstSlashCandidate.length > 0) { | ||
| 36 | candidates.add(firstSlashCandidate); | ||
| 37 | } | ||
| 38 | |||
| 39 | if (remoteName) { | ||
| 40 | const remotePrefix = `${remoteName}/`; | ||
| 41 | if (branchName.startsWith(remotePrefix) && branchName.length > remotePrefix.length) { | ||
| 42 | candidates.add(branchName.slice(remotePrefix.length)); | ||
| 43 | } | ||
| 44 | } | ||
| 45 | |||
| 46 | return [...candidates]; | ||
| 17 | } | 47 | } |
| 18 | 48 | ||
| 19 | export function dedupeRemoteBranchesWithLocalMatches( | 49 | export function dedupeRemoteBranchesWithLocalMatches( |
| ... | @@ -28,8 +58,11 @@ export function dedupeRemoteBranchesWithLocalMatches( | ... | @@ -28,8 +58,11 @@ export function dedupeRemoteBranchesWithLocalMatches( |
| 28 | return true; | 58 | return true; |
| 29 | } | 59 | } |
| 30 | 60 | ||
| 31 | const localBranchName = deriveLocalBranchNameFromRemoteRef(branch.name); | 61 | const localBranchCandidates = deriveLocalBranchNameCandidatesFromRemoteRef( |
| 32 | return !localBranchNames.has(localBranchName); | 62 | branch.name, |
| 63 | branch.remoteName, | ||
| 64 | ); | ||
| 65 | return !localBranchCandidates.some((candidate) => localBranchNames.has(candidate)); | ||
| 33 | }); | 66 | }); |
| 34 | } | 67 | } |
| 35 | 68 |
apps/web/src/components/BranchToolbar.tsx+10-3| ... | @@ -179,13 +179,20 @@ export default function BranchToolbar({ | ... | @@ -179,13 +179,20 @@ export default function BranchToolbar({ |
| 179 | } | 179 | } |
| 180 | 180 | ||
| 181 | const selectedBranchName = branch.isRemote | 181 | const selectedBranchName = branch.isRemote |
| 182 | ? deriveLocalBranchNameFromRemoteRef(branch.name) | 182 | ? deriveLocalBranchNameFromRemoteRef(branch.name, branch.remoteName) |
| 183 | : branch.name; | 183 | : branch.name; |
| 184 | 184 | ||
| 185 | checkoutMutation.mutate(branch.name, { | 185 | checkoutMutation.mutate(branch.name, { |
| 186 | onSuccess: () => { | 186 | onSuccess: async () => { |
| 187 | setThreadError(null); | 187 | setThreadError(null); |
| 188 | setThreadBranch(selectedBranchName, activeWorktreePath); | 188 | let nextBranchName = selectedBranchName; |
| 189 | if (branch.isRemote) { | ||
| 190 | const status = await api.git.status({ cwd: branchCwd }).catch(() => null); | ||
| 191 | if (status?.branch) { | ||
| 192 | nextBranchName = status.branch; | ||
| 193 | } | ||
| 194 | } | ||
| 195 | setThreadBranch(nextBranchName, activeWorktreePath); | ||
| 189 | setIsBranchMenuOpen(false); | 196 | setIsBranchMenuOpen(false); |
| 190 | onComposerFocusRequest?.(); | 197 | onComposerFocusRequest?.(); |
| 191 | }, | 198 | }, |
packages/contracts/src/git.ts+1| ... | @@ -24,6 +24,7 @@ export type GitStatusPrState = typeof GitStatusPrState.Type; | ... | @@ -24,6 +24,7 @@ export type GitStatusPrState = typeof GitStatusPrState.Type; |
| 24 | export const GitBranch = Schema.Struct({ | 24 | export const GitBranch = Schema.Struct({ |
| 25 | name: TrimmedNonEmptyStringSchema, | 25 | name: TrimmedNonEmptyStringSchema, |
| 26 | isRemote: Schema.optional(Schema.Boolean), | 26 | isRemote: Schema.optional(Schema.Boolean), |
| 27 | remoteName: Schema.optional(TrimmedNonEmptyStringSchema), | ||
| 27 | current: Schema.Boolean, | 28 | current: Schema.Boolean, |
| 28 | isDefault: Schema.Boolean, | 29 | isDefault: Schema.Boolean, |
| 29 | worktreePath: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr), | 30 | worktreePath: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr), |