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/ 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..remote set to that URL and no refs/remotes/origin/, 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)); }); });