Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 4 additions & 16 deletions src/main/agent-hooks/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1217,10 +1217,7 @@ describe('AgentHookServer ingestRemote', () => {
}
const listener = vi.fn()
server.setListener(listener)
server.ingestRemote(
{ paneKey: PANE, tabId: 'tab-1', worktreeId: 'wt-1', payload },
'conn-1'
)
server.ingestRemote({ paneKey: PANE, tabId: 'tab-1', worktreeId: 'wt-1', payload }, 'conn-1')
expect(listener).toHaveBeenCalledTimes(1)
expect(listener).toHaveBeenCalledWith({
paneKey: PANE,
Expand Down Expand Up @@ -1279,10 +1276,7 @@ describe('AgentHookServer ingestRemote', () => {
}
const listener = vi.fn()
server.setListener(listener)
server.ingestRemote(
{ paneKey: PANE, tabId: 'tab-1', worktreeId: 'wt-1', payload },
''
)
server.ingestRemote({ paneKey: PANE, tabId: 'tab-1', worktreeId: 'wt-1', payload }, '')
expect(listener).not.toHaveBeenCalled()
})

Expand All @@ -1296,10 +1290,7 @@ describe('AgentHookServer ingestRemote', () => {
}
const listener = vi.fn()
server.setListener(listener)
server.ingestRemote(
{ paneKey: PANE, tabId: 'tab-1', worktreeId: 'wt-1', payload },
' '
)
server.ingestRemote({ paneKey: PANE, tabId: 'tab-1', worktreeId: 'wt-1', payload }, ' ')
expect(listener).not.toHaveBeenCalled()
})

Expand Down Expand Up @@ -1330,10 +1321,7 @@ describe('AgentHookServer ingestRemote', () => {
}
const listener = vi.fn()
server.setListener(listener)
server.ingestRemote(
{ paneKey: ' ', tabId: 'tab-1', worktreeId: 'wt-1', payload },
'conn-1'
)
server.ingestRemote({ paneKey: ' ', tabId: 'tab-1', worktreeId: 'wt-1', payload }, 'conn-1')
expect(listener).not.toHaveBeenCalled()
})

