| 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 | 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 | 764 | it.effect("throws when checkout would overwrite uncommitted changes", () => |
| 738 | 765 | Effect.gen(function* () { |
| 739 | 766 | const tmp = yield* makeTmpDir(); |
apps/server/src/git/Layers/GitCore.ts+25| ... | ... | @@ -148,6 +148,15 @@ function parseTrackingBranchByUpstreamRef( |
| 148 | 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 | 160 | function commandLabel(args: readonly string[]): string { |
| 152 | 161 | return `git ${args.join(" ")}`; |
| 153 | 162 | } |
| ... | ... | @@ -921,8 +930,24 @@ const makeGitCore = Effect.gen(function* () { |
| 921 | 930 | ) |
| 922 | 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 | 947 | const checkoutArgs = |
| 925 | 948 | localInputExists |
| 949 | ? ["checkout", input.branch] | |
| 950 | : remoteExists && !localTrackingBranch && localTrackedBranchTargetExists | |
| 926 | 951 | ? ["checkout", input.branch] |
| 927 | 952 | : remoteExists && !localTrackingBranch |
| 928 | 953 | ? ["checkout", "--track", input.branch] |
apps/web/src/components/BranchToolbar.logic.test.ts+3-3| ... | ... | @@ -68,9 +68,9 @@ describe("deriveLocalBranchNameFromRemoteRef", () => { |
| 68 | 68 | }); |
| 69 | 69 | |
| 70 | 70 | it("supports remote names that contain slashes", () => { |
| 71 | expect( | |
| 72 | deriveLocalBranchNameFromRemoteRef("my-org/upstream/feature/demo", "my-org/upstream"), | |
| 73 | ).toBe("feature/demo"); | |
| 71 | expect(deriveLocalBranchNameFromRemoteRef("my-org/upstream/feature/demo")).toBe( | |
| 72 | "upstream/feature/demo", | |
| 73 | ); | |
| 74 | 74 | }); |
| 75 | 75 | |
| 76 | 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 | 8 | queryBranches: ReadonlyArray<GitBranch> | undefined; |
| 9 | 9 | } |
| 10 | 10 | |
| 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 | ||
| 11 | export function deriveLocalBranchNameFromRemoteRef(branchName: string): string { | |
| 22 | 12 | const firstSeparatorIndex = branchName.indexOf("/"); |
| 23 | 13 | if (firstSeparatorIndex <= 0 || firstSeparatorIndex === branchName.length - 1) { |
| 24 | 14 | return branchName; |
apps/web/src/components/BranchToolbar.tsx+1-1| ... | ... | @@ -179,7 +179,7 @@ export default function BranchToolbar({ |
| 179 | 179 | } |
| 180 | 180 | |
| 181 | 181 | const selectedBranchName = branch.isRemote |
| 182 | ? deriveLocalBranchNameFromRemoteRef(branch.name, branch.remoteName) | |
| 182 | ? deriveLocalBranchNameFromRemoteRef(branch.name) | |
| 183 | 183 | : branch.name; |
| 184 | 184 | |
| 185 | 185 | checkoutMutation.mutate(branch.name, { |