Loading files
4bdf3079fThis 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, symlink, 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()
/** The fixture builds its repos in the temp directory, outside the browsable home, so each read
* names that root — exactly as the daemon names the workspaces its agents are running in. */
const read = (path: string, options: { refresh?: boolean } = {}) =>
readGitProject(path, { ...options, knownRoots: [root] })
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 read(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 read(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 read(root)).toMatchObject({ isGit: false })
expect(await read('relative/path')).toEqual({ error: 'INVALID_PATH' })
})
it('runs git only inside the browsable home and the workspaces it was given', async () => {
// No known roots: the fixture's repo is in the temp directory, outside the home folder.
expect(await readGitProject(repo)).toEqual({ error: 'FORBIDDEN' })
expect(await readGitProject(repo, { knownRoots: [root] })).toMatchObject({ isGit: true })
// Named inside an allowed root, pointing outside it: the real path is what decides.
const outside = await mkdtemp(join(tmpdir(), 'harness-git-outside-'))
const allowed = await mkdtemp(join(tmpdir(), 'harness-git-allowed-'))
try {
await symlink(repo, join(allowed, 'link'))
expect(await readGitProject(join(allowed, 'link'), { knownRoots: [allowed] })).toEqual({ error: 'FORBIDDEN' })
// Gone, not refused: a deleted folder answers the way running git in it always did.
expect(await readGitProject(join(outside, 'gone'), { knownRoots: [outside] })).toEqual({ isGit: false, branches: [] })
} finally {
await rm(outside, { recursive: true, force: true })
await rm(allowed, { recursive: true, force: true })
}
})
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 read(clone) as any).branches.map((b: any) => b.name)).not.toContain('origin/feat/toolbar-onboarding')
const info = await read(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 read(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 read(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 read(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 read(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 read(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 read(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 read(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 read(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 read(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 read(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')
})
})