Ctrl K
ComunidadGuías…

test/worktree-cleanup.test.js

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

Archivo original ↗Preguntar a KiBot
import { describe, it, expect, vi } from 'vitest';
import { mkdtempSync, mkdirSync, existsSync, writeFileSync, rmSync, readdirSync } from 'fs';
import { execFileSync } from 'child_process';
import { tmpdir } from 'os';
import { join } from 'path';
import { removeWorktree, createWorktree, discardWorktree, pruneWorktrees, scanForOrphanedWorktrees, commitsNotInBase } from '../server/git.js';
import { orphans } from '../server/state.js';

// Real git against a real bare remote: this logic is entirely about what git
// reports, so a stubbed test would only be testing the stub. These are the
// paths that DELETE things, so the guards matter more than the happy path.
function repoWithRemote() {
  const root = mkdtempSync(join(tmpdir(), 'a007-cleanup-'));
  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' });
  execFileSync('git', ['-C', repo, 'config', 'user.name', 'bill10']);
  execFileSync('git', ['-C', repo, 'config', 'user.email', 't@t']);
  writeFileSync(join(repo, 'README.md'), 'base');
  execFileSync('git', ['-C', repo, 'add', '-A']);
  execFileSync('git', ['-C', repo, 'commit', '-q', '-m', 'base']);
  // Name the branch explicitly rather than inheriting init.defaultBranch. CI
  // runners still default to `master`, so hard-coding `main` in the push made
  // this fail with "src refspec main does not match any" everywhere but a
  // machine configured like the author's. `branch -M` works on every git
  // version, unlike `init -b`.
  execFileSync('git', ['-C', repo, 'branch', '-M', 'main']);
  execFileSync('git', ['-C', repo, 'push', '-q', '-u', 'origin', 'main']);
  // The bare remote's HEAD still names init.defaultBranch (`master` on CI),
  // so a second clone would check out nothing. Point it at main.
  execFileSync('git', ['-C', bare, 'symbolic-ref', 'HEAD', 'refs/heads/main']);
  return { root, repo };
}

function worktreeOn(repo, root, branch, { commit, push, dirty, from } = {}) {
  const wt = join(root, `wt-${branch.replace(/\//g, '-')}`);
  execFileSync('git', ['-C', repo, 'worktree', 'add', wt, '-b', branch, ...(from ? [from] : [])], { stdio: 'ignore' });
  if (commit) {
    writeFileSync(join(wt, 'work.txt'), 'the job output');
    execFileSync('git', ['-C', wt, 'add', '-A']);
    execFileSync('git', ['-C', wt, 'commit', '-q', '-m', 'job work']);
  }
  if (push) execFileSync('git', ['-C', wt, 'push', '-q', '-u', 'origin', branch]);
  if (dirty) writeFileSync(join(wt, 'scratch.txt'), 'uncommitted');
  return wt;
}

