512 lines
23 KiB
TypeScript
512 lines
23 KiB
TypeScript
import { access, mkdir, readFile, stat, symlink, writeFile } from 'node:fs/promises'
|
|
import { join } from 'node:path'
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import { Context } from '@deepseek-ai/cordis'
|
|
import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
|
|
import LocalSandbox from '@deepseek-ai/dsh-sandbox-local'
|
|
import { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
|
|
import { hasBranch, inspectCheckout, listBranches } from '../src/git.ts'
|
|
import type { GitConfig } from '../src/git.ts'
|
|
import { commitAt, git, gitContext, plainSession, repository, sandboxRecord, scratch, script } from './harness.ts'
|
|
import type { SandboxRecord } from './harness.ts'
|
|
|
|
const CONFIG: GitConfig = { gitCommand: 'git', gitTimeoutMs: 10_000, graceMs: 500, maxOutputBytes: 1_048_576 }
|
|
const GIT_PREFIX = ['--no-optional-locks', '-c', 'core.hooksPath=/dev/null', '-c', 'core.fsmonitor=false']
|
|
const posix = process.platform === 'linux' || process.platform === 'darwin'
|
|
|
|
const cleanups: (() => Promise<void>)[] = []
|
|
|
|
afterEach(async () => {
|
|
for (const cleanup of cleanups.splice(0).reverse()) await cleanup()
|
|
})
|
|
|
|
async function directory(prefix = 'dsh-forge-git-'): Promise<string> {
|
|
const { path, remove } = await scratch(prefix)
|
|
cleanups.push(remove)
|
|
return path
|
|
}
|
|
|
|
const session = plainSession
|
|
|
|
async function context(record: SandboxRecord, root: string, real = false): Promise<Context> {
|
|
const ctx = await gitContext(record, root, real)
|
|
cleanups.push(() => ctx.fiber.dispose())
|
|
return ctx
|
|
}
|
|
|
|
function live(): AbortSignal {
|
|
return new AbortController().signal
|
|
}
|
|
|
|
/** The subcommands of the recorded Git runs, after the fixed prefix, in sorted order. */
|
|
function commands(record: SandboxRecord): string[] {
|
|
return record.calls.map(call => call.argv.slice(1 + GIT_PREFIX.length).join(' ')).sort()
|
|
}
|
|
|
|
/** A repository whose `main`, `newer`, and `older` tips were committed from latest to earliest, with `main` checked out. */
|
|
async function branched(root: string): Promise<string> {
|
|
const repo = await repository(root)
|
|
await commitAt(repo, 'main.txt', '2026-03-01T00:00:00Z')
|
|
await git(repo, 'checkout', '-b', 'older')
|
|
await commitAt(repo, 'older.txt', '2026-01-01T00:00:00Z')
|
|
await git(repo, 'checkout', '-b', 'newer', 'main')
|
|
await commitAt(repo, 'newer.txt', '2026-02-01T00:00:00Z')
|
|
await git(repo, 'checkout', 'main')
|
|
return repo
|
|
}
|
|
|
|
describe('Session permissions for Git inspection', () => {
|
|
it.each(['read-only', 'workspace-write', 'danger-full-access'] as const)('uses %s for checkout and branch reads without changing the index', async (mode) => {
|
|
const repo = await repository(await directory())
|
|
const record = sandboxRecord()
|
|
const ctx = await context(record, repo)
|
|
const current = session(repo)
|
|
setSandboxMode(current, mode)
|
|
const index = join(repo, '.git', 'index')
|
|
const before = await readFile(index)
|
|
const beforeStat = await stat(index)
|
|
|
|
await expect(inspectCheckout(ctx, CONFIG, current, repo, live())).resolves.toMatchObject({ checkout: { branch: 'main' } })
|
|
await expect(listBranches(ctx, CONFIG, current, repo, 50, live())).resolves.toEqual({ current: 'main', branches: ['main'] })
|
|
await expect(hasBranch(ctx, CONFIG, current, repo, 'main', live())).resolves.toBe(true)
|
|
|
|
expect(record.calls).toHaveLength(mode === 'danger-full-access' ? 0 : 10)
|
|
expect(record.calls.every(call => call.policy.mode === mode)).toBe(true)
|
|
expect(await readFile(index)).toEqual(before)
|
|
expect((await stat(index)).mtimeMs).toBe(beforeStat.mtimeMs)
|
|
await expect(access(join(repo, '.git', 'index.lock'))).rejects.toThrow()
|
|
})
|
|
|
|
it.each(['read-only', 'workspace-write', 'danger-full-access'] as const)('handles an unavailable sandbox under %s without widening access', async (mode) => {
|
|
const repo = await repository(await directory())
|
|
const record = sandboxRecord()
|
|
const failure = new SandboxUnavailableError('read-only')
|
|
record.failure = failure
|
|
const ctx = await context(record, repo)
|
|
const current = session(repo)
|
|
setSandboxMode(current, mode)
|
|
const spawn = vi.spyOn(ctx.subprocess, 'spawn')
|
|
|
|
if (mode === 'danger-full-access') {
|
|
await expect(inspectCheckout(ctx, CONFIG, current, repo, live())).resolves.toMatchObject({ checkout: { branch: 'main' } })
|
|
expect(spawn).toHaveBeenCalledTimes(7)
|
|
} else {
|
|
await expect(inspectCheckout(ctx, CONFIG, current, repo, live())).rejects.toBe(failure)
|
|
expect(spawn).not.toHaveBeenCalled()
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('inspectCheckout', () => {
|
|
it('reads the root, shared Git directory, branch, commit, upstream, and remotes of the checkout with seven read-only commands', async () => {
|
|
const root = await directory()
|
|
const repo = await repository(root)
|
|
await git(root, 'init', '--bare', '-b', 'main', 'origin.git')
|
|
await git(repo, 'remote', 'add', 'origin', join(root, 'origin.git'))
|
|
await git(repo, 'remote', 'add', 'fake', 'https://fake.test/octo/demo.git')
|
|
await git(repo, 'push', '-u', 'origin', 'main')
|
|
const nested = join(repo, 'src')
|
|
await mkdir(nested)
|
|
const record = sandboxRecord()
|
|
const ctx = await context(record, repo)
|
|
|
|
const inspection = await inspectCheckout(ctx, CONFIG, session(repo), nested, live())
|
|
|
|
expect(inspection?.checkout).toEqual({ root: repo, branch: 'main', linked: false, commit: await git(repo, 'rev-parse', '--short', 'HEAD') })
|
|
expect(inspection?.commonDir).toBe(join(repo, '.git'))
|
|
expect(inspection?.upstream).toBe('origin/main')
|
|
expect(inspection?.remotes).toHaveLength(2)
|
|
expect(inspection?.remotes).toEqual(expect.arrayContaining([
|
|
{ name: 'fake', urls: ['https://fake.test/octo/demo.git'] },
|
|
{ name: 'origin', urls: [join(root, 'origin.git')] },
|
|
]))
|
|
expect(record.calls[0]?.argv.slice(1 + GIT_PREFIX.length)).toEqual(['rev-parse', '--show-toplevel'])
|
|
expect(commands(record)).toEqual([
|
|
'remote -v',
|
|
'rev-parse --abbrev-ref --symbolic-full-name @{upstream}',
|
|
'rev-parse --path-format=absolute --git-common-dir',
|
|
'rev-parse --path-format=absolute --git-dir',
|
|
'rev-parse --short HEAD',
|
|
'rev-parse --show-toplevel',
|
|
'symbolic-ref --quiet --short HEAD',
|
|
])
|
|
for (const call of record.calls) {
|
|
expect(call.policy).toMatchObject({ mode: 'workspace-write', workspaceRoot: repo })
|
|
expect(call.argv.slice(1, 1 + GIT_PREFIX.length)).toEqual(GIT_PREFIX)
|
|
}
|
|
})
|
|
|
|
it('inspects a linked worktree as its own checkout that shares the Git directory of its repository', async () => {
|
|
const root = await directory()
|
|
const repo = await repository(root)
|
|
const linked = join(root, 'linked')
|
|
await git(repo, 'worktree', 'add', '-b', 'feat', linked)
|
|
const ctx = await context(sandboxRecord(), repo)
|
|
|
|
await expect(inspectCheckout(ctx, CONFIG, session(repo), linked, live())).resolves.toEqual({
|
|
checkout: { root: linked, branch: 'feat', linked: true, commit: await git(linked, 'rev-parse', '--short', 'HEAD') },
|
|
commonDir: join(repo, '.git'), upstream: null, remotes: [],
|
|
})
|
|
})
|
|
|
|
it('returns undefined outside a checkout and inside a Git directory', async () => {
|
|
const root = await directory()
|
|
const repo = await repository(root)
|
|
const ctx = await context(sandboxRecord(), root)
|
|
|
|
await expect(inspectCheckout(ctx, CONFIG, session(root), root, live())).resolves.toBeUndefined()
|
|
await expect(inspectCheckout(ctx, CONFIG, session(root), join(repo, '.git'), live())).resolves.toBeUndefined()
|
|
})
|
|
|
|
it('reports an unborn branch and a detached HEAD', async () => {
|
|
const root = await directory()
|
|
await git(root, 'init', '-b', 'main', 'empty')
|
|
const repo = await repository(root)
|
|
await git(repo, 'checkout', '--detach')
|
|
const ctx = await context(sandboxRecord(), root)
|
|
|
|
await expect(inspectCheckout(ctx, CONFIG, session(root), join(root, 'empty'), live())).resolves.toEqual({
|
|
checkout: { root: join(root, 'empty'), branch: 'main', linked: false, commit: null }, commonDir: join(root, 'empty', '.git'), upstream: null, remotes: [],
|
|
})
|
|
await expect(inspectCheckout(ctx, CONFIG, session(root), repo, live())).resolves.toEqual({
|
|
checkout: { root: repo, branch: null, linked: false, commit: await git(repo, 'rev-parse', '--short', 'HEAD') },
|
|
commonDir: join(repo, '.git'), upstream: null, remotes: [],
|
|
})
|
|
})
|
|
|
|
it('stops when the caller aborts', async () => {
|
|
const root = await directory()
|
|
const ctx = await context(sandboxRecord(), root)
|
|
const controller = new AbortController()
|
|
controller.abort(new Error('closed'))
|
|
|
|
await expect(inspectCheckout(ctx, CONFIG, session(root), root, controller.signal)).rejects.toThrow('closed')
|
|
})
|
|
})
|
|
|
|
describe.skipIf(!posix)('inspectCheckout with a stand-in Git', () => {
|
|
it('never runs a configured filter, because it reads no working tree file', async () => {
|
|
const root = await directory()
|
|
const repo = await repository(root)
|
|
const marker = join(root, 'filter-executed')
|
|
await writeFile(join(repo, '.gitattributes'), '*.txt filter=marker\n')
|
|
await git(repo, 'config', 'filter.marker.clean', `sh -c 'echo executed >> "${marker}"; cat'`)
|
|
await writeFile(join(repo, 'tracked.txt'), 'changed\n')
|
|
const ctx = await context(sandboxRecord(), repo)
|
|
|
|
const inspection = await inspectCheckout(ctx, CONFIG, session(repo), repo, live())
|
|
|
|
expect(inspection?.checkout).toMatchObject({ root: repo, branch: 'main' })
|
|
await expect(access(marker)).rejects.toThrow()
|
|
await git(repo, 'status', '--porcelain')
|
|
expect(await readFile(marker, 'utf8')).toContain('executed')
|
|
})
|
|
|
|
it('runs Git with a cleared repository selector and disabled prompts, locks, and lazy fetches', async () => {
|
|
const root = await directory()
|
|
const log = join(root, 'env.log')
|
|
const fake = await script(join(root, 'git'), [
|
|
'case "$*" in',
|
|
` *--show-toplevel*) env | grep -E '^(GIT_|LC_ALL)' | sort > "${log}"; echo /work/repo ;;`,
|
|
' *--git-common-dir*) echo /work/repo/.git ;;',
|
|
' *--git-dir*) echo /work/repo/.git ;;',
|
|
' *symbolic-ref*) exit 1 ;;',
|
|
' *--abbrev-ref*) exit 128 ;;',
|
|
'esac',
|
|
].join('\n'))
|
|
vi.stubEnv('GIT_DIR', join(root, 'elsewhere.git'))
|
|
const ctx = await context(sandboxRecord(), root)
|
|
|
|
try {
|
|
await expect(inspectCheckout(ctx, { ...CONFIG, gitCommand: fake }, session(root), root, live())).resolves.toEqual({
|
|
checkout: { root: '/work/repo', branch: null, linked: false, commit: null }, commonDir: '/work/repo/.git', upstream: null, remotes: [],
|
|
})
|
|
} finally {
|
|
vi.unstubAllEnvs()
|
|
}
|
|
const environment = await readFile(log, 'utf8')
|
|
expect(environment).toContain('GIT_OPTIONAL_LOCKS=0')
|
|
expect(environment).toContain('GIT_NO_LAZY_FETCH=1')
|
|
expect(environment).toContain('GIT_TERMINAL_PROMPT=0')
|
|
expect(environment).toContain('LC_ALL=C')
|
|
expect(environment).not.toContain('GIT_DIR=')
|
|
})
|
|
|
|
it('resolves the top-level and shared Git directories Git prints into canonical process paths', async () => {
|
|
const root = await directory()
|
|
const repo = await repository(root)
|
|
const alias = join(root, 'alias')
|
|
await symlink(repo, alias)
|
|
const fake = await script(join(root, 'git'), [
|
|
'case "$*" in',
|
|
` *--show-toplevel*) echo "${alias}/" ;;`,
|
|
` *--git-common-dir*) echo "${alias}/.git" ;;`,
|
|
` *--git-dir*) echo "${alias}/.git" ;;`,
|
|
' *"rev-parse --short"*) echo 0a1b2c3 ;;',
|
|
' *symbolic-ref*) echo main ;;',
|
|
' *--abbrev-ref*) echo origin/main ;;',
|
|
'esac',
|
|
].join('\n'))
|
|
const ctx = await context(sandboxRecord(), root)
|
|
|
|
await expect(inspectCheckout(ctx, { ...CONFIG, gitCommand: fake }, session(root), root, live())).resolves.toEqual({
|
|
checkout: { root: repo, branch: 'main', linked: false, commit: '0a1b2c3' }, commonDir: join(repo, '.git'), upstream: 'origin/main', remotes: [],
|
|
})
|
|
})
|
|
|
|
it.each([
|
|
['an unexpected exit', 'echo broken >&2; exit 2', {}, 'git rev-parse failed (2): broken'],
|
|
['another rev-parse error', 'echo "fatal: detected dubious ownership" >&2; exit 128', {}, 'git rev-parse failed: fatal: detected dubious ownership'],
|
|
['a signal', 'kill -9 $$', {}, 'git rev-parse failed (SIGKILL): no diagnostic'],
|
|
['missing output', 'exit 0', {}, 'git rev-parse printed no directory'],
|
|
['oversized output', 'echo /a/very/long/top/level/path', { maxOutputBytes: 4 }, 'git rev-parse output exceeds maxOutputBytes'],
|
|
['a timeout', 'sleep 5', { gitTimeoutMs: 100 }, 'FORGE_PANEL_GIT_TIMEOUT after 100ms'],
|
|
])('fails on %s', async (_label, body, overrides: Partial<GitConfig>, message) => {
|
|
const root = await directory()
|
|
const fake = await script(join(root, 'git'), body)
|
|
const ctx = await context(sandboxRecord(), root)
|
|
|
|
await expect(inspectCheckout(ctx, { ...CONFIG, ...overrides, gitCommand: fake }, session(root), root, live())).rejects.toThrow(message)
|
|
})
|
|
|
|
it('fails when the Git executable is missing', async () => {
|
|
const root = await directory()
|
|
const ctx = await context(sandboxRecord(), root)
|
|
|
|
await expect(inspectCheckout(ctx, { ...CONFIG, gitCommand: join(root, 'missing-git') }, session(root), root, live())).rejects.toThrow()
|
|
})
|
|
|
|
it.each([
|
|
['common-dir', '*--git-common-dir*) echo "fatal: bad common dir" >&2; exit 128 ;;', 'git common-dir failed (128): fatal: bad common dir'],
|
|
['git-dir', '*--git-dir*) echo "fatal: bad git dir" >&2; exit 128 ;;', 'git git-dir failed (128): fatal: bad git dir'],
|
|
['head', '*"rev-parse --short"*) echo "fatal: bad object" >&2; exit 2 ;;', 'git head failed (2): fatal: bad object'],
|
|
['symbolic-ref', '*symbolic-ref*) echo "fatal: bad HEAD" >&2; exit 128 ;;', 'git symbolic-ref failed (128): fatal: bad HEAD'],
|
|
['upstream', '*--abbrev-ref*) echo "fatal: bad upstream" >&2; exit 1 ;;', 'git upstream failed (1): fatal: bad upstream'],
|
|
['remote', '*remote*) echo "fatal: bad config" >&2; exit 3 ;;', 'git remote failed (3): fatal: bad config'],
|
|
])('names a failing %s command after the top-level directory', async (_name, failure, message) => {
|
|
const root = await directory()
|
|
const fake = await script(join(root, 'git'), [
|
|
'case "$*" in',
|
|
` ${failure}`,
|
|
' *--show-toplevel*) echo /work/repo ;;',
|
|
' *--git-common-dir*) echo /work/repo/.git ;;',
|
|
' *--git-dir*) echo /work/repo/.git ;;',
|
|
'esac',
|
|
].join('\n'))
|
|
const ctx = await context(sandboxRecord(), root)
|
|
|
|
await expect(inspectCheckout(ctx, { ...CONFIG, gitCommand: fake }, session(root), root, live())).rejects.toThrow(message)
|
|
})
|
|
|
|
it('rejects a shared Git directory that Git does not print', async () => {
|
|
const root = await directory()
|
|
const fake = await script(join(root, 'git'), ['case "$*" in', ' *--show-toplevel*) echo /work/repo ;;', 'esac'].join('\n'))
|
|
const ctx = await context(sandboxRecord(), root)
|
|
|
|
await expect(inspectCheckout(ctx, { ...CONFIG, gitCommand: fake }, session(root), root, live())).rejects.toThrow('git rev-parse printed no directory')
|
|
})
|
|
})
|
|
|
|
describe('listBranches', () => {
|
|
it('lists the checked-out branch first, then local branches by latest commit, with two read-only commands', async () => {
|
|
const root = await directory()
|
|
const repo = await branched(root)
|
|
const record = sandboxRecord()
|
|
const ctx = await context(record, repo)
|
|
|
|
await expect(listBranches(ctx, CONFIG, session(repo), repo, 50, live())).resolves.toEqual({
|
|
current: 'main', branches: ['main', 'newer', 'older'],
|
|
})
|
|
expect(commands(record)).toEqual([
|
|
'for-each-ref --sort=-committerdate --count=50 --format=%(refname:lstrip=2) refs/heads/',
|
|
'symbolic-ref --quiet --short HEAD',
|
|
])
|
|
for (const call of record.calls) {
|
|
expect(call.policy).toMatchObject({ mode: 'workspace-write', workspaceRoot: repo })
|
|
expect(call.argv.slice(1, 1 + GIT_PREFIX.length)).toEqual(GIT_PREFIX)
|
|
}
|
|
})
|
|
|
|
it('keeps the checked-out branch first when the limit cuts it from the listing', async () => {
|
|
const root = await directory()
|
|
const repo = await branched(root)
|
|
await git(repo, 'checkout', 'older')
|
|
const ctx = await context(sandboxRecord(), repo)
|
|
|
|
await expect(listBranches(ctx, CONFIG, session(repo), repo, 2, live())).resolves.toEqual({ current: 'older', branches: ['older', 'main'] })
|
|
await expect(listBranches(ctx, CONFIG, session(repo), repo, 1, live())).resolves.toEqual({ current: 'older', branches: ['older'] })
|
|
})
|
|
|
|
it('lists the branches of a detached HEAD without a current one, and an unborn branch alone', async () => {
|
|
const root = await directory()
|
|
const repo = await branched(root)
|
|
await git(repo, 'checkout', '--detach', 'main')
|
|
await git(root, 'init', '-b', 'main', 'empty')
|
|
const ctx = await context(sandboxRecord(), root)
|
|
|
|
await expect(listBranches(ctx, CONFIG, session(root), repo, 2, live())).resolves.toEqual({ current: null, branches: ['main', 'newer'] })
|
|
await expect(listBranches(ctx, CONFIG, session(root), join(root, 'empty'), 50, live())).resolves.toEqual({ current: 'main', branches: ['main'] })
|
|
})
|
|
|
|
it('fails outside a checkout', async () => {
|
|
const root = await directory()
|
|
const ctx = await context(sandboxRecord(), root)
|
|
|
|
await expect(listBranches(ctx, CONFIG, session(root), root, 50, live()))
|
|
.rejects.toThrow(/^git (symbolic-ref|for-each-ref) failed \(128\): fatal: not a git repository/)
|
|
})
|
|
|
|
it.skipIf(!posix)('names a failing branch listing', async () => {
|
|
const root = await directory()
|
|
const fake = await script(join(root, 'git'), [
|
|
'case "$*" in',
|
|
' *for-each-ref*) echo "fatal: bad ref" >&2; exit 2 ;;',
|
|
' *symbolic-ref*) echo main ;;',
|
|
'esac',
|
|
].join('\n'))
|
|
const ctx = await context(sandboxRecord(), root)
|
|
|
|
await expect(listBranches(ctx, { ...CONFIG, gitCommand: fake }, session(root), root, 50, live()))
|
|
.rejects.toThrow('git for-each-ref failed (2): fatal: bad ref')
|
|
})
|
|
|
|
it('stops when the caller aborts', async () => {
|
|
const root = await directory()
|
|
const ctx = await context(sandboxRecord(), root)
|
|
const controller = new AbortController()
|
|
controller.abort(new Error('closed'))
|
|
|
|
await expect(listBranches(ctx, CONFIG, session(root), root, 50, controller.signal)).rejects.toThrow('closed')
|
|
})
|
|
})
|
|
|
|
describe('hasBranch', () => {
|
|
it('reports whether a local branch exists with one read-only command per question', async () => {
|
|
const root = await directory()
|
|
const repo = await branched(root)
|
|
const record = sandboxRecord()
|
|
const ctx = await context(record, repo)
|
|
|
|
await expect(hasBranch(ctx, CONFIG, session(repo), repo, 'newer', live())).resolves.toBe(true)
|
|
await expect(hasBranch(ctx, CONFIG, session(repo), repo, 'dsh/0a1b2c3d', live())).resolves.toBe(false)
|
|
|
|
expect(record.calls.map(call => call.argv.slice(1 + GIT_PREFIX.length).join(' '))).toEqual([
|
|
'show-ref --verify --quiet refs/heads/newer',
|
|
'show-ref --verify --quiet refs/heads/dsh/0a1b2c3d',
|
|
])
|
|
for (const call of record.calls) {
|
|
expect(call.policy).toMatchObject({ mode: 'workspace-write', workspaceRoot: repo })
|
|
expect(call.argv.slice(1, 1 + GIT_PREFIX.length)).toEqual(GIT_PREFIX)
|
|
}
|
|
})
|
|
|
|
it('reads the branches of the repository from a linked worktree', async () => {
|
|
const root = await directory()
|
|
const repo = await branched(root)
|
|
const linked = join(root, 'linked')
|
|
await git(repo, 'worktree', 'add', '-b', 'feat', linked)
|
|
const ctx = await context(sandboxRecord(), repo)
|
|
|
|
await expect(hasBranch(ctx, CONFIG, session(repo), linked, 'older', live())).resolves.toBe(true)
|
|
})
|
|
|
|
it('fails outside a repository', async () => {
|
|
const root = await directory()
|
|
const ctx = await context(sandboxRecord(), root)
|
|
|
|
await expect(hasBranch(ctx, CONFIG, session(root), root, 'main', live()))
|
|
.rejects.toThrow(/^git show-ref failed \(128\): fatal: not a git repository/)
|
|
})
|
|
|
|
it('stops when the caller aborts', async () => {
|
|
const root = await directory()
|
|
const repo = await repository(root)
|
|
const ctx = await context(sandboxRecord(), repo)
|
|
const controller = new AbortController()
|
|
controller.abort(new Error('closed'))
|
|
|
|
await expect(hasBranch(ctx, CONFIG, session(repo), repo, 'main', controller.signal)).rejects.toThrow('closed')
|
|
})
|
|
})
|
|
|
|
const sandboxUsable = await (async () => {
|
|
if (!posix) return false
|
|
const probe = new Context()
|
|
try {
|
|
await probe.plugin(LocalSandbox)
|
|
await probe.sandbox.confine([process.execPath, '--version'], { mode: 'read-only', workspaceRoot: process.cwd() })
|
|
return true
|
|
} catch (error: unknown) {
|
|
if (error instanceof SandboxUnavailableError) return false
|
|
throw error
|
|
} finally {
|
|
await probe.fiber.dispose()
|
|
}
|
|
})()
|
|
|
|
describe.skipIf(!sandboxUsable)('Git inspection under the local sandbox', () => {
|
|
it('reads a checkout and lists its branches under read-only confinement without writing its index', async () => {
|
|
const root = await directory()
|
|
const repo = await repository(root)
|
|
await writeFile(join(repo, 'tracked.txt'), 'changed\n')
|
|
const index = join(repo, '.git', 'index')
|
|
const before = await stat(index)
|
|
const ctx = await context(sandboxRecord(), repo, true)
|
|
const readOnly = session(repo)
|
|
setSandboxMode(readOnly, 'read-only')
|
|
const confine = vi.spyOn(ctx.sandbox, 'confine')
|
|
|
|
await expect(inspectCheckout(ctx, CONFIG, readOnly, repo, live())).resolves.toEqual({
|
|
checkout: { root: repo, branch: 'main', linked: false, commit: await git(repo, 'rev-parse', '--short', 'HEAD') },
|
|
commonDir: join(repo, '.git'), upstream: null, remotes: [],
|
|
})
|
|
await expect(listBranches(ctx, CONFIG, readOnly, repo, 50, live())).resolves.toEqual({ current: 'main', branches: ['main'] })
|
|
|
|
expect(confine).toHaveBeenCalledTimes(9)
|
|
expect(confine.mock.calls.every(call => call[1].mode === 'read-only')).toBe(true)
|
|
expect((await stat(index)).mtimeMs).toBe(before.mtimeMs)
|
|
await expect(access(join(repo, '.git', 'index.lock'))).rejects.toThrow()
|
|
})
|
|
})
|
|
|
|
|
|
describe('parallel inspection cleanup', () => {
|
|
it.each(['checkout', 'branches'] as const)('cancels and joins sibling reads when %s inspection fails', async (kind) => {
|
|
const root = await directory()
|
|
const repo = await repository(root)
|
|
const ctx = await context(sandboxRecord(), repo)
|
|
const entered: PromiseWithResolvers<void> = Promise.withResolvers()
|
|
const released: PromiseWithResolvers<void> = Promise.withResolvers()
|
|
const original = ctx.sandbox.confine.bind(ctx.sandbox)
|
|
let sibling: AbortSignal | undefined
|
|
const failure = new Error('inspection failed')
|
|
const heldArg = kind === 'checkout' ? '--git-common-dir' : 'symbolic-ref'
|
|
const failedArg = kind === 'checkout' ? 'remote' : 'for-each-ref'
|
|
const probe = vi.spyOn(ctx.sandbox, 'confine').mockImplementation(async (argv, policy, signal) => {
|
|
if (argv.includes(heldArg)) {
|
|
sibling = signal
|
|
entered.resolve()
|
|
await released.promise
|
|
}
|
|
if (argv.includes(failedArg)) { await entered.promise; throw failure }
|
|
return original(argv, policy, signal)
|
|
})
|
|
let settled = false
|
|
const operation = (kind === 'checkout'
|
|
? inspectCheckout(ctx, CONFIG, session(repo), repo, live())
|
|
: listBranches(ctx, CONFIG, session(repo), repo, 50, live()))
|
|
.then(() => { settled = true; return undefined }, (error: unknown) => { settled = true; return error })
|
|
try {
|
|
await entered.promise
|
|
await vi.waitFor(() => { expect(sibling?.aborted).toBe(true) })
|
|
expect(settled).toBe(false)
|
|
released.resolve()
|
|
expect(await operation).toBe(failure)
|
|
} finally {
|
|
released.resolve()
|
|
await operation
|
|
probe.mockRestore()
|
|
}
|
|
})
|
|
})
|