Files
orca/src/relay/git-handler.test.ts
T
JinjingandOrca 772081577e Fix fork PR/MR worktree creation race via durable review-head refs (#10429)
* Fix fork PR/MR worktree creation race via durable review-head refs

When creating a fork PR/MR worktree, concurrent `git fetch origin` operations
clobber the shared FETCH_HEAD, causing the wrong commit to be checked out.
Fetch PR/MR heads into dedicated per-review refs (`refs/orca/pull/<N>`,
`refs/orca/merge-requests/<N>`) that persist and isolate each head from other
fetches. Gracefully keep the compare-base when the fetch fails but the local
ref already exists, avoiding silent fallback to the wrong branch on transient
network errors.

* Bound PR/MR head fetches with 60s timeout

Prevent PR/MR creation from hanging when a remote is stalled or
unreachable. Both GitHub and GitLab head fetches now enforce a
60-second timeout, matching the bound used in the create-path
fetch. Durable refs (refs/orca/pull/*, refs/orca/merge-requests/*)
decouple the ref from FETCH_HEAD, preserving legacy client semantics.

* test: align CI expectations with main PowerShell/sparse regressions

PR checks merge into main, which recently changed PowerShell launch args
(cwd restore after profiles) and sparse-checkout detection (require
core.sparseCheckout). Derive PowerShell spawn args from the production
resolver, mock the sparse config flag, reset shared worktree list scan
cache between tests, and stop requiring floating polls to avoid getRepos
hydration.

* Address review follow-ups on durable review-head refs

- Unify PR review-head remote selection: local and SSH GitHub paths share
  resolveGitHubReviewHeadRemote, which prefers the remote mapping to the
  hosting GitHub project (upstream before origin, matching work-item/API
  candidate order) so contributor clones fetch refs/pull from the repo
  that actually hosts the PR.
- Soft-keep durable review heads: when the PR/MR head fetch fails but
  refs/orca/pull/<N> / refs/orca/merge-requests/<iid> still resolves,
  keep the pinned SHA (warn) instead of failing resolve, mirroring the
  compare-base fallback. Extracted shared compare-base soft-keep into
  compare-base-ref-fetch.ts.
- Extract fetchGitLabMergeRequestHeadRef (local + SSH) parallel to the
  GitHub helper; bound its local fetch with the shared 60s timeout.
- Share relay-style fetch validation (positive safe-integer id, remote
  not starting with "-") between relay and local helpers via
  review-head-tracking-ref.ts; move REVIEW_HEAD_FETCH_TIMEOUT_MS there.
- Drop the githubPullRequestHeadLocalRef re-export; resolve head SHAs via
  rev-parse --verify <ref>^{commit}.
- Add GitLab anti-FETCH_HEAD regression test plus durable-head soft-keep
  and remote-selection unit tests.

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

* test: supply live getRepos for terminal-retirement hydrates

Main's headless tab hydrate (#9343) skips worktree keys whose repo is not
in getRepos. Retirement tests that rebuild mobile tabs from a persisted
session now advertise the fixture repo as live so PR Checks merge stays green.

* fix(editor): extract RichMarkdownEditor props to stay under max-lines

Main's SSH external-image wiring (#10323) pushed RichMarkdownEditor.tsx over
the 400-line tsx budget, failing PR Checks lint on every merge into main.
Move the props type into a sibling module so the component stays under the
limit without disabling max-lines.

* Make durable review-head refs remote-identity scoped

Embed remote name + URL hash into refs/orca/pull|merge-requests refs to prevent soft-keep from serving wrong project's PR/MR when FETCH_HEAD is clobbered by concurrent fetch. Fetch functions now return the written ref path (writer-authoritative) so callers rev-parse exactly what was fetched, not re-derive identity. Soft-keep only applies to transient errors (timeout, network); fails hard on missing refs, auth failures, and stale relay. Relay returns localRef so client avoids re-hashing (URL normalization can disagree).

---------

Co-authored-by: Orca <help@stably.ai>
2026-07-24 12:53:18 -07:00

2784 lines
104 KiB
TypeScript

/* eslint-disable max-lines -- Why: one file covers ~14 relay git handlers + the addWorktree state machine; splitting would scatter related coverage. */
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import { GitHandler } from './git-handler'
import { RelayContext } from './context'
import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import { mkdtempSync, mkdirSync, symlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { execFileSync } from 'node:child_process'
import { MAX_RENDERED_DIFF_COMBINED_CHARACTERS } from '../shared/large-diff-render-limit'
import { reviewHeadRemoteRefComponent } from '../shared/review-head-tracking-ref'
import {
createMockDispatcher,
gitInit,
gitCommit,
type MockDispatcher,
type RelayDispatcher
} from './git-handler-test-setup'
type GitBufferSpyTarget = {
gitBuffer(args: string[], cwd: string): Promise<Buffer>
}
type GitSpyTarget = {
git(
args: string[],
cwd: string,
opts?: { signal?: AbortSignal }
): Promise<{ stdout: string; stderr: string }>
}
function deferredRelayBuffer(content: string): {
promise: Promise<Buffer>
resolve: () => void
} {
let resolve!: (value: Buffer) => void
const promise = new Promise<Buffer>((innerResolve) => {
resolve = innerResolve
})
return {
promise,
resolve: () => resolve(Buffer.from(content))
}
}
async function waitForSpyCalls(mock: ReturnType<typeof vi.fn>, calls: number): Promise<void> {
for (let i = 0; i < 20; i++) {
if (mock.mock.calls.length >= calls) {
return
}
await new Promise<void>((resolve) => setTimeout(resolve, 0))
}
}
describe('GitHandler', () => {
let dispatcher: MockDispatcher
let handler: GitHandler
let tmpDir: string
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 })
})
function currentBranch(cwd: string): string {
return execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
cwd,
encoding: 'utf-8'
}).trim()
}
function currentBranchFullRef(cwd: string): string {
return `refs/heads/${currentBranch(cwd)}`
}
function reportedWorktreePath(cwd: string): string {
return (
execFileSync('git', ['worktree', 'list', '--porcelain'], {
cwd,
encoding: 'utf-8'
})
.split(/\r?\n/)
.find((line) => line.startsWith('worktree '))
?.slice('worktree '.length)
.trim() ?? cwd
)
}
function normalizeGitFileText(content: string): string {
return content.replace(/\r\n/g, '\n')
}
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.checkout')
expect(methods).toContain('git.localBranches')
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.forkSync')
expect(methods).toContain('git.fetchRemoteTrackingRef')
expect(methods).toContain('git.fetchGitHubPullRequestHead')
expect(methods).toContain('git.fetchGitLabMergeRequestHead')
expect(methods).toContain('git.fetchGitLabMergeRequestHeadRef')
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.worktreeIsClean')
expect(methods).toContain('git.refreshLocalBaseRefForWorktreeCreate')
expect(methods).toContain('git.renameCurrentBranch')
expect(methods).toContain('git.forceDeletePreservedBranch')
expect(methods).toContain('git.exec')
expect(methods).toContain('git.clone')
expect(methods).toContain('git.isGitRepo')
})
it('runs remote worktree deletion inside the relay watcher fence', async () => {
const removalError = new Error('fenced before Git')
const runWithRemovalFence = vi.fn(async () => {
throw removalError
})
handler.dispose()
handler = new GitHandler(dispatcher as unknown as RelayDispatcher, new RelayContext(), {
runWithRemovalFence
})
await expect(
dispatcher.callRequest('git.removeWorktree', { worktreePath: '/repo-feature' })
).rejects.toBe(removalError)
expect(runWithRemovalFence).toHaveBeenCalledWith('/repo-feature', expect.any(Function))
})
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').then(normalizeGitFileText)
).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').then(normalizeGitFileText)
).resolves.toBe('feature\n')
})
})
describe('checkout / localBranches', () => {
it('switches to an existing local branch and lists branches current-first', 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', ['branch', 'feature'], { cwd: tmpDir, stdio: 'pipe' })
const before = (await dispatcher.callRequest('git.localBranches', {
worktreePath: tmpDir
})) as { current: string | null; branches: string[] }
expect(before.current).toBe(baseBranch)
expect(before.branches).toContain('feature')
expect(before.branches[0]).toBe(baseBranch)
await dispatcher.callRequest('git.checkout', { worktreePath: tmpDir, branch: 'feature' })
expect(
execFileSync('git', ['branch', '--show-current'], {
cwd: tmpDir,
encoding: 'utf-8',
stdio: 'pipe'
}).trim()
).toBe('feature')
const after = (await dispatcher.callRequest('git.localBranches', {
worktreePath: tmpDir
})) as { current: string | null; branches: string[] }
expect(after.current).toBe('feature')
expect(after.branches[0]).toBe('feature')
})
})
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('forceDeletePreservedBranch', () => {
function headOf(cwd: string, ref: string): string {
return execFileSync('git', ['rev-parse', ref], { cwd, encoding: 'utf-8' }).trim()
}
it('deletes a preserved branch at its expected head through the narrow RPC', async () => {
gitInit(tmpDir)
writeFileSync(path.join(tmpDir, 'file.txt'), 'hello')
gitCommit(tmpDir, 'initial')
execFileSync('git', ['branch', 'feature/preserved'], { cwd: tmpDir, stdio: 'pipe' })
const head = headOf(tmpDir, 'refs/heads/feature/preserved')
await dispatcher.callRequest('git.forceDeletePreservedBranch', {
repoPath: tmpDir,
branchName: 'feature/preserved',
expectedHead: head
})
const refs = execFileSync('git', ['branch', '--list', 'feature/preserved'], {
cwd: tmpDir,
encoding: 'utf-8'
}).trim()
expect(refs).toBe('')
})
it('refuses to delete when the branch moved past the expected head', async () => {
gitInit(tmpDir)
writeFileSync(path.join(tmpDir, 'file.txt'), 'hello')
gitCommit(tmpDir, 'initial')
const staleHead = headOf(tmpDir, 'HEAD')
execFileSync('git', ['checkout', '-b', 'feature/preserved'], { cwd: tmpDir, stdio: 'pipe' })
// Advance the branch so the saved (stale) head no longer matches its tip.
gitCommit(tmpDir, 'second')
execFileSync('git', ['checkout', '-'], { cwd: tmpDir, stdio: 'pipe' })
await expect(
dispatcher.callRequest('git.forceDeletePreservedBranch', {
repoPath: tmpDir,
branchName: 'feature/preserved',
expectedHead: staleHead
})
).rejects.toThrow('changed after the workspace was deleted')
const refs = execFileSync('git', ['branch', '--list', 'feature/preserved'], {
cwd: tmpDir,
encoding: 'utf-8'
}).trim()
expect(refs).toContain('feature/preserved')
})
it('rejects an empty repoPath at the RPC boundary', async () => {
await expect(
dispatcher.callRequest('git.forceDeletePreservedBranch', {
repoPath: '',
branchName: 'feature/preserved',
expectedHead: 'abc123'
})
).rejects.toThrow('Invalid preserved branch force-delete request.')
})
})
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<string, unknown>[]
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 #1503 — default core.quotePath=true octal-escapes non-ASCII paths (breaks sidebar + 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<string, unknown>[]
}
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 #1503 on the porcelain v2 type-1 (tracked+modified) parser branch, which the untracked '?' test misses.
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<string, unknown>[]
}
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')
})
it('omits over-limit text bodies before returning diff payloads', async () => {
gitInit(tmpDir)
writeFileSync(path.join(tmpDir, 'file.txt'), 'original')
gitCommit(tmpDir, 'initial')
const oversizedText = 'a'.repeat(MAX_RENDERED_DIFF_COMBINED_CHARACTERS + 1)
writeFileSync(path.join(tmpDir, 'file.txt'), oversizedText)
const result = (await dispatcher.callRequest('git.diff', {
worktreePath: tmpDir,
filePath: 'file.txt',
staged: false
})) as {
kind: string
originalContent: string
modifiedContent: string
largeDiffRenderLimit?: { limited: boolean; reason?: string; characterCount?: number }
}
expect(result.kind).toBe('text')
expect(result.originalContent).toBe('')
expect(result.modifiedContent).toBe('')
expect(result.largeDiffRenderLimit?.limited).toBe(true)
expect(result.largeDiffRenderLimit?.reason).toBe('character-count')
expect(result.largeDiffRenderLimit?.characterCount).toBe(
oversizedText.length + 'original'.length
)
})
it('returns diff for tracked files in valid dot-dot-prefixed directories', async () => {
gitInit(tmpDir)
mkdirSync(path.join(tmpDir, '..fixtures'))
writeFileSync(path.join(tmpDir, '..fixtures', 'file.txt'), 'original')
gitCommit(tmpDir, 'initial')
writeFileSync(path.join(tmpDir, '..fixtures', 'file.txt'), 'modified')
const result = (await dispatcher.callRequest('git.diff', {
worktreePath: tmpDir,
filePath: '..fixtures/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('rejects diff paths that traverse outside the worktree', async () => {
gitInit(tmpDir)
await expect(
dispatcher.callRequest('git.diff', {
worktreePath: tmpDir,
filePath: '../outside.txt',
staged: false
})
).rejects.toThrow('outside the worktree')
})
})
describe('submodule', () => {
const extraDirs: string[] = []
afterEach(async () => {
await Promise.all(
extraDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))
)
})
// Why: `git submodule add` against a local path is blocked since git 2.38 unless protocol.file.allow=always is set.
function addSubmodule(parent: string, name: string): string {
const src = mkdtempSync(path.join(tmpdir(), 'relay-subsrc-'))
extraDirs.push(src)
gitInit(src)
writeFileSync(path.join(src, 'lib.txt'), 'v1\n')
gitCommit(src, 'sub initial')
execFileSync('git', ['-c', 'protocol.file.allow=always', 'submodule', 'add', src, name], {
cwd: parent,
stdio: 'pipe'
})
execFileSync('git', ['commit', '-m', 'add submodule'], { cwd: parent, stdio: 'pipe' })
return path.join(parent, name)
}
it('returns inner per-file changes via git.submoduleStatus', async () => {
gitInit(tmpDir)
writeFileSync(path.join(tmpDir, 'root.txt'), 'root')
gitCommit(tmpDir, 'initial')
const sub = addSubmodule(tmpDir, 'flutter_mine')
writeFileSync(path.join(sub, 'lib.txt'), 'v2\n')
const result = (await dispatcher.callRequest('git.submoduleStatus', {
worktreePath: tmpDir,
submodulePath: 'flutter_mine'
})) as { entries: { path?: unknown; status?: unknown; area?: unknown }[] }
const inner = result.entries.find((e) => e.path === 'lib.txt')
expect(inner).toBeDefined()
expect(inner!.status).toBe('modified')
expect(inner!.area).toBe('unstaged')
})
it('rejects submoduleStatus paths that escape the worktree', async () => {
gitInit(tmpDir)
await expect(
dispatcher.callRequest('git.submoduleStatus', {
worktreePath: tmpDir,
submodulePath: '../outside'
})
).rejects.toThrow('outside the worktree')
})
it('routes inner submodule files into the submodule worktree diff', async () => {
gitInit(tmpDir)
writeFileSync(path.join(tmpDir, 'root.txt'), 'root')
gitCommit(tmpDir, 'initial')
const sub = addSubmodule(tmpDir, 'flutter_mine')
writeFileSync(path.join(sub, 'lib.txt'), 'v2\n')
const result = (await dispatcher.callRequest('git.diff', {
worktreePath: tmpDir,
filePath: 'flutter_mine/lib.txt',
staged: false
})) as { kind: string; originalContent: string; modifiedContent: string }
expect(result.kind).toBe('text')
expect(normalizeGitFileText(result.originalContent)).toBe('v1\n')
expect(normalizeGitFileText(result.modifiedContent)).toBe('v2\n')
})
it('synthesizes a Subproject commit pointer diff for the gitlink root', async () => {
gitInit(tmpDir)
writeFileSync(path.join(tmpDir, 'root.txt'), 'root')
gitCommit(tmpDir, 'initial')
const sub = addSubmodule(tmpDir, 'flutter_mine')
const oldOid = execFileSync('git', ['rev-parse', 'HEAD'], {
cwd: sub,
encoding: 'utf-8'
}).trim()
writeFileSync(path.join(sub, 'lib.txt'), 'v2\n')
execFileSync('git', ['add', 'lib.txt'], { cwd: sub, stdio: 'pipe' })
gitCommit(sub, 'sub second')
const newOid = execFileSync('git', ['rev-parse', 'HEAD'], {
cwd: sub,
encoding: 'utf-8'
}).trim()
const result = (await dispatcher.callRequest('git.diff', {
worktreePath: tmpDir,
filePath: 'flutter_mine',
staged: false
})) as { kind: string; originalContent: string; modifiedContent: string }
expect(result.kind).toBe('text')
expect(result.originalContent).toBe(`Subproject commit ${oldOid}\n`)
expect(result.modifiedContent).toBe(`Subproject commit ${newOid}\n`)
})
// Why: a moved gitlink with a clean submodule has no uncommitted rows, so status/diff must surface the committed commit-range changes.
it('lists commit-range files and diffs them when the pointer moved', async () => {
gitInit(tmpDir)
writeFileSync(path.join(tmpDir, 'root.txt'), 'root')
gitCommit(tmpDir, 'initial')
const sub = addSubmodule(tmpDir, 'flutter_mine')
writeFileSync(path.join(sub, 'lib.txt'), 'v2\n')
execFileSync('git', ['add', 'lib.txt'], { cwd: sub, stdio: 'pipe' })
gitCommit(sub, 'sub second')
const status = (await dispatcher.callRequest('git.submoduleStatus', {
worktreePath: tmpDir,
submodulePath: 'flutter_mine'
})) as { entries: { path?: unknown; status?: unknown; area?: unknown }[] }
const ranged = status.entries.find((e) => e.path === 'lib.txt')
expect(ranged).toBeDefined()
expect(ranged!.status).toBe('modified')
expect(ranged!.area).toBe('unstaged')
const diff = (await dispatcher.callRequest('git.diff', {
worktreePath: tmpDir,
filePath: 'flutter_mine/lib.txt',
staged: false
})) as { kind: string; originalContent: string; modifiedContent: string }
expect(diff.kind).toBe('text')
expect(normalizeGitFileText(diff.originalContent)).toBe('v1\n')
expect(normalizeGitFileText(diff.modifiedContent)).toBe('v2\n')
})
it('lists and diffs staged submodule pointer changes from parent HEAD to index', async () => {
gitInit(tmpDir)
writeFileSync(path.join(tmpDir, 'root.txt'), 'root')
gitCommit(tmpDir, 'initial')
const sub = addSubmodule(tmpDir, 'flutter_mine')
writeFileSync(path.join(sub, 'lib.txt'), 'v2\n')
execFileSync('git', ['add', 'lib.txt'], { cwd: sub, stdio: 'pipe' })
gitCommit(sub, 'sub second')
execFileSync('git', ['add', 'flutter_mine'], { cwd: tmpDir, stdio: 'pipe' })
const status = (await dispatcher.callRequest('git.submoduleStatus', {
worktreePath: tmpDir,
submodulePath: 'flutter_mine',
area: 'staged'
})) as { entries: { path?: unknown; status?: unknown; area?: unknown }[] }
const ranged = status.entries.find((e) => e.path === 'lib.txt')
expect(ranged).toBeDefined()
expect(ranged!.status).toBe('modified')
expect(ranged!.area).toBe('unstaged')
const diff = (await dispatcher.callRequest('git.diff', {
worktreePath: tmpDir,
filePath: 'flutter_mine/lib.txt',
staged: true
})) as { kind: string; originalContent: string; modifiedContent: string }
expect(diff.kind).toBe('text')
expect(normalizeGitFileText(diff.originalContent)).toBe('v1\n')
expect(normalizeGitFileText(diff.modifiedContent)).toBe('v2\n')
})
})
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, '[k]eep.log'), 'selected')
writeFileSync(path.join(tmpDir, 'keep.log'), 'unrelated')
writeFileSync(path.join(tmpDir, 'ignored.log'), 'ignored')
await dispatcher.callRequest('git.discard', { worktreePath: tmpDir, filePath: '[k]eep.log' })
await expect(fs.access(path.join(tmpDir, '[k]eep.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, '[k]eep.log'), 'selected')
writeFileSync(path.join(tmpDir, 'keep.log'), 'keep')
gitCommit(tmpDir, 'track log fixtures')
writeFileSync(path.join(tmpDir, '[k]eep.log'), 'selected modified')
writeFileSync(path.join(tmpDir, 'keep.log'), 'keep modified')
await dispatcher.callRequest('git.discard', { worktreePath: tmpDir, filePath: '[k]eep.log' })
await expect(fs.readFile(path.join(tmpDir, '[k]eep.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('handles large tracked path lists during bulk discard classification', async () => {
const trackedStdout = Array.from({ length: 150_000 }, (_, index) => `docs/file-${index}.ts`)
.join('\0')
.concat('\0')
const gitMock = vi
.spyOn(
handler as unknown as {
git: (args: string[], cwd: string) => Promise<{ stdout: string; stderr: string }>
},
'git'
)
.mockResolvedValueOnce({ stdout: trackedStdout, stderr: '' })
.mockResolvedValueOnce({ stdout: '', stderr: '' })
await dispatcher.callRequest('git.bulkDiscard', {
worktreePath: tmpDir,
filePaths: ['docs']
})
expect(gitMock).toHaveBeenNthCalledWith(
2,
['restore', '--worktree', '--source=HEAD', '--', ':(literal)docs'],
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<string, unknown>; entries: Record<string, unknown>[] }
// 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 #1503 on the branch-diff path; without -c core.quotePath=false diff paths are octal-escaped.
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 so the test works regardless of init.defaultBranch (master vs 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<string, unknown>; entries: Record<string, unknown>[] }
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<string, unknown>; entries: Record<string, unknown>[] }
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', () => {
it('coalesces concurrent identical git.diff reads while in flight and reads fresh after settle', async () => {
const leftBlob = deferredRelayBuffer('left\n')
const rightBlob = deferredRelayBuffer('right\n')
const pendingBuffers = [leftBlob, rightBlob]
const gitBufferSpy = vi
.spyOn(handler as unknown as GitBufferSpyTarget, 'gitBuffer')
.mockImplementation(async () => pendingBuffers.shift()!.promise)
const reads = Array.from({ length: 8 }, () =>
dispatcher.callRequest('git.diff', {
worktreePath: tmpDir,
filePath: 'src/file.ts',
staged: true
})
)
await waitForSpyCalls(gitBufferSpy, 1)
leftBlob.resolve()
await waitForSpyCalls(gitBufferSpy, 2)
rightBlob.resolve()
await Promise.all(reads)
expect(gitBufferSpy).toHaveBeenCalledTimes(2)
gitBufferSpy
.mockResolvedValueOnce(Buffer.from('fresh-left\n'))
.mockResolvedValueOnce(Buffer.from('fresh-right\n'))
await dispatcher.callRequest('git.diff', {
worktreePath: tmpDir,
filePath: 'src/file.ts',
staged: true
})
expect(gitBufferSpy).toHaveBeenCalledTimes(4)
})
it('clears pending git.diff reads when status runs', async () => {
const firstBlob = deferredRelayBuffer('left\n')
const secondBlob = deferredRelayBuffer('fresh-left\n')
const pendingBuffers = [firstBlob, secondBlob]
const gitBufferSpy = vi
.spyOn(handler as unknown as GitBufferSpyTarget, 'gitBuffer')
.mockImplementation(async () => pendingBuffers.shift()!.promise)
const gitSpy = vi
.spyOn(handler as unknown as GitSpyTarget, 'git')
.mockResolvedValue({ stdout: '', stderr: '' })
const first = dispatcher.callRequest('git.diff', {
worktreePath: tmpDir,
filePath: 'src/file.ts',
staged: false
})
await waitForSpyCalls(gitBufferSpy, 1)
await dispatcher.callRequest('git.status', { worktreePath: tmpDir })
const second = dispatcher.callRequest('git.diff', {
worktreePath: tmpDir,
filePath: 'src/file.ts',
staged: false
})
await waitForSpyCalls(gitBufferSpy, 2)
firstBlob.resolve()
secondBlob.resolve()
await Promise.all([first, second])
expect(gitBufferSpy).toHaveBeenCalledTimes(2)
expect(gitSpy).toHaveBeenCalled()
})
it('clears pending git.diff reads when a mutation runs', async () => {
const firstBlob = deferredRelayBuffer('left\n')
const secondBlob = deferredRelayBuffer('fresh-left\n')
const pendingBuffers = [firstBlob, secondBlob]
const gitBufferSpy = vi
.spyOn(handler as unknown as GitBufferSpyTarget, 'gitBuffer')
.mockImplementation(async () => pendingBuffers.shift()!.promise)
const gitSpy = vi
.spyOn(handler as unknown as GitSpyTarget, 'git')
.mockResolvedValue({ stdout: '', stderr: '' })
const first = dispatcher.callRequest('git.diff', {
worktreePath: tmpDir,
filePath: 'src/file.ts',
staged: false
})
await waitForSpyCalls(gitBufferSpy, 1)
await dispatcher.callRequest('git.stage', { worktreePath: tmpDir, filePath: 'src/file.ts' })
const second = dispatcher.callRequest('git.diff', {
worktreePath: tmpDir,
filePath: 'src/file.ts',
staged: false
})
await waitForSpyCalls(gitBufferSpy, 2)
firstBlob.resolve()
secondBlob.resolve()
await Promise.all([first, second])
expect(gitBufferSpy).toHaveBeenCalledTimes(2)
expect(gitSpy).toHaveBeenCalledWith(['add', '--', ':(literal)src/file.ts'], tmpDir)
const submodulePathReads = gitSpy.mock.calls.filter(
([args]) => args[0] === 'config' && args.includes('.gitmodules')
)
expect(submodulePathReads).toHaveLength(2)
})
it('clears pending git.diff reads when a narrow ref fetch runs', async () => {
const firstBlob = deferredRelayBuffer('left\n')
const secondBlob = deferredRelayBuffer('fresh-left\n')
const pendingBuffers = [firstBlob, secondBlob]
const gitBufferSpy = vi
.spyOn(handler as unknown as GitBufferSpyTarget, 'gitBuffer')
.mockImplementation(async () => pendingBuffers.shift()!.promise)
const gitSpy = vi
.spyOn(handler as unknown as GitSpyTarget, 'git')
.mockImplementation(async (args: string[]) => {
if (args[0] === 'remote') {
return { stdout: 'origin\n', stderr: '' }
}
return { stdout: '', stderr: '' }
})
const first = dispatcher.callRequest('git.diff', {
worktreePath: tmpDir,
filePath: 'src/file.ts',
staged: false
})
await waitForSpyCalls(gitBufferSpy, 1)
await dispatcher.callRequest('git.fetchRemoteTrackingRef', {
worktreePath: tmpDir,
remote: 'origin',
branch: 'main',
ref: 'refs/remotes/origin/main',
skipAutoMaintenance: true
})
const second = dispatcher.callRequest('git.diff', {
worktreePath: tmpDir,
filePath: 'src/file.ts',
staged: false
})
await waitForSpyCalls(gitBufferSpy, 2)
firstBlob.resolve()
secondBlob.resolve()
await Promise.all([first, second])
expect(gitBufferSpy).toHaveBeenCalledTimes(2)
expect(gitSpy).toHaveBeenCalledWith(
[
'-c',
'maintenance.auto=false',
'-c',
'maintenance.commit-graph.auto=0',
'-c',
'gc.auto=0',
'fetch',
'--no-tags',
'origin',
'+refs/heads/main:refs/remotes/origin/main'
],
tmpDir
)
})
it('coalesces concurrent identical git.branchDiff reads while in flight', async () => {
const gitSpy = vi
.spyOn(handler as unknown as GitSpyTarget, 'git')
.mockImplementation(async (args: string[]) => {
if (args[0] === 'rev-parse' && args.includes('HEAD')) {
return { stdout: `${'c'.repeat(40)}\n`, stderr: '' }
}
if (args[0] === 'rev-parse') {
return { stdout: `${'b'.repeat(40)}\n`, stderr: '' }
}
if (args[0] === 'merge-base') {
return { stdout: `${'a'.repeat(40)}\n`, stderr: '' }
}
if (args.includes('--name-status')) {
return { stdout: 'M\tsrc/file.ts\n', stderr: '' }
}
throw new Error(`unexpected git args: ${args.join(' ')}`)
})
const leftBlob = deferredRelayBuffer('left\n')
const rightBlob = deferredRelayBuffer('right\n')
const pendingBuffers = [leftBlob, rightBlob]
const gitBufferSpy = vi
.spyOn(handler as unknown as GitBufferSpyTarget, 'gitBuffer')
.mockImplementation(async () => pendingBuffers.shift()!.promise)
const reads = Array.from({ length: 8 }, () =>
dispatcher.callRequest('git.branchDiff', {
worktreePath: tmpDir,
baseRef: 'main',
includePatch: true,
filePath: 'src/file.ts'
})
)
await waitForSpyCalls(gitBufferSpy, 1)
leftBlob.resolve()
await waitForSpyCalls(gitBufferSpy, 2)
rightBlob.resolve()
await Promise.all(reads)
expect(gitBufferSpy).toHaveBeenCalledTimes(2)
expect(gitSpy).toHaveBeenCalledTimes(4)
})
it('coalesces concurrent identical git.commitDiff reads while in flight', async () => {
const leftBlob = deferredRelayBuffer('left\n')
const rightBlob = deferredRelayBuffer('right\n')
const pendingBuffers = [leftBlob, rightBlob]
const gitBufferSpy = vi
.spyOn(handler as unknown as GitBufferSpyTarget, 'gitBuffer')
.mockImplementation(async () => pendingBuffers.shift()!.promise)
const reads = Array.from({ length: 8 }, () =>
dispatcher.callRequest('git.commitDiff', {
worktreePath: tmpDir,
commitOid: 'c'.repeat(40),
parentOid: 'b'.repeat(40),
filePath: 'src/file.ts'
})
)
await waitForSpyCalls(gitBufferSpy, 1)
leftBlob.resolve()
await waitForSpyCalls(gitBufferSpy, 2)
rightBlob.resolve()
await Promise.all(reads)
expect(gitBufferSpy).toHaveBeenCalledTimes(2)
})
it('coalesces parentless root git.commitDiff reads without a left-side blob', async () => {
const rightBlob = deferredRelayBuffer('right\n')
const gitBufferSpy = vi
.spyOn(handler as unknown as GitBufferSpyTarget, 'gitBuffer')
.mockImplementation(async () => rightBlob.promise)
const reads = Array.from({ length: 8 }, () =>
dispatcher.callRequest('git.commitDiff', {
worktreePath: tmpDir,
commitOid: 'c'.repeat(40),
parentOid: null,
filePath: 'src/file.ts'
})
)
await waitForSpyCalls(gitBufferSpy, 1)
rightBlob.resolve()
await Promise.all(reads)
expect(gitBufferSpy).toHaveBeenCalledTimes(1)
})
it('keeps distinct relay diff keys independent', async () => {
const gitBufferSpy = vi
.spyOn(handler as unknown as GitBufferSpyTarget, 'gitBuffer')
.mockResolvedValue(Buffer.from('blob\n'))
await Promise.all([
dispatcher.callRequest('git.diff', {
worktreePath: tmpDir,
filePath: 'src/file.ts',
staged: true
}),
dispatcher.callRequest('git.diff', {
worktreePath: tmpDir,
filePath: 'src/file.ts',
staged: false,
compareAgainstHead: true
})
])
expect(gitBufferSpy).toHaveBeenCalledTimes(3)
gitBufferSpy.mockClear()
const gitSpy = vi
.spyOn(handler as unknown as GitSpyTarget, 'git')
.mockImplementation(async (args: string[]) => {
if (args[0] === 'rev-parse' && args.includes('HEAD')) {
return { stdout: `${'c'.repeat(40)}\n`, stderr: '' }
}
if (args[0] === 'rev-parse' && args.includes('develop')) {
return { stdout: `${'d'.repeat(40)}\n`, stderr: '' }
}
if (args[0] === 'rev-parse') {
return { stdout: `${'b'.repeat(40)}\n`, stderr: '' }
}
if (args[0] === 'merge-base' && args.includes('d'.repeat(40))) {
return { stdout: `${'e'.repeat(40)}\n`, stderr: '' }
}
if (args[0] === 'merge-base') {
return { stdout: `${'a'.repeat(40)}\n`, stderr: '' }
}
if (args.includes('--name-status')) {
return { stdout: 'M\tsrc/file.ts\n', stderr: '' }
}
throw new Error(`unexpected git args: ${args.join(' ')}`)
})
await Promise.all([
dispatcher.callRequest('git.branchDiff', {
worktreePath: tmpDir,
baseRef: 'main',
includePatch: true,
filePath: 'src/file.ts'
}),
dispatcher.callRequest('git.branchDiff', {
worktreePath: tmpDir,
baseRef: 'main',
includePatch: false,
filePath: 'src/file.ts'
}),
dispatcher.callRequest('git.branchDiff', {
worktreePath: tmpDir,
baseRef: 'develop',
includePatch: true,
filePath: 'src/file.ts'
})
])
expect(gitSpy).toHaveBeenCalledTimes(12)
expect(gitBufferSpy).toHaveBeenCalledTimes(4)
gitBufferSpy.mockClear()
await Promise.all([
dispatcher.callRequest('git.commitDiff', {
worktreePath: tmpDir,
commitOid: 'c'.repeat(40),
parentOid: 'b'.repeat(40),
filePath: 'src/file.ts'
}),
dispatcher.callRequest('git.commitDiff', {
worktreePath: tmpDir,
commitOid: 'c'.repeat(40),
parentOid: 'a'.repeat(40),
filePath: 'src/file.ts'
}),
dispatcher.callRequest('git.commitDiff', {
worktreePath: tmpDir,
commitOid: 'c'.repeat(40),
parentOid: 'b'.repeat(40),
filePath: 'src/file.ts',
oldPath: 'src/old-a.ts'
}),
dispatcher.callRequest('git.commitDiff', {
worktreePath: tmpDir,
commitOid: 'c'.repeat(40),
parentOid: 'b'.repeat(40),
filePath: 'src/file.ts',
oldPath: 'src/old-b.ts'
})
])
expect(gitBufferSpy).toHaveBeenCalledTimes(8)
})
it('retries relay diff reads after an in-flight rejection settles', async () => {
const invalidRequest = {
worktreePath: tmpDir,
commitOid: 'not-a-full-oid',
parentOid: 'b'.repeat(40),
filePath: 'src/file.ts'
}
const first = dispatcher.callRequest('git.commitDiff', invalidRequest)
const firstBurst = [
first,
...Array.from({ length: 7 }, () => dispatcher.callRequest('git.commitDiff', invalidRequest))
]
await expect(Promise.all(firstBurst)).rejects.toThrow(
'commitOid must be a full git object id'
)
const retry = dispatcher.callRequest('git.commitDiff', invalidRequest)
expect(retry).not.toBe(first)
await expect(retry).rejects.toThrow('commitOid must be a full git object id')
})
// Why: regression for #1503 on git.branchDiff — branchDiffEntries is a separate quotePath=false path that 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<string, unknown>[]
// length===1 confirms the path filter matched the raw UTF-8 path; octal-quoted (default quotePath) wouldn't 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: exercise the configured-upstream happy path (rev-parse HEAD@{u} + rev-list --left-right) the no-upstream test misses.
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'
})
// Juggle local commits/resets to produce specific ahead/behind counts vs. upstream.
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 exists only after a successful fetch, confirming the remote was actually contacted.
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('rejects malformed fork sync expected upstream metadata', async () => {
await expect(
dispatcher.callRequest('git.forkSync', {
worktreePath: tmpDir,
expectedUpstream: { owner: ' ', repo: 'orca' }
})
).rejects.toThrow('Invalid expected upstream.')
})
it('rejects fork sync requests without expected upstream metadata', async () => {
await expect(
dispatcher.callRequest('git.forkSync', {
worktreePath: tmpDir
})
).rejects.toThrow('Expected upstream is required.')
})
it('aborts fork sync when the relay request is canceled', async () => {
gitInit(tmpDir)
const controller = new AbortController()
controller.abort()
await expect(
dispatcher.callRequest(
'git.forkSync',
{ worktreePath: tmpDir, expectedUpstream: { owner: 'stablyai', repo: 'orca' } },
{ isStale: () => false, signal: controller.signal }
)
).rejects.toThrow(/abort/i)
})
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('fetches GitHub pull request heads through the narrow fetch RPC', async () => {
const bareDir = mkdtempSync(path.join(tmpdir(), 'relay-github-pr-bare-'))
try {
execFileSync('git', ['init', '--bare'], { cwd: bareDir, stdio: 'pipe' })
gitInit(tmpDir)
writeFileSync(path.join(tmpDir, 'pr.txt'), 'head')
gitCommit(tmpDir, 'pr head')
const expected = execFileSync('git', ['rev-parse', 'HEAD'], {
cwd: tmpDir,
encoding: 'utf-8'
}).trim()
execFileSync('git', ['remote', 'add', 'origin', bareDir], { cwd: tmpDir, stdio: 'pipe' })
execFileSync('git', ['push', 'origin', 'HEAD:refs/pull/42/head'], {
cwd: tmpDir,
stdio: 'pipe'
})
const result = (await dispatcher.callRequest('git.fetchGitHubPullRequestHead', {
worktreePath: tmpDir,
remote: 'origin',
prNumber: 42
})) as { localRef: string }
// The ref is scoped by remote identity so soft-keep can never serve
// another project's PR #42 out of the same object database.
const component = reviewHeadRemoteRefComponent('origin', bareDir)
expect(result.localRef).toBe(`refs/orca/pull/${component}/42`)
const actual = execFileSync('git', ['rev-parse', '--verify', result.localRef], {
cwd: tmpDir,
encoding: 'utf-8'
}).trim()
expect(actual).toBe(expected)
} finally {
await fs.rm(bareDir, { recursive: true, force: true })
}
})
it('rejects invalid GitHub pull request head fetch requests', async () => {
await expect(
dispatcher.callRequest('git.fetchGitHubPullRequestHead', {
worktreePath: tmpDir,
remote: '-origin',
prNumber: 42
})
).rejects.toThrow('GitHub pull request fetch remote must not start with "-".')
await expect(
dispatcher.callRequest('git.fetchGitHubPullRequestHead', {
worktreePath: tmpDir,
remote: 'origin',
prNumber: 0
})
).rejects.toThrow('Invalid GitHub pull request fetch request.')
})
it('fetches GitLab merge request heads through the narrow fetch RPC', async () => {
const bareDir = mkdtempSync(path.join(tmpdir(), 'relay-gitlab-mr-bare-'))
try {
execFileSync('git', ['init', '--bare'], { cwd: bareDir, stdio: 'pipe' })
gitInit(tmpDir)
writeFileSync(path.join(tmpDir, 'mr.txt'), 'head')
gitCommit(tmpDir, 'mr head')
const expected = execFileSync('git', ['rev-parse', 'HEAD'], {
cwd: tmpDir,
encoding: 'utf-8'
}).trim()
execFileSync('git', ['remote', 'add', 'origin', bareDir], { cwd: tmpDir, stdio: 'pipe' })
execFileSync('git', ['push', 'origin', 'HEAD:refs/merge-requests/42/head'], {
cwd: tmpDir,
stdio: 'pipe'
})
const result = (await dispatcher.callRequest('git.fetchGitLabMergeRequestHead', {
worktreePath: tmpDir,
remote: 'origin',
mrIid: 42
})) as { localRef: string }
// The head is fetched into a dedicated ref (not shared FETCH_HEAD) so a
// concurrent fetch can't retarget the caller's rev-parse of the checkout.
const component = reviewHeadRemoteRefComponent('origin', bareDir)
expect(result.localRef).toBe(`refs/orca/merge-requests/${component}/42`)
const actual = execFileSync('git', ['rev-parse', '--verify', result.localRef], {
cwd: tmpDir,
encoding: 'utf-8'
}).trim()
expect(actual).toBe(expected)
// Legacy contract: pre-durable-ref desktop clients call this method name
// and then resolve FETCH_HEAD, which a refspec fetch still writes.
const fetchHead = execFileSync('git', ['rev-parse', '--verify', 'FETCH_HEAD'], {
cwd: tmpDir,
encoding: 'utf-8'
}).trim()
expect(fetchHead).toBe(expected)
} finally {
await fs.rm(bareDir, { recursive: true, force: true })
}
})
it('fetches GitLab merge request heads through the versioned durable-ref RPC', async () => {
const bareDir = mkdtempSync(path.join(tmpdir(), 'relay-gitlab-mr-ref-bare-'))
try {
execFileSync('git', ['init', '--bare'], { cwd: bareDir, stdio: 'pipe' })
gitInit(tmpDir)
writeFileSync(path.join(tmpDir, 'mr.txt'), 'head')
gitCommit(tmpDir, 'mr head')
const expected = execFileSync('git', ['rev-parse', 'HEAD'], {
cwd: tmpDir,
encoding: 'utf-8'
}).trim()
execFileSync('git', ['remote', 'add', 'origin', bareDir], { cwd: tmpDir, stdio: 'pipe' })
execFileSync('git', ['push', 'origin', 'HEAD:refs/merge-requests/77/head'], {
cwd: tmpDir,
stdio: 'pipe'
})
// Why: new clients call the versioned name; old relays 404 it and prompt reconnect.
const result = (await dispatcher.callRequest('git.fetchGitLabMergeRequestHeadRef', {
worktreePath: tmpDir,
remote: 'origin',
mrIid: 77
})) as { localRef: string }
const component = reviewHeadRemoteRefComponent('origin', bareDir)
expect(result.localRef).toBe(`refs/orca/merge-requests/${component}/77`)
const actual = execFileSync('git', ['rev-parse', '--verify', result.localRef], {
cwd: tmpDir,
encoding: 'utf-8'
}).trim()
expect(actual).toBe(expected)
} finally {
await fs.rm(bareDir, { recursive: true, force: true })
}
})
it('rejects invalid GitLab merge request head fetch requests', async () => {
await expect(
dispatcher.callRequest('git.fetchGitLabMergeRequestHead', {
worktreePath: tmpDir,
remote: '-origin',
mrIid: 42
})
).rejects.toThrow('GitLab merge request fetch remote must not start with "-".')
await expect(
dispatcher.callRequest('git.fetchGitLabMergeRequestHead', {
worktreePath: tmpDir,
remote: 'origin',
mrIid: 0
})
).rejects.toThrow('Invalid GitLab merge request fetch request.')
})
it('rethrows upstreamStatus failures that are not "no upstream configured"', async () => {
// Why: the catch only swallows "no upstream"; other errors must surface so auth/corruption failures aren't masked.
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<string, unknown>[]
expect(result.length).toBeGreaterThanOrEqual(1)
expect(result[0].isMainWorktree).toBe(true)
})
it('passes request cancellation to the git worktree list subprocess', async () => {
const controller = new AbortController()
const gitSpy = vi
.spyOn(handler as unknown as GitSpyTarget, 'git')
.mockRejectedValue(new Error('aborted'))
const result = await dispatcher.callRequest(
'git.listWorktrees',
{ repoPath: tmpDir },
{ isStale: () => false, signal: controller.signal }
)
expect(result).toEqual([])
expect(gitSpy).toHaveBeenCalledWith(['worktree', 'list', '--porcelain', '-z'], tmpDir, {
signal: controller.signal
})
})
it.skipIf(process.platform === 'win32')(
'normalizes the main worktree path for a separate-git-dir repo',
async () => {
const sourcePath = path.join(tmpDir, 'source')
const worktreePath = path.join(tmpDir, 'worktree')
const gitDirPath = path.join(tmpDir, 'git-store', 'project.git')
mkdirSync(sourcePath)
mkdirSync(path.dirname(gitDirPath), { recursive: true })
gitInit(sourcePath)
writeFileSync(path.join(sourcePath, 'file.txt'), 'hello')
gitCommit(sourcePath, 'initial')
execFileSync('git', [
'clone',
'--quiet',
`--separate-git-dir=${gitDirPath}`,
sourcePath,
worktreePath
])
const result = (await dispatcher.callRequest('git.listWorktrees', {
repoPath: await fs.realpath(worktreePath)
})) as Record<string, unknown>[]
const mainWorktree = result.find((worktree) => worktree.isMainWorktree === true)
expect(mainWorktree).toMatchObject({
path: await fs.realpath(worktreePath),
isMainWorktree: true
})
expect(mainWorktree?.path).not.toBe(await fs.realpath(gitDirPath))
}
)
it.skipIf(process.platform === 'win32')(
'leaves an ordinary repo reached via a symlinked path unchanged',
async () => {
// A symlink alias defeats the path-string gate; the git-common-dir gate must still skip rewrite for an ordinary repo.
const repoPath = path.join(tmpDir, 'plain-repo')
mkdirSync(repoPath)
gitInit(repoPath)
writeFileSync(path.join(repoPath, 'file.txt'), 'hello')
gitCommit(repoPath, 'initial')
const linkedRepoPath = path.join(tmpDir, 'linked-repo')
symlinkSync(repoPath, linkedRepoPath)
const result = (await dispatcher.callRequest('git.listWorktrees', {
repoPath: linkedRepoPath
})) as Record<string, unknown>[]
const mainWorktree = result.find((worktree) => worktree.isMainWorktree === true)
expect(mainWorktree).toMatchObject({
path: await fs.realpath(repoPath),
isMainWorktree: true
})
}
)
it.skipIf(process.platform === 'win32')(
'leaves the main entry unchanged when scanned via a linked worktree',
async () => {
// The git-common-dir gate must skip rewrite so a linked worktree's main entry isn't overwritten with its own toplevel.
const repoPath = path.join(tmpDir, 'main-repo')
mkdirSync(repoPath)
gitInit(repoPath)
writeFileSync(path.join(repoPath, 'file.txt'), 'hello')
gitCommit(repoPath, 'initial')
const linkedWorktreePath = path.join(tmpDir, 'linked-wt')
execFileSync('git', ['worktree', 'add', '--quiet', linkedWorktreePath, '-b', 'feature'], {
cwd: repoPath,
stdio: 'pipe'
})
const resolvedLinked = await fs.realpath(linkedWorktreePath)
const result = (await dispatcher.callRequest('git.listWorktrees', {
repoPath: resolvedLinked
})) as Record<string, unknown>[]
const mainWorktree = result.find((worktree) => worktree.isMainWorktree === true)
expect(mainWorktree).toMatchObject({
path: await fs.realpath(repoPath),
isMainWorktree: true
})
expect(mainWorktree?.path).not.toBe(resolvedLinked)
}
)
it.skipIf(process.platform === 'win32')(
'lists worktrees whose paths contain newlines',
async () => {
gitInit(tmpDir)
writeFileSync(path.join(tmpDir, 'file.txt'), 'hello')
gitCommit(tmpDir, 'initial')
const worktreePath = path.join(
path.dirname(tmpDir),
`${path.basename(tmpDir)}-linked\nremote`
)
try {
execFileSync(
'git',
['worktree', 'add', '--quiet', '-b', 'feature/newline', worktreePath],
{
cwd: tmpDir,
stdio: 'pipe'
}
)
const realWorktreePath = await fs.realpath(worktreePath)
const result = (await dispatcher.callRequest('git.listWorktrees', {
repoPath: tmpDir
})) as Record<string, unknown>[]
expect(result.map((worktree) => worktree.path)).toContain(realWorktreePath)
} finally {
await fs.rm(worktreePath, { recursive: true, force: true })
}
}
)
})
describe('worktreeIsClean', () => {
it('can ignore untracked files', async () => {
gitInit(tmpDir)
writeFileSync(path.join(tmpDir, 'tracked.txt'), 'initial')
gitCommit(tmpDir, 'initial')
writeFileSync(path.join(tmpDir, 'scratch.txt'), 'untracked')
await expect(
dispatcher.callRequest('git.worktreeIsClean', { worktreePath: tmpDir })
).resolves.toEqual({
clean: false,
stdout: expect.stringContaining('scratch.txt')
})
await expect(
dispatcher.callRequest('git.worktreeIsClean', {
worktreePath: tmpDir,
includeUntracked: false
})
).resolves.toEqual({ clean: true })
})
})
describe('refreshLocalBaseRefForWorktreeCreate', () => {
function setupMockedRefreshHandler() {
const localDispatcher = createMockDispatcher()
const localHandler = new GitHandler(
localDispatcher as unknown as RelayDispatcher,
new RelayContext()
)
const gitMock =
vi.fn<
(
args: string[],
cwd: string,
opts?: { maxBuffer?: number }
) => Promise<{ stdout: string; stderr: string }>
>()
;(localHandler as unknown as { git: typeof gitMock }).git = gitMock
return { localDispatcher, gitMock }
}
it('resets the owning worktree to the remote-tracking ref', async () => {
gitInit(tmpDir)
writeFileSync(path.join(tmpDir, 'base.txt'), 'base')
gitCommit(tmpDir, 'initial')
const branchRef = currentBranchFullRef(tmpDir)
const ownerPath = reportedWorktreePath(tmpDir)
const firstSha = execFileSync('git', ['rev-parse', 'HEAD'], {
cwd: tmpDir,
encoding: 'utf-8'
}).trim()
writeFileSync(path.join(tmpDir, 'base.txt'), 'remote')
gitCommit(tmpDir, 'remote update')
const remoteSha = execFileSync('git', ['rev-parse', 'HEAD'], {
cwd: tmpDir,
encoding: 'utf-8'
}).trim()
execFileSync('git', ['update-ref', 'refs/remotes/origin/main', remoteSha], {
cwd: tmpDir,
stdio: 'pipe'
})
execFileSync('git', ['reset', '--hard', firstSha], { cwd: tmpDir, stdio: 'pipe' })
await dispatcher.callRequest('git.refreshLocalBaseRefForWorktreeCreate', {
repoPath: tmpDir,
fullRef: branchRef,
remoteTrackingRef: 'refs/remotes/origin/main',
ownerWorktreePath: ownerPath
})
const actual = execFileSync('git', ['rev-parse', 'HEAD'], {
cwd: tmpDir,
encoding: 'utf-8'
}).trim()
expect(actual).toBe(remoteSha)
await expect(fs.readFile(path.join(tmpDir, 'base.txt'), 'utf-8')).resolves.toBe('remote')
})
it('fast-forwards a non-checked-out local branch via update-ref', async () => {
gitInit(tmpDir)
writeFileSync(path.join(tmpDir, 'base.txt'), 'base')
gitCommit(tmpDir, 'initial')
execFileSync('git', ['branch', 'main-copy'], { cwd: tmpDir, stdio: 'pipe' })
writeFileSync(path.join(tmpDir, 'base.txt'), 'remote')
gitCommit(tmpDir, 'remote update')
const remoteSha = execFileSync('git', ['rev-parse', 'HEAD'], {
cwd: tmpDir,
encoding: 'utf-8'
}).trim()
execFileSync('git', ['update-ref', 'refs/remotes/origin/main', remoteSha], {
cwd: tmpDir,
stdio: 'pipe'
})
await dispatcher.callRequest('git.refreshLocalBaseRefForWorktreeCreate', {
repoPath: tmpDir,
fullRef: 'refs/heads/main-copy',
remoteTrackingRef: 'refs/remotes/origin/main'
})
// No working tree owns main-copy, so the bare ref fast-forwards.
const actual = execFileSync('git', ['rev-parse', 'refs/heads/main-copy'], {
cwd: tmpDir,
encoding: 'utf-8'
}).trim()
expect(actual).toBe(remoteSha)
})
it('does not move a non-checked-out local branch when checkOnly is set', async () => {
gitInit(tmpDir)
writeFileSync(path.join(tmpDir, 'base.txt'), 'base')
gitCommit(tmpDir, 'initial')
execFileSync('git', ['branch', 'main-copy'], { cwd: tmpDir, stdio: 'pipe' })
const originalSha = execFileSync('git', ['rev-parse', 'refs/heads/main-copy'], {
cwd: tmpDir,
encoding: 'utf-8'
}).trim()
writeFileSync(path.join(tmpDir, 'base.txt'), 'remote')
gitCommit(tmpDir, 'remote update')
const remoteSha = execFileSync('git', ['rev-parse', 'HEAD'], {
cwd: tmpDir,
encoding: 'utf-8'
}).trim()
execFileSync('git', ['update-ref', 'refs/remotes/origin/main', remoteSha], {
cwd: tmpDir,
stdio: 'pipe'
})
await dispatcher.callRequest('git.refreshLocalBaseRefForWorktreeCreate', {
repoPath: tmpDir,
fullRef: 'refs/heads/main-copy',
remoteTrackingRef: 'refs/remotes/origin/main',
checkOnly: true
})
const actual = execFileSync('git', ['rev-parse', 'refs/heads/main-copy'], {
cwd: tmpDir,
encoding: 'utf-8'
}).trim()
expect(actual).toBe(originalSha)
})
it('rejects invalid local base ref refresh refs', async () => {
gitInit(tmpDir)
await expect(
dispatcher.callRequest('git.refreshLocalBaseRefForWorktreeCreate', {
repoPath: tmpDir,
fullRef: 'refs/tags/main',
remoteTrackingRef: 'refs/remotes/origin/main'
})
).rejects.toThrow('Invalid local base ref refresh refs.')
})
it('rejects a dirty owner worktree before resetting', async () => {
gitInit(tmpDir)
writeFileSync(path.join(tmpDir, 'base.txt'), 'base')
gitCommit(tmpDir, 'initial')
const branchRef = currentBranchFullRef(tmpDir)
const ownerPath = reportedWorktreePath(tmpDir)
const firstSha = execFileSync('git', ['rev-parse', 'HEAD'], {
cwd: tmpDir,
encoding: 'utf-8'
}).trim()
writeFileSync(path.join(tmpDir, 'base.txt'), 'remote')
gitCommit(tmpDir, 'remote update')
const remoteSha = execFileSync('git', ['rev-parse', 'HEAD'], {
cwd: tmpDir,
encoding: 'utf-8'
}).trim()
execFileSync('git', ['update-ref', 'refs/remotes/origin/main', remoteSha], {
cwd: tmpDir,
stdio: 'pipe'
})
execFileSync('git', ['reset', '--hard', firstSha], { cwd: tmpDir, stdio: 'pipe' })
writeFileSync(path.join(tmpDir, 'base.txt'), 'local dirty')
await expect(
dispatcher.callRequest('git.refreshLocalBaseRefForWorktreeCreate', {
repoPath: tmpDir,
fullRef: branchRef,
remoteTrackingRef: 'refs/remotes/origin/main',
ownerWorktreePath: ownerPath
})
).rejects.toThrow('Local base ref worktree has tracked changes.')
const actual = execFileSync('git', ['rev-parse', 'HEAD'], {
cwd: tmpDir,
encoding: 'utf-8'
}).trim()
expect(actual).toBe(firstSha)
await expect(fs.readFile(path.join(tmpDir, 'base.txt'), 'utf-8')).resolves.toBe('local dirty')
})
it('rejects when the caller-supplied owner path is not the checked-out branch owner', async () => {
gitInit(tmpDir)
writeFileSync(path.join(tmpDir, 'base.txt'), 'base')
gitCommit(tmpDir, 'initial')
const branchRef = currentBranchFullRef(tmpDir)
const headSha = execFileSync('git', ['rev-parse', 'HEAD'], {
cwd: tmpDir,
encoding: 'utf-8'
}).trim()
execFileSync('git', ['update-ref', 'refs/remotes/origin/main', headSha], {
cwd: tmpDir,
stdio: 'pipe'
})
await expect(
dispatcher.callRequest('git.refreshLocalBaseRefForWorktreeCreate', {
repoPath: tmpDir,
fullRef: branchRef,
remoteTrackingRef: 'refs/remotes/origin/main',
ownerWorktreePath: path.join(path.dirname(tmpDir), 'different-owner')
})
).rejects.toThrow('Local base ref is checked out in a different worktree.')
})
it('rejects diverged local refs before mutating', async () => {
gitInit(tmpDir)
writeFileSync(path.join(tmpDir, 'base.txt'), 'base')
gitCommit(tmpDir, 'initial')
execFileSync('git', ['branch', 'main-copy'], { cwd: tmpDir, stdio: 'pipe' })
writeFileSync(path.join(tmpDir, 'remote.txt'), 'remote')
gitCommit(tmpDir, 'remote update')
const remoteSha = execFileSync('git', ['rev-parse', 'HEAD'], {
cwd: tmpDir,
encoding: 'utf-8'
}).trim()
execFileSync('git', ['update-ref', 'refs/remotes/origin/main', remoteSha], {
cwd: tmpDir,
stdio: 'pipe'
})
execFileSync('git', ['checkout', 'main-copy'], { cwd: tmpDir, stdio: 'pipe' })
writeFileSync(path.join(tmpDir, 'local.txt'), 'local')
gitCommit(tmpDir, 'local update')
const localSha = execFileSync('git', ['rev-parse', 'refs/heads/main-copy'], {
cwd: tmpDir,
encoding: 'utf-8'
}).trim()
await expect(
dispatcher.callRequest('git.refreshLocalBaseRefForWorktreeCreate', {
repoPath: tmpDir,
fullRef: 'refs/heads/main-copy',
remoteTrackingRef: 'refs/remotes/origin/main',
ownerWorktreePath: tmpDir
})
).rejects.toThrow('Local base ref is not a fast-forward update.')
const actual = execFileSync('git', ['rev-parse', 'refs/heads/main-copy'], {
cwd: tmpDir,
encoding: 'utf-8'
}).trim()
expect(actual).toBe(localSha)
})
it('resets owner worktree to captured remote OID without update-ref', async () => {
const { localDispatcher, gitMock } = setupMockedRefreshHandler()
gitMock.mockImplementation(async (args: string[]) => {
if (args[0] === 'check-ref-format') {
return { stdout: '', stderr: '' }
}
if (args[0] === 'rev-parse' && args[2] === 'refs/remotes/origin/main^{commit}') {
return { stdout: 'remote-oid\n', stderr: '' }
}
if (args[0] === 'rev-parse') {
return { stdout: 'old-local-oid\n', stderr: '' }
}
if (args[0] === 'merge-base') {
return { stdout: '', stderr: '' }
}
if (args[0] === 'worktree') {
return {
stdout: 'worktree /repo\nHEAD old-local-oid\nbranch refs/heads/main\n',
stderr: ''
}
}
if (args[0] === 'status') {
return { stdout: '', stderr: '' }
}
if (args[0] === 'reset') {
return { stdout: '', stderr: '' }
}
throw new Error(`unexpected git call: ${args.join(' ')}`)
})
await expect(
localDispatcher.callRequest('git.refreshLocalBaseRefForWorktreeCreate', {
repoPath: '/repo',
fullRef: 'refs/heads/main',
remoteTrackingRef: 'refs/remotes/origin/main'
})
).resolves.toBeUndefined()
expect(gitMock).toHaveBeenCalledWith(
['merge-base', '--is-ancestor', 'old-local-oid', 'remote-oid'],
'/repo'
)
expect(gitMock).toHaveBeenCalledWith(['reset', '--hard', 'remote-oid'], '/repo')
expect(gitMock.mock.calls.map((call) => call[0])).not.toContainEqual([
'update-ref',
'refs/heads/main',
'remote-oid',
'old-local-oid'
])
})
it('fails closed when worktree ownership cannot be listed', async () => {
const { localDispatcher, gitMock } = setupMockedRefreshHandler()
gitMock.mockImplementation(async (args: string[]) => {
if (args[0] === 'check-ref-format') {
return { stdout: '', stderr: '' }
}
if (args[0] === 'rev-parse' && args[2] === 'refs/remotes/origin/main^{commit}') {
return { stdout: 'remote-oid\n', stderr: '' }
}
if (args[0] === 'rev-parse') {
return { stdout: 'old-local-oid\n', stderr: '' }
}
if (args[0] === 'merge-base') {
return { stdout: '', stderr: '' }
}
if (args[0] === 'worktree') {
throw new Error('worktree list failed')
}
throw new Error(`unexpected git call: ${args.join(' ')}`)
})
await expect(
localDispatcher.callRequest('git.refreshLocalBaseRefForWorktreeCreate', {
repoPath: '/repo',
fullRef: 'refs/heads/main',
remoteTrackingRef: 'refs/remotes/origin/main'
})
).rejects.toThrow('worktree list failed')
expect(gitMock.mock.calls.map((call) => call[0])).not.toContainEqual([
'update-ref',
'refs/heads/main',
'refs/remotes/origin/main',
'old-local-oid'
])
expect(gitMock.mock.calls.map((call) => call[0])).not.toContainEqual([
'reset',
'--hard',
'refs/heads/main'
])
})
})
describe('addWorktree', () => {
// Why: mock git to control exit codes (e.g. --get exit 1 vs other) deterministically, independent of host git config.
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.<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: a local tag named 'main' makes bare-name `worktree add ... main` ambiguous; refs/heads/ disambiguates.
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.<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.<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.<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.<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: --get exits 0 for any value including empty string, so an empty value must not fall through to set-true.
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.<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: only --get exit 1 means "unset"; any other code is a real read failure, so don't fall through to set-true.
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.<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.<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: config probes must run only after worktree add succeeds (never against an uncreated dir).
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']
])
})
})
})