feat: extract shared utilities into lib/util.ts

DRY up atomicWriteSync, readJSON, getGitInfo, getVersion, getRemoteSlug,
and sanitizeForFilename from eval-store.ts, session-runner.ts, and
eval-watch.ts into a shared module.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-03-15 02:02:32 -05:00
parent 5c1ea088d8
commit caed287496
+126
View File
@@ -0,0 +1,126 @@
/**
* 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';
}
}
// --- 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, '-');
}