feat: add WorktreeManager for isolated test environments

Reusable platform module (lib/worktree.ts) that creates git worktrees
for test isolation and harvests useful changes as patches. Includes
SHA-256 dedup, original SHA tracking for committed change detection,
and automatic gitignored artifact copying (.agents/, browse/dist/).

12 unit tests covering lifecycle, harvest, dedup, and error handling.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-03-23 17:31:43 -07:00
parent 062b92fec7
commit 0f1f1eb1c5
3 changed files with 569 additions and 0 deletions
+1
View File
@@ -6,6 +6,7 @@ bin/gstack-global-discover
.claude/skills/
.agents/
.context/
.gstack-worktrees/
/tmp/
*.log
bun.lock
+297
View File
@@ -0,0 +1,297 @@
/**
* Git worktree manager for isolated test execution with change harvesting.
*
* Creates git worktrees for test suites that need real repo context,
* harvests any changes the test agent makes as patches, and provides
* deduplication across runs.
*
* Reusable platform module — future /batch or /codex challenge skills
* can import this directly.
*/
import { spawnSync } from 'child_process';
import * as crypto from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
// --- Interfaces ---
export interface WorktreeInfo {
path: string;
testName: string;
originalSha: string;
createdAt: number;
}
export interface HarvestResult {
testName: string;
worktreePath: string;
diffStat: string;
patchPath: string;
changedFiles: string[];
isDuplicate: boolean;
}
// --- Utility ---
/** Recursive directory copy (pure TypeScript, no external deps). */
function copyDirSync(src: string, dest: string): void {
fs.mkdirSync(dest, { recursive: true });
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
copyDirSync(srcPath, destPath);
} else {
fs.copyFileSync(srcPath, destPath);
}
}
}
/** Run a git command and return stdout. Throws on failure unless tolerateFailure is set. */
function git(args: string[], cwd: string, tolerateFailure = false): string {
const result = spawnSync('git', args, { cwd, stdio: 'pipe', timeout: 30_000 });
const stdout = result.stdout?.toString().trim() ?? '';
const stderr = result.stderr?.toString().trim() ?? '';
if (result.status !== 0 && !tolerateFailure) {
throw new Error(`git ${args.join(' ')} failed (exit ${result.status}): ${stderr || stdout}`);
}
return stdout;
}
// --- Dedup index ---
interface DedupIndex {
hashes: Record<string, string>; // hash → first-seen runId
}
function getDedupPath(): string {
return path.join(os.homedir(), '.gstack-dev', 'harvests', 'dedup.json');
}
function loadDedupIndex(): DedupIndex {
try {
const raw = fs.readFileSync(getDedupPath(), 'utf-8');
return JSON.parse(raw);
} catch {
return { hashes: {} };
}
}
function saveDedupIndex(index: DedupIndex): void {
const dir = path.dirname(getDedupPath());
fs.mkdirSync(dir, { recursive: true });
const tmp = getDedupPath() + '.tmp';
fs.writeFileSync(tmp, JSON.stringify(index, null, 2));
fs.renameSync(tmp, getDedupPath());
}
// --- WorktreeManager ---
export class WorktreeManager {
private repoRoot: string;
private runId: string;
private active: Map<string, WorktreeInfo> = new Map();
private harvestResults: HarvestResult[] = [];
constructor(repoRoot?: string) {
if (repoRoot) {
this.repoRoot = repoRoot;
} else {
this.repoRoot = git(['rev-parse', '--show-toplevel'], process.cwd());
}
this.runId = crypto.randomUUID();
// Register cleanup on process exit
process.on('exit', () => {
this.cleanupAll();
});
}
/** Create an isolated worktree. Returns the worktree path. Throws on failure. */
create(testName: string): string {
const originalSha = git(['rev-parse', 'HEAD'], this.repoRoot);
const worktreeBase = path.join(this.repoRoot, '.gstack-worktrees', this.runId);
fs.mkdirSync(worktreeBase, { recursive: true });
const worktreePath = path.join(worktreeBase, testName);
// Create detached worktree at current HEAD
git(['worktree', 'add', '--detach', worktreePath, 'HEAD'], this.repoRoot);
// Copy gitignored build artifacts that tests need
const agentsSrc = path.join(this.repoRoot, '.agents');
if (fs.existsSync(agentsSrc)) {
copyDirSync(agentsSrc, path.join(worktreePath, '.agents'));
}
const browseDist = path.join(this.repoRoot, 'browse', 'dist');
if (fs.existsSync(browseDist)) {
copyDirSync(browseDist, path.join(worktreePath, 'browse', 'dist'));
}
const info: WorktreeInfo = {
path: worktreePath,
testName,
originalSha,
createdAt: Date.now(),
};
this.active.set(testName, info);
return worktreePath;
}
/** Harvest changes from a worktree. Returns null if clean or on error. */
harvest(testName: string): HarvestResult | null {
const info = this.active.get(testName);
if (!info) return null;
try {
// Check if worktree directory still exists (agent may have deleted it)
if (!fs.existsSync(info.path)) {
process.stderr.write(` HARVEST [${testName}]: worktree dir deleted, skipping\n`);
return null;
}
// Stage everything including untracked files
git(['-C', info.path, 'add', '-A'], info.path, true);
// Get diff against original SHA (captures both committed and uncommitted changes)
const patch = git(['-C', info.path, 'diff', info.originalSha, '--cached'], info.path, true);
if (!patch) return null;
// Get diff stat for human-readable output
const diffStat = git(['-C', info.path, 'diff', info.originalSha, '--cached', '--stat'], info.path, true);
// Get changed file names
const nameOnly = git(['-C', info.path, 'diff', info.originalSha, '--cached', '--name-only'], info.path, true);
const changedFiles = nameOnly.split('\n').filter(Boolean);
// Dedup check
const hash = crypto.createHash('sha256').update(patch).digest('hex');
const dedupIndex = loadDedupIndex();
const isDuplicate = hash in dedupIndex.hashes;
let patchPath = '';
if (!isDuplicate) {
// Save patch
const harvestDir = path.join(os.homedir(), '.gstack-dev', 'harvests', this.runId);
fs.mkdirSync(harvestDir, { recursive: true });
patchPath = path.join(harvestDir, `${testName}.patch`);
fs.writeFileSync(patchPath, patch);
// Update dedup index
dedupIndex.hashes[hash] = this.runId;
saveDedupIndex(dedupIndex);
}
const result: HarvestResult = {
testName,
worktreePath: info.path,
diffStat,
patchPath,
changedFiles,
isDuplicate,
};
this.harvestResults.push(result);
return result;
} catch (err) {
process.stderr.write(` HARVEST [${testName}]: error — ${err}\n`);
return null;
}
}
/** Remove a worktree. Non-fatal on error. */
cleanup(testName: string): void {
const info = this.active.get(testName);
if (!info) return;
try {
git(['worktree', 'remove', '--force', info.path], this.repoRoot, true);
} catch {
// Force remove the directory if git worktree remove fails
try {
fs.rmSync(info.path, { recursive: true, force: true });
git(['worktree', 'prune'], this.repoRoot, true);
} catch { /* non-fatal */ }
}
this.active.delete(testName);
}
/** Force-remove all active worktrees (for process exit handler). */
cleanupAll(): void {
for (const testName of [...this.active.keys()]) {
this.cleanup(testName);
}
// Clean up the run directory if empty
const runDir = path.join(this.repoRoot, '.gstack-worktrees', this.runId);
try {
const entries = fs.readdirSync(runDir);
if (entries.length === 0) {
fs.rmdirSync(runDir);
}
} catch { /* non-fatal */ }
}
/** Remove worktrees from previous runs that weren't cleaned up. */
pruneStale(): void {
try {
git(['worktree', 'prune'], this.repoRoot, true);
const worktreeBase = path.join(this.repoRoot, '.gstack-worktrees');
if (!fs.existsSync(worktreeBase)) return;
for (const entry of fs.readdirSync(worktreeBase)) {
// Don't prune our own run
if (entry === this.runId) continue;
const entryPath = path.join(worktreeBase, entry);
try {
fs.rmSync(entryPath, { recursive: true, force: true });
} catch { /* non-fatal */ }
}
} catch {
process.stderr.write(' WORKTREE: prune failed (non-fatal)\n');
}
}
/** Print harvest report summary. */
printReport(): void {
if (this.harvestResults.length === 0) return;
const nonDuplicates = this.harvestResults.filter(r => !r.isDuplicate);
process.stderr.write('\n=== HARVEST REPORT ===\n');
process.stderr.write(`${nonDuplicates.length} of ${this.harvestResults.length} test suites produced new changes:\n\n`);
for (const result of this.harvestResults) {
if (result.isDuplicate) {
process.stderr.write(` ${result.testName}: duplicate patch (skipped)\n`);
} else {
process.stderr.write(` ${result.testName}: ${result.changedFiles.length} files changed\n`);
process.stderr.write(` Patch: ${result.patchPath}\n`);
process.stderr.write(` Apply: git apply ${result.patchPath}\n`);
if (result.diffStat) {
process.stderr.write(` ${result.diffStat}\n`);
}
}
process.stderr.write('\n');
}
}
/** Get the run ID (for testing). */
getRunId(): string {
return this.runId;
}
/** Get active worktree info (for testing). */
getInfo(testName: string): WorktreeInfo | undefined {
return this.active.get(testName);
}
}
+271
View File
@@ -0,0 +1,271 @@
/**
* 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' });
spawnSync('git', ['config', 'user.email', 'test@test.com'], { cwd: dir, stdio: 'pipe' });
spawnSync('git', ['config', 'user.name', 'Test'], { cwd: dir, stdio: 'pipe' });
// 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' });
spawnSync('git', ['commit', '-m', 'Initial commit'], { cwd: dir, stdio: 'pipe' });
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' });
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' })
.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' });
spawnSync('git', ['commit', '-m', 'Agent commit'], { cwd: worktreePath, stdio: 'pipe' });
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' });
// Recreate the directory to simulate orphaned state
fs.mkdirSync(oldPath, { recursive: true });
// 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');
});
});