| 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 | 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 | 451 | // ── checkoutGitBranch ── |
| ... | ... | @@ -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 | 725 | it.effect("throws when checkout would overwrite uncommitted changes", () => |
| 640 | 726 | Effect.gen(function* () { |
| 641 | 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 | 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 | 102 | branchName: string, |
| 95 | ): { remoteRef: string; localBranch: string } | null { | |
| 96 | const separatorIndex = branchName.indexOf("/"); | |
| 97 | if (separatorIndex <= 0 || separatorIndex === branchName.length - 1) return null; | |
| 98 | const localBranch = branchName.slice(separatorIndex + 1).trim(); | |
| 99 | if (localBranch.length === 0) return null; | |
| 100 | return { | |
| 101 | remoteRef: branchName.trim(), | |
| 102 | localBranch, | |
| 103 | }; | |
| 103 | remoteNames: ReadonlyArray<string>, | |
| 104 | ): { remoteRef: string; remoteName: string; localBranch: string } | null { | |
| 105 | const trimmedBranchName = branchName.trim(); | |
| 106 | if (trimmedBranchName.length === 0) return null; | |
| 107 | ||
| 108 | for (const remoteName of remoteNames) { | |
| 109 | const remotePrefix = `${remoteName}/`; | |
| 110 | if (!trimmedBranchName.startsWith(remotePrefix)) { | |
| 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 | 150 | function commandLabel(args: readonly string[]): string { |
| ... | ... | @@ -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 | 669 | executeGit( |
| 625 | 670 | "GitCore.listBranches.defaultRef", |
| ... | ... | @@ -648,11 +693,17 @@ const makeGitCore = Effect.gen(function* () { |
| 648 | 693 | allowNonZeroExit: true, |
| 649 | 694 | }, |
| 650 | 695 | ), |
| 696 | executeGit("GitCore.listBranches.remoteNames", input.cwd, ["remote"], { | |
| 697 | timeoutMs: 5_000, | |
| 698 | allowNonZeroExit: true, | |
| 699 | }), | |
| 651 | 700 | branchRecencyPromise, |
| 652 | 701 | ], |
| 653 | 702 | { concurrency: "unbounded" }, |
| 654 | 703 | ); |
| 655 | 704 | |
| 705 | const remoteNames = remoteNamesResult.code === 0 ? parseRemoteNames(remoteNamesResult.stdout) : []; | |
| 706 | ||
| 656 | 707 | const defaultBranch = |
| 657 | 708 | defaultRef.code === 0 |
| 658 | 709 | ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "") |
| ... | ... | @@ -705,13 +756,27 @@ const makeGitCore = Effect.gen(function* () { |
| 705 | 756 | .split("\n") |
| 706 | 757 | .map(parseBranchLine) |
| 707 | 758 | .filter((branch): branch is { name: string; current: boolean } => branch !== null) |
| 708 | .map((branch) => ({ | |
| 709 | name: branch.name, | |
| 710 | current: false, | |
| 711 | isRemote: true, | |
| 712 | isDefault: false, | |
| 713 | worktreePath: null, | |
| 714 | })) | |
| 759 | .map((branch) => { | |
| 760 | const parsedRemoteRef = parseRemoteRefWithRemoteNames(branch.name, remoteNames); | |
| 761 | const remoteBranch: { | |
| 762 | name: string; | |
| 763 | current: boolean; | |
| 764 | isRemote: boolean; | |
| 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 | 780 | .toSorted((a, b) => { |
| 716 | 781 | const aLastCommit = branchLastCommit.get(a.name) ?? 0; |
| 717 | 782 | const bLastCommit = branchLastCommit.get(b.name) ?? 0; |
| ... | ... | @@ -781,23 +846,7 @@ const makeGitCore = Effect.gen(function* () { |
| 781 | 846 | |
| 782 | 847 | const checkoutBranch: GitCoreShape["checkoutBranch"] = (input) => |
| 783 | 848 | Effect.gen(function* () { |
| 784 | const trackingCandidate = parseRemoteTrackingCandidate(input.branch); | |
| 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( | |
| 849 | const [localInputExists, remoteExists] = yield* Effect.all( | |
| 801 | 850 | [ |
| 802 | 851 | executeGit( |
| 803 | 852 | "GitCore.checkoutBranch.localInputExists", |
| ... | ... | @@ -811,16 +860,7 @@ const makeGitCore = Effect.gen(function* () { |
| 811 | 860 | executeGit( |
| 812 | 861 | "GitCore.checkoutBranch.remoteExists", |
| 813 | 862 | input.cwd, |
| 814 | ["show-ref", "--verify", "--quiet", `refs/remotes/${trackingCandidate.remoteRef}`], | |
| 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}`], | |
| 863 | ["show-ref", "--verify", "--quiet", `refs/remotes/${input.branch}`], | |
| 824 | 864 | { |
| 825 | 865 | timeoutMs: 5_000, |
| 826 | 866 | allowNonZeroExit: true, |
| ... | ... | @@ -830,12 +870,33 @@ const makeGitCore = Effect.gen(function* () { |
| 830 | 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 | 892 | const checkoutArgs = |
| 834 | 893 | localInputExists |
| 835 | 894 | ? ["checkout", input.branch] |
| 836 | : remoteExists && !localTrackingExists | |
| 837 | ? ["checkout", "--track", trackingCandidate.remoteRef] | |
| 838 | : ["checkout", localTrackingExists ? trackingCandidate.localBranch : input.branch]; | |
| 895 | : remoteExists && !localTrackingBranch | |
| 896 | ? ["checkout", "--track", input.branch] | |
| 897 | : remoteExists && localTrackingBranch | |
| 898 | ? ["checkout", localTrackingBranch] | |
| 899 | : ["checkout", input.branch]; | |
| 839 | 900 | |
| 840 | 901 | yield* executeGit("GitCore.checkoutBranch.checkout", input.cwd, checkoutArgs, { |
| 841 | 902 | timeoutMs: 10_000, |
apps/web/src/components/BranchToolbar.logic.test.ts+55| ... | ... | @@ -67,6 +67,12 @@ describe("deriveLocalBranchNameFromRemoteRef", () => { |
| 67 | 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 | 76 | it("returns the original name when ref is malformed", () => { |
| 71 | 77 | expect(deriveLocalBranchNameFromRemoteRef("origin/")).toBe("origin/"); |
| 72 | 78 | expect(deriveLocalBranchNameFromRemoteRef("/feature/demo")).toBe("/feature/demo"); |
| ... | ... | @@ -85,6 +91,7 @@ describe("dedupeRemoteBranchesWithLocalMatches", () => { |
| 85 | 91 | { |
| 86 | 92 | name: "origin/feature/demo", |
| 87 | 93 | isRemote: true, |
| 94 | remoteName: "origin", | |
| 88 | 95 | current: false, |
| 89 | 96 | isDefault: false, |
| 90 | 97 | worktreePath: null, |
| ... | ... | @@ -92,6 +99,7 @@ describe("dedupeRemoteBranchesWithLocalMatches", () => { |
| 92 | 99 | { |
| 93 | 100 | name: "origin/feature/remote-only", |
| 94 | 101 | isRemote: true, |
| 102 | remoteName: "origin", | |
| 95 | 103 | current: false, |
| 96 | 104 | isDefault: false, |
| 97 | 105 | worktreePath: null, |
| ... | ... | @@ -115,6 +123,7 @@ describe("dedupeRemoteBranchesWithLocalMatches", () => { |
| 115 | 123 | { |
| 116 | 124 | name: "origin/feature/remote-only", |
| 117 | 125 | isRemote: true, |
| 126 | remoteName: "origin", | |
| 118 | 127 | current: false, |
| 119 | 128 | isDefault: false, |
| 120 | 129 | worktreePath: null, |
| ... | ... | @@ -126,4 +135,50 @@ describe("dedupeRemoteBranchesWithLocalMatches", () => { |
| 126 | 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 | 8 | queryBranches: ReadonlyArray<GitBranch> | undefined; |
| 9 | 9 | } |
| 10 | 10 | |
| 11 | export function deriveLocalBranchNameFromRemoteRef(branchName: string): string { | |
| 12 | const separatorIndex = branchName.indexOf("/"); | |
| 13 | if (separatorIndex <= 0 || separatorIndex === branchName.length - 1) { | |
| 11 | export function deriveLocalBranchNameFromRemoteRef( | |
| 12 | branchName: string, | |
| 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 | 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 | 49 | export function dedupeRemoteBranchesWithLocalMatches( |
| ... | ... | @@ -28,8 +58,11 @@ export function dedupeRemoteBranchesWithLocalMatches( |
| 28 | 58 | return true; |
| 29 | 59 | } |
| 30 | 60 | |
| 31 | const localBranchName = deriveLocalBranchNameFromRemoteRef(branch.name); | |
| 32 | return !localBranchNames.has(localBranchName); | |
| 61 | const localBranchCandidates = deriveLocalBranchNameCandidatesFromRemoteRef( | |
| 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 | 179 | } |
| 180 | 180 | |
| 181 | 181 | const selectedBranchName = branch.isRemote |
| 182 | ? deriveLocalBranchNameFromRemoteRef(branch.name) | |
| 182 | ? deriveLocalBranchNameFromRemoteRef(branch.name, branch.remoteName) | |
| 183 | 183 | : branch.name; |
| 184 | 184 | |
| 185 | 185 | checkoutMutation.mutate(branch.name, { |
| 186 | onSuccess: () => { | |
| 186 | onSuccess: async () => { | |
| 187 | 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 | 196 | setIsBranchMenuOpen(false); |
| 190 | 197 | onComposerFocusRequest?.(); |
| 191 | 198 | }, |
packages/contracts/src/git.ts+1| ... | ... | @@ -24,6 +24,7 @@ export type GitStatusPrState = typeof GitStatusPrState.Type; |
| 24 | 24 | export const GitBranch = Schema.Struct({ |
| 25 | 25 | name: TrimmedNonEmptyStringSchema, |
| 26 | 26 | isRemote: Schema.optional(Schema.Boolean), |
| 27 | remoteName: Schema.optional(TrimmedNonEmptyStringSchema), | |
| 27 | 28 | current: Schema.Boolean, |
| 28 | 29 | isDefault: Schema.Boolean, |
| 29 | 30 | worktreePath: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr), |