// 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 { SigningInfo, parseIdentityShow } from "./commitbox"; 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"]); } /** Restore paths to the working copy's base. */ discard(root: string, paths: string[]): Promise { return this.check(root, ["discard", ...paths]).then(() => undefined); } discardHunks(root: string, hunks: number[]): Promise { return this.check(root, ["discard", "--hunks", hunks.join(",")]).then(() => undefined); } hunks(root: string): Promise { return this.json(root, ["hunks"]); } commitHunks(root: string, message: string, hunks: number[]): Promise { return this.check(root, ["commit", "-m", message, "--hunks", hunks.join(",")]).then(() => undefined); } amend(root: string, message?: string): Promise { const args = ["amend"]; if (message) { args.push("-m", message); } return this.check(root, args).then(() => undefined); } undo(root: string): Promise { return this.check(root, ["undo"]).then((r) => r.stdout.toString("utf8").trim()); } opLog(root: string): Promise { return this.json(root, ["op", "log"]); } // ---------------------------------------------------------------- bookmarks bookmarkCreate(root: string, name: string, changes: string[] = []): Promise { return this.check(root, ["bookmark", "create", name, ...changes]).then(() => undefined); } bookmarkSwitch(root: string, name: string, discard = false): Promise { const args = ["bookmark", "switch", name]; if (discard) { args.push("--discard"); } return this.check(root, args).then(() => undefined); } bookmarkDelete(root: string, name: string): Promise { return this.check(root, ["bookmark", "delete", name]).then(() => undefined); } merge(root: string, name: string): Promise { return this.check(root, ["merge", name]).then((r) => r.stdout.toString("utf8").trim()); } // ---------------------------------------------------------------- remotes and sync remotes(root: string): Promise { return this.json(root, ["remote", "list"]); } fetch(root: string, remote?: string): Promise { return this.json(root, ["fetch", ...(remote ? [remote] : [])]); } pull(root: string, remote?: string): Promise { return this.json(root, ["pull", ...(remote ? [remote] : [])]); } push(root: string, remote?: string, bookmark?: string, force = false): Promise { const args = ["push", ...(remote ? [remote] : [])]; if (bookmark) { args.push("-b", bookmark); } if (force) { args.push("--force"); } return this.json(root, args, [0, 1]); } clone(cwd: string, url: string, dest: string): Promise { return this.check(cwd, ["clone", url, dest]).then(() => undefined); } // ---------------------------------------------------------------- history and blame blame(root: string, path: string): Promise { return this.json(root, ["blame", path]); } show(root: string, change: string): Promise { return this.json(root, ["show", change]); } /** The rendered diff of a change (or the working copy), as `oxy diff` prints it. */ diffText(root: string, change?: string): Promise { return this.check(root, ["diff", ...(change ? [change] : [])]).then((r) => r.stdout.toString("utf8")); } describe(root: string, change: string, message: string): Promise { return this.check(root, ["describe", change, "-m", message]).then(() => undefined); } abandon(root: string, change: string): Promise { return this.check(root, ["abandon", change]).then((r) => r.stdout.toString("utf8").trim()); } /** The configured identity and how commits are signed; `null` without one. */ identity(root: string): Promise { return this.check(root, ["identity", "show"]).then((r) => parseIdentityShow(r.stdout.toString("utf8"))); } /** Split a head: the edits to `paths` become a separate first change. Returns oxy's summary line. */ split(root: string, change: string, paths: string[]): Promise { return this.check(root, ["split", change, ...paths]).then((r) => r.stdout.toString("utf8").trim()); } /** Fold `change` into its dependency `into`, which keeps its ChangeId. */ squash(root: string, change: string, into: string): Promise { return this.check(root, ["squash", change, "--into", into]).then((r) => r.stdout.toString("utf8").trim()); } /** Re-express `change` against the state of `onto` (changes, a bookmark or a tag). */ rebase(root: string, change: string, onto: string[]): Promise { return this.check(root, ["rebase", change, "--onto", ...onto]).then((r) => r.stdout.toString("utf8").trim()); } checkout(root: string, changes: string[], discard = false): Promise { return this.check(root, ["checkout", ...changes, ...(discard ? ["--discard"] : [])]).then(() => undefined); } tagCreate(root: string, name: string, changes: string[] = [], message = ""): Promise { return this.check(root, ["tag", "create", name, ...changes, "-m", message]).then(() => undefined); } // ---------------------------------------------------------------- subrepos, bisect, trust, tags, workspaces submodules(root: string): Promise { return this.json(root, ["submodule", "status"]); } submoduleUpdate(root: string, init: boolean, paths: string[] = []): Promise { return this.check(root, ["submodule", "update", ...(init ? ["--init"] : []), ...paths], [0, 1]).then((r) => r.stdout.toString("utf8")); } bisectStatus(root: string): Promise { return this.json(root, ["bisect", "status"]); } bisectStart(root: string): Promise { return this.check(root, ["bisect", "start"]).then(() => undefined); } bisectMark(root: string, verdict: "good" | "bad" | "skip", changes: string[] = []): Promise { return this.check(root, ["bisect", verdict, ...changes]).then((r) => r.stdout.toString("utf8").trim()); } bisectRun(root: string, command: string[]): Promise { return this.check(root, ["bisect", "run", ...command], [0, 1]).then((r) => r.stdout.toString("utf8").trim()); } bisectReset(root: string): Promise { return this.check(root, ["bisect", "reset"]).then(() => undefined); } policyShow(root: string): Promise { return this.json(root, ["policy", "show"]); } verify(root: string, change?: string): Promise { return this.json(root, ["verify", ...(change ? [change] : [])], [0, 1]); } tags(root: string): Promise { return this.json(root, ["tag", "list"]); } workspaces(root: string): Promise { return this.json(root, ["workspace", "list"]); } workspaceAdd(root: string, path: string, changes: string[] = []): Promise { return this.check(root, ["workspace", "add", path, ...changes]).then(() => undefined); } } export interface SubrepoInfo { path: string; pin: PinInfo; actual: PinInfo | null; at_pin: boolean; dirty: boolean; url: string | null; } export type PinInfo = { kind: "oxy"; repo: string; heads: string[]; bookmark: string } | { kind: "git"; sha: string; branch: string }; export type BisectStatus = | { state: "need-marks"; good: number; bad: number } | { state: "testing"; heads: string[]; remaining: number; steps_left: number } | { state: "found"; culprit: string } | { state: "inconclusive"; candidates: string[] }; export interface PolicyInfo { hash: string; serial: number; expires_ms: number; expired: boolean; root_threshold: number; roots: { key: string; name: string; email: string }[]; rules: { pattern: string; keys: { key: string; name: string; email: string }[]; threshold: number; require: string; allow_conflicts: boolean }[]; revoked: { key: string; name: string; email: string }[]; } export interface VerifyInfo { hash: string; change_id: string; status: string; key: string | null; name: string | null; revoked: boolean; reason: string | null; } export interface TagInfo { name: string; state: string; heads: string[]; message: string; signer: string | null; } export interface WorkspaceInfo { id: string; path: string; current: boolean; working_copy: string | null; } export interface BlameLine { line: number; change_id: string; hash: string; index: number; author: string; when_ms: number; } export interface ShowInfo extends LogEntry { files: { path: string; kind: string; from: string | null }[]; } export interface RemoteInfo { name: string; url: string; bookmarks: { name: string; heads: string[] }[]; } export interface FetchInfo { remote: string; refs: { name: string; heads: string[]; new: number }[]; new_tags: number; unverified: boolean; } export interface PullInfo { remote: string; heads: string[]; changed: boolean; checked_out: boolean; conflicted: boolean; } export interface PushResult { name: string; ok: boolean; reason: string; } export interface HunkInfo { index: number; path: string; text: string; } export interface OperationInfo { id: string; when_ms: number; command: string; snapshot: boolean; current: boolean; } // ---------------------------------------------------------------- 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; }