Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
73bd3ac42This file is pinned to the selected release. Download the original to open it in your design tool.
import { execFile } from 'node:child_process'
import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { promisify } from 'node:util'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { encryptDownFrame, encryptRpcResult } from './e2ee/applicationFrames.js'
import { placeholderBranch, sessionBranchNames, worktreeFolderName } from './agentNames.js'
import { nameBranchAfterSession } from './branchNaming.js'
import { prepareGitProject, readGitProject, validGitPath } from './gitProject.js'
import { parseProjectFolder, prepareProjectFolder } from './projectFolder.js'
import { engineSessionTitle, namingTitle } from './sessionTitle.js'
const exec = promisify(execFile)
// Real Git, several worktrees a test: slower than the default 5s under a full, parallel run.
describe('launch Git preparation', { timeout: 30_000 }, () => {
let root: string, repo: string
const git = async (...args: string[]) => (await exec('git', ['-C', repo, ...args])).stdout.trim()
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'harness-git-test-'))
repo = join(root, 'project with spaces')
await mkdir(repo)
await git('init', '-b', 'main')
await git('config', 'user.name', 'Test')
await git('config', 'user.email', 'test@example.invalid')
await git('config', 'commit.gpgsign', 'false')
await git('config', 'core.hooksPath', '/dev/null')
await mkdir(join(repo, 'src'))
await writeFile(join(repo, 'src', 'value'), 'main')
await git('add', '.')
await git('commit', '-m', 'initial')
await git('switch', '-c', 'feature')
await writeFile(join(repo, 'src', 'value'), 'feature')
await git('commit', '-am', 'feature')
await git('update-ref', 'refs/remotes/origin/feature', 'HEAD')
await git('symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/feature')
await git('switch', 'main')
})
afterEach(async () => { vi.unstubAllEnvs(); await rm(root, { recursive: true, force: true }) })
const options = () => ({ root: join(root, 'harnesses'), label: 'Codex', now: () => new Date(2026, 8, 21, 12, 0) })
const prepare = (source: 'worktree' | 'branch', branchRef = 'refs/heads/feature', path = repo, extra: Record<string, unknown> = {}) =>
prepareProjectFolder(parseProjectFolder({ projectSource: source, gitSource: path, branchRef, ...extra })!, options())
const worktrees = () => join(root, 'harnesses', 'worktrees', 'project with spaces')
const current = async (path: string) => (await exec('git', ['-C', path, 'branch', '--show-current'])).stdout.trim()
it('encrypts Git metadata requests and replies', () => {
expect(encryptDownFrame('git_project_info')).toBe(true)
expect(encryptRpcResult('git_project_info_result')).toBe(true)
})
it('never runs a program the repository names as its fsmonitor', async () => {
const marker = join(root, 'fsmonitor-ran')
const hook = join(root, 'fsmonitor.sh')
await writeFile(hook, `#!/bin/sh\ntouch '${marker}'\n`, { mode: 0o755 })
await git('config', 'core.fsmonitor', hook)
await readGitProject(repo)
await prepare('branch', 'refs/heads/feature')
await expect(readFile(marker)).rejects.toThrow()
})
it('reads local and remote branches without switching or creating anything', async () => {
expect(await readGitProject(repo)).toMatchObject({ isGit: true, branch: 'main', branches: [
{ ref: 'refs/heads/feature', name: 'feature', remote: false },
{ ref: 'refs/heads/main', name: 'main', remote: false },
{ ref: 'refs/remotes/origin/feature', name: 'origin/feature', remote: true },
] })
expect(await git('branch', '--show-current')).toBe('main')
expect(await git('worktree', 'list', '--porcelain')).not.toContain('harness/')
expect(await readGitProject(root)).toMatchObject({ isGit: false })
expect(await readGitProject('relative/path')).toEqual({ error: 'INVALID_PATH' })
})
it('discovers a newly pushed branch without fetching objects, then fetches only on Start', async () => {
const remote = join(root, 'remote.git')
const clone = join(root, 'other computer')
await git('clone', '--bare', repo, remote)
await git('clone', '--no-local', '--single-branch', '--branch', 'main', remote, clone)
const there = async (...args: string[]) => (await exec('git', ['-C', clone, ...args])).stdout.trim()
await git('remote', 'add', 'origin', remote)
await git('switch', '-c', 'feat/toolbar-onboarding')
await writeFile(join(repo, 'src', 'value'), 'new remote work')
await git('commit', '-am', 'new work')
await git('push', 'origin', 'feat/toolbar-onboarding')
await writeFile(join(clone, 'src', 'value'), 'unsaved local work')
const refs = await there('show-ref')
expect((await readGitProject(clone) as any).branches.map((b: any) => b.name)).not.toContain('origin/feat/toolbar-onboarding')
const info = await readGitProject(clone, { refresh: true })
expect(info).toMatchObject({ refreshed: true, branch: 'main', branches: expect.arrayContaining([
{ ref: 'refs/remotes/origin/feat/toolbar-onboarding', name: 'origin/feat/toolbar-onboarding', remote: true },
]) })
expect(await there('show-ref')).toBe(refs)
expect(await readFile(join(clone, 'src', 'value'), 'utf8')).toBe('unsaved local work')
const path = await prepare('worktree', 'refs/remotes/origin/feat/toolbar-onboarding', clone,
{ branchName: 'feat/toolbar-onboarding' })
expect(await readFile(join(path, 'src', 'value'), 'utf8')).toBe('new remote work')
expect(await there('branch', '--show-current')).toBe('main')
})
it('keeps saved branches from unreachable remotes and refreshes the others', async () => {
const remote = join(root, 'remote.git')
await git('clone', '--bare', repo, remote)
await git('remote', 'add', 'origin', join(root, 'missing.git'))
await git('remote', 'add', 'upstream', remote)
const info = await readGitProject(repo, { refresh: true })
expect(info).toMatchObject({ refreshed: false, branches: expect.arrayContaining([
{ ref: 'refs/remotes/origin/feature', name: 'origin/feature', remote: true },
{ ref: 'refs/remotes/upstream/main', name: 'upstream/main', remote: true },
]) })
expect(await git('branch', '--show-current')).toBe('main')
})
it('removes deleted remote choices after a successful lookup without changing saved refs', async () => {
const remote = join(root, 'remote.git')
await git('clone', '--bare', repo, remote)
await git('remote', 'add', 'origin', remote)
await exec('git', ['-C', remote, 'branch', '-D', 'feature'])
const info = await readGitProject(repo, { refresh: true }) as { branches: Array<{ name: string }> }
expect(info.branches.map(b => b.name)).toContain('feature')
expect(info.branches.map(b => b.name)).not.toContain('origin/feature')
expect(await git('show-ref', '--verify', 'refs/remotes/origin/feature')).toBeTruthy()
})
it('starts concurrent worktrees on distinct branches from the chosen ref and preserves dirty source files', async () => {
await writeFile(join(repo, 'src', 'value'), 'my uncommitted work')
const paths = await Promise.all([prepare('worktree'), prepare('worktree', 'refs/remotes/origin/feature')])
expect(new Set(paths).size).toBe(2)
const branches = []
for (const path of paths) {
expect(await readFile(join(path, 'src', 'value'), 'utf8')).toBe('feature')
const branch = (await exec('git', ['-C', path, 'branch', '--show-current'])).stdout.trim()
expect(branch).toMatch(/^[a-z]+-[a-z]+(-\d+)?$/)
branches.push(branch)
}
expect(new Set(branches).size).toBe(2)
expect(await readFile(join(repo, 'src', 'value'), 'utf8')).toBe('my uncommitted work')
expect(await git('branch', '--show-current')).toBe('main')
})
it('makes up a two-word branch no branch uses, in a folder named for it, grouped by repository', async () => {
expect(placeholderBranch([], () => 0)).toBe('amber-badger')
expect(placeholderBranch(['refs/heads/amber-badger', 'amber-badger-2'], () => 0)).toBe('amber-badger-3')
expect(worktreeFolderName('deehw/brave-otter')).toBe('brave-otter')
expect(worktreeFolderName('fix/login page')).toBe('loginpage')
// Two words when both carry the meaning.
expect(sessionBranchNames('Worktree and branches organization')).toEqual(['worktree-branches', 'worktree-branches-organization'])
expect(sessionBranchNames('Fix the harness list order')).toEqual(['harness-list', 'harness-list-order'])
expect(sessionBranchNames('Add dark mode to settings')).toEqual(['dark-mode', 'dark-mode-settings'])
expect(sessionBranchNames('Review and fix flaky tests')).toEqual(['flaky-tests'])
expect(sessionBranchNames('Portable harnesses, launch models')).toEqual(['portable-harnesses', 'portable-harnesses-launch'])
expect(sessionBranchNames('Release 0.3.1 notes')).toEqual(['release-notes'])
expect(sessionBranchNames('Café résumé')).toEqual(['cafe-resume'])
// One word when the second is generic, the two-word name kept for a clash.
expect(sessionBranchNames('✳ Fix: the login page — redirects twice?')).toEqual(['login', 'login-page', 'login-page-redirects'])
expect(sessionBranchNames('Onboarding experience')).toEqual(['onboarding', 'onboarding-experience'])
expect(sessionBranchNames('UI polish')).toEqual(['ui', 'ui-polish'])
// One word when that is all the title has.
expect(sessionBranchNames('Onboarding')).toEqual(['onboarding'])
expect(sessionBranchNames('Fix')).toEqual(['fix'])
expect(sessionBranchNames('0.3.1')).toEqual(['0-3', '0-3-1'])
expect(sessionBranchNames('a'.repeat(30) + ' ' + 'b'.repeat(30))).toEqual(['a'.repeat(24) + '-' + 'b'.repeat(24)])
// Real session titles from Claude Code, Codex and this repository's PRs (2026-09-25).
for (const [title, name] of [
['Catch up on autonomous-grid', 'autonomous-grid'],
['ok catch up on this landing page. we', 'landing'],
['Look at my Chrome. Open the file, au', 'chrome'],
['What time is it', 'time'],
['Build simple Pacman game', 'pacman-game'],
['Respond to greeting', 'greeting'],
['Define GPU Pod concept', 'gpu-pod'],
['Research roleplay app names', 'roleplay-app'],
['Device stuck issue', 'device-stuck'],
['Harness landing page redesign', 'harness-landing'],
['Review inventory protection', 'inventory-protection'],
['Remove Grid desktop app', 'grid-desktop'],
['feat(cli): add a Requesty preset to saved APIs', 'requesty-preset'],
['perf(desktop): redraw only terminal lines that changed', 'redraw-terminal'],
['fix(cli): unlink a linked dsh on remove instead of rmSync', 'unlink-linked'],
['feat(harnesses): add eight interactive experiences', 'interactive-experiences'],
['feat(login): record whether a sign-in came from the terminal', 'sign-in'],
['Study autonomous-code repo', 'autonomous-code'],
['8-bit CPU Fibonacci on iCEBreaker', '8-bit'],
['Restore split-right and split-down pane controls', 'split-right'],
['App auto-opening extra tabs', 'app-auto'],
['Explore print-in-place uses', 'print-place'],
['desktop: an on-screen banner for the same two moments', 'desktop'],
['Harness on-off switch', 'harness-switch'],
['Simplify Cmd-P with single-line results', 'cmd-p'],
['Give Harness Monitor its stacked-terminal identity', 'harness-monitor'],
['ci: CI and release workflows', 'ci-release'],
['catch up', 'catch'],
] as const) expect(sessionBranchNames(title)[0], title).toBe(name)
expect(sessionBranchNames('The and of')).toEqual([])
expect(sessionBranchNames('✳ ✳')).toEqual([])
expect(sessionBranchNames(null)).toEqual([])
const made = [await prepare('worktree'), await prepare('worktree')]
expect(new Set(made).size).toBe(2)
for (const path of made) {
const branch = await current(path)
expect(branch).toMatch(/^[a-z]+-[a-z]+(-\d+)?$/)
expect(path).toBe(join(worktrees(), worktreeFolderName(branch)))
expect(await git('config', '--get', `branch.${branch}.harness`)).toBe('placeholder')
}
})
it('creates a named branch once, and refuses names Git would not take', async () => {
const named = await prepare('worktree', 'refs/heads/feature', repo, { branchName: 'fix/login' })
expect(named).toBe(join(worktrees(), 'login'))
expect(await current(named)).toBe('fix/login')
expect(await git('config', '--get', 'branch.fix/login.harness')).toBe('created')
const made = await prepare('worktree', 'refs/heads/feature', repo, { branchName: 'quiet-owl', branchMode: 'placeholder' })
expect(await current(made)).toBe('quiet-owl')
expect(await git('config', '--get', 'branch.quiet-owl.harness')).toBe('placeholder')
const info = await readGitProject(repo) as { branches: Array<{ name: string; harness?: true }> }
expect(info.branches.filter(b => b.harness).map(b => b.name).sort()).toEqual(['fix/login', 'quiet-owl'])
await expect(prepare('worktree', 'refs/heads/feature', repo, { branchName: 'fix/login' })).rejects.toMatchObject({ code: 'BRANCH_EXISTS' })
await expect(prepare('worktree', 'refs/heads/feature', repo, { branchName: 'bad..name' })).rejects.toMatchObject({ code: 'INVALID_BRANCH' })
for (const extra of [{ branchName: '-x' }, { branchName: 'a b' }, { branchName: 'ok', branchMode: 'other' }]) {
expect(() => parseProjectFolder({ projectSource: 'worktree', gitSource: repo, ...extra })).toThrow()
}
})
it('makes a new branch for the folder itself, keeping its uncommitted work, and refuses one that exists', async () => {
await writeFile(join(repo, 'src', 'value'), 'in progress')
expect(await prepare('branch', 'refs/heads/login-fix', repo, { branchName: 'login-fix' })).toBe(repo)
expect(await git('branch', '--show-current')).toBe('login-fix')
expect(await readFile(join(repo, 'src', 'value'), 'utf8')).toBe('in progress')
await expect(prepare('branch', 'refs/heads/feature', repo, { branchName: 'feature' })).rejects.toMatchObject({ code: 'BRANCH_EXISTS' })
expect(() => parseProjectFolder({ projectSource: 'branch', gitSource: repo, branchRef: 'refs/heads/other', branchName: 'login-fix' })).toThrow()
})
it('names a made-up worktree branch after its session once, and never a pushed or chosen one', async () => {
const path = await prepare('worktree', 'refs/heads/feature', repo, { branchName: 'quiet-owl', branchMode: 'placeholder' })
expect(await nameBranchAfterSession(path, null)).toBeNull()
expect(await nameBranchAfterSession(path, 'Worktree and branches organization')).toBe('worktree-branches')
expect(await current(path)).toBe('worktree-branches')
expect(await git('config', '--get', 'branch.worktree-branches.harness')).toBe('created')
expect(await nameBranchAfterSession(path, 'A later name')).toBeNull()
expect(await current(path)).toBe('worktree-branches')
// Taken, with no third word to add: a number.
const second = await prepare('worktree', 'refs/heads/feature', repo, { branchName: 'calm-fox', branchMode: 'placeholder' })
expect(await nameBranchAfterSession(second, 'Fix the worktree branches')).toBe('worktree-branches-2')
// A clash takes the fuller name, then a third word, then numbers the shortest.
const named = async (title: string, placeholders: string[]) => {
const out = []
for (const branchName of placeholders) {
const at = await prepare('worktree', 'refs/heads/feature', repo, { branchName, branchMode: 'placeholder' })
out.push(await nameBranchAfterSession(at, title))
}
return out
}
expect(await named('Fix the login page', ['keen-lynx', 'tidy-heron', 'rosy-finch']))
.toEqual(['login', 'login-page', 'login-2'])
expect(await named('Harness monitor DDOS requests', ['warm-ibis', 'glad-crane', 'bold-lark']))
.toEqual(['harness-monitor', 'harness-monitor-ddos', 'harness-monitor-2'])
// A repository's own names are never taken, whatever the title says.
// This repository has no `master`: only the reserved list keeps a session from taking it.
expect(await git('branch', '--list', 'master')).toBe('')
expect(await named('Master', ['sunny-moose'])).toEqual(['master-2'])
// Case does not tell names apart: on macOS `Deploy` and `deploy` are one ref file.
await git('update-ref', 'refs/remotes/origin/Deploy', 'main')
expect(await named('Deploy', ['noble-reef'])).toEqual(['deploy-2'])
// A name a remote has is taken too.
const third = await prepare('worktree', 'refs/heads/feature', repo, { branchName: 'quiet-fox', branchMode: 'placeholder' })
await git('update-ref', 'refs/remotes/origin/onboarding', 'main')
expect(await nameBranchAfterSession(third, 'Onboarding experience')).toBe('onboarding-experience')
const chosen = await prepare('worktree', 'refs/heads/feature', repo, { branchName: 'fix/mine' })
expect(await nameBranchAfterSession(chosen, 'Anything')).toBeNull()
const pushed = await prepare('worktree', 'refs/heads/feature', repo, { branchName: 'sunny-owl', branchMode: 'placeholder' })
await git('config', 'branch.sunny-owl.remote', 'origin')
expect(await nameBranchAfterSession(pushed, 'Anything')).toBeNull()
expect(await current(pushed)).toBe('sunny-owl')
})
it('waits through Codex rename statuses before naming the worktree after its conversation', async () => {
const path = await prepare('worktree', 'refs/heads/feature', repo, { branchName: 'quiet-owl', branchMode: 'placeholder' })
const session = { engine: 'codex', sessionId: 'naming-test', codexHome: join(root, 'codex'), cwd: path }
await writeFile(join(path, 'src', 'value'), 'work in progress')
for (const status of ['Starting | quiet-owl', 'renaming... ⠹', 'renaming… ⠴']) {
const title = namingTitle(engineSessionTitle(session, status), session)
expect(await nameBranchAfterSession(path, title)).toBeNull()
expect(await current(path)).toBe('quiet-owl')
expect(await git('config', '--get', 'branch.quiet-owl.harness')).toBe('placeholder')
}
await mkdir(session.codexHome)
await writeFile(join(session.codexHome, 'session_index.jsonl'), JSON.stringify({
id: session.sessionId, thread_name: 'Discuss configurable harness agents',
}) + '\n')
const title = namingTitle(engineSessionTitle(session, 'renaming... ⠴'), session)
expect(await nameBranchAfterSession(path, title)).toBe('configurable-harness')
expect(await current(path)).toBe('configurable-harness')
expect(await git('config', '--get', 'branch.configurable-harness.harness')).toBe('created')
expect(await git('rev-parse', 'configurable-harness')).toBe(await git('rev-parse', 'feature'))
expect(await readFile(join(path, 'src', 'value'), 'utf8')).toBe('work in progress')
expect(await nameBranchAfterSession(path, 'A later conversation title')).toBeNull()
expect(await current(path)).toBe('configurable-harness')
})
it('checks out an existing branch as it is in a new worktree, once', async () => {
await git('branch', 'topic', 'main')
const path = await prepare('worktree', 'refs/heads/topic', repo, { branchName: 'topic', branchMode: 'existing' })
expect(path).toBe(join(worktrees(), 'topic'))
expect(await current(path)).toBe('topic')
expect(await readFile(join(path, 'src', 'value'), 'utf8')).toBe('main')
await expect(prepare('worktree', 'refs/heads/topic', repo, { branchName: 'topic', branchMode: 'existing' }))
.rejects.toMatchObject({ code: 'BRANCH_IN_USE' })
})
it('fetches a remote base first, reports the default, and tracks a remote branch under its own name', async () => {
const origin = join(root, 'origin.git'), upstream = join(root, 'upstream')
await exec('git', ['clone', '--quiet', '--bare', repo, origin])
await git('remote', 'add', 'origin', origin)
await git('fetch', '--quiet', 'origin')
await git('remote', 'set-head', 'origin', 'main')
expect(await readGitProject(repo)).toMatchObject({ defaultRef: 'refs/remotes/origin/main' })
await exec('git', ['clone', '--quiet', origin, upstream])
const up = (...args: string[]) => exec('git', ['-C', upstream, '-c', 'user.name=Test', '-c', 'user.email=test@example.invalid', '-c', 'commit.gpgsign=false', '-c', 'core.hooksPath=/dev/null', ...args])
await writeFile(join(upstream, 'src', 'value'), 'pushed')
await up('commit', '-qam', 'pushed')
await up('push', '--quiet', 'origin', 'main', 'main:fix/typo')
await git('fetch', '--quiet', 'origin', 'fix/typo:refs/remotes/origin/fix/typo')
const fresh = await prepare('worktree', 'refs/remotes/origin/main', repo, { branchName: 'harness/fresh' })
expect(await readFile(join(fresh, 'src', 'value'), 'utf8')).toBe('pushed')
await expect(exec('git', ['-C', fresh, 'rev-parse', '--abbrev-ref', '@{upstream}'])).rejects.toThrow()
// A local branch starts from the newer of itself and its upstream.
await git('branch', '--set-upstream-to=origin/main', 'main')
await git('update-ref', 'refs/remotes/origin/main', 'main')
const behind = await prepare('worktree', 'refs/heads/main', repo, { branchName: 'from-behind' })
expect(await readFile(join(behind, 'src', 'value'), 'utf8')).toBe('pushed')
await writeFile(join(repo, 'src', 'value'), 'local')
await git('-c', 'user.name=Test', '-c', 'user.email=test@example.invalid', 'commit', '-qam', 'local')
const ahead = await prepare('worktree', 'refs/heads/main', repo, { branchName: 'from-ahead' })
expect(await readFile(join(ahead, 'src', 'value'), 'utf8')).toBe('local')
const tracking = await prepare('worktree', 'refs/remotes/origin/fix/typo', repo, { branchName: 'fix/typo' })
expect((await exec('git', ['-C', tracking, 'rev-parse', '--abbrev-ref', '@{upstream}'])).stdout.trim()).toBe('origin/fix/typo')
})
it('copies ignored files named in .worktreeinclude into new worktrees', async () => {
await writeFile(join(repo, '.gitignore'), '.env\n*.log\nlocal/\n')
await writeFile(join(repo, '.worktreeinclude'), '.env\nlocal/\n')
await writeFile(join(repo, '.env'), 'SECRET=1')
await writeFile(join(repo, 'debug.log'), 'noise')
await mkdir(join(repo, 'local'))
await writeFile(join(repo, 'local', 'settings.json'), '{}')
const path = await prepare('worktree')
expect(await readFile(join(path, '.env'), 'utf8')).toBe('SECRET=1')
expect(await readFile(join(path, 'local', 'settings.json'), 'utf8')).toBe('{}')
await expect(readFile(join(path, 'debug.log'))).rejects.toThrow()
})
it('reads a linked worktree as its repository, and a worktree started from one joins the same repository', async () => {
const linked = await prepare('worktree', 'refs/heads/feature', repo, { branchName: 'harness/linked' })
const info = await readGitProject(join(linked, 'src'))
expect(info).toMatchObject({ isGit: true, branch: 'harness/linked', mainBranch: 'main' })
expect(await realpath((info as { mainFolder: string }).mainFolder)).toBe(await realpath(join(repo, 'src')))
const branches = (info as { branches: Array<{ name: string; worktree?: string }> }).branches
expect(await realpath(branches.find(branch => branch.name === 'harness/linked')!.worktree!)).toBe(await realpath(linked))
expect(await realpath(branches.find(branch => branch.name === 'main')!.worktree!)).toBe(await realpath(repo))
expect(await readGitProject(repo)).not.toHaveProperty('mainFolder')
expect(await prepare('worktree', 'refs/heads/main', linked, { branchName: 'harness/second' })).toBe(join(worktrees(), 'second'))
})
it('keeps the selected subfolder in its new worktree', async () => {
const path = await prepare('worktree', 'refs/heads/feature', join(repo, 'src'))
expect(await readFile(join(path, 'value'), 'utf8')).toBe('feature')
expect(path.endsWith('/src/')).toBe(false)
})
it('switches the shared folder only when requested, without forcing conflicting changes', async () => {
expect(await prepare('branch')).toBe(repo)
expect(await git('branch', '--show-current')).toBe('feature')
await writeFile(join(repo, 'src', 'value'), 'keep this')
await expect(prepare('branch', 'refs/heads/main')).rejects.toMatchObject({ code: 'BRANCH_SWITCH_FAILED' })
expect(await git('branch', '--show-current')).toBe('feature')
expect(await readFile(join(repo, 'src', 'value'), 'utf8')).toBe('keep this')
expect(await git('stash', 'list')).toBe('')
// Selecting the current branch is a no-op even with dirty files.
expect(await prepare('branch')).toBe(repo)
await git('checkout', '--', '.')
await writeFile(join(repo, 'notes.txt'), 'untracked')
await expect(prepare('branch', 'refs/heads/main')).rejects.toMatchObject({ code: 'BRANCH_SWITCH_FAILED' })
expect(await git('branch', '--show-current')).toBe('feature')
})
it('opens a branch checked out elsewhere in its worktree, and refuses missing refs, revision expressions, and untracked subfolders', async () => {
await git('worktree', 'add', join(root, 'other'), 'feature')
expect(await realpath(await prepare('branch'))).toBe(await realpath(join(root, 'other')))
expect(await realpath(await prepare('branch', 'refs/heads/feature', join(repo, 'src')))).toBe(await realpath(join(root, 'other', 'src')))
expect(await git('branch', '--show-current')).toBe('main')
for (const ref of ['refs/heads/missing', 'refs/heads/main~0', 'refs/heads/main^{commit}']) {
await expect(prepare('worktree', ref)).rejects.toMatchObject({ code: 'GIT_PROJECT_UNAVAILABLE' })
}
await mkdir(join(repo, 'untracked'))
await expect(prepare('worktree', 'refs/heads/main', join(repo, 'untracked'))).rejects.toMatchObject({ code: 'GIT_PROJECT_UNAVAILABLE' })
expect(() => parseProjectFolder({ projectSource: 'branch', gitSource: repo, branchRef: 'refs/remotes/origin/feature' })).toThrow()
})
it('detects an empty repository but refuses a worktree until its first commit', async () => {
const empty = join(root, 'empty')
await mkdir(empty)
await exec('git', ['-C', empty, 'init', '-b', 'main'])
expect(await readGitProject(empty)).toMatchObject({ isGit: true, branch: 'main', branches: [] })
await expect(prepareProjectFolder({ source: 'worktree', gitSource: empty }, options()))
.rejects.toMatchObject({ code: 'GIT_PROJECT_UNAVAILABLE' })
})
it('validates source paths and requires an exact local branch when isolation is disabled', async () => {
for (const path of [null, 7, '', 'relative', '/bad\npath', '/bad\0path', `/${'x'.repeat(4096)}`]) {
expect(validGitPath(path)).toBe(false)
expect(() => parseProjectFolder({ projectSource: 'worktree', gitSource: path })).toThrow()
}
await expect(prepareGitProject('relative', { ...options(), worktree: true }))
.rejects.toMatchObject({ code: 'INVALID_PROJECT_SOURCE' })
await expect(prepareGitProject(repo, { ...options(), worktree: true, ref: '--help' }))
.rejects.toMatchObject({ code: 'GIT_PROJECT_UNAVAILABLE' })
for (const ref of [undefined, 'refs/remotes/origin/feature']) {
await expect(prepareGitProject(repo, { ...options(), worktree: false, ref }))
.rejects.toMatchObject({ code: 'INVALID_BRANCH' })
}
for (const payload of [
{ branchRef: 7 }, { branchRef: 'refs/heads/bad name' },
{ branchRef: `refs/heads/${'x'.repeat(1024)}` }, { repositoryUrl: 'https://github.com/a/b' },
]) {
expect(() => parseProjectFolder({ projectSource: 'worktree', gitSource: repo, ...payload })).toThrow()
}
})
it('supports detached HEAD, default worktree names, and switching back to a local branch', async () => {
await git('checkout', '--detach', 'HEAD')
expect(await readGitProject(repo)).toMatchObject({ isGit: true, branch: null })
const path = await prepareGitProject(repo, { root, worktree: true })
expect(await readFile(join(path, 'src', 'value'), 'utf8')).toBe('main')
expect(path).toMatch(/\/worktrees\/project with spaces\/[a-z]+-[a-z]+(-\d+)?$/)
expect(await prepare('branch')).toBe(repo)
expect(await git('branch', '--show-current')).toBe('feature')
})
it('distinguishes an unavailable Git executable from a non-Git folder', async () => {
vi.stubEnv('PATH', join(root, 'missing-binaries'))
expect(await readGitProject(repo)).toEqual({ error: 'GIT_UNAVAILABLE' })
})
it('reports unreadable refs instead of silently treating the repository as non-Git', async () => {
await writeFile(join(repo, '.git', 'packed-refs'), 'invalid packed refs\n')
expect(await readGitProject(repo)).toEqual({ error: 'GIT_UNAVAILABLE' })
})
it('rejects a subfolder that became a file on the selected branch', async () => {
await git('switch', 'feature')
await git('rm', '-r', 'src')
await writeFile(join(repo, 'src'), 'a file now')
await git('add', 'src')
await git('commit', '-m', 'replace folder with file')
await git('switch', 'main')
await expect(prepare('worktree', 'refs/heads/feature', join(repo, 'src')))
.rejects.toMatchObject({ code: 'GIT_PROJECT_UNAVAILABLE' })
})
it('keeps a worktree created before a checkout hook fails so the user can recover it', async () => {
const hooks = join(root, 'hooks')
await mkdir(hooks)
await writeFile(join(hooks, 'post-checkout'), '#!/bin/sh\nexit 1\n', { mode: 0o755 })
await git('config', 'core.hooksPath', hooks)
await expect(prepare('worktree')).rejects.toMatchObject({ code: 'WORKTREE_FAILED' })
const linked = (await git('worktree', 'list', '--porcelain')).split('\n')
.find(line => line.startsWith('worktree ') && line.includes('/worktrees/'))!.slice(9)
expect(await readFile(join(linked, 'src', 'value'), 'utf8')).toBe('feature')
expect(await git('branch', '--show-current')).toBe('main')
})
})