Ctrl K
ComunidadGuías…

test/worktree-start-point.test.js

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

Archivo original ↗Preguntar a KiBot
import { describe, it, expect } from 'vitest';
import { mkdtempSync, writeFileSync, readFileSync, existsSync } from 'fs';
import { execFileSync } from 'child_process';
import { tmpdir } from 'os';
import { join } from 'path';
import { createWorktree, resolveBaseBranch } from '../server/git.js';

const git = (repo, ...args) => execFileSync('git', ['-C', repo, ...args], { encoding: 'utf8' });

// A clone whose local base branch is one commit BEHIND the remote, which is the
// normal state of a checkout you have not pulled today.
function staleClone() {
  const root = mkdtempSync(join(tmpdir(), 'a007-sp-'));
  const bare = join(root, 'remote.git');
  execFileSync('git', ['init', '-q', '--bare', bare]);
  const repo = join(root, 'repo');
  execFileSync('git', ['clone', '-q', bare, repo], { stdio: 'ignore' });
  git(repo, 'config', 'user.name', 'bill10');
  git(repo, 'config', 'user.email', 'b@b');
  writeFileSync(join(repo, 'f.txt'), 'v1');
  git(repo, 'add', '-A');
  git(repo, 'commit', '-q', '-m', 'commit A');
  // Explicit, so the test does not inherit init.defaultBranch (master on CI).
  git(repo, 'branch', '-M', 'main');
  git(repo, 'push', '-q', '-u', 'origin', 'main');
  // Point the bare repo's HEAD at main. `git init --bare` sets it from
  // init.defaultBranch, which is still master on CI runners, so without this a
  // fresh clone lands on an unborn master instead of main. A real remote's HEAD
  // tracks its default branch, so this also makes the fixture realistic — and
  // it is what resolveBaseBranch reads first.
  execFileSync('git', ['-C', bare, 'symbolic-ref', 'HEAD', 'refs/heads/main']);

  // Someone else lands a commit on the remote.
  const other = join(root, 'other');
  execFileSync('git', ['clone', '-q', bare, other], { stdio: 'ignore' });
  git(other, 'config', 'user.name', 'other');
  git(other, 'config', 'user.email', 'o@o');
  writeFileSync(join(other, 'f.txt'), 'v2');
  git(other, 'commit', '-q', '-am', 'commit B');
  git(other, 'push', '-q', 'origin', 'main');

  return { root, repo, bare };
}

describe('what a new agent branch starts from', () => {
  it('branches from the remote base, not from a stale local base', async () => {
    // `worktree add -b` with no start-point uses HEAD, so an un-pulled checkout
    // silently started every agent on superseded code.
    const { repo, bare } = staleClone();
    // Precondition: local main is behind what is actually on the remote. Note
    // the local `origin/main` ref is ALSO stale here — nothing has fetched yet,
    // which is exactly the situation the fetch inside createWorktree exists for.
    const remoteHead = execFileSync('git', ['-C', bare, 'rev-parse', 'main'], { encoding: 'utf8' }).trim();
    expect(git(repo, 'rev-parse', 'main').trim()).not.toBe(remoteHead);

    const result = await createWorktree(repo, 'Viper');
    expect(result.error).toBeUndefined();
    expect(readFileSync(join(result.worktreePath, 'f.txt'), 'utf8')).toBe('v2');
    expect(result.startPoint).toBe('origin/main');
  });

  it('does not inherit an unrelated branch the user happens to have checked out', async () => {
    // The worse case: the agent's PR would carry the user's half-finished work.
    const { repo } = staleClone();
    git(repo, 'checkout', '-q', '-b', 'wip/experiment');
    writeFileSync(join(repo, 'scratch.txt'), 'half-finished');
    git(repo, 'add', '-A');
    git(repo, 'commit', '-q', '-m', 'unrelated experiment');

    const result = await createWorktree(repo, 'Apex');
    expect(existsSync(join(result.worktreePath, 'scratch.txt'))).toBe(false);
    expect(readFileSync(join(result.worktreePath, 'f.txt'), 'utf8')).toBe('v2');
  });

  it('honours an explicit start-point without second-guessing it', async () => {
    // The escape hatch: "branch off what I am working on".
    const { repo } = staleClone();
    git(repo, 'checkout', '-q', '-b', 'wip/experiment');
    writeFileSync(join(repo, 'scratch.txt'), 'half-finished');
    git(repo, 'add', '-A');
    git(repo, 'commit', '-q', '-m', 'unrelated experiment');

    const result = await createWorktree(repo, 'Onyx', null, { startPoint: 'wip/experiment' });
    expect(result.startPoint).toBe('wip/experiment');
    expect(existsSync(join(result.worktreePath, 'scratch.txt'))).toBe(true);
  });

  it('falls back to the local base branch when there is no remote', async () => {
    const root = mkdtempSync(join(tmpdir(), 'a007-sp-local-'));
    const repo = join(root, 'repo');
    execFileSync('git', ['init', '-q', repo]);
    git(repo, 'config', 'user.name', 'bill10');
    git(repo, 'config', 'user.email', 'b@b');
    git(repo, 'commit', '-q', '--allow-empty', '-m', 'init');
    git(repo, 'branch', '-M', 'main');

    const result = await createWorktree(repo, 'Ghost');
    expect(result.error).toBeUndefined();
    expect(result.startPoint).toBe('main');
  });

  it('still works in a repo whose only branch is neither main nor master', async () => {
    // No base branch resolvable at all -> null start-point -> HEAD, the old
    // behaviour, which is right for a repo like this.
    const root = mkdtempSync(join(tmpdir(), 'a007-sp-odd-'));
    const repo = join(root, 'repo');
    execFileSync('git', ['init', '-q', repo]);
    git(repo, 'config', 'user.name', 'bill10');
    git(repo, 'config', 'user.email', 'b@b');
    git(repo, 'commit', '-q', '--allow-empty', '-m', 'init');
    git(repo, 'branch', '-M', 'trunk-of-our-own');

    const result = await createWorktree(repo, 'Ember');
    expect(result.error).toBeUndefined();
    expect(result.startPoint).toBeNull();
    expect(existsSync(result.worktreePath)).toBe(true);
  });
});

