Files
orca/src/main/git/worktree.test.ts
T
Brennan BensonandOrca 8f3c783767 fix(worktree): create branches with --no-track and auto-setup remote (#1563)
* WIP: Changes before auto-review fixes

Co-authored-by: Orca <help@stably.ai>

* WIP: Changes before auto-review fixes

Co-authored-by: Orca <help@stably.ai>

* fix(worktree): preserve user push.autoSetupRemote, include path in warn

- Probe push.autoSetupRemote with `git config --get` before writing so a
  deliberate user value at any scope (local/global/system) is preserved.
- Include worktree path in the warn log for failed config writes.
- Add test pinning the preserve-existing-value behavior.
- Remove stray 00-review-context.md committed during review tooling.

Co-authored-by: Orca <help@stably.ai>

* WIP: Changes before auto-review fixes

Co-authored-by: Orca <help@stably.ai>

* fix(worktree): narrow config --get error handling, tighten test asserts

Treat only exit code 1 from `git config --get push.autoSetupRemote`
as "key unset". Other read failures (corrupt config, locked file,
parse error) now re-throw to the outer warn handler instead of being
silently treated as unset and overwriting whatever value the user
actually has.

Also: add test for the non-unset read-error path; convert the
"preserves existing value" test from `.some()` predicates to a
full-array `toEqual` matching sibling-test style; explicitly mock
`config --get` (with code: 1) in the sparse-failure rollback test
so it exercises the intended branch instead of the helper's empty-
stdout fallthrough; document in the design notes that
addSparseWorktree's rollback intentionally does not unset
push.autoSetupRemote.

Co-authored-by: Orca <help@stably.ai>

* test(worktree): pin --get-empty-stdout and worktree-add-fail invariants

Why: addWorktree's post-create config probe has two ordering
invariants worth pinning so a future refactor can't silently
regress them: (1) `git config --get` succeeding with empty stdout
still counts as "already set" so we don't overwrite an explicit
empty value, and (2) the entire config block is skipped when
`worktree add` itself rejects.

Co-authored-by: Orca <help@stably.ai>

* WIP: Changes before auto-review fixes

Co-authored-by: Orca <help@stably.ai>

* docs(worktree): cross-ref local↔SSH addWorktree, clarify SSH-host git version, add empty-stdout parity test

JSDoc on local addWorktree now flags the push.autoSetupRemote side
effect; both paths cross-reference each other so the next change keeps
them in lockstep. Relay comment clarifies that the git version that
matters is the SSH host's, not the client's. Adds the missing
empty-stdout-as-already-set parity test on the relay side.

Co-authored-by: Orca <help@stably.ai>

* chore: remove 00-review-context.md from PR

Stray file from local review workflow; should not ship in this PR.

Co-authored-by: Orca <help@stably.ai>

* chore: remove worktree-ssh-no-track-parity.md from PR

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
2026-05-11 12:46:03 -07:00

480 lines
17 KiB
TypeScript

/* eslint-disable max-lines -- Why: addWorktree has multiple code paths (no refresh,
reset --hard vs update-ref, dirty worktree, diverged branch, custom remote) that each
need dedicated test coverage. Splitting into separate files would scatter related tests
without a meaningful boundary. */
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { gitExecFileAsyncMock, gitExecFileSyncMock, translateWslOutputPathsMock } = vi.hoisted(
() => ({
gitExecFileAsyncMock: vi.fn(),
gitExecFileSyncMock: vi.fn(),
translateWslOutputPathsMock: vi.fn((output: string) => output)
})
)
vi.mock('./runner', () => ({
gitExecFileAsync: gitExecFileAsyncMock,
gitExecFileSync: gitExecFileSyncMock,
translateWslOutputPaths: translateWslOutputPathsMock
}))
import { addWorktree, parseWorktreeList } from './worktree'
describe('parseWorktreeList', () => {
it('parses regular and bare worktree blocks from porcelain output', () => {
const output = `
worktree /repo
HEAD abc123
branch refs/heads/main
worktree /repo-feature
HEAD def456
branch refs/heads/feature/test
worktree /repo-bare
HEAD 0000000
bare
`
expect(parseWorktreeList(output)).toEqual([
{
path: '/repo',
head: 'abc123',
branch: 'refs/heads/main',
isBare: false,
isMainWorktree: true
},
{
path: '/repo-feature',
head: 'def456',
branch: 'refs/heads/feature/test',
isBare: false,
isMainWorktree: false
},
{
path: '/repo-bare',
head: '0000000',
branch: '',
isBare: true,
isMainWorktree: false
}
])
})
it('returns empty array for empty string input', () => {
expect(parseWorktreeList('')).toEqual([])
})
it('returns empty array for whitespace-only input', () => {
expect(parseWorktreeList(' \n\n \n ')).toEqual([])
})
it('parses a single worktree block', () => {
const output = `worktree /single-repo
HEAD aaa111
branch refs/heads/main
`
expect(parseWorktreeList(output)).toEqual([
{
path: '/single-repo',
head: 'aaa111',
branch: 'refs/heads/main',
isBare: false,
isMainWorktree: true
}
])
})
it('parses a detached HEAD worktree (no branch line)', () => {
const output = `worktree /repo-detached
HEAD abc123
detached
`
expect(parseWorktreeList(output)).toEqual([
{
path: '/repo-detached',
head: 'abc123',
branch: '',
isBare: false,
isMainWorktree: true
}
])
})
it('handles extra blank lines between blocks', () => {
const output = `worktree /repo-a
HEAD aaa111
branch refs/heads/main
worktree /repo-b
HEAD bbb222
branch refs/heads/dev
`
expect(parseWorktreeList(output)).toEqual([
{
path: '/repo-a',
head: 'aaa111',
branch: 'refs/heads/main',
isBare: false,
isMainWorktree: true
},
{
path: '/repo-b',
head: 'bbb222',
branch: 'refs/heads/dev',
isBare: false,
isMainWorktree: false
}
])
})
it('returns entry with empty head when HEAD line is missing', () => {
const output = `worktree /repo-no-head
branch refs/heads/main
`
expect(parseWorktreeList(output)).toEqual([
{
path: '/repo-no-head',
head: '',
branch: 'refs/heads/main',
isBare: false,
isMainWorktree: true
}
])
})
it('correctly captures worktree path with spaces', () => {
const output = `worktree /path/to/my worktree
HEAD ccc333
branch refs/heads/main
`
expect(parseWorktreeList(output)).toEqual([
{
path: '/path/to/my worktree',
head: 'ccc333',
branch: 'refs/heads/main',
isBare: false,
isMainWorktree: true
}
])
})
it('parses multiple bare entries mixed with regular entries', () => {
const output = `worktree /bare-one
HEAD 0000000
bare
worktree /regular
HEAD abc123
branch refs/heads/main
worktree /bare-two
HEAD 1111111
bare
`
expect(parseWorktreeList(output)).toEqual([
{
path: '/bare-one',
head: '0000000',
branch: '',
isBare: true,
isMainWorktree: true
},
{
path: '/regular',
head: 'abc123',
branch: 'refs/heads/main',
isBare: false,
isMainWorktree: false
},
{
path: '/bare-two',
head: '1111111',
branch: '',
isBare: true,
isMainWorktree: false
}
])
})
})
describe('addWorktree', () => {
beforeEach(() => {
gitExecFileAsyncMock.mockReset()
gitExecFileSyncMock.mockReset()
translateWslOutputPathsMock.mockClear()
})
it('creates the worktree without touching the local base ref by default', async () => {
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add
gitExecFileAsyncMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset)
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote
await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main')
expect(gitExecFileAsyncMock.mock.calls).toEqual([
[
['worktree', 'add', '--no-track', '-b', 'feature/test', '/repo-feature', 'origin/main'],
{ cwd: '/repo' }
],
[['config', '--get', 'push.autoSetupRemote'], { cwd: '/repo-feature' }],
[['config', '--local', 'push.autoSetupRemote', 'true'], { cwd: '/repo-feature' }]
])
})
it('warns but does not throw when push.autoSetupRemote config fails', async () => {
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add
gitExecFileAsyncMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get (unset, expected)
gitExecFileAsyncMock.mockRejectedValueOnce(new Error('config locked')) // config --local set fails
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
await expect(
addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main')
).resolves.toBeUndefined()
expect(warnSpy).toHaveBeenCalledWith(
'addWorktree: failed to set push.autoSetupRemote for /repo-feature',
expect.any(Error)
)
warnSpy.mockRestore()
})
it('warns and skips --local set when --get fails with non-unset error (e.g. corrupt config)', async () => {
// Why: exit 1 from `git config --get` means "key unset" — anything else
// is a real read failure (parse error, locked file). We must NOT fall
// through to `--local set true`, which would silently overwrite whatever
// value the user actually has.
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add
gitExecFileAsyncMock.mockRejectedValueOnce(Object.assign(new Error('parse error'), { code: 3 })) // --get fails non-unset
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
await expect(
addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main')
).resolves.toBeUndefined()
expect(warnSpy).toHaveBeenCalledWith(
'addWorktree: failed to set push.autoSetupRemote for /repo-feature',
expect.any(Error)
)
// No --local set was attempted: only worktree add + the failing --get.
expect(gitExecFileAsyncMock.mock.calls).toEqual([
[
['worktree', 'add', '--no-track', '-b', 'feature/test', '/repo-feature', 'origin/main'],
{ cwd: '/repo' }
],
[['config', '--get', 'push.autoSetupRemote'], { cwd: '/repo-feature' }]
])
warnSpy.mockRestore()
})
it('preserves existing push.autoSetupRemote value (does not overwrite user-set false)', async () => {
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'false\n' }) // config --get returns existing value
await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main')
// No --local set: --get succeeded so we preserve the user's value.
expect(gitExecFileAsyncMock.mock.calls).toEqual([
[
['worktree', 'add', '--no-track', '-b', 'feature/test', '/repo-feature', 'origin/main'],
{ cwd: '/repo' }
],
[['config', '--get', 'push.autoSetupRemote'], { cwd: '/repo-feature' }]
])
})
it('treats --get success with empty stdout as "already set" (key present but blank)', async () => {
// Why: `git config --get key` exits 0 if the key has any value at any
// scope, including the unusual case of an explicitly empty string. We
// must not fall through to `--local set true` and overwrite that.
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // config --get succeeds with empty value
await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main')
expect(gitExecFileAsyncMock.mock.calls).toEqual([
[
['worktree', 'add', '--no-track', '-b', 'feature/test', '/repo-feature', 'origin/main'],
{ cwd: '/repo' }
],
[['config', '--get', 'push.autoSetupRemote'], { cwd: '/repo-feature' }]
])
})
it('does not probe or write config when worktree add itself fails', async () => {
// Why: a refactor that moves the config block earlier could try to write
// push.autoSetupRemote against a worktree directory that was never
// created. Pin the current ordering invariant: config calls happen only
// after worktree add succeeds.
gitExecFileAsyncMock.mockRejectedValueOnce(new Error('worktree add failed'))
await expect(
addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main')
).rejects.toThrow('worktree add failed')
expect(gitExecFileAsyncMock.mock.calls).toEqual([
[
['worktree', 'add', '--no-track', '-b', 'feature/test', '/repo-feature', 'origin/main'],
{ cwd: '/repo' }
]
])
})
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'
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: '' }) // merge-base --is-ancestor
.mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain
.mockResolvedValueOnce({ stdout: '' }) // status --porcelain (in /repo)
.mockResolvedValueOnce({ stdout: '' }) // reset --hard (in /repo)
.mockResolvedValueOnce({ stdout: '' }) // worktree add
.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset)
.mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote
await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true)
expect(gitExecFileAsyncMock.mock.calls).toEqual([
[['merge-base', '--is-ancestor', 'main', 'origin/main'], { cwd: '/repo' }],
[['worktree', 'list', '--porcelain'], { cwd: '/repo' }],
[['status', '--porcelain', '--untracked-files=no'], { cwd: '/repo' }],
[['reset', '--hard', 'origin/main'], { cwd: '/repo' }],
[
['worktree', 'add', '--no-track', '-b', 'feature/test', '/repo-feature', 'origin/main'],
{ cwd: '/repo' }
],
[['config', '--get', 'push.autoSetupRemote'], { cwd: '/repo-feature' }],
[['config', '--local', 'push.autoSetupRemote', 'true'], { cwd: '/repo-feature' }]
])
})
it('fast-forwards with reset --hard in sibling worktree when localBranch is checked out there', async () => {
const worktreeListOutput =
'worktree /repo\nHEAD abc123\nbranch refs/heads/develop\n\nworktree /repo-main-wt\nHEAD def456\nbranch refs/heads/main\n'
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: '' }) // merge-base --is-ancestor
.mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain
.mockResolvedValueOnce({ stdout: '' }) // status --porcelain (in /repo-main-wt)
.mockResolvedValueOnce({ stdout: '' }) // reset --hard (in /repo-main-wt)
.mockResolvedValueOnce({ stdout: '' }) // worktree add
.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset)
.mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote
await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true)
expect(gitExecFileAsyncMock.mock.calls[2]).toEqual([
['status', '--porcelain', '--untracked-files=no'],
expect.objectContaining({ cwd: '/repo-main-wt' })
])
expect(gitExecFileAsyncMock.mock.calls[3]).toEqual([
['reset', '--hard', 'origin/main'],
expect.objectContaining({ cwd: '/repo-main-wt' })
])
})
it('uses update-ref when localBranch is not checked out in any worktree', async () => {
const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/develop\n'
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: '' }) // merge-base --is-ancestor
.mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain
.mockResolvedValueOnce({ stdout: '' }) // update-ref
.mockResolvedValueOnce({ stdout: '' }) // worktree add
.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset)
.mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote
await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true)
expect(gitExecFileAsyncMock.mock.calls[2]).toEqual([
['update-ref', 'refs/heads/main', 'origin/main'],
expect.objectContaining({ cwd: '/repo' })
])
})
it('skips update when the owning worktree is dirty', async () => {
const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n'
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: '' }) // merge-base --is-ancestor
.mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain
.mockResolvedValueOnce({ stdout: ' M package.json\n' }) // status --porcelain (dirty)
.mockResolvedValueOnce({ stdout: '' }) // worktree add
.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset)
.mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote
await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true)
// No reset --hard or update-ref — just merge-base, worktree list, status, worktree add, config --get, config --local set
expect(gitExecFileAsyncMock.mock.calls).toHaveLength(6)
expect(gitExecFileAsyncMock.mock.calls[3]?.[0]).toEqual([
'worktree',
'add',
'--no-track',
'-b',
'feature/test',
'/repo-feature',
'origin/main'
])
expect(gitExecFileAsyncMock.mock.calls[4]?.[0]).toEqual([
'config',
'--get',
'push.autoSetupRemote'
])
expect(gitExecFileAsyncMock.mock.calls[5]?.[0]).toEqual([
'config',
'--local',
'push.autoSetupRemote',
'true'
])
})
it('skips updating the local branch when it has diverged', async () => {
gitExecFileAsyncMock.mockRejectedValueOnce(new Error('not a fast-forward'))
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add
gitExecFileAsyncMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset)
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote
await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true)
expect(gitExecFileAsyncMock.mock.calls).toEqual([
[
['merge-base', '--is-ancestor', 'main', 'origin/main'],
expect.objectContaining({ cwd: '/repo' })
],
[
['worktree', 'add', '--no-track', '-b', 'feature/test', '/repo-feature', 'origin/main'],
expect.objectContaining({ cwd: '/repo' })
],
[
['config', '--get', 'push.autoSetupRemote'],
expect.objectContaining({ cwd: '/repo-feature' })
],
[
['config', '--local', 'push.autoSetupRemote', 'true'],
expect.objectContaining({ cwd: '/repo-feature' })
]
])
})
it('uses the remote name from the base ref instead of hardcoding origin', async () => {
const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n'
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: '' }) // merge-base --is-ancestor
.mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain
.mockResolvedValueOnce({ stdout: '' }) // status --porcelain
.mockResolvedValueOnce({ stdout: '' }) // reset --hard
.mockResolvedValueOnce({ stdout: '' }) // worktree add
.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset)
.mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote
await addWorktree('/repo', '/repo-feature', 'feature/test', 'upstream/main', true)
expect(gitExecFileAsyncMock.mock.calls[0]?.[0]).toEqual([
'merge-base',
'--is-ancestor',
'main',
'upstream/main'
])
expect(gitExecFileAsyncMock.mock.calls[3]?.[0]).toEqual(['reset', '--hard', 'upstream/main'])
})
})