authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-28 21:47:48-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-28 21:56:22-08:00
log1af43dbf90add958b02ac5935b103f0eba6dc60a
treea1d238b286341225b40a06c0bf1744a9cb9492b1
parent16fed12dcc5432978d3bfcd78f2debf1890522b0

Handle remote checkout conflicts without duplicate local branch

- fall back to plain checkout (detached HEAD) when `--track` would clash with an existing local branch - add GitCore coverage for the conflict fallback path - simplify branch-name derivation in BranchToolbar to consistently strip the first remote segment

5 files changed, 57 insertions(+), 15 deletions(-)

apps/server/src/git/Layers/GitCore.test.ts+27
...@@ -734,6 +734,33 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -734,6 +734,33 @@ it.layer(TestLayer)("git integration", (it) => {
734 }),734 }),
735 );735 );
736736
737 it.effect(
738 "falls back to detached checkout when --track would conflict with an existing local branch",
739 () =>
740 Effect.gen(function* () {
741 const remote = yield* makeTmpDir();
742 const source = yield* makeTmpDir();
743 yield* git(remote, ["init", "--bare"]);
744
745 yield* initRepoWithCommit(source);
746 const defaultBranch = (yield* listGitBranches({ cwd: source })).branches.find(
747 (branch) => branch.current,
748 )!.name;
749 yield* git(source, ["remote", "add", "origin", remote]);
750 yield* git(source, ["push", "-u", "origin", defaultBranch]);
751
752 // Keep local branch but remove tracking so `--track origin/<branch>`
753 // would attempt to create an already-existing local branch.
754 yield* git(source, ["branch", "--unset-upstream"]);
755
756 yield* checkoutGitBranch({ cwd: source, branch: `origin/${defaultBranch}` });
757
758 const core = yield* GitCore;
759 const status = yield* core.statusDetails(source);
760 expect(status.branch).toBeNull();
761 }),
762 );
763
737 it.effect("throws when checkout would overwrite uncommitted changes", () =>764 it.effect("throws when checkout would overwrite uncommitted changes", () =>
738 Effect.gen(function* () {765 Effect.gen(function* () {
739 const tmp = yield* makeTmpDir();766 const tmp = yield* makeTmpDir();
apps/server/src/git/Layers/GitCore.ts+25
...@@ -148,6 +148,15 @@ function parseTrackingBranchByUpstreamRef(...@@ -148,6 +148,15 @@ function parseTrackingBranchByUpstreamRef(
148 return null;148 return null;
149}149}
150150
151function deriveLocalBranchNameFromRemoteRef(branchName: string): string | null {
152 const separatorIndex = branchName.indexOf("/");
153 if (separatorIndex <= 0 || separatorIndex === branchName.length - 1) {
154 return null;
155 }
156 const localBranch = branchName.slice(separatorIndex + 1).trim();
157 return localBranch.length > 0 ? localBranch : null;
158}
159
151function commandLabel(args: readonly string[]): string {160function commandLabel(args: readonly string[]): string {
152 return `git ${args.join(" ")}`;161 return `git ${args.join(" ")}`;
153}162}
...@@ -921,8 +930,24 @@ const makeGitCore = Effect.gen(function* () {...@@ -921,8 +930,24 @@ const makeGitCore = Effect.gen(function* () {
921 )930 )
922 : null;931 : null;
923932
933 const localTrackedBranchCandidate = deriveLocalBranchNameFromRemoteRef(input.branch);
934 const localTrackedBranchTargetExists =
935 remoteExists && localTrackedBranchCandidate
936 ? yield* executeGit(
937 "GitCore.checkoutBranch.localTrackedBranchTargetExists",
938 input.cwd,
939 ["show-ref", "--verify", "--quiet", `refs/heads/${localTrackedBranchCandidate}`],
940 {
941 timeoutMs: 5_000,
942 allowNonZeroExit: true,
943 },
944 ).pipe(Effect.map((result) => result.code === 0))
945 : false;
946
924 const checkoutArgs =947 const checkoutArgs =
925 localInputExists948 localInputExists
949 ? ["checkout", input.branch]
950 : remoteExists && !localTrackingBranch && localTrackedBranchTargetExists
926 ? ["checkout", input.branch]951 ? ["checkout", input.branch]
927 : remoteExists && !localTrackingBranch952 : remoteExists && !localTrackingBranch
928 ? ["checkout", "--track", input.branch]953 ? ["checkout", "--track", input.branch]
apps/web/src/components/BranchToolbar.logic.test.ts+3-3
...@@ -68,9 +68,9 @@ describe("deriveLocalBranchNameFromRemoteRef", () => {...@@ -68,9 +68,9 @@ describe("deriveLocalBranchNameFromRemoteRef", () => {
68 });68 });
6969
70 it("supports remote names that contain slashes", () => {70 it("supports remote names that contain slashes", () => {
71 expect(71 expect(deriveLocalBranchNameFromRemoteRef("my-org/upstream/feature/demo")).toBe(
72 deriveLocalBranchNameFromRemoteRef("my-org/upstream/feature/demo", "my-org/upstream"),72 "upstream/feature/demo",
73 ).toBe("feature/demo");73 );
74 });74 });
7575
76 it("returns the original name when ref is malformed", () => {76 it("returns the original name when ref is malformed", () => {
apps/web/src/components/BranchToolbar.logic.ts+1-11
...@@ -8,17 +8,7 @@ interface DeriveSyncedLocalBranchInput {...@@ -8,17 +8,7 @@ interface DeriveSyncedLocalBranchInput {
8 queryBranches: ReadonlyArray<GitBranch> | undefined;8 queryBranches: ReadonlyArray<GitBranch> | undefined;
9}9}
1010
11export function deriveLocalBranchNameFromRemoteRef(11export function deriveLocalBranchNameFromRemoteRef(branchName: string): string {
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("/");12 const firstSeparatorIndex = branchName.indexOf("/");
23 if (firstSeparatorIndex <= 0 || firstSeparatorIndex === branchName.length - 1) {13 if (firstSeparatorIndex <= 0 || firstSeparatorIndex === branchName.length - 1) {
24 return branchName;14 return branchName;
apps/web/src/components/BranchToolbar.tsx+1-1
...@@ -179,7 +179,7 @@ export default function BranchToolbar({...@@ -179,7 +179,7 @@ export default function BranchToolbar({
179 }179 }
180180
181 const selectedBranchName = branch.isRemote181 const selectedBranchName = branch.isRemote
182 ? deriveLocalBranchNameFromRemoteRef(branch.name, branch.remoteName)182 ? deriveLocalBranchNameFromRemoteRef(branch.name)
183 : branch.name;183 : branch.name;
184184
185 checkoutMutation.mutate(branch.name, {185 checkoutMutation.mutate(branch.name, {