mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-02 03:35:09 +02:00
dc5e0538e5
* refactor: extract gen-skill-docs into modular resolver architecture Break the 3000-line monolith into 10 domain modules under scripts/resolvers/: types, constants, preamble, utility, browse, design, testing, review, codex-helpers, and index. Each module owns one domain of template generation. The preamble module introduces a 4-tier composition system (T1-T4) so skills only pay for the preamble sections they actually need, reducing token usage for lightweight skills by ~40%. Adds a token budget dashboard that prints after every generation run showing per-skill and total token counts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: tiered preamble — skills only pay for what they use Tag all 23 templates with preamble-tier (T1-T4). Lightweight skills like /browse and /benchmark get a minimal preamble (~40% fewer tokens), while review skills get the full stack. Regenerate all SKILL.md files. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: migrate eval storage to project-scoped paths Move eval results and E2E run artifacts from ~/.gstack-dev/evals/ to ~/.gstack/projects/$SLUG/evals/ so each project's eval history lives alongside its other gstack data. Falls back to legacy path if slug detection fails. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: sync package.json version with VERSION after merge Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * 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> * feat: integrate worktree isolation into E2E test infrastructure Add createTestWorktree(), harvestAndCleanup(), and describeWithWorktree() helpers to e2e-helpers.ts. Add harvest field to EvalTestEntry for eval-store integration. Register lib/worktree.ts as a global touchfile. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: run Gemini and Codex E2E tests in worktrees Switch both test suites from cwd: ROOT to worktree isolation. Gemini (--yolo) no longer pollutes the working tree. Codex (read-only) gets worktree for consistency. Useful changes are harvested as patches for cherry-picking. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: skip symlinks in copyDirSync to prevent infinite recursion Adversarial review caught that .claude/skills/gstack may be a symlink back to the repo root, causing copyDirSync to recurse infinitely when copying gitignored artifacts into worktrees. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: bump version and changelog (v0.11.12.0) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: relax session-awareness assertion to accept structured options The LLM consistently presents well-formatted A/B choices with pros/cons but doesn't always use the exact string "RECOMMENDATION". Accept case-insensitive "recommend", "option a", "which do you want", or "which approach" as equivalent signals of a structured recommendation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
272 lines
9.2 KiB
TypeScript
272 lines
9.2 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' });
|
|
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');
|
|
});
|
|
});
|