From caed2874960d3f53b02ce74579bf943483972540 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 15 Mar 2026 02:02:32 -0500 Subject: [PATCH] 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) --- lib/util.ts | 126 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 lib/util.ts diff --git a/lib/util.ts b/lib/util.ts new file mode 100644 index 00000000..7dba7f97 --- /dev/null +++ b/lib/util.ts @@ -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(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, '-'); +}