describe('resolveBaseBranch', () => {
  it('prefers the remote HEAD, so a develop/trunk default is handled', async () => {
    const { repo } = staleClone();
    git(repo, 'symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main');
    expect(await resolveBaseBranch(repo)).toBe('main');
  });

  it('falls back to a local main or master', async () => {
    const root = mkdtempSync(join(tmpdir(), 'a007-base-'));
    const repo = join(root, 'repo');
    execFileSync('git', ['init', '-q', repo]);
    git(repo, 'config', 'user.name', 'x');
    git(repo, 'config', 'user.email', 'x@x');
    git(repo, 'commit', '-q', '--allow-empty', '-m', 'i');
    git(repo, 'branch', '-M', 'master');
    expect(await resolveBaseBranch(repo)).toBe('master');
  });

  it('returns null when there is no recognisable base branch', async () => {
    const root = mkdtempSync(join(tmpdir(), 'a007-base-none-'));
    const repo = join(root, 'repo');
    execFileSync('git', ['init', '-q', repo]);
    git(repo, 'config', 'user.name', 'x');
    git(repo, 'config', 'user.email', 'x@x');
    git(repo, 'commit', '-q', '--allow-empty', '-m', 'i');
    git(repo, 'branch', '-M', 'somethingelse');
    expect(await resolveBaseBranch(repo)).toBeNull();
  });
});

describe('the pre-spawn fetch cannot hang a spawn', () => {
  it('fails fast against an unreachable remote instead of prompting', async () => {
    // A private remote with no cached credentials must not park git on a
    // credential prompt nobody can answer. GIT_TERMINAL_PROMPT=0 in gitExec
    // makes it error immediately; the spawn then falls back to local refs.
    const root = mkdtempSync(join(tmpdir(), 'a007-sp-unreachable-'));
    const repo = join(root, 'repo');
    execFileSync('git', ['init', '-q', repo]);
    git(repo, 'config', 'user.name', 'bill10');
    git(repo, 'config', 'user.email', 'b@b');
    git(repo, 'commit', '-q', '--allow-empty', '-m', 'init');
    git(repo, 'branch', '-M', 'main');
    git(repo, 'remote', 'add', 'origin', 'https://127.0.0.1:1/nope/nope.git');

    const started = Date.now();
    const result = await createWorktree(repo, 'Spectre');
    const elapsed = Date.now() - started;

    // The spawn still succeeds, on the local base branch.
    expect(result.error).toBeUndefined();
    expect(result.startPoint).toBe('main');
    expect(existsSync(result.worktreePath)).toBe(true);
    // And it did not sit on the full fetch timeout waiting for a prompt.
    expect(elapsed).toBeLessThan(10000);
  });
});

describe('the "Start from" value is untrusted input', () => {
  function plainRepo() {
    const root = mkdtempSync(join(tmpdir(), 'a007-sp-untrusted-'));
    const repo = join(root, 'repo');
    execFileSync('git', ['init', '-q', repo]);
    git(repo, 'config', 'user.name', 'bill10');
    git(repo, 'config', 'user.email', 'b@b');
    git(repo, 'commit', '-q', '--allow-empty', '-m', 'init');
    git(repo, 'branch', '-M', 'main');
    return repo;
  }

  it('refuses a value that git would parse as an option', async () => {
    // `worktree add <path> -b <name> --detach` consumes the value as a FLAG,
    // not a commit-ish, and a `--` delimiter does not protect this position.
    // A git ref name cannot begin with a dash, so rejecting costs nothing.
    const repo = plainRepo();
    for (const bad of ['--detach', '--force', '-b', '--orphan']) {
      const result = await createWorktree(repo, 'Cobra', null, { startPoint: bad });
      expect(result.error).toMatch(/cannot start with/i);
      expect(result.branchName).toBeUndefined();
    }
  });

  it('refuses a start point that does not exist, naming what was asked for', async () => {
    const repo = plainRepo();
    const result = await createWorktree(repo, 'Mirage', null, { startPoint: 'no-such-branch' });
    expect(result.error).toMatch(/no-such-branch/);
    expect(result.error).toMatch(/not found/i);
  });

  it('accepts a tag or a raw commit sha, not just a branch', async () => {
    const repo = plainRepo();
    git(repo, 'tag', 'v1.0');
    const sha = git(repo, 'rev-parse', 'HEAD').trim();

    const byTag = await createWorktree(repo, 'Dagger', null, { startPoint: 'v1.0' });
    expect(byTag.error).toBeUndefined();
    expect(byTag.startPoint).toBe('v1.0');

    const bySha = await createWorktree(repo, 'Falcon', null, { startPoint: sha });
    expect(bySha.error).toBeUndefined();
    expect(bySha.startPoint).toBe(sha);
  });
});