Files
gstack/test/worktree.test.ts
T
Garry TanandClaude Fable 5 3e674e4c01 fix: sweep — every sync spawn in the test trees carries a timeout (436 sites, 157 files)
spawnSync/execSync/Bun.spawnSync BLOCK the main thread, so bun's in-process
per-test timeout can never fire while one waits — a hung child (stdin read,
network probe, dead daemon) wedges the whole shard until the runner's
external wall-clock SIGKILL. This exact class reached main: free-tests run
33262077256, test/gstack-memory-ingest.test.ts (normally 2.3s) held shard 2
at the 360s wall while its five siblings finished in ~65s.

Mechanical sweep in two waves (12 + 4 fan-out agents, every edit verified
against its call site): default timeout: 30_000 (matches the free runner's
per-test budget), 120_000 for genuinely slow ops (installs, builds,
playwright, provider CLIs), helper wrappers fixed ONCE where call sites
route through them. Sites that only LOOK like calls (string fixtures, grep
needles, comments) were skipped with reasons — the enforcement commit that
follows marks them exempt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 04:49:57 +00:00

275 lines
9.5 KiB
TypeScript

/**
* Unit tests for WorktreeManager.
*
* Tests worktree lifecycle: create, harvest, dedup, cleanup, prune.
* Each test creates real git worktrees in a temporary repo.
*/
import { describe, test, expect, afterEach } from 'bun:test';
import { WorktreeManager } from '../lib/worktree';
import type { HarvestResult } from '../lib/worktree';
import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
/** Create a minimal git repo in a tmpdir for testing. */
function createTestRepo(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'worktree-test-'));
spawnSync('git', ['init'], { cwd: dir, stdio: 'pipe', timeout: 30_000 });
spawnSync('git', ['config', 'user.email', 'test@test.com'], { cwd: dir, stdio: 'pipe', timeout: 30_000 });
spawnSync('git', ['config', 'user.name', 'Test'], { cwd: dir, stdio: 'pipe', timeout: 30_000 });
// Create initial commit so HEAD exists
fs.writeFileSync(path.join(dir, 'README.md'), '# Test repo\n');
// Add .gitignore matching real repo (so copied build artifacts don't appear as changes)
fs.writeFileSync(path.join(dir, '.gitignore'), '.agents/\nbrowse/dist/\n.gstack-worktrees/\n');
// Create a .agents directory (simulating gitignored build artifacts)
fs.mkdirSync(path.join(dir, '.agents', 'skills'), { recursive: true });
fs.writeFileSync(path.join(dir, '.agents', 'skills', 'test-skill.md'), '# Test skill\n');
// Create browse/dist (simulating build artifacts)
fs.mkdirSync(path.join(dir, 'browse', 'dist'), { recursive: true });
fs.writeFileSync(path.join(dir, 'browse', 'dist', 'browse'), '#!/bin/sh\necho browse\n');
spawnSync('git', ['add', 'README.md', '.gitignore'], { cwd: dir, stdio: 'pipe', timeout: 30_000 });
spawnSync('git', ['commit', '-m', 'Initial commit'], { cwd: dir, stdio: 'pipe', timeout: 30_000 });
return dir;
}
/** Clean up a test repo. */
function cleanupRepo(dir: string): void {
// Prune worktrees first to avoid git lock issues
spawnSync('git', ['worktree', 'prune'], { cwd: dir, stdio: 'pipe', timeout: 30_000 });
fs.rmSync(dir, { recursive: true, force: true });
}
// Track repos to clean up
const repos: string[] = [];
// Dedup index path — clear before each test to avoid cross-run contamination
const DEDUP_PATH = path.join(os.homedir(), '.gstack-dev', 'harvests', 'dedup.json');
afterEach(() => {
for (const repo of repos) {
try { cleanupRepo(repo); } catch { /* best effort */ }
}
repos.length = 0;
// Clear dedup index so tests are independent
try { fs.unlinkSync(DEDUP_PATH); } catch { /* may not exist */ }
});
describe('WorktreeManager', () => {
test('create() produces a valid worktree at the expected path', () => {
const repo = createTestRepo();
repos.push(repo);
const mgr = new WorktreeManager(repo);
const worktreePath = mgr.create('test-1');
expect(fs.existsSync(worktreePath)).toBe(true);
expect(fs.existsSync(path.join(worktreePath, 'README.md'))).toBe(true);
expect(worktreePath).toContain('.gstack-worktrees');
expect(worktreePath).toContain('test-1');
mgr.cleanup('test-1');
});
test('create() worktree has .agents/skills/ (gitignored artifacts copied)', () => {
const repo = createTestRepo();
repos.push(repo);
const mgr = new WorktreeManager(repo);
const worktreePath = mgr.create('test-agents');
expect(fs.existsSync(path.join(worktreePath, '.agents', 'skills', 'test-skill.md'))).toBe(true);
expect(fs.existsSync(path.join(worktreePath, 'browse', 'dist', 'browse'))).toBe(true);
mgr.cleanup('test-agents');
});
test('create() stores correct originalSha', () => {
const repo = createTestRepo();
repos.push(repo);
const mgr = new WorktreeManager(repo);
const expectedSha = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: repo, stdio: 'pipe', timeout: 30_000 })
.stdout.toString().trim();
mgr.create('test-sha');
const info = mgr.getInfo('test-sha');
expect(info).toBeDefined();
expect(info!.originalSha).toBe(expectedSha);
mgr.cleanup('test-sha');
});
test('harvest() captures modifications to tracked files', () => {
const repo = createTestRepo();
repos.push(repo);
const mgr = new WorktreeManager(repo);
const worktreePath = mgr.create('test-harvest-mod');
// Modify a tracked file in the worktree
fs.writeFileSync(path.join(worktreePath, 'README.md'), '# Modified!\n');
const result = mgr.harvest('test-harvest-mod');
expect(result).not.toBeNull();
expect(result!.changedFiles).toContain('README.md');
expect(result!.isDuplicate).toBe(false);
expect(result!.patchPath).toBeTruthy();
expect(fs.existsSync(result!.patchPath)).toBe(true);
mgr.cleanup('test-harvest-mod');
});
test('harvest() captures new untracked files (git add -A path)', () => {
const repo = createTestRepo();
repos.push(repo);
const mgr = new WorktreeManager(repo);
const worktreePath = mgr.create('test-harvest-new');
// Create a new file in the worktree
fs.writeFileSync(path.join(worktreePath, 'new-file.txt'), 'Hello from agent\n');
const result = mgr.harvest('test-harvest-new');
expect(result).not.toBeNull();
expect(result!.changedFiles).toContain('new-file.txt');
mgr.cleanup('test-harvest-new');
});
test('harvest() captures committed changes (git diff originalSha)', () => {
const repo = createTestRepo();
repos.push(repo);
const mgr = new WorktreeManager(repo);
const worktreePath = mgr.create('test-harvest-commit');
// Make a commit in the worktree (simulating agent running git commit)
fs.writeFileSync(path.join(worktreePath, 'committed.txt'), 'Agent committed this\n');
spawnSync('git', ['add', 'committed.txt'], { cwd: worktreePath, stdio: 'pipe', timeout: 30_000 });
spawnSync('git', ['commit', '-m', 'Agent commit'], { cwd: worktreePath, stdio: 'pipe', timeout: 30_000 });
const result = mgr.harvest('test-harvest-commit');
expect(result).not.toBeNull();
expect(result!.changedFiles).toContain('committed.txt');
mgr.cleanup('test-harvest-commit');
});
test('harvest() returns null when worktree is clean', () => {
const repo = createTestRepo();
repos.push(repo);
const mgr = new WorktreeManager(repo);
mgr.create('test-harvest-clean');
// Don't modify anything
const result = mgr.harvest('test-harvest-clean');
expect(result).toBeNull();
mgr.cleanup('test-harvest-clean');
});
test('harvest() dedup skips identical patches', () => {
const repo = createTestRepo();
repos.push(repo);
// First run
const mgr1 = new WorktreeManager(repo);
const wt1 = mgr1.create('test-dedup-1');
fs.writeFileSync(path.join(wt1, 'dedup-test.txt'), 'same content\n');
const result1 = mgr1.harvest('test-dedup-1');
mgr1.cleanup('test-dedup-1');
expect(result1).not.toBeNull();
expect(result1!.isDuplicate).toBe(false);
// Second run with same change
const mgr2 = new WorktreeManager(repo);
const wt2 = mgr2.create('test-dedup-2');
fs.writeFileSync(path.join(wt2, 'dedup-test.txt'), 'same content\n');
const result2 = mgr2.harvest('test-dedup-2');
mgr2.cleanup('test-dedup-2');
expect(result2).not.toBeNull();
expect(result2!.isDuplicate).toBe(true);
});
test('cleanup() removes worktree directory', () => {
const repo = createTestRepo();
repos.push(repo);
const mgr = new WorktreeManager(repo);
const worktreePath = mgr.create('test-cleanup');
expect(fs.existsSync(worktreePath)).toBe(true);
mgr.cleanup('test-cleanup');
expect(fs.existsSync(worktreePath)).toBe(false);
});
test('pruneStale() removes orphaned worktrees from previous runs', () => {
const repo = createTestRepo();
repos.push(repo);
// Create a worktree with a different manager (simulating a previous run)
const oldMgr = new WorktreeManager(repo);
const oldPath = oldMgr.create('stale-test');
const oldRunDir = path.dirname(oldPath);
expect(fs.existsSync(oldPath)).toBe(true);
// Remove via git but leave directory (simulating a crash)
spawnSync('git', ['worktree', 'remove', '--force', oldPath], { cwd: repo, stdio: 'pipe', timeout: 30_000 });
// Recreate the directory to simulate orphaned state
fs.mkdirSync(oldPath, { recursive: true });
// Backdate mtime to simulate a stale worktree (> 1 hour old)
const staleTime = new Date(Date.now() - 7200_000);
fs.utimesSync(oldRunDir, staleTime, staleTime);
// New manager should prune the old run's directory
const newMgr = new WorktreeManager(repo);
newMgr.pruneStale();
expect(fs.existsSync(oldRunDir)).toBe(false);
});
test('create() throws on failure (no silent fallback to ROOT)', () => {
const repo = createTestRepo();
repos.push(repo);
const mgr = new WorktreeManager(repo);
// Create the same worktree twice — second should fail because path exists
mgr.create('test-fail');
expect(() => mgr.create('test-fail')).toThrow();
mgr.cleanup('test-fail');
});
test('harvest() returns null gracefully when worktree dir was deleted by agent', () => {
const repo = createTestRepo();
repos.push(repo);
const mgr = new WorktreeManager(repo);
const worktreePath = mgr.create('test-deleted');
// Simulate agent deleting its own worktree directory
fs.rmSync(worktreePath, { recursive: true, force: true });
// harvest should return null gracefully, not throw
const result = mgr.harvest('test-deleted');
expect(result).toBeNull();
// cleanup should also be non-fatal
mgr.cleanup('test-deleted');
});
});