From 0f1f1eb1c5bd22373cbea428647431d1feebbee8 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Mon, 23 Mar 2026 17:31:43 -0700 Subject: [PATCH] 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) --- .gitignore | 1 + lib/worktree.ts | 297 ++++++++++++++++++++++++++++++++++++++++++ test/worktree.test.ts | 271 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 569 insertions(+) create mode 100644 lib/worktree.ts create mode 100644 test/worktree.test.ts diff --git a/.gitignore b/.gitignore index 3a57aa4a..189276fb 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ bin/gstack-global-discover .claude/skills/ .agents/ .context/ +.gstack-worktrees/ /tmp/ *.log bun.lock diff --git a/lib/worktree.ts b/lib/worktree.ts new file mode 100644 index 00000000..07906573 --- /dev/null +++ b/lib/worktree.ts @@ -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; // 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 = 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); + } +} diff --git a/test/worktree.test.ts b/test/worktree.test.ts new file mode 100644 index 00000000..be1533ae --- /dev/null +++ b/test/worktree.test.ts @@ -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'); + }); +});