mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-04 04:35:09 +02:00
7f7035f55a
DRY up eval I/O duplicated across scripts/eval-list.ts, eval-compare.ts, and eval-summary.ts. Adds EVAL_DIR constant, formatTimestamp(), listEvalFiles(), loadEvalResults() with --limit support. 13 new tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
171 lines
5.3 KiB
TypeScript
171 lines
5.3 KiB
TypeScript
/**
|
|
* Shared utilities for gstack.
|
|
*
|
|
* Extracted from eval-store.ts, session-runner.ts, eval-watch.ts to avoid
|
|
* duplication. All functions are pure or side-effect-minimal.
|
|
*/
|
|
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import * as os from 'os';
|
|
import { spawnSync } from 'child_process';
|
|
|
|
// --- Paths ---
|
|
|
|
export const GSTACK_STATE_DIR = process.env.GSTACK_STATE_DIR || path.join(os.homedir(), '.gstack');
|
|
export const GSTACK_DEV_DIR = path.join(os.homedir(), '.gstack-dev');
|
|
|
|
// --- File I/O ---
|
|
|
|
/** Atomic write: write to .tmp then rename. Non-fatal on error. */
|
|
export function atomicWriteSync(filePath: string, data: string): void {
|
|
const tmp = filePath + '.tmp';
|
|
fs.writeFileSync(tmp, data);
|
|
fs.renameSync(tmp, filePath);
|
|
}
|
|
|
|
/** Atomic JSON write: stringify + atomic write. Creates parent dirs. */
|
|
export function atomicWriteJSON(filePath: string, data: unknown, mode?: number): void {
|
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
const content = JSON.stringify(data, null, 2) + '\n';
|
|
atomicWriteSync(filePath, content);
|
|
if (mode !== undefined) {
|
|
fs.chmodSync(filePath, mode);
|
|
}
|
|
}
|
|
|
|
/** Read and parse a JSON file, returning null on any error. */
|
|
export function readJSON<T>(filePath: string): T | null {
|
|
try {
|
|
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// --- Git ---
|
|
|
|
/** Detect the git repository root, or null if not in a repo. */
|
|
export function getGitRoot(): string | null {
|
|
try {
|
|
const proc = spawnSync('git', ['rev-parse', '--show-toplevel'], {
|
|
stdio: 'pipe',
|
|
timeout: 2_000,
|
|
});
|
|
if (proc.status !== 0) return null;
|
|
return proc.stdout?.toString().trim() || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Get current branch name and short SHA. */
|
|
export function getGitInfo(): { branch: string; sha: string } {
|
|
try {
|
|
const branch = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { stdio: 'pipe', timeout: 5000 });
|
|
const sha = spawnSync('git', ['rev-parse', '--short', 'HEAD'], { stdio: 'pipe', timeout: 5000 });
|
|
return {
|
|
branch: branch.stdout?.toString().trim() || 'unknown',
|
|
sha: sha.stdout?.toString().trim() || 'unknown',
|
|
};
|
|
} catch {
|
|
return { branch: 'unknown', sha: 'unknown' };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Derive a slug from the git remote origin URL (owner-repo format).
|
|
* Falls back to the directory basename if no remote is configured.
|
|
*/
|
|
export function getRemoteSlug(): string {
|
|
try {
|
|
const proc = spawnSync('git', ['remote', 'get-url', 'origin'], {
|
|
stdio: 'pipe',
|
|
timeout: 2_000,
|
|
});
|
|
if (proc.status !== 0) throw new Error('no remote');
|
|
const url = proc.stdout?.toString().trim() || '';
|
|
// SSH: git@github.com:owner/repo.git → owner-repo
|
|
// HTTPS: https://github.com/owner/repo.git → owner-repo
|
|
const match = url.match(/[:/]([^/]+)\/([^/]+?)(?:\.git)?$/);
|
|
if (match) return `${match[1]}-${match[2]}`;
|
|
throw new Error('unparseable');
|
|
} catch {
|
|
const root = getGitRoot();
|
|
return path.basename(root || process.cwd());
|
|
}
|
|
}
|
|
|
|
// --- Version ---
|
|
|
|
/** Read the gstack version from package.json. */
|
|
export function getVersion(): string {
|
|
try {
|
|
// Try relative to this file first (lib/), then try common locations
|
|
const candidates = [
|
|
path.resolve(__dirname, '..', 'package.json'),
|
|
path.resolve(__dirname, '..', '..', 'package.json'),
|
|
];
|
|
for (const pkgPath of candidates) {
|
|
try {
|
|
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
if (pkg.version) return pkg.version;
|
|
} catch { continue; }
|
|
}
|
|
return 'unknown';
|
|
} catch {
|
|
return 'unknown';
|
|
}
|
|
}
|
|
|
|
// --- Eval I/O ---
|
|
|
|
export const EVAL_DIR = path.join(GSTACK_DEV_DIR, 'evals');
|
|
|
|
/** Format ISO timestamp to "YYYY-MM-DD HH:MM" for display. */
|
|
export function formatTimestamp(iso: string): string {
|
|
return iso.replace('T', ' ').slice(0, 16);
|
|
}
|
|
|
|
/**
|
|
* List JSON eval files in the eval directory, sorted by filename descending (newest first).
|
|
* Returns full paths. Returns empty array if directory doesn't exist.
|
|
*/
|
|
export function listEvalFiles(evalDir?: string): string[] {
|
|
const dir = evalDir || EVAL_DIR;
|
|
try {
|
|
const files = fs.readdirSync(dir)
|
|
.filter(f => f.endsWith('.json') && !f.startsWith('_'));
|
|
files.sort().reverse();
|
|
return files.map(f => path.join(dir, f));
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load and parse all eval result JSON files from the eval directory.
|
|
* Skips files that fail to parse. Sorted newest-first by timestamp.
|
|
* Optional limit returns only the N most recent.
|
|
*/
|
|
export function loadEvalResults<T = unknown>(evalDir?: string, limit?: number): T[] {
|
|
const files = listEvalFiles(evalDir);
|
|
const results: Array<{ data: T; timestamp: string }> = [];
|
|
for (const file of files) {
|
|
try {
|
|
const data = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
results.push({ data, timestamp: data.timestamp || '' });
|
|
} catch { continue; }
|
|
}
|
|
results.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
|
const sliced = limit ? results.slice(0, limit) : results;
|
|
return sliced.map(r => r.data);
|
|
}
|
|
|
|
// --- String helpers ---
|
|
|
|
/** Sanitize a name for use as a filename: strip leading slashes, replace / with - */
|
|
export function sanitizeForFilename(name: string): string {
|
|
return name.replace(/^\/+/, '').replace(/\//g, '-');
|
|
}
|