import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { createWorktree, gitExec } from '../server/git.js'; import { COCKTAILS } from '../lib/helpers.js'; import { mkdtempSync, existsSync, readdirSync } from 'fs'; import { execFileSync } from 'child_process'; import { join, dirname } from 'path'; import { tmpdir } from 'os'; import { removeTempDir } from './temp-dir.js'; // The whole feature, end to end: ask git for a name instead of tracking which // names are free. These run against a real repo because git IS the design — a // test that stubbed it would only be testing the stub. // // Hermetic via AGENT007_WORKTREE_DIR (test/setup.js), which keeps worktrees out of // the developer's live ~/.agent-007/worktrees. describe('createWorktree finds a free branch by trying', () => { let base, repo, agentN; beforeEach(async () => { base = mkdtempSync(join(tmpdir(), 'a007-retry-')); repo = join(base, 'repo'); agentN = 0; await gitExec(['init', '-q', repo]); await gitExec(['-C', repo, 'config', 'user.name', 'Bill Slung']); await gitExec(['-C', repo, 'config', 'user.email', 't@t.com']); await gitExec(['-C', repo, 'commit', '-q', '--allow-empty', '-m', 'init']); }); afterEach(() => { removeTempDir(base); }); // Each call needs its own agent name, since the worktree dir is keyed by it. const spawn = (custom) => createWorktree(repo, `agent${++agentN}`, custom); it('normalizes the git user into the branch prefix', async () => { const r = await spawn(); expect(r.branchName).toBe(`bill-slung/${r.cocktail}`); // "Bill Slung" -> bill-slung expect(COCKTAILS).toContain(r.cocktail); expect(existsSync(r.worktreePath)).toBe(true); }); it('skips a cocktail whose branch already exists', async () => { for (const c of COCKTAILS.slice(0, COCKTAILS.length - 1)) { await gitExec(['-C', repo, 'branch', `bill-slung/${c}`]); } const only = COCKTAILS[COCKTAILS.length - 1]; const r = await spawn(); expect(r.cocktail).toBe(only); // the one name left expect(r.error).toBeUndefined(); }); it('falls back to an ordinal prefix when every cocktail is taken', async () => { for (const c of COCKTAILS) await gitExec(['-C', repo, 'branch', `bill-slung/${c}`]); const r = await spawn(); expect(r.error).toBeUndefined(); expect(r.cocktail).toMatch(/^2nd-/); expect(r.branchName).toBe(`bill-slung/${r.cocktail}`); }); it('hands out distinct names across repeated spawns', async () => { const seen = new Set(); for (let i = 0; i < 5; i++) { const r = await spawn(); expect(r.error).toBeUndefined(); expect(seen.has(r.cocktail)).toBe(false); seen.add(r.cocktail); } }); // Branches made outside the app are exactly what the old bookkeeping missed. it('respects a branch created behind the app\'s back', async () => { const first = await spawn(); await gitExec(['-C', repo, 'branch', `bill-slung/${first.cocktail}-decoy`]); const second = await spawn(); expect(second.cocktail).not.toBe(first.cocktail); }); it('leaves no worktree directory behind for a skipped name', async () => { for (const c of COCKTAILS.slice(0, 5)) { await gitExec(['-C', repo, 'branch', `bill-slung/${c}`]); } const r = await spawn(); expect(r.error).toBeUndefined(); // This repo's worktree root, not whichever dir happens to sort first — the // temp WORKTREE_DIR is shared by every test in this file. expect(readdirSync(dirname(r.worktreePath))).toEqual(['agent1']); // one dir, not one per attempt }); it('finds a safe unused name past 50 historical job branches without deleting any', async () => { const names = Array.from({ length: 65 }, (_, i) => `bill-slung/repeated-job${i ? `-${i + 1}` : ''}`); for (const name of names) await gitExec(['-C', repo, 'branch', name]); const result = await createWorktree(repo, 'board1', 'repeated-job', { suffixOnCollision: true }); expect(result.error).toBeUndefined(); expect(result.branchName).toMatch(/^bill-slung\/repeated-job-[a-f0-9-]{36}$/); await gitExec(['check-ref-format', '--branch', result.branchName]); const branches = (await gitExec(['-C', repo, 'branch', '--format=%(refname:short)'])).split('\n'); for (const name of names) expect(branches).toContain(name); }); it('gives concurrent jobs with the same title unique branches', async () => { const results = await Promise.all(Array.from({ length: 6 }, (_, i) => createWorktree(repo, `board${i}`, 'same-job', { suffixOnCollision: true }))); expect(results.every(r => !r.error)).toBe(true); expect(new Set(results.map(r => r.branchName)).size).toBe(6); }); it('returns non-collision errors without consuming a namespace', async () => { const result = await createWorktree(repo, 'invalid', 'bad..name', { suffixOnCollision: true }); expect(result.error).toMatch(/Failed to create worktree/); expect(result.error).not.toMatch(/Could not find a free/); }); // Adds into one repo take turns; one that fails must not hold up the rest. it('keeps serving concurrent spawns after one of them fails', async () => { const [bad, ...good] = await Promise.all([ createWorktree(repo, 'invalid', 'bad..name'), spawn(), spawn(), ]); expect(bad.error).toMatch(/Failed to create worktree/); for (const r of good) expect(r.error).toBeUndefined(); }); // --- custom branch: report the collision, never silently rename --- it('uses a custom name verbatim', async () => { const r = await spawn('my-feature'); expect(r.branchName).toBe('bill-slung/my-feature'); expect(r.cocktail).toBe('my-feature'); }); it('errors instead of retrying when a custom name is taken', async () => { await gitExec(['-C', repo, 'branch', 'bill-slung/my-feature']); const r = await spawn('my-feature'); expect(r.error).toMatch(/already in use/); expect(r.cocktail).toBeUndefined(); }); // Concurrent spawns are the case the retry design is supposed to settle for // free. Losers must each land on their own name, not error out. it('gives concurrent spawns into one repo distinct names', async () => { const results = await Promise.all( Array.from({ length: 6 }, () => spawn()), ); for (const r of results) expect(r.error).toBeUndefined(); expect(new Set(results.map(r => r.cocktail)).size).toBe(6); expect(new Set(results.map(r => r.branchName)).size).toBe(6); }); // A worktree-path collision says "already exists" too, but no other cocktail // can fix it — retrying would walk the whole pool for nothing. it('does not retry a worktree-path collision', async () => { const first = await spawn(); expect(first.error).toBeUndefined(); const again = await createWorktree(repo, 'agent1', undefined); // same agent name expect(again.error).toMatch(/Failed to create worktree/); expect(again.error).not.toMatch(/already in use/); }); }); describe('branch prefix fallback', () => { it('falls back to "agent" when user.name is set to the empty string', async () => { // `git config user.name` exits 0 with empty output in that case, so it // never reaches branchPrefix's catch. An empty prefix makes every branch // `/`, which git rejects outright. const repo = mkdtempSync(join(tmpdir(), 'a007-emptyuser-')); execFileSync('git', ['init', '-q', repo]); execFileSync('git', ['-C', repo, 'config', 'user.email', 'x@x']); // Commit under a real name first — git refuses to commit with an empty // ident — then blank it, which is the state that reaches branchPrefix. execFileSync('git', ['-C', repo, 'config', 'user.name', 'temp']); execFileSync('git', ['-C', repo, 'commit', '-q', '--allow-empty', '-m', 'init']); execFileSync('git', ['-C', repo, 'config', 'user.name', '']); const result = await createWorktree(repo, 'Viper'); expect(result.error).toBeUndefined(); expect(result.branchName.startsWith('agent/')).toBe(true); }); });