describe('removeWorktree after a job opens its PR', () => {
  it('releases a clean, fully pushed branch — and leaves the PR alone', async () => {
    // This is the state right after `/ship` opens the PR. Before the upstream
    // check existed, `git log main..branch` reported these commits as
    // "unpushed" (they are simply not MERGED), so every finished job left an
    // orphan behind — the exact worktree the board is trying to release.
    const { root, repo } = repoWithRemote();
    const wt = worktreeOn(repo, root, 'bill10/add-a-thing', { commit: true, push: true });

    const result = await removeWorktree({ worktreePath: wt, repoPath: repo, branchName: 'bill10/add-a-thing' });

    expect(result.orphaned).toBe(false);
    expect(existsSync(wt)).toBe(false);
    const locals = execFileSync('git', ['-C', repo, 'branch', '--list'], { encoding: 'utf8' });
    expect(locals).not.toContain('bill10/add-a-thing');
    // The pull request lives on the remote branch. Deleting it would close the
    // PR and throw away the work.
    const remotes = execFileSync('git', ['-C', repo, 'ls-remote', '--heads', 'origin'], { encoding: 'utf8' });
    expect(remotes).toContain('refs/heads/bill10/add-a-thing');
  });

  it('keeps a worktree with uncommitted changes', async () => {
    const { root, repo } = repoWithRemote();
    const wt = worktreeOn(repo, root, 'bill10/dirty', { commit: true, push: true, dirty: true });
    const result = await removeWorktree({ worktreePath: wt, repoPath: repo, branchName: 'bill10/dirty' });
    expect(result).toMatchObject({ orphaned: true, reason: 'uncommitted' });
    expect(existsSync(wt)).toBe(true);
  });

  it('discards uncommitted files when asked, but still keeps unpushed commits', async () => {
    const { root, repo } = repoWithRemote();
    const wt = worktreeOn(repo, root, 'bill10/scratch', { commit: true, push: true, dirty: true });
    const result = await removeWorktree({ worktreePath: wt, repoPath: repo, branchName: 'bill10/scratch' }, { discardChanges: true });
    expect(result).toEqual({ orphaned: false });
    expect(existsSync(wt)).toBe(false);

    const wt2 = worktreeOn(repo, root, 'bill10/scratch-ahead', { commit: true, push: false, dirty: true });
    const result2 = await removeWorktree({ worktreePath: wt2, repoPath: repo, branchName: 'bill10/scratch-ahead' }, { discardChanges: true });
    expect(result2).toMatchObject({ orphaned: true, reason: 'unpushed' });
    expect(existsSync(wt2)).toBe(true);
  });

  it('keeps a branch when the repo has no base branch to compare against', async () => {
    // A remote whose default is neither main nor master, cloned before it had
    // any commits, so origin/HEAD was never written: resolveBaseBranch answers
    // null. With nothing to diff against, the branch could hold commits nobody
    // else has, and `branch -D` would destroy them — even with discardChanges.
    const root = mkdtempSync(join(tmpdir(), 'a007-cleanup-'));
    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' });
    execFileSync('git', ['-C', repo, 'config', 'user.name', 'bill10']);
    execFileSync('git', ['-C', repo, 'config', 'user.email', 't@t']);
    writeFileSync(join(repo, 'README.md'), 'base');
    execFileSync('git', ['-C', repo, 'add', '-A']);
    execFileSync('git', ['-C', repo, 'commit', '-q', '-m', 'base']);
    execFileSync('git', ['-C', repo, 'branch', '-M', 'trunk']);
    execFileSync('git', ['-C', repo, 'push', '-q', '-u', 'origin', 'trunk']);
    const wt = worktreeOn(repo, root, 'bill10/no-base', { commit: true, push: false, dirty: true });
    const result = await removeWorktree({ worktreePath: wt, repoPath: repo, branchName: 'bill10/no-base' }, { discardChanges: true });
    expect(result).toMatchObject({ orphaned: true, reason: 'unpushed' });
    expect(existsSync(wt)).toBe(true);
  });

  it('keeps a branch whose commits never reached the remote', async () => {
    const { root, repo } = repoWithRemote();
    const wt = worktreeOn(repo, root, 'bill10/local-only', { commit: true, push: false });
    const result = await removeWorktree({ worktreePath: wt, repoPath: repo, branchName: 'bill10/local-only' });
    expect(result).toMatchObject({ orphaned: true, reason: 'unpushed' });
    expect(existsSync(wt)).toBe(true);
  });

  it('keeps a branch that is pushed but has drifted ahead of its upstream', async () => {
    // Fully-pushed means HEAD === @{u}. One extra local commit must not read as
    // "safe to delete" just because an upstream exists.
    const { root, repo } = repoWithRemote();
    const wt = worktreeOn(repo, root, 'bill10/ahead', { commit: true, push: true });
    writeFileSync(join(wt, 'more.txt'), 'later work');
    execFileSync('git', ['-C', wt, 'add', '-A']);
    execFileSync('git', ['-C', wt, 'commit', '-q', '-m', 'not pushed yet']);

    const result = await removeWorktree({ worktreePath: wt, repoPath: repo, branchName: 'bill10/ahead' });
    expect(result).toMatchObject({ orphaned: true, reason: 'unpushed' });
    expect(existsSync(wt)).toBe(true);
  });

  it('releases a worktree that produced nothing', async () => {
    const { root, repo } = repoWithRemote();
    const wt = worktreeOn(repo, root, 'bill10/no-op', {});
    const result = await removeWorktree({ worktreePath: wt, repoPath: repo, branchName: 'bill10/no-op' });
    expect(result.orphaned).toBe(false);
    expect(existsSync(wt)).toBe(false);
  });
});

