authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-28 20:32:39-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-28 21:56:22-08:00
logefcee4b86354fba7457a62d8beafb4011ec3ae52
tree31172a0685896a2377111397b74f8e0010dec49f
parent26edcab8b4bfde31046000a410fbc9ae27ce30ee

Handle branch checkout and dedupe for slash-named remotes

- 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 correctly

6 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) => {
414414 ).toBe(true);
415415 }),
416416 );
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 );
417449 });
418450
419451 // ── checkoutGitBranch ──
......@@ -636,6 +668,60 @@ it.layer(TestLayer)("git integration", (it) => {
636668 }),
637669 );
638670
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
639725 it.effect("throws when checkout would overwrite uncommitted changes", () =>
640726 Effect.gen(function* () {
641727 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
9090 };
9191}
9292
93function parseRemoteTrackingCandidate(
93function 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
101function parseRemoteRefWithRemoteNames(
94102 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
127function 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;
104148}
105149
106150function commandLabel(args: readonly string[]): string {
......@@ -619,7 +663,8 @@ const makeGitCore = Effect.gen(function* () {
619663 );
620664 }
621665
622 const [defaultRef, worktreeList, remoteBranchResult, branchLastCommit] = yield* Effect.all(
666 const [defaultRef, worktreeList, remoteBranchResult, remoteNamesResult, branchLastCommit] =
667 yield* Effect.all(
623668 [
624669 executeGit(
625670 "GitCore.listBranches.defaultRef",
......@@ -648,11 +693,17 @@ const makeGitCore = Effect.gen(function* () {
648693 allowNonZeroExit: true,
649694 },
650695 ),
696 executeGit("GitCore.listBranches.remoteNames", input.cwd, ["remote"], {
697 timeoutMs: 5_000,
698 allowNonZeroExit: true,
699 }),
651700 branchRecencyPromise,
652701 ],
653702 { concurrency: "unbounded" },
654703 );
655704
705 const remoteNames = remoteNamesResult.code === 0 ? parseRemoteNames(remoteNamesResult.stdout) : [];
706
656707 const defaultBranch =
657708 defaultRef.code === 0
658709 ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "")
......@@ -705,13 +756,27 @@ const makeGitCore = Effect.gen(function* () {
705756 .split("\n")
706757 .map(parseBranchLine)
707758 .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 })
715780 .toSorted((a, b) => {
716781 const aLastCommit = branchLastCommit.get(a.name) ?? 0;
717782 const bLastCommit = branchLastCommit.get(b.name) ?? 0;
......@@ -781,23 +846,7 @@ const makeGitCore = Effect.gen(function* () {
781846
782847 const checkoutBranch: GitCoreShape["checkoutBranch"] = (input) =>
783848 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(
801850 [
802851 executeGit(
803852 "GitCore.checkoutBranch.localInputExists",
......@@ -811,16 +860,7 @@ const makeGitCore = Effect.gen(function* () {
811860 executeGit(
812861 "GitCore.checkoutBranch.remoteExists",
813862 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}`],
824864 {
825865 timeoutMs: 5_000,
826866 allowNonZeroExit: true,
......@@ -830,12 +870,33 @@ const makeGitCore = Effect.gen(function* () {
830870 { concurrency: "unbounded" },
831871 );
832872
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
833892 const checkoutArgs =
834893 localInputExists
835894 ? ["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];
839900
840901 yield* executeGit("GitCore.checkoutBranch.checkout", input.cwd, checkoutArgs, {
841902 timeoutMs: 10_000,
apps/web/src/components/BranchToolbar.logic.test.ts+55
......@@ -67,6 +67,12 @@ describe("deriveLocalBranchNameFromRemoteRef", () => {
6767 expect(deriveLocalBranchNameFromRemoteRef("origin/feature/demo")).toBe("feature/demo");
6868 });
6969
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
7076 it("returns the original name when ref is malformed", () => {
7177 expect(deriveLocalBranchNameFromRemoteRef("origin/")).toBe("origin/");
7278 expect(deriveLocalBranchNameFromRemoteRef("/feature/demo")).toBe("/feature/demo");
......@@ -85,6 +91,7 @@ describe("dedupeRemoteBranchesWithLocalMatches", () => {
8591 {
8692 name: "origin/feature/demo",
8793 isRemote: true,
94 remoteName: "origin",
8895 current: false,
8996 isDefault: false,
9097 worktreePath: null,
......@@ -92,6 +99,7 @@ describe("dedupeRemoteBranchesWithLocalMatches", () => {
9299 {
93100 name: "origin/feature/remote-only",
94101 isRemote: true,
102 remoteName: "origin",
95103 current: false,
96104 isDefault: false,
97105 worktreePath: null,
......@@ -115,6 +123,7 @@ describe("dedupeRemoteBranchesWithLocalMatches", () => {
115123 {
116124 name: "origin/feature/remote-only",
117125 isRemote: true,
126 remoteName: "origin",
118127 current: false,
119128 isDefault: false,
120129 worktreePath: null,
......@@ -126,4 +135,50 @@ describe("dedupeRemoteBranchesWithLocalMatches", () => {
126135 "origin/feature/remote-only",
127136 ]);
128137 });
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 });
129184});
apps/web/src/components/BranchToolbar.logic.ts+39-6
......@@ -8,12 +8,42 @@ interface DeriveSyncedLocalBranchInput {
88 queryBranches: ReadonlyArray<GitBranch> | undefined;
99}
1010
11export function deriveLocalBranchNameFromRemoteRef(branchName: string): string {
12 const separatorIndex = branchName.indexOf("/");
13 if (separatorIndex <= 0 || separatorIndex === branchName.length - 1) {
11export 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) {
1424 return branchName;
1525 }
16 return branchName.slice(separatorIndex + 1);
26 return branchName.slice(firstSeparatorIndex + 1);
27}
28
29function 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];
1747}
1848
1949export function dedupeRemoteBranchesWithLocalMatches(
......@@ -28,8 +58,11 @@ export function dedupeRemoteBranchesWithLocalMatches(
2858 return true;
2959 }
3060
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));
3366 });
3467}
3568
apps/web/src/components/BranchToolbar.tsx+10-3
......@@ -179,13 +179,20 @@ export default function BranchToolbar({
179179 }
180180
181181 const selectedBranchName = branch.isRemote
182 ? deriveLocalBranchNameFromRemoteRef(branch.name)
182 ? deriveLocalBranchNameFromRemoteRef(branch.name, branch.remoteName)
183183 : branch.name;
184184
185185 checkoutMutation.mutate(branch.name, {
186 onSuccess: () => {
186 onSuccess: async () => {
187187 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);
189196 setIsBranchMenuOpen(false);
190197 onComposerFocusRequest?.();
191198 },
packages/contracts/src/git.ts+1
......@@ -24,6 +24,7 @@ export type GitStatusPrState = typeof GitStatusPrState.Type;
2424export const GitBranch = Schema.Struct({
2525 name: TrimmedNonEmptyStringSchema,
2626 isRemote: Schema.optional(Schema.Boolean),
27 remoteName: Schema.optional(TrimmedNonEmptyStringSchema),
2728 current: Schema.Boolean,
2829 isDefault: Schema.Boolean,
2930 worktreePath: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr),