mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-09 06:28:59 +02:00
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>
275 lines
9.5 KiB
TypeScript
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');
|
|
});
|
|
});
|