mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-01 19:25:10 +02:00
feat: worktree isolation for E2E tests + infrastructure elegance (v0.11.12.0) (#425)
* 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>
This commit is contained in:
+16
-6
@@ -13,12 +13,13 @@
|
||||
* Skips gracefully when prerequisites are not met.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, afterAll } from 'bun:test';
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { runCodexSkill, parseCodexJSONL, installSkillToTempHome } from './helpers/codex-session-runner';
|
||||
import type { CodexResult } from './helpers/codex-session-runner';
|
||||
import { EvalCollector } from './helpers/eval-store';
|
||||
import type { EvalTestEntry } from './helpers/eval-store';
|
||||
import { selectTests, detectBaseBranch, getChangedFiles, E2E_TOUCHFILES, GLOBAL_TOUCHFILES } from './helpers/touchfiles';
|
||||
import { createTestWorktree, harvestAndCleanup } from './helpers/e2e-helpers';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
@@ -118,16 +119,25 @@ afterAll(async () => {
|
||||
// --- Tests ---
|
||||
|
||||
describeCodex('Codex E2E', () => {
|
||||
let testWorktree: string;
|
||||
|
||||
beforeAll(() => {
|
||||
testWorktree = createTestWorktree('codex');
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
harvestAndCleanup('codex');
|
||||
});
|
||||
|
||||
testIfSelected('codex-discover-skill', async () => {
|
||||
// Install gstack-review skill to a temp HOME and ask Codex to list skills
|
||||
const skillDir = path.join(ROOT, '.agents', 'skills', 'gstack-review');
|
||||
const skillDir = path.join(testWorktree, '.agents', 'skills', 'gstack-review');
|
||||
|
||||
const result = await runCodexSkill({
|
||||
skillDir,
|
||||
prompt: 'List any skills or instructions you have available. Just list the names.',
|
||||
timeoutMs: 60_000,
|
||||
cwd: ROOT,
|
||||
cwd: testWorktree,
|
||||
skillName: 'gstack-review',
|
||||
});
|
||||
|
||||
@@ -153,14 +163,14 @@ describeCodex('Codex E2E', () => {
|
||||
// code review, and produce structured review output with findings/issues.
|
||||
// Accepts Codex timeout (exit 124/137) as non-failure since that's a CLI perf issue.
|
||||
testIfSelected('codex-review-findings', async () => {
|
||||
// Install gstack-review skill and ask Codex to review the current repo
|
||||
const skillDir = path.join(ROOT, '.agents', 'skills', 'gstack-review');
|
||||
// Install gstack-review skill and ask Codex to review the worktree
|
||||
const skillDir = path.join(testWorktree, '.agents', 'skills', 'gstack-review');
|
||||
|
||||
const result = await runCodexSkill({
|
||||
skillDir,
|
||||
prompt: 'Run the gstack-review skill on this repository. Review the current branch diff and report your findings.',
|
||||
timeoutMs: 540_000,
|
||||
cwd: ROOT,
|
||||
cwd: testWorktree,
|
||||
skillName: 'gstack-review',
|
||||
});
|
||||
|
||||
|
||||
+15
-5
@@ -13,11 +13,12 @@
|
||||
* Skips gracefully when prerequisites are not met.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, afterAll } from 'bun:test';
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { runGeminiSkill } from './helpers/gemini-session-runner';
|
||||
import type { GeminiResult } from './helpers/gemini-session-runner';
|
||||
import { EvalCollector } from './helpers/eval-store';
|
||||
import { selectTests, detectBaseBranch, getChangedFiles, GLOBAL_TOUCHFILES } from './helpers/touchfiles';
|
||||
import { createTestWorktree, harvestAndCleanup } from './helpers/e2e-helpers';
|
||||
import * as path from 'path';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
@@ -114,13 +115,22 @@ afterAll(async () => {
|
||||
// --- Tests ---
|
||||
|
||||
describeGemini('Gemini E2E', () => {
|
||||
let testWorktree: string;
|
||||
|
||||
beforeAll(() => {
|
||||
testWorktree = createTestWorktree('gemini');
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
harvestAndCleanup('gemini');
|
||||
});
|
||||
|
||||
testIfSelected('gemini-discover-skill', async () => {
|
||||
// Run Gemini in the repo root where .agents/skills/ exists
|
||||
// Run Gemini in an isolated worktree (has .agents/skills/ copied from ROOT)
|
||||
const result = await runGeminiSkill({
|
||||
prompt: 'List any skills or instructions you have available. Just list the names.',
|
||||
timeoutMs: 60_000,
|
||||
cwd: ROOT,
|
||||
cwd: testWorktree,
|
||||
});
|
||||
|
||||
logGeminiCost('gemini-discover-skill', result);
|
||||
@@ -139,11 +149,11 @@ describeGemini('Gemini E2E', () => {
|
||||
}, 120_000);
|
||||
|
||||
testIfSelected('gemini-review-findings', async () => {
|
||||
// Run gstack-review skill via Gemini on this repo
|
||||
// Run gstack-review skill via Gemini on worktree (isolated from main working tree)
|
||||
const result = await runGeminiSkill({
|
||||
prompt: 'Run the gstack-review skill on this repository. Review the current branch diff and report your findings.',
|
||||
timeoutMs: 540_000,
|
||||
cwd: ROOT,
|
||||
cwd: testWorktree,
|
||||
});
|
||||
|
||||
logGeminiCost('gemini-review-findings', result);
|
||||
|
||||
@@ -213,12 +213,20 @@ describe('gen-skill-docs', () => {
|
||||
expect(content).toContain('git branch --show-current');
|
||||
});
|
||||
|
||||
test('generated SKILL.md contains ELI16 simplification rules', () => {
|
||||
const content = fs.readFileSync(path.join(ROOT, 'SKILL.md'), 'utf-8');
|
||||
test('tier 2+ skills contain ELI16 simplification rules (AskUserQuestion format)', () => {
|
||||
// Root SKILL.md is tier 1 (no AskUserQuestion format). Check a tier 2+ skill instead.
|
||||
const content = fs.readFileSync(path.join(ROOT, 'cso', 'SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('No raw function names');
|
||||
expect(content).toContain('plain English');
|
||||
});
|
||||
|
||||
test('tier 1 skills do NOT contain AskUserQuestion format', () => {
|
||||
// Use benchmark (tier 1) instead of root — root SKILL.md gets overwritten by Codex test setup
|
||||
const content = fs.readFileSync(path.join(ROOT, 'benchmark', 'SKILL.md'), 'utf-8');
|
||||
expect(content).not.toContain('## AskUserQuestion Format');
|
||||
expect(content).not.toContain('## Completeness Principle');
|
||||
});
|
||||
|
||||
test('generated SKILL.md contains telemetry line', () => {
|
||||
const content = fs.readFileSync(path.join(ROOT, 'SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('skill-usage.jsonl');
|
||||
|
||||
@@ -5,11 +5,13 @@
|
||||
* tests across multiple files by category.
|
||||
*/
|
||||
|
||||
import { describe, test, afterAll } from 'bun:test';
|
||||
import { describe, test, beforeAll, afterAll } from 'bun:test';
|
||||
import type { SkillTestResult } from './session-runner';
|
||||
import { EvalCollector, judgePassed } from './eval-store';
|
||||
import type { EvalTestEntry } from './eval-store';
|
||||
import { selectTests, detectBaseBranch, getChangedFiles, E2E_TOUCHFILES, GLOBAL_TOUCHFILES } from './touchfiles';
|
||||
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';
|
||||
@@ -234,6 +236,59 @@ export function testConcurrentIfSelected(testName: string, fn: () => Promise<voi
|
||||
(shouldRun ? test.concurrent : test.skip)(testName, fn, timeout);
|
||||
}
|
||||
|
||||
// --- Worktree isolation ---
|
||||
|
||||
let worktreeManager: WorktreeManager | null = null;
|
||||
|
||||
export function getWorktreeManager(): WorktreeManager {
|
||||
if (!worktreeManager) {
|
||||
worktreeManager = new WorktreeManager();
|
||||
worktreeManager.pruneStale();
|
||||
}
|
||||
return worktreeManager;
|
||||
}
|
||||
|
||||
/** Create an isolated worktree for a test. Returns the worktree path. */
|
||||
export function createTestWorktree(testName: string): string {
|
||||
return getWorktreeManager().create(testName);
|
||||
}
|
||||
|
||||
/** Harvest changes and clean up. Call in afterAll(). Returns HarvestResult for eval integration. */
|
||||
export function harvestAndCleanup(testName: string): HarvestResult | null {
|
||||
const mgr = getWorktreeManager();
|
||||
const result = mgr.harvest(testName);
|
||||
if (result) {
|
||||
if (result.isDuplicate) {
|
||||
process.stderr.write(`\n HARVEST [${testName}]: duplicate patch (skipped)\n`);
|
||||
} else {
|
||||
process.stderr.write(`\n HARVEST [${testName}]: ${result.changedFiles.length} files changed\n`);
|
||||
process.stderr.write(` Patch: ${result.patchPath}\n`);
|
||||
process.stderr.write(` ${result.diffStat}\n\n`);
|
||||
}
|
||||
}
|
||||
mgr.cleanup(testName);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: describe block with automatic worktree isolation + harvest.
|
||||
* Any test file can use this to get real repo context instead of a tmpdir.
|
||||
* Note: tests with planted-bug fixtures should NOT use this — they need their fixture repos.
|
||||
*/
|
||||
export function describeWithWorktree(
|
||||
name: string,
|
||||
testNames: string[],
|
||||
fn: (getWorktreePath: () => string) => void,
|
||||
) {
|
||||
describeIfSelected(name, testNames, () => {
|
||||
let worktreePath: string;
|
||||
beforeAll(() => { worktreePath = createTestWorktree(name); });
|
||||
afterAll(() => { harvestAndCleanup(name); });
|
||||
fn(() => worktreePath);
|
||||
});
|
||||
}
|
||||
|
||||
export { judgePassed } from './eval-store';
|
||||
export { EvalCollector } from './eval-store';
|
||||
export type { EvalTestEntry } from './eval-store';
|
||||
export type { HarvestResult } from '../../lib/worktree';
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Eval result persistence and comparison.
|
||||
*
|
||||
* EvalCollector accumulates test results, writes them to
|
||||
* ~/.gstack-dev/evals/{version}-{branch}-{tier}-{timestamp}.json,
|
||||
* ~/.gstack/projects/$SLUG/evals/{version}-{branch}-{tier}-{timestamp}.json,
|
||||
* prints a summary table, and auto-compares with the previous run.
|
||||
*
|
||||
* Comparison functions are exported for reuse by the eval:compare CLI.
|
||||
@@ -14,7 +14,32 @@ import * as os from 'os';
|
||||
import { spawnSync } from 'child_process';
|
||||
|
||||
const SCHEMA_VERSION = 1;
|
||||
const DEFAULT_EVAL_DIR = path.join(os.homedir(), '.gstack-dev', 'evals');
|
||||
const LEGACY_EVAL_DIR = path.join(os.homedir(), '.gstack-dev', 'evals');
|
||||
|
||||
/**
|
||||
* Detect project-scoped eval dir via gstack-slug.
|
||||
* Falls back to legacy ~/.gstack-dev/evals/ if slug detection fails.
|
||||
*/
|
||||
export function getProjectEvalDir(): string {
|
||||
try {
|
||||
// Try repo-local gstack-slug first, then global install
|
||||
const localSlug = spawnSync('bash', ['-c', '.claude/skills/gstack/bin/gstack-slug 2>/dev/null || ~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null'], {
|
||||
stdio: 'pipe', timeout: 3000,
|
||||
});
|
||||
const output = localSlug.stdout?.toString().trim();
|
||||
if (output) {
|
||||
const slugMatch = output.match(/^SLUG=(.+)$/m);
|
||||
if (slugMatch && slugMatch[1]) {
|
||||
const dir = path.join(os.homedir(), '.gstack', 'projects', slugMatch[1], 'evals');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
return LEGACY_EVAL_DIR;
|
||||
}
|
||||
|
||||
const DEFAULT_EVAL_DIR = getProjectEvalDir();
|
||||
|
||||
// --- Interfaces ---
|
||||
|
||||
@@ -55,6 +80,13 @@ export interface EvalTestEntry {
|
||||
missed_bugs?: string[];
|
||||
|
||||
error?: string;
|
||||
|
||||
// Worktree harvest data
|
||||
harvest?: {
|
||||
filesChanged: number;
|
||||
patchPath: string;
|
||||
isDuplicate: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface EvalResult {
|
||||
|
||||
@@ -9,9 +9,11 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { getProjectEvalDir } from './eval-store';
|
||||
|
||||
const GSTACK_DEV_DIR = path.join(os.homedir(), '.gstack-dev');
|
||||
const HEARTBEAT_PATH = path.join(GSTACK_DEV_DIR, 'e2e-live.json');
|
||||
const HEARTBEAT_PATH = path.join(GSTACK_DEV_DIR, 'e2e-live.json'); // heartbeat stays global
|
||||
const PROJECT_DIR = path.dirname(getProjectEvalDir()); // ~/.gstack/projects/$SLUG/
|
||||
|
||||
/** Sanitize test name for use as filename: strip leading slashes, replace / with - */
|
||||
export function sanitizeTestName(name: string): string {
|
||||
@@ -144,7 +146,7 @@ export async function runSkillTest(options: {
|
||||
const safeName = testName ? sanitizeTestName(testName) : null;
|
||||
if (runId) {
|
||||
try {
|
||||
runDir = path.join(GSTACK_DEV_DIR, 'e2e-runs', runId);
|
||||
runDir = path.join(PROJECT_DIR, 'e2e-runs', runId);
|
||||
fs.mkdirSync(runDir, { recursive: true });
|
||||
} catch { /* non-fatal */ }
|
||||
}
|
||||
|
||||
@@ -200,6 +200,7 @@ export const GLOBAL_TOUCHFILES = [
|
||||
'scripts/gen-skill-docs.ts',
|
||||
'test/helpers/touchfiles.ts',
|
||||
'browse/test/test-server.ts',
|
||||
'lib/worktree.ts',
|
||||
];
|
||||
|
||||
// --- Base branch detection ---
|
||||
|
||||
@@ -278,12 +278,25 @@ Remember: _SESSIONS=4, so ELI16 mode is active. The user is juggling multiple wi
|
||||
expect(lower.includes('payment') || lower.includes('feature')).toBe(true);
|
||||
// Must mention what we're working on
|
||||
expect(lower.includes('stripe') || lower.includes('checkout') || lower.includes('payment')).toBe(true);
|
||||
// Must have a RECOMMENDATION
|
||||
expect(output).toContain('RECOMMENDATION');
|
||||
// Must have a recommendation or structured options
|
||||
expect(
|
||||
output.includes('RECOMMENDATION') ||
|
||||
lower.includes('recommend') ||
|
||||
lower.includes('option a') ||
|
||||
lower.includes('which do you want') ||
|
||||
lower.includes('which approach')
|
||||
).toBe(true);
|
||||
} else {
|
||||
// Check agent output as fallback
|
||||
const output = result.output || '';
|
||||
expect(output).toContain('RECOMMENDATION');
|
||||
const lowerOut = output.toLowerCase();
|
||||
expect(
|
||||
output.includes('RECOMMENDATION') ||
|
||||
lowerOut.includes('recommend') ||
|
||||
lowerOut.includes('option a') ||
|
||||
lowerOut.includes('which do you want') ||
|
||||
lowerOut.includes('which approach')
|
||||
).toBe(true);
|
||||
}
|
||||
|
||||
// Clean up
|
||||
|
||||
@@ -542,10 +542,12 @@ describe('TODOS-format.md reference consistency', () => {
|
||||
// --- v0.4.1 feature coverage: RECOMMENDATION format, session awareness, enum completeness ---
|
||||
|
||||
describe('v0.4.1 preamble features', () => {
|
||||
const skillsWithPreamble = [
|
||||
'SKILL.md', 'browse/SKILL.md', 'qa/SKILL.md',
|
||||
'qa-only/SKILL.md',
|
||||
'setup-browser-cookies/SKILL.md',
|
||||
// Tier 1 skills have core preamble only (no AskUserQuestion format)
|
||||
const tier1Skills = ['SKILL.md', 'browse/SKILL.md', 'setup-browser-cookies/SKILL.md', 'benchmark/SKILL.md'];
|
||||
|
||||
// Tier 2+ skills have AskUserQuestion format with RECOMMENDATION
|
||||
const tier2PlusSkills = [
|
||||
'qa/SKILL.md', 'qa-only/SKILL.md',
|
||||
'ship/SKILL.md', 'review/SKILL.md',
|
||||
'plan-ceo-review/SKILL.md', 'plan-eng-review/SKILL.md',
|
||||
'retro/SKILL.md',
|
||||
@@ -555,23 +557,25 @@ describe('v0.4.1 preamble features', () => {
|
||||
'design-consultation/SKILL.md',
|
||||
'document-release/SKILL.md',
|
||||
'canary/SKILL.md',
|
||||
'benchmark/SKILL.md',
|
||||
'land-and-deploy/SKILL.md',
|
||||
'setup-deploy/SKILL.md',
|
||||
'cso/SKILL.md',
|
||||
];
|
||||
|
||||
for (const skill of skillsWithPreamble) {
|
||||
const skillsWithPreamble = [...tier1Skills, ...tier2PlusSkills];
|
||||
|
||||
for (const skill of tier2PlusSkills) {
|
||||
test(`${skill} contains RECOMMENDATION format`, () => {
|
||||
const content = fs.readFileSync(path.join(ROOT, skill), 'utf-8');
|
||||
expect(content).toContain('RECOMMENDATION: Choose');
|
||||
expect(content).toContain('AskUserQuestion');
|
||||
});
|
||||
}
|
||||
|
||||
for (const skill of skillsWithPreamble) {
|
||||
test(`${skill} contains session awareness`, () => {
|
||||
const content = fs.readFileSync(path.join(ROOT, skill), 'utf-8');
|
||||
expect(content).toContain('_SESSIONS');
|
||||
expect(content).toContain('RECOMMENDATION');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -754,14 +758,8 @@ describe('Contributor mode preamble structure', () => {
|
||||
for (const skill of skillsWithPreamble) {
|
||||
test(`${skill} has 0-10 rating in contributor mode`, () => {
|
||||
const content = fs.readFileSync(path.join(ROOT, skill), 'utf-8');
|
||||
expect(content).toContain('0 to 10');
|
||||
expect(content).toContain('My rating');
|
||||
});
|
||||
|
||||
test(`${skill} has calibration example`, () => {
|
||||
const content = fs.readFileSync(path.join(ROOT, skill), 'utf-8');
|
||||
expect(content).toContain('Calibration');
|
||||
expect(content).toContain('the bar');
|
||||
expect(content).toContain('0-10');
|
||||
expect(content).toContain('Rating');
|
||||
});
|
||||
|
||||
test(`${skill} has "what would make this a 10" field`, () => {
|
||||
@@ -847,17 +845,12 @@ describe('Completeness Principle in generated SKILL.md files', () => {
|
||||
});
|
||||
}
|
||||
|
||||
test('Completeness Principle includes compression table', () => {
|
||||
const content = fs.readFileSync(path.join(ROOT, 'SKILL.md'), 'utf-8');
|
||||
test('Completeness Principle includes compression table in tier 2+ skills', () => {
|
||||
// Root is tier 1 (no completeness). Check tier 2+ skill.
|
||||
const content = fs.readFileSync(path.join(ROOT, 'cso', 'SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('CC+gstack');
|
||||
expect(content).toContain('Compression');
|
||||
});
|
||||
|
||||
test('Completeness Principle includes anti-patterns', () => {
|
||||
const content = fs.readFileSync(path.join(ROOT, 'SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('BAD:');
|
||||
expect(content).toContain('Anti-patterns');
|
||||
});
|
||||
});
|
||||
|
||||
// --- Part 7: Planted-bug fixture validation (A4) ---
|
||||
@@ -1513,8 +1506,9 @@ describe('Repo mode preamble validation', () => {
|
||||
expect(content).toContain('gstack-repo-mode');
|
||||
});
|
||||
|
||||
test('generated SKILL.md contains See Something Say Something section', () => {
|
||||
const content = fs.readFileSync(path.join(ROOT, 'SKILL.md'), 'utf-8');
|
||||
test('tier 3+ skills contain See Something Say Something section', () => {
|
||||
// Root SKILL.md is tier 1 (no Repo Mode). Check a tier 3 skill instead.
|
||||
const content = fs.readFileSync(path.join(ROOT, 'plan-ceo-review', 'SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('See Something, Say Something');
|
||||
expect(content).toContain('REPO_MODE');
|
||||
expect(content).toContain('solo');
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user