mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-06 21:46:40 +02:00
Merge branch 'main' into garrytan/team-supabase-store
Brings in 55 commits from main (v0.12.x–v0.13.5.0): Factory Droid compat, prompt injection defense, user sovereignty, security audit, design binary, skill namespacing, modular resolvers, Chrome sidebar, and more. Conflict resolution: - .agents/ SKILL.md files: deleted (main moved to .factory/) - 8 .tmpl templates: accepted main (new features: CDP mode, design tools, global retro, parallelization, distribution checks, plan audits) - scripts/gen-skill-docs.ts: accepted main's modular resolver refactor - test/helpers/session-runner.ts: accepted main + layered back CostEntry tracking from team branch - Generated SKILL.md files: regenerated via bun run gen:skill-docs - Updated tests to match main's gstack-slug output (2 lines, no PROJECTS_DIR) and review log mechanism (gstack-review-log, not $BRANCH.jsonl) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+299
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* 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 })) {
|
||||
// Skip symlinks to avoid infinite recursion (e.g., .claude/skills/gstack → repo root)
|
||||
if (entry.isSymbolicLink()) continue;
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user