import { describe, expect, it, vi } from 'vitest' import type { GitExec } from './git-handler-ops' import { addWorktreeOp, removeWorktreeOp, worktreeIsCleanOp } from './git-handler-worktree-ops' function worktreeList(...entries: { path: string; branch?: string }[]): string { return entries .map((entry, index) => [ `worktree ${entry.path}`, `HEAD ${index}`, ...(entry.branch ? [`branch refs/heads/${entry.branch}`] : []) ].join('\n') ) .join('\n\n') } describe('addWorktreeOp', () => { it('writes durable branch base config after creating an SSH new-branch worktree', async () => { const git = vi.fn(async () => ({ stdout: '', stderr: '' })) await addWorktreeOp(git, { repoPath: '/repo', branchName: 'feature/test', targetDir: '/repo-feature', base: 'origin/main' }) expect(git.mock.calls.map((call) => call[0])).toEqual([ ['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main^{commit}'], [ 'worktree', 'add', '--no-track', '-b', 'feature/test', '/repo-feature', 'refs/remotes/origin/main' ], [ 'config', '--local', '--replace-all', 'branch.feature/test.base', 'refs/remotes/origin/main' ], ['config', '--get', 'push.autoSetupRemote'] ]) expect(git.mock.calls.map((call) => call[0])).not.toContainEqual([ 'config', '--local', 'branch.feature/test.remote', 'origin' ]) expect(git.mock.calls.map((call) => call[0])).not.toContainEqual([ 'config', '--local', 'branch.feature/test.merge', 'refs/heads/main' ]) }) it('does not write branch base config when checking out an existing SSH branch', async () => { const git = vi.fn(async () => ({ stdout: '', stderr: '' })) await addWorktreeOp(git, { repoPath: '/repo', branchName: 'feature/test', targetDir: '/repo-feature', base: 'origin/main', checkoutExistingBranch: true }) expect(git.mock.calls.map((call) => call[0])).toEqual([ ['worktree', 'add', '/repo-feature', 'feature/test'] ]) }) it('does not write branch base config when SSH creation has no base', async () => { const git = vi.fn(async () => ({ stdout: '', stderr: '' })) await addWorktreeOp(git, { repoPath: '/repo', branchName: 'feature/no-base', targetDir: '/repo-feature' }) expect(git.mock.calls.map((call) => call[0])).toEqual([ ['worktree', 'add', '--no-track', '-b', 'feature/no-base', '/repo-feature'], ['config', '--get', 'push.autoSetupRemote'] ]) }) it('warns and unsets stale branch base config when SSH base persistence fails', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) const git = vi.fn(async (args) => { if (args[0] === 'config' && args[2] === '--replace-all') { throw new Error('config locked') } return { stdout: '', stderr: '' } }) await expect( addWorktreeOp(git, { repoPath: '/repo', branchName: 'feature/test', targetDir: '/repo-feature', base: 'origin/main' }) ).resolves.toBeUndefined() expect(warnSpy).toHaveBeenCalledWith( 'relay addWorktree: failed to set branch.feature/test.base for /repo-feature', expect.any(Error) ) expect(git.mock.calls.map((call) => call[0])).toContainEqual([ 'config', '--local', '--unset-all', 'branch.feature/test.base' ]) warnSpy.mockRestore() }) }) describe('removeWorktreeOp', () => { it('deletes the now-unused branch after removing an SSH worktree', async () => { const calls: string[] = [] let listCount = 0 const git = vi.fn(async (args, cwd) => { calls.push(`${cwd}$ ${args.join(' ')}`) if (args[0] === 'rev-parse') { return { stdout: '/repo/.git\n', stderr: '' } } if (args[0] === 'worktree' && args[1] === 'list') { listCount += 1 return { stdout: listCount === 1 ? worktreeList( { path: '/repo', branch: 'main' }, { path: '/repo-feature', branch: 'feature/test' } ) : worktreeList({ path: '/repo', branch: 'main' }), stderr: '' } } return { stdout: '', stderr: '' } }) await removeWorktreeOp(git, { worktreePath: '/repo-feature' }) expect(calls).toEqual([ '/repo-feature$ rev-parse --git-common-dir', '/repo$ worktree list --porcelain', '/repo$ worktree remove /repo-feature', '/repo$ worktree prune', '/repo$ worktree list --porcelain', '/repo$ branch -d feature/test' ]) }) it('preserves the branch (does not throw) when `branch -d` refuses an unmerged branch', async () => { let listCount = 0 const git = vi.fn(async (args) => { if (args[0] === 'rev-parse') { return { stdout: '/repo/.git\n', stderr: '' } } if (args[0] === 'worktree' && args[1] === 'list') { listCount += 1 return { stdout: listCount === 1 ? worktreeList( { path: '/repo', branch: 'main' }, { path: '/repo-feature', branch: 'feature/test' } ) : worktreeList({ path: '/repo', branch: 'main' }), stderr: '' } } if (args[0] === 'branch' && args[1] === '-d') { throw new Error('error: the branch feature/test is not fully merged') } return { stdout: '', stderr: '' } }) // The unmerged-branch refusal must be swallowed, not propagated. await expect(removeWorktreeOp(git, { worktreePath: '/repo-feature' })).resolves.toBeUndefined() expect(git).toHaveBeenCalledWith(['branch', '-d', 'feature/test'], expect.any(String)) expect(git).not.toHaveBeenCalledWith(['branch', '-D', 'feature/test'], expect.any(String)) }) it('force-deletes the just-created branch during failed sparse setup rollback', async () => { let listCount = 0 const git = vi.fn(async (args) => { if (args[0] === 'rev-parse') { return { stdout: '/repo/.git\n', stderr: '' } } if (args[0] === 'worktree' && args[1] === 'list') { listCount += 1 return { stdout: listCount === 1 ? worktreeList( { path: '/repo', branch: 'main' }, { path: '/repo-feature', branch: 'feature/test' } ) : worktreeList({ path: '/repo', branch: 'main' }), stderr: '' } } return { stdout: '', stderr: '' } }) await removeWorktreeOp(git, { worktreePath: '/repo-feature', force: true, forceBranchDelete: true }) expect(git).toHaveBeenCalledWith(['branch', '-D', 'feature/test'], expect.any(String)) expect(git).not.toHaveBeenCalledWith(['branch', '-d', 'feature/test'], expect.any(String)) }) it('skips branch deletion entirely when deleteBranch is false', async () => { const calls: string[] = [] const git = vi.fn(async (args, cwd) => { calls.push(`${cwd}$ ${args.join(' ')}`) if (args[0] === 'rev-parse') { return { stdout: '/repo/.git\n', stderr: '' } } if (args[0] === 'worktree' && args[1] === 'list') { return { stdout: worktreeList( { path: '/repo', branch: 'main' }, { path: '/repo-feature', branch: 'feature/test' } ), stderr: '' } } return { stdout: '', stderr: '' } }) await removeWorktreeOp(git, { worktreePath: '/repo-feature', deleteBranch: false }) expect(calls).toEqual([ '/repo-feature$ rev-parse --git-common-dir', '/repo$ worktree list --porcelain', '/repo$ worktree remove /repo-feature', '/repo$ worktree prune' ]) }) it('keeps the branch when another SSH worktree still uses it', async () => { let listCount = 0 const git = vi.fn(async (args, _cwd) => { if (args[0] === 'rev-parse') { return { stdout: '/repo/.git\n', stderr: '' } } if (args[0] === 'worktree' && args[1] === 'list') { listCount += 1 return { stdout: listCount === 1 ? worktreeList( { path: '/repo', branch: 'main' }, { path: '/repo-feature', branch: 'feature/test' } ) : worktreeList( { path: '/repo', branch: 'main' }, { path: '/repo-other', branch: 'feature/test' } ), stderr: '' } } return { stdout: '', stderr: '' } }) await removeWorktreeOp(git, { worktreePath: '/repo-feature' }) expect(git).not.toHaveBeenCalledWith(['branch', '-d', 'feature/test'], expect.any(String)) expect(git).not.toHaveBeenCalledWith(['branch', '-D', 'feature/test'], expect.any(String)) }) }) describe('worktreeIsCleanOp', () => { it('reports clean SSH worktrees without returning porcelain output', async () => { const git = vi.fn(async () => ({ stdout: '\n', stderr: '' })) await expect(worktreeIsCleanOp(git, { worktreePath: '/repo-feature' })).resolves.toEqual({ clean: true, stdout: undefined }) expect(git).toHaveBeenCalledWith( ['status', '--porcelain', '--untracked-files=all'], '/repo-feature' ) }) it('returns porcelain output for dirty SSH worktrees', async () => { const stdout = ' M src/file.ts\n?? scratch.txt\n' const git = vi.fn(async () => ({ stdout, stderr: '' })) await expect(worktreeIsCleanOp(git, { worktreePath: '/repo-feature' })).resolves.toEqual({ clean: false, stdout }) }) })