diff --git a/src/main/git/worktree-add-local-base-refresh.test.ts b/src/main/git/worktree-add-local-base-refresh.test.ts index ccc277412a0..00972a2a51c 100644 --- a/src/main/git/worktree-add-local-base-refresh.test.ts +++ b/src/main/git/worktree-add-local-base-refresh.test.ts @@ -42,7 +42,6 @@ describe('addWorktree', () => { translateWslOutputPathsMock.mockClear() }) - it('fast-forwards a checked-out existing branch inside the new worktree (#15645)', async () => { // The branch pre-exists locally, is behind origin, and the new worktree just // checked it out — so the branch is OWNED by /repo-feature now. @@ -71,8 +70,6 @@ describe('addWorktree', () => { { checkoutExistingBranch: true } ) - // eslint-disable-next-line no-console - console.log('CALLS', JSON.stringify(gitExecFileAsyncMock.mock.calls.map((c) => c[0]))) expect(result.localBaseRefRefresh).toMatchObject({ status: 'updated' }) const reset = gitExecFileAsyncMock.mock.calls.find( (call) => call[0][0] === 'reset' @@ -81,6 +78,50 @@ describe('addWorktree', () => { expect(reset[1].cwd).toBe('/repo-feature') }) + it('leaves a claimed branch with local-only commits silent (#15645)', async () => { + gitExecFileAsyncMock + .mockResolvedValueOnce({ stdout: '' }) // worktree add /repo-feature feature + .mockResolvedValueOnce({ stdout: 'remote-tip\n' }) // resolveWorktreeAddBaseRef existence probe + .mockResolvedValueOnce({ stdout: '1\t2\n' }) // rev-list --left-right --count (ahead 1 -> not fast-forwardable) + + const result = await addWorktree( + '/repo', + '/repo-feature', + 'feature', + 'origin/feature', + true, + false, + { checkoutExistingBranch: true } + ) + + // Claiming a branch you already have commits on is the normal case, not a warning worth an infinite toast. + expect(result.localBaseRefRefresh).toBeUndefined() + expect(gitExecFileAsyncMock.mock.calls.find((call) => call[0][0] === 'reset')).toBeUndefined() + }) + + it('skips the claim-path refresh for sparse (--no-checkout) creates', async () => { + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add --no-checkout + + const result = await addWorktree( + '/repo', + '/repo-feature', + 'feature', + 'origin/feature', + true, + true, + { checkoutExistingBranch: true } + ) + + // A --no-checkout worktree has an empty index, so probing it would report a bogus dirty status. + expect(result.localBaseRefRefresh).toBeUndefined() + expect(gitExecFileAsyncMock.mock.calls).toEqual([ + [ + ['worktree', 'add', '--no-checkout', '/repo-feature', 'feature'], + { cwd: '/repo', timeout: WORKTREE_ADD_TIMEOUT_MS } + ] + ]) + }) + it('fast-forwards with reset --hard when localBranch is checked out in primary worktree', async () => { const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n\nworktree /repo-other\nHEAD def456\nbranch refs/heads/feature\n' diff --git a/src/main/git/worktree.ts b/src/main/git/worktree.ts index ccc09941c47..f7f77bba59a 100644 --- a/src/main/git/worktree.ts +++ b/src/main/git/worktree.ts @@ -936,7 +936,9 @@ async function refreshLocalBaseRefForWorktreeCreate( * @param baseBranch - Optional base branch to create from (defaults to HEAD) * @remarks Side effects (best-effort, warn-only): passes `--no-track`, writes * `branch..base` for new-branch worktrees with a base ref, and may - * write `push.autoSetupRemote=true` to the repo's shared config. + * write `push.autoSetupRemote=true` to the repo's shared config. When + * `checkoutExistingBranch` and `refreshLocalBaseRef` are both set, the claimed + * branch itself may be fast-forwarded inside the new worktree after the add. */ export async function addWorktree( repoPath: string, @@ -1018,22 +1020,21 @@ async function performAddWorktree( }) if (options.checkoutExistingBranch) { - // Why (#15645): the just-created worktree owns the branch now, so the - // refresh's owner path fast-forwards it in place (reset --hard inside the - // owning worktree is the one mutation git permits here). Skipping this - // left a pre-existing local branch checked out at its own stale commit — - // with no upstream, nothing ever signalled the staleness. - if (refreshLocalBaseRef && baseBranch) { + // Why (#15645): run after the add — the new worktree owns the branch now, so the refresh's owner path can reset --hard it in place. + // Why !noCheckout: a sparse create's index is still empty here, so the clean-status probe would false-positive dirty. + if (refreshLocalBaseRef && baseBranch && !noCheckout) { const effectiveExistingBase = await resolveWorktreeAddBaseRef(baseBranch, (qualifiedRef) => hasWorktreeBaseCommitRef(repoPath, qualifiedRef, options) ) - localBaseRefRefresh = await refreshLocalBaseRefForWorktreeCreate( + const claimedRefresh = await refreshLocalBaseRefForWorktreeCreate( repoPath, baseBranch, effectiveExistingBase, options.remoteTrackingBase, options ) + // Why: the user deliberately claimed this branch — local-only commits are expected, so only report a successful fast-forward instead of an infinite "not refreshed" warning this path never used to raise. + localBaseRefRefresh = claimedRefresh?.status === 'updated' ? claimedRefresh : undefined } return localBaseRefRefresh ? { localBaseRefRefresh } : {} }