| author | |
| committer | |
| log | 1af43dbf90add958b02ac5935b103f0eba6dc60a |
| tree | a1d238b286341225b40a06c0bf1744a9cb9492b1 |
| parent | 16fed12dcc5432978d3bfcd78f2debf1890522b0 |
- 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 segment5 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 | ); |
| 736 | 736 | ||
| 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 | } |
| 150 | 150 | ||
| 151 | function 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 | |||
| 151 | function commandLabel(args: readonly string[]): string { | 160 | function 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; |
| 923 | 932 | ||
| 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 | localInputExists | 948 | localInputExists |
| 949 | ? ["checkout", input.branch] | ||
| 950 | : remoteExists && !localTrackingBranch && localTrackedBranchTargetExists | ||
| 926 | ? ["checkout", input.branch] | 951 | ? ["checkout", input.branch] |
| 927 | : remoteExists && !localTrackingBranch | 952 | : 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 | }); |
| 69 | 69 | ||
| 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 | }); |
| 75 | 75 | ||
| 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 | } |
| 10 | 10 | ||
| 11 | export function deriveLocalBranchNameFromRemoteRef( | 11 | export 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 | } |
| 180 | 180 | ||
| 181 | const selectedBranchName = branch.isRemote | 181 | const selectedBranchName = branch.isRemote |
| 182 | ? deriveLocalBranchNameFromRemoteRef(branch.name, branch.remoteName) | 182 | ? deriveLocalBranchNameFromRemoteRef(branch.name) |
| 183 | : branch.name; | 183 | : branch.name; |
| 184 | 184 | ||
| 185 | checkoutMutation.mutate(branch.name, { | 185 | checkoutMutation.mutate(branch.name, { |