/* eslint-disable max-lines -- Why: this file covers ~14 distinct relay git handlers plus the addWorktree state machine (--no-track + push.autoSetupRemote probe/write across four flow branches). Splitting per-handler would scatter related coverage without a meaningful boundary. */ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' import { GitHandler } from './git-handler' import { RelayContext } from './context' import * as fs from 'fs/promises' import * as path from 'path' import { mkdtempSync, mkdirSync, symlinkSync, writeFileSync } from 'fs' import { tmpdir } from 'os' import { execFileSync } from 'child_process' import { createMockDispatcher, gitInit, gitCommit, type MockDispatcher, type RelayDispatcher } from './git-handler-test-setup' import type { GitExec } from './git-handler-ops' describe('GitHandler', () => { let dispatcher: MockDispatcher let tmpDir: string let handler: GitHandler beforeEach(() => { tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-git-')) dispatcher = createMockDispatcher() const ctx = new RelayContext() handler = new GitHandler(dispatcher as unknown as RelayDispatcher, ctx) }) afterEach(async () => { await fs.rm(tmpDir, { recursive: true, force: true }) }) it('registers all expected handlers', () => { const methods = Array.from(dispatcher._requestHandlers.keys()) expect(methods).toContain('git.status') expect(methods).toContain('git.checkIgnored') expect(methods).toContain('git.history') expect(methods).toContain('git.commit') expect(methods).toContain('git.diff') expect(methods).toContain('git.stage') expect(methods).toContain('git.unstage') expect(methods).toContain('git.bulkStage') expect(methods).toContain('git.bulkUnstage') expect(methods).toContain('git.abortMerge') expect(methods).toContain('git.abortRebase') expect(methods).toContain('git.discard') expect(methods).toContain('git.bulkDiscard') expect(methods).toContain('git.conflictOperation') expect(methods).toContain('git.branchCompare') expect(methods).toContain('git.upstreamStatus') expect(methods).toContain('git.fetch') expect(methods).toContain('git.fetchRemoteTrackingRef') expect(methods).toContain('git.push') expect(methods).toContain('git.pull') expect(methods).toContain('git.fastForward') expect(methods).toContain('git.rebaseFromBase') expect(methods).toContain('git.branchDiff') expect(methods).toContain('git.listWorktrees') expect(methods).toContain('git.addWorktree') expect(methods).toContain('git.removeWorktree') expect(methods).toContain('git.renameCurrentBranch') expect(methods).toContain('git.exec') expect(methods).toContain('git.isGitRepo') }) describe('abortMerge', () => { it('aborts an in-progress merge', async () => { gitInit(tmpDir) writeFileSync(path.join(tmpDir, 'file.txt'), 'base\n') gitCommit(tmpDir, 'initial') const baseBranch = execFileSync('git', ['branch', '--show-current'], { cwd: tmpDir, encoding: 'utf-8', stdio: 'pipe' }).trim() execFileSync('git', ['checkout', '-b', 'feature'], { cwd: tmpDir, stdio: 'pipe' }) writeFileSync(path.join(tmpDir, 'file.txt'), 'feature\n') gitCommit(tmpDir, 'feature change') execFileSync('git', ['checkout', baseBranch], { cwd: tmpDir, stdio: 'pipe' }) writeFileSync(path.join(tmpDir, 'file.txt'), 'main\n') gitCommit(tmpDir, 'main change') expect(() => execFileSync('git', ['merge', 'feature'], { cwd: tmpDir, stdio: 'pipe' }) ).toThrow() await expect(fs.access(path.join(tmpDir, '.git', 'MERGE_HEAD'))).resolves.toBeUndefined() await dispatcher.callRequest('git.abortMerge', { worktreePath: tmpDir }) await expect(fs.access(path.join(tmpDir, '.git', 'MERGE_HEAD'))).rejects.toThrow() await expect(fs.readFile(path.join(tmpDir, 'file.txt'), 'utf-8')).resolves.toBe('main\n') }) }) describe('abortRebase', () => { it('aborts an in-progress rebase', async () => { gitInit(tmpDir) writeFileSync(path.join(tmpDir, 'file.txt'), 'base\n') gitCommit(tmpDir, 'initial') const baseBranch = execFileSync('git', ['branch', '--show-current'], { cwd: tmpDir, encoding: 'utf-8', stdio: 'pipe' }).trim() execFileSync('git', ['checkout', '-b', 'feature'], { cwd: tmpDir, stdio: 'pipe' }) writeFileSync(path.join(tmpDir, 'file.txt'), 'feature\n') gitCommit(tmpDir, 'feature change') execFileSync('git', ['checkout', baseBranch], { cwd: tmpDir, stdio: 'pipe' }) writeFileSync(path.join(tmpDir, 'file.txt'), 'main\n') gitCommit(tmpDir, 'main change') execFileSync('git', ['checkout', 'feature'], { cwd: tmpDir, stdio: 'pipe' }) expect(() => execFileSync('git', ['rebase', baseBranch], { cwd: tmpDir, stdio: 'pipe' }) ).toThrow() await expect(fs.access(path.join(tmpDir, '.git', 'rebase-merge'))).resolves.toBeUndefined() await dispatcher.callRequest('git.abortRebase', { worktreePath: tmpDir }) await expect(fs.access(path.join(tmpDir, '.git', 'rebase-merge'))).rejects.toThrow() await expect(fs.access(path.join(tmpDir, '.git', 'rebase-apply'))).rejects.toThrow() await expect(fs.readFile(path.join(tmpDir, 'file.txt'), 'utf-8')).resolves.toBe('feature\n') }) }) describe('renameCurrentBranch', () => { it('renames only the checked-out branch through the narrow RPC', async () => { gitInit(tmpDir) writeFileSync(path.join(tmpDir, 'file.txt'), 'hello') gitCommit(tmpDir, 'initial') execFileSync('git', ['checkout', '-b', 'you/Nautilus'], { cwd: tmpDir }) await dispatcher.callRequest('git.renameCurrentBranch', { worktreePath: tmpDir, newBranch: 'you/fix-auth' }) const current = execFileSync('git', ['branch', '--show-current'], { cwd: tmpDir, encoding: 'utf-8' }).trim() expect(current).toBe('you/fix-auth') }) it('rejects branch names that look like flags', async () => { gitInit(tmpDir) await expect( dispatcher.callRequest('git.renameCurrentBranch', { worktreePath: tmpDir, newBranch: '-bad' }) ).rejects.toThrow('Branch name must not start with "-"') }) }) describe('history', () => { it('returns bounded git history for a repo', async () => { gitInit(tmpDir) writeFileSync(path.join(tmpDir, 'file.txt'), 'hello') gitCommit(tmpDir, 'initial') writeFileSync(path.join(tmpDir, 'file.txt'), 'changed') gitCommit(tmpDir, 'second') const result = (await dispatcher.callRequest('git.history', { worktreePath: tmpDir, limit: 10 })) as { items: { subject: string; displayId?: string }[] currentRef?: { category?: string; revision?: string } hasMore: boolean limit: number } expect(result.items.map((item) => item.subject)).toEqual(['second', 'initial']) expect(result.currentRef?.category).toBe('branches') expect(result.currentRef?.revision).toMatch(/^[0-9a-f]{40}$/) expect(result.items[0]?.displayId).toHaveLength(7) expect(result.hasMore).toBe(false) expect(result.limit).toBe(10) }) }) describe('status', () => { it('returns empty entries for clean repo', async () => { gitInit(tmpDir) writeFileSync(path.join(tmpDir, 'file.txt'), 'hello') gitCommit(tmpDir, 'initial') const result = (await dispatcher.callRequest('git.status', { worktreePath: tmpDir })) as { entries: Record[] conflictOperation: string head?: string branch?: string } expect(result.entries).toEqual([]) expect(result.conflictOperation).toBe('unknown') expect(result.branch).toMatch(/^refs\/heads\//) expect(typeof result.head).toBe('string') }) it('detects untracked files', async () => { gitInit(tmpDir) writeFileSync(path.join(tmpDir, 'tracked.txt'), 'tracked') gitCommit(tmpDir, 'initial') writeFileSync(path.join(tmpDir, 'new.txt'), 'new') const result = (await dispatcher.callRequest('git.status', { worktreePath: tmpDir })) as { entries: { path?: unknown status?: unknown area?: unknown added?: unknown removed?: unknown }[] } const untracked = result.entries.find((e) => e.path === 'new.txt') expect(untracked).toBeDefined() expect(untracked!.status).toBe('untracked') expect(untracked!.area).toBe('untracked') expect(untracked!.added).toBe(1) expect(untracked!.removed).toBeUndefined() }) it('returns ignored paths only when requested', async () => { gitInit(tmpDir) writeFileSync(path.join(tmpDir, '.gitignore'), 'dist/\n.env\n') gitCommit(tmpDir, 'initial') mkdirSync(path.join(tmpDir, 'dist'), { recursive: true }) writeFileSync(path.join(tmpDir, 'dist', 'bundle.js'), 'compiled') writeFileSync(path.join(tmpDir, '.env'), 'TOKEN=secret') const defaultResult = (await dispatcher.callRequest('git.status', { worktreePath: tmpDir })) as { ignoredPaths?: string[] } const ignoredResult = (await dispatcher.callRequest('git.status', { worktreePath: tmpDir, includeIgnored: true })) as { ignoredPaths?: string[] } expect('ignoredPaths' in defaultResult).toBe(false) expect(ignoredResult.ignoredPaths).toEqual(expect.arrayContaining(['dist/', '.env'])) }) it('checks ignored status for selected paths', async () => { gitInit(tmpDir) writeFileSync(path.join(tmpDir, '.gitignore'), 'dist/\n.env\n') gitCommit(tmpDir, 'initial') mkdirSync(path.join(tmpDir, 'dist'), { recursive: true }) writeFileSync(path.join(tmpDir, 'dist', 'bundle.js'), 'compiled') writeFileSync(path.join(tmpDir, '.env'), 'TOKEN=secret') const result = (await dispatcher.callRequest('git.checkIgnored', { worktreePath: tmpDir, paths: ['dist/bundle.js', 'src/index.ts', '.env'] })) as string[] expect(result).toEqual(expect.arrayContaining(['dist/bundle.js', '.env'])) expect(result).not.toContain('src/index.ts') }) it('detects modified files', async () => { gitInit(tmpDir) writeFileSync(path.join(tmpDir, 'file.txt'), 'original') gitCommit(tmpDir, 'initial') writeFileSync(path.join(tmpDir, 'file.txt'), 'modified') const result = (await dispatcher.callRequest('git.status', { worktreePath: tmpDir })) as { entries: { path?: unknown status?: unknown area?: unknown added?: unknown removed?: unknown }[] } const modified = result.entries.find((e) => e.path === 'file.txt') expect(modified).toBeDefined() expect(modified!.status).toBe('modified') expect(modified!.area).toBe('unstaged') expect(modified!.added).toBe(1) expect(modified!.removed).toBe(1) }) it('detects staged files', async () => { gitInit(tmpDir) writeFileSync(path.join(tmpDir, 'file.txt'), 'original') gitCommit(tmpDir, 'initial') writeFileSync(path.join(tmpDir, 'file.txt'), 'changed') execFileSync('git', ['add', 'file.txt'], { cwd: tmpDir, stdio: 'pipe' }) const result = (await dispatcher.callRequest('git.status', { worktreePath: tmpDir })) as { entries: { path?: unknown status?: unknown area?: unknown added?: unknown removed?: unknown }[] } const staged = result.entries.find((e) => e.area === 'staged') expect(staged).toBeDefined() expect(staged!.status).toBe('modified') expect(staged!.added).toBe(1) expect(staged!.removed).toBe(1) }) // Why: regression for issue #1503 — git's default core.quotePath=true // emits non-ASCII paths as octal-escaped, double-quoted strings (e.g. // "docs/\346\227\245\346\234\254\350\252\236/sample.md"), which made the // sidebar show gibberish and broke downstream blob reads. it('preserves UTF-8 paths in status output', async () => { gitInit(tmpDir) const utf8Dir = path.join(tmpDir, 'docs', '日本語') mkdirSync(utf8Dir, { recursive: true }) writeFileSync(path.join(utf8Dir, 'sample.md'), 'hello') const result = (await dispatcher.callRequest('git.status', { worktreePath: tmpDir })) as { entries: Record[] } const entry = result.entries.find((e) => typeof e.path === 'string' ? e.path.endsWith('sample.md') : false ) expect(entry).toBeDefined() expect(entry!.path).toBe('docs/日本語/sample.md') }) // Why: regression for issue #1503 on the porcelain v2 type-1 entry parser // branch (tracked + modified). The existing UTF-8 test exercises only the // untracked '?' branch; this one exercises the path-reconstruction code in // parseStatusOutput that joins parts.slice(8). it('preserves UTF-8 paths for tracked-modified entries', async () => { gitInit(tmpDir) const utf8Dir = path.join(tmpDir, 'docs', '日本語') mkdirSync(utf8Dir, { recursive: true }) const utf8File = path.join(utf8Dir, 'sample.md') writeFileSync(utf8File, 'original') gitCommit(tmpDir, 'initial') writeFileSync(utf8File, 'modified') const result = (await dispatcher.callRequest('git.status', { worktreePath: tmpDir })) as { entries: Record[] } const entry = result.entries.find((e) => typeof e.path === 'string' ? e.path.endsWith('sample.md') : false ) expect(entry).toBeDefined() expect(entry!.path).toBe('docs/日本語/sample.md') expect(entry!.status).toBe('modified') expect(entry!.area).toBe('unstaged') }) }) describe('stage and unstage', () => { it('stages a file', async () => { gitInit(tmpDir) writeFileSync(path.join(tmpDir, 'file.txt'), 'content') gitCommit(tmpDir, 'initial') writeFileSync(path.join(tmpDir, 'file.txt'), 'changed') await dispatcher.callRequest('git.stage', { worktreePath: tmpDir, filePath: 'file.txt' }) const output = execFileSync('git', ['diff', '--cached', '--name-only'], { cwd: tmpDir, encoding: 'utf-8' }) expect(output.trim()).toBe('file.txt') }) it('unstages a file', async () => { gitInit(tmpDir) writeFileSync(path.join(tmpDir, 'file.txt'), 'content') gitCommit(tmpDir, 'initial') writeFileSync(path.join(tmpDir, 'file.txt'), 'changed') execFileSync('git', ['add', 'file.txt'], { cwd: tmpDir, stdio: 'pipe' }) await dispatcher.callRequest('git.unstage', { worktreePath: tmpDir, filePath: 'file.txt' }) const output = execFileSync('git', ['diff', '--cached', '--name-only'], { cwd: tmpDir, encoding: 'utf-8' }) expect(output.trim()).toBe('') }) }) describe('diff', () => { it('returns text diff for modified file', async () => { gitInit(tmpDir) writeFileSync(path.join(tmpDir, 'file.txt'), 'original') gitCommit(tmpDir, 'initial') writeFileSync(path.join(tmpDir, 'file.txt'), 'modified') const result = (await dispatcher.callRequest('git.diff', { worktreePath: tmpDir, filePath: 'file.txt', staged: false })) as { kind: string; originalContent: string; modifiedContent: string } expect(result.kind).toBe('text') expect(result.originalContent).toBe('original') expect(result.modifiedContent).toBe('modified') }) it('returns staged diff', async () => { gitInit(tmpDir) writeFileSync(path.join(tmpDir, 'file.txt'), 'original') gitCommit(tmpDir, 'initial') writeFileSync(path.join(tmpDir, 'file.txt'), 'staged-content') execFileSync('git', ['add', 'file.txt'], { cwd: tmpDir, stdio: 'pipe' }) const result = (await dispatcher.callRequest('git.diff', { worktreePath: tmpDir, filePath: 'file.txt', staged: true })) as { kind: string; originalContent: string; modifiedContent: string } expect(result.kind).toBe('text') expect(result.originalContent).toBe('original') expect(result.modifiedContent).toBe('staged-content') }) }) describe('discard', () => { it('discards changes to tracked file', async () => { gitInit(tmpDir) writeFileSync(path.join(tmpDir, 'file.txt'), 'original') gitCommit(tmpDir, 'initial') writeFileSync(path.join(tmpDir, 'file.txt'), 'modified') await dispatcher.callRequest('git.discard', { worktreePath: tmpDir, filePath: 'file.txt' }) const content = await fs.readFile(path.join(tmpDir, 'file.txt'), 'utf-8') expect(content).toBe('original') }) it('deletes untracked file on discard', async () => { gitInit(tmpDir) gitCommit(tmpDir, 'initial') writeFileSync(path.join(tmpDir, 'new.txt'), 'untracked') await dispatcher.callRequest('git.discard', { worktreePath: tmpDir, filePath: 'new.txt' }) await expect(fs.access(path.join(tmpDir, 'new.txt'))).rejects.toThrow() }) it('treats untracked discard paths with Git glob characters as literal paths', async () => { gitInit(tmpDir) writeFileSync(path.join(tmpDir, '.gitignore'), 'ignored.log\n') gitCommit(tmpDir, 'initial') writeFileSync(path.join(tmpDir, '*.log'), 'selected') writeFileSync(path.join(tmpDir, 'keep.log'), 'unrelated') writeFileSync(path.join(tmpDir, 'ignored.log'), 'ignored') await dispatcher.callRequest('git.discard', { worktreePath: tmpDir, filePath: '*.log' }) await expect(fs.access(path.join(tmpDir, '*.log'))).rejects.toThrow() await expect(fs.access(path.join(tmpDir, 'keep.log'))).resolves.toBeUndefined() await expect(fs.access(path.join(tmpDir, 'ignored.log'))).resolves.toBeUndefined() }) it('treats tracked discard paths with Git glob characters as literal paths', async () => { gitInit(tmpDir) writeFileSync(path.join(tmpDir, '*.log'), 'selected') writeFileSync(path.join(tmpDir, 'keep.log'), 'keep') gitCommit(tmpDir, 'track log fixtures') writeFileSync(path.join(tmpDir, '*.log'), 'selected modified') writeFileSync(path.join(tmpDir, 'keep.log'), 'keep modified') await dispatcher.callRequest('git.discard', { worktreePath: tmpDir, filePath: '*.log' }) await expect(fs.readFile(path.join(tmpDir, '*.log'), 'utf-8')).resolves.toBe('selected') await expect(fs.readFile(path.join(tmpDir, 'keep.log'), 'utf-8')).resolves.toBe( 'keep modified' ) }) it('bulk discards tracked and untracked files', async () => { gitInit(tmpDir) writeFileSync(path.join(tmpDir, 'a.txt'), 'a') writeFileSync(path.join(tmpDir, 'b.txt'), 'b') gitCommit(tmpDir, 'initial') writeFileSync(path.join(tmpDir, 'a.txt'), 'a-modified') writeFileSync(path.join(tmpDir, 'b.txt'), 'b-modified') writeFileSync(path.join(tmpDir, 'new.txt'), 'untracked') await dispatcher.callRequest('git.bulkDiscard', { worktreePath: tmpDir, filePaths: ['a.txt', 'b.txt', 'new.txt'] }) await expect(fs.readFile(path.join(tmpDir, 'a.txt'), 'utf-8')).resolves.toBe('a') await expect(fs.readFile(path.join(tmpDir, 'b.txt'), 'utf-8')).resolves.toBe('b') await expect(fs.access(path.join(tmpDir, 'new.txt'))).rejects.toThrow() }) it('bulk discards a tracked directory with a very large tracked child list', async () => { const trackedOutput = `${Array.from( { length: 130_000 }, (_, index) => `src/generated/file-${index}.ts` ).join('\0')}\0` const git = vi.fn(async (args) => { if (args[0] === 'ls-files') { return { stdout: trackedOutput, stderr: '' } } return { stdout: '', stderr: '' } }) ;(handler as unknown as { git: GitExec }).git = git await expect( dispatcher.callRequest('git.bulkDiscard', { worktreePath: tmpDir, filePaths: ['src'] }) ).resolves.toBeUndefined() expect(git).toHaveBeenCalledWith( ['restore', '--worktree', '--source=HEAD', '--', ':(literal)src'], tmpDir ) }) it('rejects path traversal', async () => { gitInit(tmpDir) await expect( dispatcher.callRequest('git.discard', { worktreePath: tmpDir, filePath: '../../../etc/passwd' }) ).rejects.toThrow('outside the worktree') }) it('rejects bulk discard path traversal', async () => { gitInit(tmpDir) await expect( dispatcher.callRequest('git.bulkDiscard', { worktreePath: tmpDir, filePaths: ['file.txt', '../../../etc/passwd'] }) ).rejects.toThrow('outside the worktree') }) it('rejects untracked child paths through symlinked parents', async () => { gitInit(tmpDir) gitCommit(tmpDir, 'initial') const outsideDir = mkdtempSync(path.join(tmpdir(), 'relay-git-outside-')) const outsideFile = path.join(outsideDir, 'keep.txt') writeFileSync(outsideFile, 'outside') symlinkSync( outsideDir, path.join(tmpDir, 'link'), process.platform === 'win32' ? 'junction' : 'dir' ) try { await expect( dispatcher.callRequest('git.discard', { worktreePath: tmpDir, filePath: 'link/keep.txt' }) ).rejects.toThrow('outside the worktree') await expect(fs.access(outsideFile)).resolves.toBeUndefined() } finally { await fs.rm(outsideDir, { recursive: true, force: true }) } }) it('rejects bulk untracked child paths through symlinked parents before deleting anything', async () => { gitInit(tmpDir) gitCommit(tmpDir, 'initial') const outsideDir = mkdtempSync(path.join(tmpdir(), 'relay-git-outside-')) const outsideFile = path.join(outsideDir, 'keep.txt') const untrackedFile = path.join(tmpDir, 'new.txt') writeFileSync(outsideFile, 'outside') writeFileSync(untrackedFile, 'untracked') symlinkSync( outsideDir, path.join(tmpDir, 'link'), process.platform === 'win32' ? 'junction' : 'dir' ) try { await expect( dispatcher.callRequest('git.bulkDiscard', { worktreePath: tmpDir, filePaths: ['new.txt', 'link/keep.txt'] }) ).rejects.toThrow('outside the worktree') await expect(fs.access(outsideFile)).resolves.toBeUndefined() await expect(fs.access(untrackedFile)).resolves.toBeUndefined() } finally { await fs.rm(outsideDir, { recursive: true, force: true }) } }) }) describe('conflictOperation', () => { it('returns unknown for normal repo', async () => { gitInit(tmpDir) gitCommit(tmpDir, 'initial') const result = await dispatcher.callRequest('git.conflictOperation', { worktreePath: tmpDir }) expect(result).toBe('unknown') }) }) describe('branchCompare', () => { it('compares branch against base', async () => { gitInit(tmpDir) writeFileSync(path.join(tmpDir, 'base.txt'), 'base') gitCommit(tmpDir, 'initial') execFileSync('git', ['checkout', '-b', 'feature'], { cwd: tmpDir, stdio: 'pipe' }) writeFileSync(path.join(tmpDir, 'feature.txt'), 'feature') gitCommit(tmpDir, 'feature commit') const result = (await dispatcher.callRequest('git.branchCompare', { worktreePath: tmpDir, baseRef: 'master' })) as { summary: Record; entries: Record[] } // May be 'master' or error if default branch is 'main' if (result.summary.status === 'ready') { expect(result.entries.length).toBeGreaterThan(0) expect(result.summary.commitsAhead).toBe(1) } }) // Why: regression for issue #1503 on the branch-diff path. Without // -c core.quotePath=false the diff --name-status output is octal-escaped, // which broke the "Committed on branch" file list. it('preserves UTF-8 paths in branch-compare entries', async () => { gitInit(tmpDir) writeFileSync(path.join(tmpDir, 'base.txt'), 'base') gitCommit(tmpDir, 'initial') // Capture the default branch name before switching, so the test works // regardless of whether git's init.defaultBranch is master or main. const baseRef = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: tmpDir, encoding: 'utf-8' }).trim() execFileSync('git', ['checkout', '-b', 'feature'], { cwd: tmpDir, stdio: 'pipe' }) const utf8Dir = path.join(tmpDir, 'docs', '日本語') mkdirSync(utf8Dir, { recursive: true }) writeFileSync(path.join(utf8Dir, 'sample.md'), 'hello') gitCommit(tmpDir, 'feature commit') const result = (await dispatcher.callRequest('git.branchCompare', { worktreePath: tmpDir, baseRef })) as { summary: Record; entries: Record[] } expect(result.summary.status).toBe('ready') const entry = result.entries.find((e) => typeof e.path === 'string' ? e.path.endsWith('sample.md') : false ) expect(entry).toBeDefined() expect(entry!.path).toBe('docs/日本語/sample.md') }) it('treats an unborn branch with a resolvable base as having no committed branch changes', async () => { gitInit(tmpDir) writeFileSync(path.join(tmpDir, 'base.txt'), 'base') gitCommit(tmpDir, 'initial') const baseRef = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: tmpDir, encoding: 'utf-8' }).trim() execFileSync('git', ['checkout', '--orphan', 'feature'], { cwd: tmpDir, stdio: 'pipe' }) execFileSync('git', ['rm', '-rf', '.'], { cwd: tmpDir, stdio: 'pipe' }) const result = (await dispatcher.callRequest('git.branchCompare', { worktreePath: tmpDir, baseRef })) as { summary: Record; entries: Record[] } expect(result.summary).toMatchObject({ baseRef, compareRef: 'feature', headOid: null, changedFiles: 0, commitsAhead: 0, status: 'ready' }) expect(result.summary.baseOid).toMatch(/^[0-9a-f]{40}$/) expect(result.entries).toEqual([]) }) }) describe('branchDiff', () => { // Why: regression for issue #1503 on git.branchDiff. The branchCompare test // covers loadBranchChanges in git-handler.ts, but branchDiffEntries in // git-handler-ops.ts is a separate code path that also passes // -c core.quotePath=false and must round-trip UTF-8. it('preserves UTF-8 paths in branch-diff entries', async () => { gitInit(tmpDir) writeFileSync(path.join(tmpDir, 'base.txt'), 'base') gitCommit(tmpDir, 'initial') const baseRef = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: tmpDir, encoding: 'utf-8' }).trim() execFileSync('git', ['checkout', '-b', 'feature'], { cwd: tmpDir, stdio: 'pipe' }) const utf8Dir = path.join(tmpDir, 'docs', '日本語') mkdirSync(utf8Dir, { recursive: true }) writeFileSync(path.join(utf8Dir, 'sample.md'), 'hello') gitCommit(tmpDir, 'feature commit') const result = (await dispatcher.callRequest('git.branchDiff', { worktreePath: tmpDir, baseRef, filePath: 'docs/日本語/sample.md' })) as Record[] // Without includePatch, branchDiffEntries returns one stub entry per // changed file. Asserting length===1 confirms the filter matched the // raw UTF-8 path emitted by `git diff --name-status` — if quotePath // were left at default, the entry's path would be the octal-quoted // form and the filter at git-handler-ops.ts:230-237 would not match. expect(result).toHaveLength(1) }) }) describe('remote operations', () => { it('returns upstream divergence for tracked branches', async () => { gitInit(tmpDir) writeFileSync(path.join(tmpDir, 'base.txt'), 'base') gitCommit(tmpDir, 'initial') const result = (await dispatcher.callRequest('git.upstreamStatus', { worktreePath: tmpDir })) as { hasUpstream: boolean; upstreamName?: string; ahead: number; behind: number } expect(result.hasUpstream).toBe(false) expect(result.ahead).toBe(0) expect(result.behind).toBe(0) }) it('reports ahead/behind counts against a real upstream remote', async () => { // Why: the upstream branch exists but isn't configured — exercise the // full path through `git rev-parse HEAD@{u}` + `rev-list --left-right` // so a future refactor can't silently break the happy-path roundtrip // the no-upstream test doesn't cover. const bareDir = mkdtempSync(path.join(tmpdir(), 'relay-git-bare-')) try { execFileSync('git', ['init', '--bare'], { cwd: bareDir, stdio: 'pipe' }) gitInit(tmpDir) writeFileSync(path.join(tmpDir, 'base.txt'), 'base') gitCommit(tmpDir, 'initial') const firstSha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: tmpDir, encoding: 'utf-8' }).trim() const branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: tmpDir, encoding: 'utf-8' }).trim() execFileSync('git', ['remote', 'add', 'origin', bareDir], { cwd: tmpDir, stdio: 'pipe' }) execFileSync('git', ['push', '--set-upstream', 'origin', branch], { cwd: tmpDir, stdio: 'pipe' }) // Add two local commits (ahead=2), then reset behind the remote tip // and add one different commit so we end up ahead=1, behind=0 vs. // upstream; then reset to first commit to produce behind=1 ahead=0. writeFileSync(path.join(tmpDir, 'ahead1.txt'), 'a1') gitCommit(tmpDir, 'ahead1') writeFileSync(path.join(tmpDir, 'ahead2.txt'), 'a2') gitCommit(tmpDir, 'ahead2') // Push so remote is at ahead2 (so after we reset below, we are behind). execFileSync('git', ['push', 'origin', branch], { cwd: tmpDir, stdio: 'pipe' }) // Reset local back to the first commit: 0 ahead, 2 behind. execFileSync('git', ['reset', '--hard', firstSha], { cwd: tmpDir, stdio: 'pipe' }) const result = (await dispatcher.callRequest('git.upstreamStatus', { worktreePath: tmpDir })) as { hasUpstream: boolean; upstreamName?: string; ahead: number; behind: number } expect(result.hasUpstream).toBe(true) expect(result.upstreamName).toBe(`origin/${branch}`) expect(result.ahead).toBe(0) expect(result.behind).toBe(2) } finally { await fs.rm(bareDir, { recursive: true, force: true }) } }) it('reports ahead/behind counts against a configured local-branch upstream', async () => { gitInit(tmpDir) writeFileSync(path.join(tmpDir, 'base.txt'), 'base') gitCommit(tmpDir, 'initial') const baseRef = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: tmpDir, encoding: 'utf-8' }).trim() execFileSync('git', ['checkout', '-b', 'feature'], { cwd: tmpDir, stdio: 'pipe' }) execFileSync('git', ['branch', '--set-upstream-to', baseRef], { cwd: tmpDir, stdio: 'pipe' }) writeFileSync(path.join(tmpDir, 'feature.txt'), 'feature') gitCommit(tmpDir, 'feature commit') const result = (await dispatcher.callRequest('git.upstreamStatus', { worktreePath: tmpDir })) as { hasUpstream: boolean; upstreamName?: string; ahead: number; behind: number } expect(result.hasUpstream).toBe(true) expect(result.upstreamName).toBe(baseRef) expect(result.ahead).toBe(1) expect(result.behind).toBe(0) }) it('fetches from a configured remote without throwing', async () => { const bareDir = mkdtempSync(path.join(tmpdir(), 'relay-git-bare-')) try { execFileSync('git', ['init', '--bare'], { cwd: bareDir, stdio: 'pipe' }) gitInit(tmpDir) writeFileSync(path.join(tmpDir, 'base.txt'), 'base') gitCommit(tmpDir, 'initial') const branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: tmpDir, encoding: 'utf-8' }).trim() execFileSync('git', ['remote', 'add', 'origin', bareDir], { cwd: tmpDir, stdio: 'pipe' }) execFileSync('git', ['push', '--set-upstream', 'origin', branch], { cwd: tmpDir, stdio: 'pipe' }) await expect( dispatcher.callRequest('git.fetch', { worktreePath: tmpDir }) ).resolves.not.toThrow() // FETCH_HEAD is created by any successful fetch, confirming the // remote was actually contacted (not just silently no-op'd). await expect(fs.access(path.join(tmpDir, '.git', 'FETCH_HEAD'))).resolves.toBeUndefined() } finally { await fs.rm(bareDir, { recursive: true, force: true }) } }) it('fetches the explicit publish target remote', async () => { const bareDir = mkdtempSync(path.join(tmpdir(), 'relay-git-fork-bare-')) try { execFileSync('git', ['init', '--bare'], { cwd: bareDir, stdio: 'pipe' }) gitInit(tmpDir) writeFileSync(path.join(tmpDir, 'base.txt'), 'base') gitCommit(tmpDir, 'initial') execFileSync('git', ['remote', 'add', 'fork', bareDir], { cwd: tmpDir, stdio: 'pipe' }) execFileSync('git', ['push', 'fork', 'HEAD:feature/fix'], { cwd: tmpDir, stdio: 'pipe' }) await expect( dispatcher.callRequest('git.fetch', { worktreePath: tmpDir, pushTarget: { remoteName: 'fork', branchName: 'feature/fix' } }) ).resolves.not.toThrow() await expect(fs.access(path.join(tmpDir, '.git', 'FETCH_HEAD'))).resolves.toBeUndefined() } finally { await fs.rm(bareDir, { recursive: true, force: true }) } }) it('fast-forwards the tracked branch with ff-only pull semantics', async () => { const bareDir = mkdtempSync(path.join(tmpdir(), 'relay-git-bare-')) const producerParent = mkdtempSync(path.join(tmpdir(), 'relay-git-producer-')) const producerDir = path.join(producerParent, 'repo') try { execFileSync('git', ['init', '--bare'], { cwd: bareDir, stdio: 'pipe' }) gitInit(tmpDir) writeFileSync(path.join(tmpDir, 'base.txt'), 'base') gitCommit(tmpDir, 'initial') const branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: tmpDir, encoding: 'utf-8' }).trim() execFileSync('git', ['remote', 'add', 'origin', bareDir], { cwd: tmpDir, stdio: 'pipe' }) execFileSync('git', ['push', '--set-upstream', 'origin', branch], { cwd: tmpDir, stdio: 'pipe' }) execFileSync('git', ['clone', bareDir, producerDir], { stdio: 'pipe' }) execFileSync('git', ['config', 'user.email', 'test@test.com'], { cwd: producerDir, stdio: 'pipe' }) execFileSync('git', ['config', 'user.name', 'Test'], { cwd: producerDir, stdio: 'pipe' }) writeFileSync(path.join(producerDir, 'remote.txt'), 'remote') gitCommit(producerDir, 'remote commit') execFileSync('git', ['push', 'origin', branch], { cwd: producerDir, stdio: 'pipe' }) await dispatcher.callRequest('git.fastForward', { worktreePath: tmpDir }) await expect(fs.readFile(path.join(tmpDir, 'remote.txt'), 'utf-8')).resolves.toBe('remote') } finally { await fs.rm(bareDir, { recursive: true, force: true }) await fs.rm(producerParent, { recursive: true, force: true }) } }) it('refreshes one remote-tracking ref from a configured remote', async () => { const bareDir = mkdtempSync(path.join(tmpdir(), 'relay-git-bare-')) const producerParent = mkdtempSync(path.join(tmpdir(), 'relay-git-producer-')) const producerDir = path.join(producerParent, 'repo') try { execFileSync('git', ['init', '--bare'], { cwd: bareDir, stdio: 'pipe' }) gitInit(tmpDir) writeFileSync(path.join(tmpDir, 'base.txt'), 'base') gitCommit(tmpDir, 'initial') const branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: tmpDir, encoding: 'utf-8' }).trim() execFileSync('git', ['remote', 'add', 'origin', bareDir], { cwd: tmpDir, stdio: 'pipe' }) execFileSync('git', ['push', '--set-upstream', 'origin', branch], { cwd: tmpDir, stdio: 'pipe' }) execFileSync('git', ['clone', bareDir, producerDir], { stdio: 'pipe' }) execFileSync('git', ['config', 'user.email', 'test@test.com'], { cwd: producerDir, stdio: 'pipe' }) execFileSync('git', ['config', 'user.name', 'Test'], { cwd: producerDir, stdio: 'pipe' }) writeFileSync(path.join(producerDir, 'base.txt'), 'updated') gitCommit(producerDir, 'remote update') execFileSync('git', ['push', 'origin', branch], { cwd: producerDir, stdio: 'pipe' }) const expected = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: producerDir, encoding: 'utf-8' }).trim() await dispatcher.callRequest('git.fetchRemoteTrackingRef', { worktreePath: tmpDir, remote: 'origin', branch, ref: `refs/remotes/origin/${branch}` }) const actual = execFileSync('git', ['rev-parse', `refs/remotes/origin/${branch}`], { cwd: tmpDir, encoding: 'utf-8' }).trim() expect(actual).toBe(expected) } finally { await fs.rm(bareDir, { recursive: true, force: true }) await fs.rm(producerParent, { recursive: true, force: true }) } }) it('rejects remote-tracking refreshes that target a different ref', async () => { gitInit(tmpDir) execFileSync('git', ['remote', 'add', 'origin', tmpDir], { cwd: tmpDir, stdio: 'pipe' }) await expect( dispatcher.callRequest('git.fetchRemoteTrackingRef', { worktreePath: tmpDir, remote: 'origin', branch: 'main', ref: 'refs/remotes/origin/other' }) ).rejects.toThrow('Remote-tracking ref does not match the requested remote and branch.') }) it('rethrows upstreamStatus failures that are not "no upstream configured"', async () => { // Why: the handler's catch is narrowed to only swallow the expected // "no upstream" signal. A non-repo path should surface its error rather // than silently returning hasUpstream=false, which would mask auth or // corruption failures in production. const nonRepoDir = path.join(tmpDir, 'not-a-repo') await fs.mkdir(nonRepoDir, { recursive: true }) await expect( dispatcher.callRequest('git.upstreamStatus', { worktreePath: nonRepoDir }) ).rejects.toThrow(/not a git repository/i) }) }) describe('listWorktrees', () => { it('lists worktrees for a repo', async () => { gitInit(tmpDir) writeFileSync(path.join(tmpDir, 'file.txt'), 'hello') gitCommit(tmpDir, 'initial') const result = (await dispatcher.callRequest('git.listWorktrees', { repoPath: tmpDir })) as Record[] expect(result.length).toBeGreaterThanOrEqual(1) expect(result[0].isMainWorktree).toBe(true) }) }) describe('addWorktree', () => { // Why: relay handler tests for addWorktree use a mock-injection approach // to deterministically control git exit codes (in particular `--get` exit // 1 vs other non-zero codes) without relying on the test host's global // git config. Mirrors the pattern in src/main/git/worktree.test.ts. function setupMockedHandler(roots: string[]) { const ctx = new RelayContext() for (const r of roots) { ctx.registerRoot(r) } const localDispatcher = createMockDispatcher() const handler = new GitHandler(localDispatcher as unknown as RelayDispatcher, ctx) const gitMock = vi.fn< ( args: string[], cwd: string, opts?: { maxBuffer?: number } ) => Promise<{ stdout: string; stderr: string }> >() ;(handler as unknown as { git: typeof gitMock }).git = gitMock return { localDispatcher, gitMock } } it('passes --no-track and writes push.autoSetupRemote when unset', async () => { const { localDispatcher, gitMock } = setupMockedHandler(['/relay/repo', '/relay/wt']) gitMock.mockResolvedValueOnce({ stdout: 'abc123\n', stderr: '' }) // rev-parse refs/remotes/origin/main^{commit} gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // worktree add gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // config --local --replace-all branch..base gitMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // --get gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // --local set await localDispatcher.callRequest('git.addWorktree', { repoPath: '/relay/repo', branchName: 'feature/test', targetDir: '/relay/wt', base: 'origin/main' }) expect(gitMock.mock.calls.map((c) => c[0])).toEqual([ ['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main^{commit}'], [ 'worktree', 'add', '--no-track', '-b', 'feature/test', '/relay/wt', 'refs/remotes/origin/main' ], [ 'config', '--local', '--replace-all', 'branch.feature/test.base', 'refs/remotes/origin/main' ], ['config', '--get', 'push.autoSetupRemote'], ['config', '--local', 'push.autoSetupRemote', 'true'] ]) // cwd for worktree add is repoPath; cwd for config calls is targetDir. expect(gitMock.mock.calls[0]?.[1]).toBe('/relay/repo') expect(gitMock.mock.calls[1]?.[1]).toBe('/relay/repo') expect(gitMock.mock.calls[2]?.[1]).toBe('/relay/wt') expect(gitMock.mock.calls[3]?.[1]).toBe('/relay/wt') expect(gitMock.mock.calls[4]?.[1]).toBe('/relay/wt') }) it('checks out a selected existing local branch without creating a new branch', async () => { const { localDispatcher, gitMock } = setupMockedHandler(['/relay/repo', '/relay/wt']) gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // worktree add await localDispatcher.callRequest('git.addWorktree', { repoPath: '/relay/repo', branchName: 'feature/test', targetDir: '/relay/wt', base: 'feature/test', checkoutExistingBranch: true }) expect(gitMock.mock.calls.map((c) => c[0])).toEqual([ ['worktree', 'add', '/relay/wt', 'feature/test'] ]) }) it('qualifies bare branch name as refs/heads/ when a same-named tag exists', async () => { // Why: repos that fetch with --tags can end up with a local tag named // 'main', making `git worktree add ... main` fail with "fatal: Ambiguous // object name". Qualifying as refs/heads/main tells git exactly which // object to use. const { localDispatcher, gitMock } = setupMockedHandler(['/relay/repo', '/relay/wt']) gitMock.mockResolvedValueOnce({ stdout: 'abc123\n', stderr: '' }) // rev-parse refs/heads/main^{commit} gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // worktree add gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // config --local --replace-all branch..base gitMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // --get unset gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // --local set await localDispatcher.callRequest('git.addWorktree', { repoPath: '/relay/repo', branchName: 'feature/disambig', targetDir: '/relay/wt', base: 'main' }) expect(gitMock.mock.calls.map((c) => c[0])).toEqual([ ['rev-parse', '--verify', '--quiet', 'refs/heads/main^{commit}'], ['worktree', 'add', '--no-track', '-b', 'feature/disambig', '/relay/wt', 'refs/heads/main'], ['config', '--local', '--replace-all', 'branch.feature/disambig.base', 'refs/heads/main'], ['config', '--get', 'push.autoSetupRemote'], ['config', '--local', 'push.autoSetupRemote', 'true'] ]) }) it('qualifies slash-containing local branch names when no remote ref matches', async () => { const { localDispatcher, gitMock } = setupMockedHandler(['/relay/repo', '/relay/wt']) gitMock.mockRejectedValueOnce(new Error('no remote ref')) // rev-parse refs/remotes/release/main^{commit} gitMock.mockResolvedValueOnce({ stdout: 'abc123\n', stderr: '' }) // rev-parse refs/heads/release/main^{commit} gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // worktree add gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // config --local --replace-all branch..base gitMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // --get unset gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // --local set await localDispatcher.callRequest('git.addWorktree', { repoPath: '/relay/repo', branchName: 'feature/release', targetDir: '/relay/wt', base: 'release/main' }) expect(gitMock.mock.calls.map((c) => c[0])).toEqual([ ['rev-parse', '--verify', '--quiet', 'refs/remotes/release/main^{commit}'], ['rev-parse', '--verify', '--quiet', 'refs/heads/release/main^{commit}'], [ 'worktree', 'add', '--no-track', '-b', 'feature/release', '/relay/wt', 'refs/heads/release/main' ], [ 'config', '--local', '--replace-all', 'branch.feature/release.base', 'refs/heads/release/main' ], ['config', '--get', 'push.autoSetupRemote'], ['config', '--local', 'push.autoSetupRemote', 'true'] ]) }) it('passes --no-checkout when sparse setup will checkout after configuration', async () => { const { localDispatcher, gitMock } = setupMockedHandler(['/relay/repo', '/relay/wt']) gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // rev-parse refs/remotes/origin/main gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // worktree add gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // config --local --replace-all branch..base gitMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // --get gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // --local set await localDispatcher.callRequest('git.addWorktree', { repoPath: '/relay/repo', branchName: 'feature/sparse', targetDir: '/relay/wt', base: 'origin/main', noCheckout: true }) expect(gitMock.mock.calls[1]?.[0]).toEqual([ 'worktree', 'add', '--no-track', '--no-checkout', '-b', 'feature/sparse', '/relay/wt', 'refs/remotes/origin/main' ]) }) it('preserves an existing push.autoSetupRemote value (does not overwrite user-set false)', async () => { const { localDispatcher, gitMock } = setupMockedHandler(['/relay/repo', '/relay/wt']) gitMock.mockRejectedValueOnce(new Error('not a branch')) // rev-parse refs/heads/main^{commit} gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // worktree add gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // config --local --replace-all branch..base gitMock.mockResolvedValueOnce({ stdout: 'false\n', stderr: '' }) // --get returns value await localDispatcher.callRequest('git.addWorktree', { repoPath: '/relay/repo', branchName: 'feature/preserve', targetDir: '/relay/wt', base: 'main' }) // No --local set: --get succeeded so we preserve the user's value. expect(gitMock.mock.calls.map((c) => c[0])).toEqual([ ['rev-parse', '--verify', '--quiet', 'refs/heads/main^{commit}'], ['worktree', 'add', '--no-track', '-b', 'feature/preserve', '/relay/wt', 'main'], ['config', '--local', '--replace-all', 'branch.feature/preserve.base', 'main'], ['config', '--get', 'push.autoSetupRemote'] ]) }) 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 an explicitly empty string. We must not fall through // to `--local set true` and overwrite that. Mirrors the local addWorktree // parity case in src/main/git/worktree.test.ts. const { localDispatcher, gitMock } = setupMockedHandler(['/relay/repo', '/relay/wt']) gitMock.mockRejectedValueOnce(new Error('not a branch')) // rev-parse refs/heads/main^{commit} gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // worktree add gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // config --local --replace-all branch..base gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // --get success, empty value await localDispatcher.callRequest('git.addWorktree', { repoPath: '/relay/repo', branchName: 'feature/empty', targetDir: '/relay/wt', base: 'main' }) expect(gitMock.mock.calls.map((c) => c[0])).toEqual([ ['rev-parse', '--verify', '--quiet', 'refs/heads/main^{commit}'], ['worktree', 'add', '--no-track', '-b', 'feature/empty', '/relay/wt', 'main'], ['config', '--local', '--replace-all', 'branch.feature/empty.base', 'main'], ['config', '--get', 'push.autoSetupRemote'] ]) }) it('does not write --local when --get fails with non-unset code (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. const { localDispatcher, gitMock } = setupMockedHandler(['/relay/repo', '/relay/wt']) gitMock.mockRejectedValueOnce(new Error('not a branch')) // rev-parse refs/heads/main^{commit} gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // worktree add gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // config --local --replace-all branch..base gitMock.mockRejectedValueOnce(Object.assign(new Error('parse error'), { code: 3 })) // --get non-unset const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) await expect( localDispatcher.callRequest('git.addWorktree', { repoPath: '/relay/repo', branchName: 'feature/corrupt', targetDir: '/relay/wt', base: 'main' }) ).resolves.toBeUndefined() expect(gitMock.mock.calls.map((c) => c[0])).toEqual([ ['rev-parse', '--verify', '--quiet', 'refs/heads/main^{commit}'], ['worktree', 'add', '--no-track', '-b', 'feature/corrupt', '/relay/wt', 'main'], ['config', '--local', '--replace-all', 'branch.feature/corrupt.base', 'main'], ['config', '--get', 'push.autoSetupRemote'] ]) expect(warnSpy).toHaveBeenCalledWith( 'relay addWorktree: failed to set push.autoSetupRemote for /relay/wt', expect.any(Error) ) warnSpy.mockRestore() }) it('warns but resolves when --local set fails (write-failure is warn-only)', async () => { const { localDispatcher, gitMock } = setupMockedHandler(['/relay/repo', '/relay/wt']) gitMock.mockRejectedValueOnce(new Error('not a branch')) // rev-parse refs/heads/main^{commit} gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // worktree add gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // config --local --replace-all branch..base gitMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // --get unset gitMock.mockRejectedValueOnce(new Error('config locked')) // --local set fails const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) await expect( localDispatcher.callRequest('git.addWorktree', { repoPath: '/relay/repo', branchName: 'feature/writefail', targetDir: '/relay/wt', base: 'main' }) ).resolves.toBeUndefined() expect(warnSpy).toHaveBeenCalledWith( 'relay addWorktree: failed to set push.autoSetupRemote for /relay/wt', expect.any(Error) ) warnSpy.mockRestore() }) it('does not write config when worktree add itself fails', async () => { // Why: a refactor that moves the config block earlier could try to // probe config against a worktree directory that was never created. Pin // the ordering invariant: config calls happen only after worktree add succeeds. const { localDispatcher, gitMock } = setupMockedHandler(['/relay/repo', '/relay/wt']) gitMock.mockRejectedValueOnce(new Error('not a branch')) // rev-parse refs/heads/main^{commit} gitMock.mockRejectedValueOnce(new Error('worktree add failed')) await expect( localDispatcher.callRequest('git.addWorktree', { repoPath: '/relay/repo', branchName: 'feature/fail', targetDir: '/relay/wt', base: 'main' }) ).rejects.toThrow('worktree add failed') expect(gitMock.mock.calls.map((c) => c[0])).toEqual([ ['rev-parse', '--verify', '--quiet', 'refs/heads/main^{commit}'], ['worktree', 'add', '--no-track', '-b', 'feature/fail', '/relay/wt', 'main'] ]) }) }) })