Expand Down
12 changes: 11 additions & 1 deletion src/main/git/remove-worktree.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,14 @@ describe('addSparseWorktree', () => {

it('removes the worktree and deletes the created branch when sparse setup fails', async () => {
mockGitCommands({
// Why: addWorktree probes push.autoSetupRemote after `worktree add` to
// decide whether to set it locally. Without an explicit mock the helper
// returns empty stdout and the production code skips the `--local` write,
// exercising the wrong branch. Throw with code 1 to mirror git's "key
// unset" exit, which is what worktree.ts treats as "needs to be set".
'git config --get push.autoSetupRemote': {
error: Object.assign(new Error('key unset'), { code: 1 })
},
'git sparse-checkout set -- packages/web': {
error: new Error('sparse setup failed')
},
Expand Down Expand Up @@ -472,7 +480,9 @@ branch refs/heads/main
const calls = getGitCalls()
expect(calls).toEqual(
expect.arrayContaining([
'git worktree add --no-checkout -b feature/test /repo-feature',
'git worktree add --no-checkout --no-track -b feature/test /repo-feature',
'git config --get push.autoSetupRemote',
'git config --local push.autoSetupRemote true',
'git sparse-checkout init --cone',
'git sparse-checkout set -- packages/web',
'git worktree remove --force /repo-feature',
Expand Down
156 changes: 149 additions & 7 deletions src/main/git/worktree.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,12 +207,117 @@ describe('addWorktree', () => {
})

it('creates the worktree without touching the local base ref by default', async () => {
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' })
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', '-b', 'feature/test', '/repo-feature', '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('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' }
]
])
})

Expand All @@ -225,6 +330,8 @@ describe('addWorktree', () => {
.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)

Expand All @@ -233,7 +340,12 @@ describe('addWorktree', () => {
[['worktree', 'list', '--porcelain'], { cwd: '/repo' }],
[['status', '--porcelain', '--untracked-files=no'], { cwd: '/repo' }],
[['reset', '--hard', 'origin/main'], { cwd: '/repo' }],
[['worktree', 'add', '-b', 'feature/test', '/repo-feature', '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' }]
])
})

Expand All @@ -246,6 +358,8 @@ describe('addWorktree', () => {
.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)

Expand All @@ -266,6 +380,8 @@ describe('addWorktree', () => {
.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)

Expand All @@ -282,24 +398,40 @@ describe('addWorktree', () => {
.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
expect(gitExecFileAsyncMock.mock.calls).toHaveLength(4)
// 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: '' })
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)

Expand All @@ -309,8 +441,16 @@ describe('addWorktree', () => {
expect.objectContaining({ cwd: '/repo' })
],
[
['worktree', 'add', '-b', 'feature/test', '/repo-feature', 'origin/main'],
['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' })
]
])
})
Expand All @@ -323,6 +463,8 @@ describe('addWorktree', () => {
.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)

Expand Down
67 changes: 66 additions & 1 deletion src/main/git/worktree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,9 @@ export async function listWorktrees(repoPath: string): Promise<GitWorktreeInfo[]
* @param worktreePath - Absolute path where the worktree will be created
* @param branch - Branch name for the new worktree
* @param baseBranch - Optional base branch to create from (defaults to HEAD)
* @remarks Side effect: passes `--no-track` and may write `push.autoSetupRemote=true`
* to the repo's shared config (best-effort, warn-only on failure; preserves any
* user-set value at any scope). See body comment below for the full rationale.
*/
export async function addWorktree(
repoPath: string,
Expand Down Expand Up @@ -203,11 +206,73 @@ export async function addWorktree(
if (noCheckout) {
args.push('--no-checkout')
}
args.push('-b', branch, worktreePath)
// Why: --no-track keeps the new branch from inheriting the base ref's
// upstream, so `git status` doesn't report "behind by N" against the base
// pre-publish and tools/agents don't misread an unpublished branch as
// out-of-sync. First push sets the upstream — see push.autoSetupRemote
// below for the terminal ergonomics.
args.push('--no-track', '-b', branch, worktreePath)
if (baseBranch) {
args.push(baseBranch)
}
await gitExecFileAsync(args, { cwd: repoPath })

// SSH parity: src/relay/git-handler.ts addWorktree mirrors this exact
// probe-and-write state machine. If you change the logic here, update
// the relay handler in lockstep so local and SSH paths stay aligned.
//
// Why: with --no-track there is no upstream until first push. Setting
// push.autoSetupRemote=true makes a plain `git push` from the terminal
// create origin/<branch> and set it as upstream automatically — matching
// user expectations from modern git without requiring `-u`. Note that
// `--local` on a linked worktree writes to the shared common-dir config,
// so this affects the whole repo, not just this worktree. That is
// intentional and acceptable: the value is benign and idempotent, and
// every Orca-created worktree wants the same default. True per-worktree
// scope would require enabling extensions.worktreeConfig=true repo-wide,
// which is a larger change we deliberately avoid.
//
// Notes on the design:
// - push.autoSetupRemote is honored by git >= 2.37; older clients ignore
// the value, so `git push` falls back to the pre-2.37 "no upstream"
// error and the user runs `git push -u` once.
// - Failures here are warn-only: config writes are best-effort and a
// missing write degrades to the same fallback as old git.
// - The write is skipped when any value is already set (local, global,
// or system) so a deliberate user `false` is preserved.
// - Not rolled back on creation failure: addSparseWorktree's catch path
// removes the worktree but does not unset this config. That is consistent
// with the "benign and idempotent" rationale above — every Orca-created
// worktree wants this default, and a future creation will silently re-set
// it via the existing-value check anyway.
try {
// Why: `--get` (not `--local --get`) so a value set at any scope
// (local/global/system) counts as "user already chose" and we don't
// overwrite it.
let alreadySet = false
try {
await gitExecFileAsync(['config', '--get', 'push.autoSetupRemote'], {
cwd: worktreePath
})
alreadySet = true
} catch (readError) {
// Why: `git config --get` exits 1 only when the key is unset at every
// scope. Any other exit code means a real read failure (corrupt config,
// locked file, parse error) — surface that via the outer catch instead
// of silently overwriting whatever value the user actually has.
const code = (readError as { code?: unknown })?.code
if (code !== 1) {
throw readError
}
}
if (!alreadySet) {
await gitExecFileAsync(['config', '--local', 'push.autoSetupRemote', 'true'], {
cwd: worktreePath
})
}
} catch (error) {
console.warn(`addWorktree: failed to set push.autoSetupRemote for ${worktreePath}`, error)
}
}

export async function addSparseWorktree(
Expand Down
Loading
Loading