fix(worktree): keep the claimed-branch fast-forward silent and off sparse creates

Only surface a successful fast-forward from the claim path: a claimed branch
with local-only commits is the normal case, and the "not refreshed" toast is
infinite-duration and factually wrong there. Skip the probe entirely for
--no-checkout (sparse) creates, whose empty index reports staged deletions and
would false-positive as a dirty worktree.

Refs #15645
Co-authored-by: yzxcj797 <54314860+yzxcj797@users.noreply.github.com>
This commit is contained in:
Neil
2026-08-21 23:01:17 -07:00
parent 9fe6998c7f
commit e28f459dd4
2 changed files with 53 additions and 11 deletions
@@ -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'
+9 -8
View File
@@ -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.<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 } : {}
}