// A second clone of the same remote: the person (or GitHub's merge button)
// moving main on while this machine's checkout sits still.
function elsewhere(root) {
  const other = join(root, `other-${readdirSync(root).length}`);
  execFileSync('git', ['clone', '-q', '-b', 'main', join(root, 'remote.git'), other], { stdio: 'ignore' });
  execFileSync('git', ['-C', other, 'config', 'user.name', 'someone']);
  execFileSync('git', ['-C', other, 'config', 'user.email', 's@s']);
  return other;
}
function commitFile(dir, file, text, message) {
  writeFileSync(join(dir, file), text);
  execFileSync('git', ['-C', dir, 'add', '-A']);
  execFileSync('git', ['-C', dir, 'commit', '-q', '-m', message]);
}
// What a squash-merged PR leaves: the branch's change as one new commit on
// main, and the remote branch deleted (its tracking ref gone with it).
function squashMerge(repo, root, branch) {
  const other = elsewhere(root);
  execFileSync('git', ['-C', other, 'merge', '-q', '--squash', `origin/${branch}`], { stdio: 'ignore' });
  execFileSync('git', ['-C', other, 'commit', '-q', '-m', `squash of ${branch} (#1)`]);
  execFileSync('git', ['-C', other, 'push', '-q', 'origin', 'main']);
  execFileSync('git', ['-C', repo, 'push', '-q', 'origin', '--delete', branch]);
  return other;
}

