// The oxy process wrapper: one function per command the extension uses, // all through `oxy --plain --json` (spec §4). This module never imports // the vscode API so it can be tested with plain Node. import { execFile } from "node:child_process"; import { existsSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; export interface RunResult { code: number; stdout: Buffer; stderr: string; } export class OxyError extends Error { constructor( public readonly args: string[], public readonly code: number, public readonly stderr: string, ) { super(stderr.trim() || `oxy ${args.join(" ")} exited with status ${code}`); this.name = "OxyError"; } } /** Where to find `oxy`: the configured path, else `oxy` on PATH, else ~/.local/bin/oxy. */ export function findBinary(configured: string | undefined): string { if (configured && configured.trim()) { return configured.trim(); } const local = join(homedir(), ".local", "bin", "oxy"); return existsSync(local) ? local : "oxy"; } export type Logger = (line: string) => void; export class Oxy { constructor( public readonly binary: string, private readonly logger: Logger = () => {}, ) {} /** Run oxy in `cwd`; resolves with the exit status, never rejects on a non-zero status. */ run(cwd: string, args: string[]): Promise { const started = Date.now(); const full = ["--plain", ...args]; return new Promise((resolve, reject) => { execFile( this.binary, full, { cwd, maxBuffer: 256 * 1024 * 1024, encoding: "buffer", env: { ...process.env, NO_COLOR: "1" } }, (error, stdout, stderr) => { const err = stderr.toString("utf8"); const code = error && typeof (error as NodeJS.ErrnoException).code === "number" ? ((error as NodeJS.ErrnoException).code as unknown as number) : error ? -1 : 0; this.logger(`oxy ${args.join(" ")} (${cwd}) → ${code} in ${Date.now() - started} ms${err.trim() ? `: ${err.trim()}` : ""}`); if (error && (error as NodeJS.ErrnoException).code === "ENOENT") { reject(new OxyError(full, -1, `oxy not found at ${this.binary}`)); return; } resolve({ code, stdout: stdout as Buffer, stderr: err }); }, ); }); } /** Run and require success (exit 0, or one of `okCodes`). */ async check(cwd: string, args: string[], okCodes: number[] = [0]): Promise { const r = await this.run(cwd, args); if (!okCodes.includes(r.code)) { throw new OxyError(args, r.code, r.stderr); } return r; } async json(cwd: string, args: string[], okCodes: number[] = [0]): Promise { const r = await this.check(cwd, [...args, "--json"], okCodes); const text = r.stdout.toString("utf8").trim(); try { return JSON.parse(text) as T; } catch (e) { throw new OxyError(args, r.code, `oxy ${args.join(" ")} did not print JSON: ${text.slice(0, 200)}`); } } // ---------------------------------------------------------------- commands async version(): Promise { return this.json(process.cwd(), ["version"]); } /** The repository containing `cwd`, or null when there is none. */ async root(cwd: string): Promise { const r = await this.run(cwd, ["root", "--json"]); if (r.code !== 0) { return null; } const v = JSON.parse(r.stdout.toString("utf8")); return v === null ? null : (v as RootInfo); } status(root: string): Promise { return this.json(root, ["status"], [0, 2]); } async prompt(root: string, format?: string): Promise { const args = ["prompt", "--glyphs"]; if (format) { args.push("--format", format); } const r = await this.run(root, args); return r.code === 0 ? r.stdout.toString("utf8").trim() : ""; } /** File content at a state; `change` may be a hash prefix, id prefix, bookmark, or tag. */ async cat(root: string, path: string, change?: string): Promise { const args = ["cat", path]; if (change) { args.push("-r", change); } return (await this.check(root, args)).stdout; } log(root: string, limit?: number, path?: string): Promise { const args = ["log"]; if (limit !== undefined) { args.push("-n", String(limit)); } if (path) { args.push("--path", path); } return this.json(root, args); } commit(root: string, message: string, paths: string[] = []): Promise { return this.check(root, ["commit", "-m", message, ...paths]).then(() => undefined); } bookmarks(root: string): Promise { return this.json(root, ["bookmark", "list"]); } } // ---------------------------------------------------------------- types export interface VersionInfo { version: string; protocol: number; format: number; } export interface RootInfo { root: string; workspace: string; bookmark: string; } export type PathStatus = "Added" | "Modified" | "Removed" | "Renamed" | "ModeChanged" | "ConflictEdited" | "Resolved"; export interface StatusInfo { bookmark: string; working_copy: string; heads: number; clean: boolean; conflicted: boolean; paths: { path: string; status: PathStatus }[]; conflicts: ConflictInfo[]; } export interface ConflictInfo { kind: string; path?: string; node?: string; nodes?: string[]; changes?: string[]; resolved?: boolean | "markers"; } export interface LogEntry { hash: string; change_id: string; author: string; author_key: string; when_ms: number; message: string; kind: string; deps: string[]; head: boolean; working_copy: boolean; signature: { status: string; key?: string; name?: string; revoked?: boolean; reason?: string } | null; } export interface LogInfo { bookmark: string; total: number; changes: LogEntry[]; } export interface BookmarkInfo { name: string; heads: string[]; current: boolean; ahead: number | null; behind: number | null; }