Files
orca/src/main/git/checkpoints/worktree-checkpoint-real-git.test.ts
T
Neil 027ca19046 fix(checkpoints): newline-safe undo coverage, path-matched workspace busy, focus Cancel
- assertUndoCovers never asks batch-check about a path with a newline (counts as
  unsaved), so answers can't shift.
- The workspace-busy probe matches rows by workspace id or worktree realpath and
  treats a truncated listing as busy.
- Rewind dialog opens with focus on Cancel; a disabled option reads as disabled.
- Notes: unreadable tracked deletes keep their staged blob; the turn-starting
  mark is process-local by design.
2026-11-10 01:14:22 -08:00

415 lines
16 KiB
TypeScript

import { chmod, mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises'
import { existsSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { runProcess } from '@orca/process-host'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
openCheckpointRepo,
type CheckpointRepo,
WorktreeCheckpointError
} from './worktree-checkpoint-git'
import {
captureWorktreeCheckpoint,
CHECKPOINT_MAX_UNTRACKED_BYTES,
readWorktreeCheckpoint
} from './worktree-checkpoint-snapshot'
import { previewWorktreeRestore, restoreWorktreeCheckpoint } from './worktree-checkpoint-restore'
import { assertUndoCovers } from './worktree-checkpoint-restore-guards'
import { listCheckpointRefs, pruneCheckpointRefs } from './worktree-checkpoint-retention'
let dir: string
let repo: CheckpointRepo
async function git(...args: string[]): Promise<string> {
const result = await runProcess({ program: 'git', args, cwd: dir })
if (result.code !== 0) {
throw new Error(result.stderr)
}
return result.stdout.trim()
}
const put = async (path: string, text: string | Buffer) => {
await mkdir(join(dir, path, '..'), { recursive: true })
await writeFile(join(dir, path), text)
}
const read = (path: string) => readFile(join(dir, path), 'utf8')
const T1 = 'refs/orca/checkpoints/s1/turn/t1'
const NOW = 1_800_000_000_000
const capture = (ref = T1) => captureWorktreeCheckpoint(repo, ref, { kind: 'turn', createdAt: NOW })
const restore = async (ref = T1) => {
const target = await readWorktreeCheckpoint(repo, ref)
return restoreWorktreeCheckpoint(repo, target!, {
ref: 'refs/orca/checkpoints/s1/undo/u1',
now: 2
})
}
async function userState() {
return {
head: await git('rev-parse', 'HEAD'),
branch: await git('branch', '--show-current'),
staged: await git('diff', '--cached', '--name-status'),
stash: await git('stash', 'list'),
status: await git('status', '--porcelain=v1', '-uall')
}
}
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'orca-checkpoint-'))
await git('init', '-q')
await git('symbolic-ref', 'HEAD', 'refs/heads/main')
await git('config', 'user.name', 'Audit')
await git('config', 'user.email', 'audit@example.test')
await git('config', 'commit.gpgsign', 'true')
await git('config', 'core.hooksPath', '/nonexistent')
await put('.gitignore', 'ignored/\n*.log\n')
await put('a.txt', 'a1\n')
await put('keep/b.txt', 'b1\n')
await git('add', '.')
await git('-c', 'commit.gpgsign=false', 'commit', '-qm', 'base')
repo = await openCheckpointRepo(dir)
})
afterEach(async () => {
await rm(dir, { recursive: true, force: true })
})
describe('worktree checkpoints', () => {
it('snapshots without touching HEAD, branch, index, stash or status', async () => {
await put('a.txt', 'a2\n')
await git('add', 'a.txt')
await put('a.txt', 'a3\n')
await put('new.txt', 'untracked\n')
const before = await userState()
const checkpoint = await capture()
expect(await userState()).toEqual(before)
expect(checkpoint.head).toBe(before.head)
expect(await git('show', `${checkpoint.commit}:a.txt`)).toBe('a3')
expect(await git('show', `${checkpoint.commit}:new.txt`)).toBe('untracked')
expect(await git('show', `${checkpoint.indexTree}:a.txt`)).toBe('a2')
expect(await git('for-each-ref', '--format=%(refname)')).toContain(T1)
expect(existsSync(join(dir, '.git', 'index.lock'))).toBe(false)
})
it('sees a same-size edit made in the same second the index was written (racy git)', async () => {
// Git rehashes an entry whose stat matches only when it is not older than the index file. A
// temp index stamped "now" (a later second) would trust the stale stat and drop the edit.
// Racy git is about real file-system seconds: start early in one so the edit shares it.
// ast-grep-ignore: test-no-polling-loop, test-no-wall-clock -- the file system clock is real
while (Date.now() % 1000 > 300) {
// ast-grep-ignore: test-no-real-time-wait -- the file system clock can't be faked
await new Promise((resolve) => setTimeout(resolve, 20))
}
await put('a.txt', 'r1\n')
await git('add', 'a.txt')
await put('a.txt', 's1\n')
// ast-grep-ignore: test-no-real-time-wait -- the snapshot must run in a later second
await new Promise((resolve) => setTimeout(resolve, 1100))
const checkpoint = await capture()
expect(await git('show', `${checkpoint.commit}:a.txt`)).toBe('s1')
})
it('restores untracked, deleted, binary, renamed and modified files', async () => {
const binary = Buffer.from([0, 1, 2, 255, 254, 0, 10])
await put('bin.dat', binary)
await put('untracked.txt', 'u1\n')
await capture()
await put('a.txt', 'changed\n')
await rm(join(dir, 'untracked.txt'))
await put('bin.dat', Buffer.from([9, 9]))
await mkdir(join(dir, 'moved'))
await git('mv', 'keep/b.txt', 'moved/b.txt')
await put('agent-new/deep.txt', 'agent\n')
const preview = await previewWorktreeRestore(repo, (await readWorktreeCheckpoint(repo, T1))!)
expect(preview.changes.map((c) => `${c.status} ${c.path}`).sort()).toEqual([
'A keep/b.txt',
'A untracked.txt',
'D agent-new/deep.txt',
'D moved/b.txt',
'M a.txt',
'M bin.dat'
])
const result = await restore()
expect(result.changes).toHaveLength(6)
expect(await read('a.txt')).toBe('a1\n')
expect(await read('untracked.txt')).toBe('u1\n')
expect(await readFile(join(dir, 'bin.dat'))).toEqual(binary)
expect(await read('keep/b.txt')).toBe('b1\n')
expect(existsSync(join(dir, 'moved'))).toBe(false)
expect(existsSync(join(dir, 'agent-new'))).toBe(false)
// The staged rename is gone from the index too: HEAD never moved, so staging comes back.
expect(result.indexRestored).toBe(true)
expect(await git('diff', '--cached', '--name-status')).toBe('')
expect(await git('status', '--porcelain=v1')).toBe('?? bin.dat\n?? untracked.txt'.trim())
expect(
(await previewWorktreeRestore(repo, (await readWorktreeCheckpoint(repo, T1))!)).changes
).toEqual([])
})
it('leaves gitignored files alone', async () => {
await put('ignored/cache.bin', 'before\n')
await put('debug.log', 'log1\n')
await capture()
await put('ignored/cache.bin', 'after\n')
await put('ignored/new.bin', 'new\n')
await put('debug.log', 'log2\n')
await put('a.txt', 'changed\n')
await restore()
expect(await read('ignored/cache.bin')).toBe('after\n')
expect(await read('ignored/new.bin')).toBe('new\n')
expect(await read('debug.log')).toBe('log2\n')
expect(await read('a.txt')).toBe('a1\n')
})
it('brings back a dirty index while HEAD is unchanged', async () => {
await put('a.txt', 'staged\n')
await git('add', 'a.txt')
await put('a.txt', 'staged-then-edited\n')
await capture()
await git('reset', '-q')
await put('a.txt', 'agent\n')
const result = await restore()
expect(result.indexRestored).toBe(true)
expect(await git('show', ':a.txt')).toBe('staged')
expect(await read('a.txt')).toBe('staged-then-edited\n')
})
it('keeps commits made after the checkpoint and leaves the index when HEAD moved', async () => {
await capture()
const base = await git('rev-parse', 'HEAD')
await put('a.txt', 'committed\n')
await git('-c', 'commit.gpgsign=false', 'commit', '-qam', 'agent commit')
const agentHead = await git('rev-parse', 'HEAD')
const result = await restore()
expect(result.headMoved).toBe(true)
expect(result.indexRestored).toBe(false)
expect(await git('rev-parse', 'HEAD')).toBe(agentHead)
expect(agentHead).not.toBe(base)
expect(await read('a.txt')).toBe('a1\n')
})
it('undoes a restore, including an ignored file the restore overwrote', async () => {
await put('later-ignored.txt', 'v1\n')
await capture()
await put('.gitignore', 'ignored/\n*.log\nlater-ignored.txt\n')
await put('later-ignored.txt', 'precious\n')
await put('a.txt', 'agent\n')
const result = await restore()
expect(await read('later-ignored.txt')).toBe('v1\n')
const undo = await readWorktreeCheckpoint(repo, result.undo.ref)
await restoreWorktreeCheckpoint(repo, undo!, {
ref: 'refs/orca/checkpoints/s1/undo/u2',
now: 3
})
expect(await read('later-ignored.txt')).toBe('precious\n')
expect(await read('a.txt')).toBe('agent\n')
expect(await read('.gitignore')).toContain('later-ignored.txt')
})
it('restores file modes and symlinks', async () => {
await put('run.sh', '#!/bin/sh\n')
await chmod(join(dir, 'run.sh'), 0o755)
await symlink('a.txt', join(dir, 'link'))
await capture()
await chmod(join(dir, 'run.sh'), 0o644)
await rm(join(dir, 'link'))
await put('link', 'now a file\n')
await restore()
expect((await stat(join(dir, 'run.sh'))).mode & 0o111).not.toBe(0)
expect(await read('link')).toBe('a1\n')
})
it('refuses while merge conflicts are unresolved, before writing anything', async () => {
await capture()
await git('checkout', '-qb', 'other')
await put('a.txt', 'other\n')
await git('-c', 'commit.gpgsign=false', 'commit', '-qam', 'other')
await git('checkout', '-q', 'main')
await put('a.txt', 'main\n')
await git('-c', 'commit.gpgsign=false', 'commit', '-qam', 'main')
await runProcess({ program: 'git', args: ['merge', 'other'], cwd: dir })
const conflicted = await read('a.txt')
await expect(restore()).rejects.toMatchObject({
reason: 'merge-conflicts'
})
expect(await read('a.txt')).toBe(conflicted)
expect(await git('for-each-ref', 'refs/orca/checkpoints/s1/undo/')).toBe('')
})
it('refuses when a folder now sits where the checkpoint has a file', async () => {
await capture()
await rm(join(dir, 'a.txt'))
await put('a.txt/inside.log', 'ignored content\n')
await expect(restore()).rejects.toMatchObject({ reason: 'blocked-path' })
expect(await read('a.txt/inside.log')).toBe('ignored content\n')
})
it('refuses when an ignored file now sits where the checkpoint has a folder', async () => {
await put('cache/x.txt', 'tracked then\n')
await capture()
await rm(join(dir, 'cache'), { recursive: true })
await put('.gitignore', 'ignored/\n*.log\ncache\n')
await put('cache', 'precious ignored file\n')
await expect(restore()).rejects.toMatchObject({ reason: 'blocked-path' })
expect(await read('cache')).toBe('precious ignored file\n')
})
it('snapshots past an unreadable file and refuses to overwrite it', async () => {
await put('secret.txt', 'v1\n')
await capture()
await put('secret.txt', 'unreadable now\n')
await chmod(join(dir, 'secret.txt'), 0o000)
await put('a.txt', 'agent\n')
try {
const later = await capture('refs/orca/checkpoints/s1/turn/t2')
expect(await git('show', `${later.commit}:a.txt`)).toBe('agent')
await expect(restore()).rejects.toMatchObject({ reason: 'blocked-path' })
expect(await read('a.txt')).toBe('agent\n')
} finally {
await chmod(join(dir, 'secret.txt'), 0o644)
}
expect(await read('secret.txt')).toBe('unreadable now\n')
})
it('leaves large untracked files out of snapshots and alone on restore', async () => {
await capture()
await put('big.bin', Buffer.alloc(CHECKPOINT_MAX_UNTRACKED_BYTES + 1, 7))
await put('a.txt', 'agent\n')
const later = await capture('refs/orca/checkpoints/s1/turn/t2')
await expect(git('cat-file', '-e', `${later.commit}:big.bin`)).rejects.toThrow()
await restore()
expect((await stat(join(dir, 'big.bin'))).size).toBe(CHECKPOINT_MAX_UNTRACKED_BYTES + 1)
expect(await read('a.txt')).toBe('a1\n')
})
it('leaves skip-worktree and assume-unchanged files alone', async () => {
await capture()
await git('update-index', '--skip-worktree', 'a.txt')
await git('update-index', '--assume-unchanged', 'keep/b.txt')
await put('a.txt', 'local only\n')
await put('keep/b.txt', 'local too\n')
await put('new.txt', 'agent\n')
const result = await restore()
expect(result.changes.map((c) => c.path)).toEqual(['new.txt'])
expect(await read('a.txt')).toBe('local only\n')
expect(await read('keep/b.txt')).toBe('local too\n')
expect(await git('ls-files', '-v', 'a.txt')).toBe('S a.txt')
})
it('names its undo point when a restore fails partway', async () => {
await put('locked/keep.txt', 'k\n')
await put('locked/gone.txt', 'g\n')
await capture()
await rm(join(dir, 'locked/gone.txt'))
await put('extra.txt', 'agent\n')
await chmod(join(dir, 'locked'), 0o555)
try {
const error = await restore().then(
() => null,
(failure: unknown) => failure
)
expect(error).toBeInstanceOf(WorktreeCheckpointError)
const undoRef = error instanceof WorktreeCheckpointError ? error.undoRef : undefined
expect(error).toMatchObject({ reason: 'partial' })
expect(existsSync(join(dir, 'extra.txt'))).toBe(false)
await chmod(join(dir, 'locked'), 0o755)
const undo = await readWorktreeCheckpoint(repo, undoRef ?? '')
await restoreWorktreeCheckpoint(repo, undo!, {
ref: 'refs/orca/checkpoints/s1/undo/u9',
now: NOW
})
expect(await read('extra.txt')).toBe('agent\n')
} finally {
await chmod(join(dir, 'locked'), 0o755)
}
})
it('never writes into a nested repository the agent created', async () => {
await put('sub/a.txt', 'checkpointed\n')
await capture()
await git('-C', join(dir, 'sub'), 'init', '-q')
await put('sub/a.txt', 'uncommitted work in the nested repo\n')
await put('a.txt', 'agent\n')
await expect(restore()).rejects.toMatchObject({ reason: 'blocked-path' })
expect(await read('sub/a.txt')).toBe('uncommitted work in the nested repo\n')
expect(await read('a.txt')).toBe('agent\n')
})
it('refuses when a path on disk is missing from the undo tree', async () => {
await put('present.txt', 'on disk\n')
const empty = (await git('hash-object', '-t', 'tree', '-w', '--stdin')).trim()
await expect(
assertUndoCovers(repo, empty, [{ status: 'M', path: 'present.txt' }])
).rejects.toMatchObject({ reason: 'blocked-path', paths: ['present.txt'] })
await expect(
assertUndoCovers(repo, empty, [{ status: 'A', path: 'absent.txt' }])
).resolves.toBeUndefined()
// A saved file next to an unsaved one whose name holds a newline: answers must not shift.
await put('x', 'saved\n')
await put('x\ny', 'not saved\n')
await git('add', 'x')
const tree = await git('write-tree')
await expect(
assertUndoCovers(repo, tree, [
{ status: 'M', path: 'x\ny' },
{ status: 'M', path: 'x' }
])
).rejects.toMatchObject({ reason: 'blocked-path', paths: ['x\ny'] })
})
it('works on an unborn branch', async () => {
await rm(join(dir, '.git'), { recursive: true })
await git('init', '-q')
repo = await openCheckpointRepo(dir)
await capture()
await put('a.txt', 'agent\n')
await put('fresh.txt', 'agent\n')
await restore()
expect(await read('a.txt')).toBe('a1\n')
expect(existsSync(join(dir, 'fresh.txt'))).toBe(false)
})
it('prunes beyond the newest N and anything past the max age', async () => {
for (let index = 0; index < 5; index += 1) {
await capture(`refs/orca/checkpoints/s1/turn/t${index}`)
}
await capture('refs/orca/checkpoints/s2/turn/x')
const deleted = await pruneCheckpointRefs(repo, {
prefix: 'refs/orca/checkpoints/s1/turn/',
keep: 3,
maxAgeMs: 60_000,
now: NOW
})
expect(deleted).toHaveLength(2)
expect(await listCheckpointRefs(repo, 'refs/orca/checkpoints/s1/turn/')).toHaveLength(3)
expect(await listCheckpointRefs(repo, 'refs/orca/checkpoints/s2/')).toHaveLength(1)
await pruneCheckpointRefs(repo, {
prefix: 'refs/orca/checkpoints/s1/turn/',
keep: 50,
maxAgeMs: -60_000,
now: NOW
})
expect(await listCheckpointRefs(repo, 'refs/orca/checkpoints/')).toEqual([])
await capture('refs/orca/checkpoints/s1/turn/fresh')
await pruneCheckpointRefs(repo, {
prefix: 'refs/orca/checkpoints/s1/turn/',
keep: 0,
maxAgeMs: -60_000,
now: NOW,
protect: ['refs/orca/checkpoints/s1/turn/fresh']
})
expect(await listCheckpointRefs(repo, 'refs/orca/checkpoints/')).toEqual([
'refs/orca/checkpoints/s1/turn/fresh'
])
})
it('rejects plain folders', async () => {
const plain = await mkdtemp(join(tmpdir(), 'orca-checkpoint-plain-'))
try {
await expect(openCheckpointRepo(plain)).rejects.toMatchObject({
reason: 'not-a-repo'
})
} finally {
await rm(plain, { recursive: true, force: true })
}
})
})