describe('removeWorktree once the work is on main', () => {
  it('releases a squash-merged branch whose remote branch was deleted', async () => {
    // Every finished card ended here: the PR squash-merged, so none of the
    // branch's own commits is on main, and with the remote branch gone nothing
    // matched upstream either. Its content is on main all the same.
    const { root, repo } = repoWithRemote();
    const wt = worktreeOn(repo, root, 'bill10/squashed', { push: true });
    commitFile(wt, 'a.txt', 'one', 'first');
    commitFile(wt, 'b.txt', 'two', 'second');
    execFileSync('git', ['-C', wt, 'push', '-q']);
    squashMerge(repo, root, 'bill10/squashed');

    // This machine has not fetched since: the check fetches origin/main itself.
    const result = await removeWorktree({ worktreePath: wt, repoPath: repo, branchName: 'bill10/squashed' });
    expect(result).toEqual({ orphaned: false });
    expect(existsSync(wt)).toBe(false);
    expect(execFileSync('git', ['-C', repo, 'branch', '--list'], { encoding: 'utf8' })).not.toContain('bill10/squashed');
  });

  it('releases a squash-merged branch after main has built on the same file', async () => {
    const { root, repo } = repoWithRemote();
    const wt = worktreeOn(repo, root, 'bill10/built-on', { push: true });
    commitFile(wt, 'notes.txt', 'line 1\n', 'first');
    commitFile(wt, 'notes.txt', 'line 1\nline 2\n', 'second');
    execFileSync('git', ['-C', wt, 'push', '-q']);
    const other = squashMerge(repo, root, 'bill10/built-on');
    commitFile(other, 'notes.txt', 'line 0\nline 1\nline 2\n', 'later on main');
    execFileSync('git', ['-C', other, 'push', '-q', 'origin', 'main']);

    const result = await removeWorktree({ worktreePath: wt, repoPath: repo, branchName: 'bill10/built-on' });
    expect(result).toEqual({ orphaned: false });
    expect(existsSync(wt)).toBe(false);
  });

  it('releases a branch whose commits reached main rebased', async () => {
    const { root, repo } = repoWithRemote();
    const wt = worktreeOn(repo, root, 'bill10/rebased', { commit: true });
    const other = elsewhere(root);
    commitFile(other, 'unrelated.txt', 'x', 'someone else first');
    const sha = execFileSync('git', ['-C', wt, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim();
    execFileSync('git', ['-C', other, 'fetch', '-q', repo, 'bill10/rebased']);
    execFileSync('git', ['-C', other, 'cherry-pick', sha], { stdio: 'ignore' });
    execFileSync('git', ['-C', other, 'push', '-q', 'origin', 'main']);
    execFileSync('git', ['-C', repo, 'fetch', '-q', 'origin']);

    const result = await removeWorktree({ worktreePath: wt, repoPath: repo, branchName: 'bill10/rebased' });
    expect(result).toEqual({ orphaned: false });
    expect(existsSync(wt)).toBe(false);
  });

  it('releases a run with no upstream and nothing beyond origin/main, though local main is behind', async () => {
    // The hourly runs: branched from origin/main, no commits, no PR. Counted
    // against the stale local main, origin's newer commits read as the run's.
    const { root, repo } = repoWithRemote();
    const other = elsewhere(root);
    commitFile(other, 'newer.txt', 'x', 'main moves on');
    execFileSync('git', ['-C', other, 'push', '-q', 'origin', 'main']);
    execFileSync('git', ['-C', repo, 'fetch', '-q', 'origin']);
    const wt = worktreeOn(repo, root, 'bill10/hourly', { from: 'origin/main' });
    execFileSync('git', ['-C', repo, 'branch', '--unset-upstream', 'bill10/hourly'], { stdio: 'ignore' });

    const result = await removeWorktree({ worktreePath: wt, repoPath: repo, branchName: 'bill10/hourly' });
    expect(result).toEqual({ orphaned: false });
    expect(existsSync(wt)).toBe(false);
  });

  it('keeps a commit main does not have, even when local main is behind', async () => {
    const { root, repo } = repoWithRemote();
    const other = elsewhere(root);
    commitFile(other, 'newer.txt', 'x', 'main moves on');
    execFileSync('git', ['-C', other, 'push', '-q', 'origin', 'main']);
    execFileSync('git', ['-C', repo, 'fetch', '-q', 'origin']);
    const wt = worktreeOn(repo, root, 'bill10/real-work', { from: 'origin/main', commit: true });

    const result = await removeWorktree({ worktreePath: wt, repoPath: repo, branchName: 'bill10/real-work' });
    expect(result).toMatchObject({ orphaned: true, reason: 'unpushed' });
    expect(existsSync(wt)).toBe(true);
  });

  it('keeps a squash-merged branch with uncommitted files', async () => {
    const { root, repo } = repoWithRemote();
    const wt = worktreeOn(repo, root, 'bill10/merged-dirty', { commit: true, push: true });
    squashMerge(repo, root, 'bill10/merged-dirty');
    writeFileSync(join(wt, 'scratch.txt'), 'not committed');

    const result = await removeWorktree({ worktreePath: wt, repoPath: repo, branchName: 'bill10/merged-dirty' });
    expect(result).toMatchObject({ orphaned: true, reason: 'uncommitted' });
    expect(existsSync(wt)).toBe(true);
  });
});

describe('removeWorktree never mistakes different work for merged work', () => {
  it('keeps a branch whose edit differs from main\'s only in whitespace', async () => {
    // A patch-id ignores whitespace, so "hello world" and "helloworld" made
    // from the same line match by patch-id. The files are not the same.
    const { root, repo } = repoWithRemote();
    commitFile(repo, 'msg.txt', 'message = "old"\n', 'msg');
    execFileSync('git', ['-C', repo, 'push', '-q', 'origin', 'main']);
    const wt = worktreeOn(repo, root, 'bill10/spaced', {});
    commitFile(wt, 'msg.txt', 'message = "hello world"\n', 'branch edit');
    const other = elsewhere(root);
    commitFile(other, 'msg.txt', 'message = "helloworld"\n', 'main edit');
    execFileSync('git', ['-C', other, 'push', '-q', 'origin', 'main']);

    const result = await removeWorktree({ worktreePath: wt, repoPath: repo, branchName: 'bill10/spaced' });
    expect(result).toMatchObject({ orphaned: true, reason: 'unpushed' });
    expect(existsSync(wt)).toBe(true);
  });

  it('keeps a branch even when a repo merge driver would drop its change', async () => {
    // A trial merge runs the repo's merge drivers; `merge=ours` keeps main's
    // side and would make the merge look like main. No merge is tried.
    const { root, repo } = repoWithRemote();
    commitFile(repo, '.gitattributes', 'config.json merge=ours\n', 'attrs');
    commitFile(repo, 'config.json', '{"a":1}\n', 'config');
    execFileSync('git', ['-C', repo, 'push', '-q', 'origin', 'main']);
    execFileSync('git', ['-C', repo, 'config', 'merge.ours.driver', 'true']);
    const wt = worktreeOn(repo, root, 'bill10/driver', {});
    commitFile(wt, 'config.json', '{"a":2}\n', 'branch edit');
    const other = elsewhere(root);
    commitFile(other, 'config.json', '{"a":3}\n', 'main edit');
    execFileSync('git', ['-C', other, 'push', '-q', 'origin', 'main']);

    const result = await removeWorktree({ worktreePath: wt, repoPath: repo, branchName: 'bill10/driver' });
    expect(result).toMatchObject({ orphaned: true, reason: 'unpushed' });
  });

  it('keeps a squash-merged branch once the remote main no longer has the squash', async () => {
    // This machine fetched the squash, then main was force-reset past it. The
    // cached origin/main must not clear the branch: it is fetched first.
    const { root, repo } = repoWithRemote();
    const wt = worktreeOn(repo, root, 'bill10/reset', { commit: true, push: true });
    const other = squashMerge(repo, root, 'bill10/reset');
    execFileSync('git', ['-C', repo, 'fetch', '-q', 'origin']);
    execFileSync('git', ['-C', other, 'push', '-q', '-f', 'origin', 'HEAD~1:main']);

    const result = await removeWorktree({ worktreePath: wt, repoPath: repo, branchName: 'bill10/reset' });
    expect(result).toMatchObject({ orphaned: true, reason: 'unpushed' });
  });

  it('keeps commits made on a detached HEAD in the worktree', async () => {
    // The branch is still at main, but the worktree's HEAD, the thing removal
    // throws away, holds a commit nothing else reaches.
    const { root, repo } = repoWithRemote();
    const wt = worktreeOn(repo, root, 'bill10/detached', { from: 'origin/main' });
    execFileSync('git', ['-C', wt, 'checkout', '-q', '--detach']);
    commitFile(wt, 'precious.txt', 'only here', 'detached work');

    const result = await removeWorktree({ worktreePath: wt, repoPath: repo, branchName: 'bill10/detached' });
    expect(result).toMatchObject({ orphaned: true, reason: 'unpushed' });
    expect(existsSync(wt)).toBe(true);
  });

  it('keeps a squash-merged branch when origin cannot be fetched', async () => {
    const { root, repo } = repoWithRemote();
    const wt = worktreeOn(repo, root, 'bill10/offline', { commit: true, push: true });
    squashMerge(repo, root, 'bill10/offline');
    execFileSync('git', ['-C', repo, 'fetch', '-q', 'origin']);
    execFileSync('git', ['-C', repo, 'remote', 'set-url', 'origin', join(root, 'gone.git')]);

    const result = await removeWorktree({ worktreePath: wt, repoPath: repo, branchName: 'bill10/offline' });
    expect(result).toMatchObject({ orphaned: true, reason: 'unpushed' });
    // The doctor reads local refs only, and there the work is on main.
    expect(await commitsNotInBase({ repoPath: repo, branchName: 'bill10/offline' }, { fetch: false })).toBe(0);
  });
});

describe('commitsNotInBase edges', () => {
  it('cannot decide without a usable branch name or branch ref', async () => {
    const { repo } = repoWithRemote();
    expect(await commitsNotInBase({ repoPath: repo, branchName: '' })).toBe(-1);
    // A name git would read as an option never reaches argv.
    expect(await commitsNotInBase({ repoPath: repo, branchName: '--all' })).toBe(-1);
    expect(await commitsNotInBase({ repoPath: repo, branchName: 'bill10/never-made' })).toBe(-1);
  });

  it('without fetch, a squash merge since the last fetch still counts as ahead', async () => {
    // The doctor's path: local refs only, so the merge shows once something fetches.
    const { root, repo } = repoWithRemote();
    const wt = worktreeOn(repo, root, 'bill10/stale-view', { commit: true, push: true });
    squashMerge(repo, root, 'bill10/stale-view');
    const session = { repoPath: repo, branchName: 'bill10/stale-view' };
    expect(await commitsNotInBase(session, { fetch: false })).toBe(1);
    expect(await commitsNotInBase(session)).toBe(0);
    expect(existsSync(wt)).toBe(true);
  });

  it('counts a branch whose change conflicts with main as not there', async () => {
    const { root, repo } = repoWithRemote();
    const wt = worktreeOn(repo, root, 'bill10/conflict', {});
    commitFile(wt, 'README.md', 'branch version', 'branch edit');
    const other = elsewhere(root);
    commitFile(other, 'README.md', 'main version', 'main edit');
    execFileSync('git', ['-C', other, 'push', '-q', 'origin', 'main']);
    execFileSync('git', ['-C', repo, 'fetch', '-q', 'origin']);
    expect(await commitsNotInBase({ repoPath: repo, branchName: 'bill10/conflict' })).toBe(1);
  });

  it('falls back to the local base branch when the repo has no origin', async () => {
    const root = mkdtempSync(join(tmpdir(), 'a007-cleanup-'));
    const repo = join(root, 'repo');
    execFileSync('git', ['init', '-q', repo]);
    execFileSync('git', ['-C', repo, 'config', 'user.name', 'bill10']);
    execFileSync('git', ['-C', repo, 'config', 'user.email', 't@t']);
    commitFile(repo, 'README.md', 'base', 'base');
    execFileSync('git', ['-C', repo, 'branch', '-M', 'main']);
    execFileSync('git', ['-C', repo, 'branch', 'bill10/empty']);
    expect(await commitsNotInBase({ repoPath: repo, branchName: 'bill10/empty' })).toBe(0);
    const wt = worktreeOn(repo, root, 'bill10/local-work', { commit: true });
    expect(existsSync(wt)).toBe(true);
    expect(await commitsNotInBase({ repoPath: repo, branchName: 'bill10/local-work' })).toBe(1);
  });
});

describe('branch naming against an open PR', () => {
  it('skips a name whose remote branch still exists', async () => {
    // A finished job deletes its LOCAL branch but leaves the remote one — that
    // IS the open PR. Checking only local refs hands the name straight back
    // out, and the next agent fails non-fast-forward on its first push.
    const { root, repo } = repoWithRemote();
    const wt = worktreeOn(repo, root, 'bill10/fix-flaky-test', { commit: true, push: true });
    await removeWorktree({ worktreePath: wt, repoPath: repo, branchName: 'bill10/fix-flaky-test' });

    const result = await createWorktree(repo, 'Viper', 'fix-flaky-test', { suffixOnCollision: true });
    expect(result.error).toBeUndefined();
    expect(result.branchName).toMatch(/^bill10\/fix-flaky-test-[a-f0-9-]{36}$/);
  });

  it('still uses the plain name when the remote is clear', async () => {
    const { repo } = repoWithRemote();
    const result = await createWorktree(repo, 'Apex', 'brand-new-job', { suffixOnCollision: true });
    expect(result.branchName).toBe('bill10/brand-new-job');
  });
});

describe('removeWorktree when the remote-tracking ref is stale', () => {
  // A worker that pushes again (a rebase fix, a force-with-lease) can leave the
  // shared repo's refs/remotes/origin/<branch> on an old SHA, so @{u} resolves
  // but is not HEAD. The remote itself is the authority.
  function staleTracking(branch) {
    const { root, repo } = repoWithRemote();
    const wt = worktreeOn(repo, root, branch, { commit: true, push: true });
    const old = execFileSync('git', ['-C', wt, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim();
    writeFileSync(join(wt, 'fix.txt'), 'review fix');
    execFileSync('git', ['-C', wt, 'add', '-A']);
    execFileSync('git', ['-C', wt, 'commit', '-q', '-m', 'review fix']);
    execFileSync('git', ['-C', wt, 'push', '-q', 'origin', 'HEAD']);
    execFileSync('git', ['-C', repo, 'update-ref', `refs/remotes/origin/${branch}`, old]);
    return { repo, wt, old };
  }

  it('releases it when HEAD matches the branch on the remote', async () => {
    const { repo, wt, old } = staleTracking('bill10/stale-ref');
    expect(execFileSync('git', ['-C', wt, 'rev-parse', '@{u}'], { encoding: 'utf8' }).trim()).toBe(old);

    const result = await removeWorktree({ worktreePath: wt, repoPath: repo, branchName: 'bill10/stale-ref' });

    expect(result).toEqual({ orphaned: false });
    expect(existsSync(wt)).toBe(false);
  });

  it('keeps it when the remote holds a different SHA', async () => {
    const { repo, wt } = staleTracking('bill10/stale-diverged');
    execFileSync('git', ['-C', wt, 'commit', '-q', '--amend', '-m', 'amended, never pushed']);

    const result = await removeWorktree({ worktreePath: wt, repoPath: repo, branchName: 'bill10/stale-diverged' });

    expect(result).toMatchObject({ orphaned: true, reason: 'unpushed' });
    expect(existsSync(wt)).toBe(true);
  });
});

// A worker that pushed to a URL (`git push -u https://…@github.com/… HEAD:b`)
// leaves branch.<b>.remote set to that URL and no refs/remotes/origin/<b>, so
// `@{u}` cannot resolve. Cleanup must then ask the remote directly.
describe('removeWorktree when the branch was pushed to a URL', () => {
  function pushedToUrl(branch) {
    const { root, repo } = repoWithRemote();
    const wt = worktreeOn(repo, root, branch, { commit: true });
    const url = `file://${join(root, 'remote.git')}`;
    execFileSync('git', ['-C', wt, 'push', '-q', '-u', url, `HEAD:${branch}`], { stdio: 'ignore' });
    return { root, repo, wt };
  }

  it('releases it when HEAD matches the branch on the remote', async () => {
    const { repo, wt } = pushedToUrl('bill10/url-pushed');
    expect(() => execFileSync('git', ['-C', wt, 'rev-parse', '@{u}'], { stdio: 'ignore' })).toThrow();

    const result = await removeWorktree({ worktreePath: wt, repoPath: repo, branchName: 'bill10/url-pushed' });

    expect(result).toEqual({ orphaned: false });
    expect(existsSync(wt)).toBe(false);
  });

  it('keeps it when HEAD has commits the remote does not', async () => {
    const { repo, wt } = pushedToUrl('bill10/url-ahead');
    writeFileSync(join(wt, 'more.txt'), 'not pushed');
    execFileSync('git', ['-C', wt, 'add', '-A']);
    execFileSync('git', ['-C', wt, 'commit', '-q', '-m', 'local only']);

    const result = await removeWorktree({ worktreePath: wt, repoPath: repo, branchName: 'bill10/url-ahead' });

    expect(result).toMatchObject({ orphaned: true, reason: 'unpushed' });
    expect(existsSync(wt)).toBe(true);
  });

  it('keeps it when the remote is unreachable, and moves a credentialed URL back to origin', async () => {
    const { repo, wt } = pushedToUrl('bill10/url-offline');
    // Port 1 refuses at once: an offline remote, and a fake token in the URL.
    // Assembled so the pre-push credential scanner doesn't flag a fake token.
    const credUrl = ['https://x-access-token', 'fake@127.0.0.1:1/r.git'].join(':');
    execFileSync('git', ['-C', repo, 'config', 'branch.bill10/url-offline.remote', credUrl]);

    const result = await removeWorktree({ worktreePath: wt, repoPath: repo, branchName: 'bill10/url-offline' });

    expect(result).toMatchObject({ orphaned: true, reason: 'unpushed' });
    expect(existsSync(wt)).toBe(true);
    const remote = execFileSync('git', ['-C', repo, 'config', 'branch.bill10/url-offline.remote'], { encoding: 'utf8' }).trim();
    expect(remote).toBe('origin');
  });
});

describe('discardWorktree', () => {
  it('never deletes a folder that is neither a worktree nor under WORKTREE_DIR', async () => {
    const { root, repo } = repoWithRemote();
    const stray = join(root, 'not-a-worktree');
    mkdirSync(stray);
    expect(await discardWorktree(repo, stray)).toBe(false);
    expect(await discardWorktree(repo, repo)).toBe(false);
    expect(existsSync(stray)).toBe(true);
    expect(existsSync(join(repo, 'README.md'))).toBe(true);
  });

  it('deletes in place when the folder cannot be moved to the trash', async () => {
    const { root, repo } = repoWithRemote();
    const wt = worktreeOn(repo, root, 'bill10/no-trash');
    // A file where the trash folder should be makes the move impossible, as a
    // worktree on another disk (EXDEV) would.
    const trash = join(process.env.AGENT007_WORKTREE_DIR, '.trash');
    // Earlier tests' trash is still being emptied in the background.
    await vi.waitFor(() => expect(existsSync(trash) && readdirSync(trash).length).toBeFalsy());
    rmSync(trash, { recursive: true, force: true });
    writeFileSync(trash, '');
    try {
      expect(await discardWorktree(repo, wt)).toBe(true);
    } finally { rmSync(trash); }
    expect(existsSync(wt)).toBe(false);
    expect(execFileSync('git', ['-C', repo, 'worktree', 'list'], { encoding: 'utf8' })).not.toContain('no-trash');
  });

  it('keeps the trash out of the orphan scan and empties it at startup', async () => {
    // Still a valid worktree, as a trashed one is until the prune runs.
    const { repo } = repoWithRemote();
    const leftover = join(process.env.AGENT007_WORKTREE_DIR, '.trash', 'Old-123');
    execFileSync('git', ['-C', repo, 'worktree', 'add', '-q', leftover, '-b', 'old']);
    await scanForOrphanedWorktrees(() => {});
    expect([...orphans.values()].some(o => o.worktreePath.includes('.trash'))).toBe(false);
    await pruneWorktrees();
    await vi.waitFor(() => expect(existsSync(leftover)).toBe(false));
  });
});