Ctrl K
ComunidadGuías…

test/worktree-retry.test.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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
    // `/<name>`, 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);
  });
});