diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts index 5a8cf2cc1dd5962224a74abf0ac394c55cb4b812..b8a0dc17d2b0ef2d113d5771b6aa9a0833e581fe 100644 --- a/apps/server/src/git/Layers/GitCore.test.ts +++ b/apps/server/src/git/Layers/GitCore.test.ts @@ -734,6 +734,33 @@ it.layer(TestLayer)("git integration", (it) => { }), ); + it.effect( + "falls back to detached checkout when --track would conflict with an existing local branch", + () => + Effect.gen(function* () { + const remote = yield* makeTmpDir(); + const source = yield* makeTmpDir(); + yield* git(remote, ["init", "--bare"]); + + yield* initRepoWithCommit(source); + const defaultBranch = (yield* listGitBranches({ cwd: source })).branches.find( + (branch) => branch.current, + )!.name; + yield* git(source, ["remote", "add", "origin", remote]); + yield* git(source, ["push", "-u", "origin", defaultBranch]); + + // Keep local branch but remove tracking so `--track origin/` + // would attempt to create an already-existing local branch. + yield* git(source, ["branch", "--unset-upstream"]); + + yield* checkoutGitBranch({ cwd: source, branch: `origin/${defaultBranch}` }); + + const core = yield* GitCore; + const status = yield* core.statusDetails(source); + expect(status.branch).toBeNull(); + }), + ); + it.effect("throws when checkout would overwrite uncommitted changes", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index 78a2d94ddd2da785a94d3bcb01c2b06c6b425589..84cb88cbc1095d4a1e8c600c1a27d8949d316e78 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -148,6 +148,15 @@ function parseTrackingBranchByUpstreamRef( return null; } +function deriveLocalBranchNameFromRemoteRef(branchName: string): string | null { + const separatorIndex = branchName.indexOf("/"); + if (separatorIndex <= 0 || separatorIndex === branchName.length - 1) { + return null; + } + const localBranch = branchName.slice(separatorIndex + 1).trim(); + return localBranch.length > 0 ? localBranch : null; +} + function commandLabel(args: readonly string[]): string { return `git ${args.join(" ")}`; } @@ -921,8 +930,24 @@ const makeGitCore = Effect.gen(function* () { ) : null; + const localTrackedBranchCandidate = deriveLocalBranchNameFromRemoteRef(input.branch); + const localTrackedBranchTargetExists = + remoteExists && localTrackedBranchCandidate + ? yield* executeGit( + "GitCore.checkoutBranch.localTrackedBranchTargetExists", + input.cwd, + ["show-ref", "--verify", "--quiet", `refs/heads/${localTrackedBranchCandidate}`], + { + timeoutMs: 5_000, + allowNonZeroExit: true, + }, + ).pipe(Effect.map((result) => result.code === 0)) + : false; + const checkoutArgs = localInputExists + ? ["checkout", input.branch] + : remoteExists && !localTrackingBranch && localTrackedBranchTargetExists ? ["checkout", input.branch] : remoteExists && !localTrackingBranch ? ["checkout", "--track", input.branch] diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index a5c10d7d725d2489b13c2ade45da09213b3defbf..31339930d23e231a7f9ebc22ca346a047d19832c 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -68,9 +68,9 @@ describe("deriveLocalBranchNameFromRemoteRef", () => { }); it("supports remote names that contain slashes", () => { - expect( - deriveLocalBranchNameFromRemoteRef("my-org/upstream/feature/demo", "my-org/upstream"), - ).toBe("feature/demo"); + expect(deriveLocalBranchNameFromRemoteRef("my-org/upstream/feature/demo")).toBe( + "upstream/feature/demo", + ); }); it("returns the original name when ref is malformed", () => { diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 07d081396418760d2bd809fae557eaa142a0915c..7985033b200973aeee84a43c3818b69d612756d1 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -8,17 +8,7 @@ interface DeriveSyncedLocalBranchInput { queryBranches: ReadonlyArray | undefined; } -export function deriveLocalBranchNameFromRemoteRef( - branchName: string, - remoteName?: string, -): string { - if (remoteName) { - const remotePrefix = `${remoteName}/`; - if (branchName.startsWith(remotePrefix) && branchName.length > remotePrefix.length) { - return branchName.slice(remotePrefix.length); - } - } - +export function deriveLocalBranchNameFromRemoteRef(branchName: string): string { const firstSeparatorIndex = branchName.indexOf("/"); if (firstSeparatorIndex <= 0 || firstSeparatorIndex === branchName.length - 1) { return branchName; diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index be5f05bb9a41c8cb43843a455de4953803305476..94fcdba409fa623e38ccf45af71d69e6b592c7e7 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -179,7 +179,7 @@ export default function BranchToolbar({ } const selectedBranchName = branch.isRemote - ? deriveLocalBranchNameFromRemoteRef(branch.name, branch.remoteName) + ? deriveLocalBranchNameFromRemoteRef(branch.name) : branch.name; checkoutMutation.mutate(branch.name, {