OXYGENHUB
# 25103dddd993 oxygen-vscode / src / oxy.ts
11.0 KiB · last changed Milestone 0.4: history graph webview with per-change actions, file history diffs between versions, inline blame aligned to unsaved edits with a Myers line diff 1 month ago source blame history raw
1
// The oxy process wrapper: one function per command the extension uses,
2
// all through `oxy --plain --json` (spec §4). This module never imports
3
// the vscode API so it can be tested with plain Node.
4
5
import { execFile } from "node:child_process";
6
import { existsSync } from "node:fs";
7
import { homedir } from "node:os";
8
import { join } from "node:path";
9
10
export interface RunResult {
11
  code: number;
12
  stdout: Buffer;
13
  stderr: string;
14
}
15
16
export class OxyError extends Error {
17
  constructor(
18
    public readonly args: string[],
19
    public readonly code: number,
20
    public readonly stderr: string,
21
  ) {
22
    super(stderr.trim() || `oxy ${args.join(" ")} exited with status ${code}`);
23
    this.name = "OxyError";
24
  }
25
}
26
27
/** Where to find `oxy`: the configured path, else `oxy` on PATH, else ~/.local/bin/oxy. */
28
export function findBinary(configured: string | undefined): string {
29
  if (configured && configured.trim()) {
30
    return configured.trim();
31
  }
32
  const local = join(homedir(), ".local", "bin", "oxy");
33
  return existsSync(local) ? local : "oxy";
34
}
35
36
export type Logger = (line: string) => void;
37
38
export class Oxy {
39
  constructor(
40
    public readonly binary: string,
41
    private readonly logger: Logger = () => {},
42
  ) {}
43
44
  /** Run oxy in `cwd`; resolves with the exit status, never rejects on a non-zero status. */
45
  run(cwd: string, args: string[]): Promise<RunResult> {
46
    const started = Date.now();
47
    const full = ["--plain", ...args];
48
    return new Promise((resolve, reject) => {
49
      execFile(
50
        this.binary,
51
        full,
52
        { cwd, maxBuffer: 256 * 1024 * 1024, encoding: "buffer", env: { ...process.env, NO_COLOR: "1" } },
53
        (error, stdout, stderr) => {
54
          const err = stderr.toString("utf8");
55
          const code = error && typeof (error as NodeJS.ErrnoException).code === "number" ? ((error as NodeJS.ErrnoException).code as unknown as number) : error ? -1 : 0;
56
          this.logger(`oxy ${args.join(" ")} (${cwd}) → ${code} in ${Date.now() - started} ms${err.trim() ? `: ${err.trim()}` : ""}`);
57
          if (error && (error as NodeJS.ErrnoException).code === "ENOENT") {
58
            reject(new OxyError(full, -1, `oxy not found at ${this.binary}`));
59
            return;
60
          }
61
          resolve({ code, stdout: stdout as Buffer, stderr: err });
62
        },
63
      );
64
    });
65
  }
66
67
  /** Run and require success (exit 0, or one of `okCodes`). */
68
  async check(cwd: string, args: string[], okCodes: number[] = [0]): Promise<RunResult> {
69
    const r = await this.run(cwd, args);
70
    if (!okCodes.includes(r.code)) {
71
      throw new OxyError(args, r.code, r.stderr);
72
    }
73
    return r;
74
  }
75
76
  async json<T>(cwd: string, args: string[], okCodes: number[] = [0]): Promise<T> {
77
    const r = await this.check(cwd, [...args, "--json"], okCodes);
78
    const text = r.stdout.toString("utf8").trim();
79
    try {
80
      return JSON.parse(text) as T;
81
    } catch (e) {
82
      throw new OxyError(args, r.code, `oxy ${args.join(" ")} did not print JSON: ${text.slice(0, 200)}`);
83
    }
84
  }
85
86
  // ---------------------------------------------------------------- commands
87
88
  async version(): Promise<VersionInfo> {
89
    return this.json<VersionInfo>(process.cwd(), ["version"]);
90
  }
91
92
  /** The repository containing `cwd`, or null when there is none. */
93
  async root(cwd: string): Promise<RootInfo | null> {
94
    const r = await this.run(cwd, ["root", "--json"]);
95
    if (r.code !== 0) {
96
      return null;
97
    }
98
    const v = JSON.parse(r.stdout.toString("utf8"));
99
    return v === null ? null : (v as RootInfo);
100
  }
101
102
  status(root: string): Promise<StatusInfo> {
103
    return this.json<StatusInfo>(root, ["status"], [0, 2]);
104
  }
105
106
  async prompt(root: string, format?: string): Promise<string> {
107
    const args = ["prompt", "--glyphs"];
108
    if (format) {
109
      args.push("--format", format);
110
    }
111
    const r = await this.run(root, args);
112
    return r.code === 0 ? r.stdout.toString("utf8").trim() : "";
113
  }
114
115
  /** File content at a state; `change` may be a hash prefix, id prefix, bookmark, or tag. */
116
  async cat(root: string, path: string, change?: string): Promise<Buffer> {
117
    const args = ["cat", path];
118
    if (change) {
119
      args.push("-r", change);
120
    }
121
    return (await this.check(root, args)).stdout;
122
  }
123
124
  log(root: string, limit?: number, path?: string): Promise<LogInfo> {
125
    const args = ["log"];
126
    if (limit !== undefined) {
127
      args.push("-n", String(limit));
128
    }
129
    if (path) {
130
      args.push("--path", path);
131
    }
132
    return this.json<LogInfo>(root, args);
133
  }
134
135
  commit(root: string, message: string, paths: string[] = []): Promise<void> {
136
    return this.check(root, ["commit", "-m", message, ...paths]).then(() => undefined);
137
  }
138
139
  bookmarks(root: string): Promise<BookmarkInfo[]> {
140
    return this.json<BookmarkInfo[]>(root, ["bookmark", "list"]);
141
  }
142
143
  /** Restore paths to the working copy's base. */
144
  discard(root: string, paths: string[]): Promise<void> {
145
    return this.check(root, ["discard", ...paths]).then(() => undefined);
146
  }
147
148
  discardHunks(root: string, hunks: number[]): Promise<void> {
149
    return this.check(root, ["discard", "--hunks", hunks.join(",")]).then(() => undefined);
150
  }
151
152
  hunks(root: string): Promise<HunkInfo[]> {
153
    return this.json<HunkInfo[]>(root, ["hunks"]);
154
  }
155
156
  commitHunks(root: string, message: string, hunks: number[]): Promise<void> {
157
    return this.check(root, ["commit", "-m", message, "--hunks", hunks.join(",")]).then(() => undefined);
158
  }
159
160
  amend(root: string, message?: string): Promise<void> {
161
    const args = ["amend"];
162
    if (message) {
163
      args.push("-m", message);
164
    }
165
    return this.check(root, args).then(() => undefined);
166
  }
167
168
  undo(root: string): Promise<string> {
169
    return this.check(root, ["undo"]).then((r) => r.stdout.toString("utf8").trim());
170
  }
171
172
  opLog(root: string): Promise<OperationInfo[]> {
173
    return this.json<OperationInfo[]>(root, ["op", "log"]);
174
  }
175
176
  // ---------------------------------------------------------------- bookmarks
177
178
  bookmarkCreate(root: string, name: string, changes: string[] = []): Promise<void> {
179
    return this.check(root, ["bookmark", "create", name, ...changes]).then(() => undefined);
180
  }
181
182
  bookmarkSwitch(root: string, name: string, discard = false): Promise<void> {
183
    const args = ["bookmark", "switch", name];
184
    if (discard) {
185
      args.push("--discard");
186
    }
187
    return this.check(root, args).then(() => undefined);
188
  }
189
190
  bookmarkDelete(root: string, name: string): Promise<void> {
191
    return this.check(root, ["bookmark", "delete", name]).then(() => undefined);
192
  }
193
194
  merge(root: string, name: string): Promise<string> {
195
    return this.check(root, ["merge", name]).then((r) => r.stdout.toString("utf8").trim());
196
  }
197
198
  // ---------------------------------------------------------------- remotes and sync
199
200
  remotes(root: string): Promise<RemoteInfo[]> {
201
    return this.json<RemoteInfo[]>(root, ["remote", "list"]);
202
  }
203
204
  fetch(root: string, remote?: string): Promise<FetchInfo> {
205
    return this.json<FetchInfo>(root, ["fetch", ...(remote ? [remote] : [])]);
206
  }
207
208
  pull(root: string, remote?: string): Promise<PullInfo> {
209
    return this.json<PullInfo>(root, ["pull", ...(remote ? [remote] : [])]);
210
  }
211
212
  push(root: string, remote?: string, bookmark?: string, force = false): Promise<PushResult[]> {
213
    const args = ["push", ...(remote ? [remote] : [])];
214
    if (bookmark) {
215
      args.push("-b", bookmark);
216
    }
217
    if (force) {
218
      args.push("--force");
219
    }
220
    return this.json<PushResult[]>(root, args, [0, 1]);
221
  }
222
223
  clone(cwd: string, url: string, dest: string): Promise<void> {
224
    return this.check(cwd, ["clone", url, dest]).then(() => undefined);
225
  }
226
227
  // ---------------------------------------------------------------- history and blame
228
229
  blame(root: string, path: string): Promise<BlameLine[]> {
230
    return this.json<BlameLine[]>(root, ["blame", path]);
231
  }
232
233
  show(root: string, change: string): Promise<ShowInfo> {
234
    return this.json<ShowInfo>(root, ["show", change]);
235
  }
236
237
  /** The rendered diff of a change (or the working copy), as `oxy diff` prints it. */
238
  diffText(root: string, change?: string): Promise<string> {
239
    return this.check(root, ["diff", ...(change ? [change] : [])]).then((r) => r.stdout.toString("utf8"));
240
  }
241
242
  describe(root: string, change: string, message: string): Promise<void> {
243
    return this.check(root, ["describe", change, "-m", message]).then(() => undefined);
244
  }
245
246
  abandon(root: string, change: string): Promise<string> {
247
    return this.check(root, ["abandon", change]).then((r) => r.stdout.toString("utf8").trim());
248
  }
249
250
  checkout(root: string, changes: string[], discard = false): Promise<void> {
251
    return this.check(root, ["checkout", ...changes, ...(discard ? ["--discard"] : [])]).then(() => undefined);
252
  }
253
254
  tagCreate(root: string, name: string, changes: string[] = [], message = ""): Promise<void> {
255
    return this.check(root, ["tag", "create", name, ...changes, "-m", message]).then(() => undefined);
256
  }
257
}
258
259
export interface BlameLine {
260
  line: number;
261
  change_id: string;
262
  hash: string;
263
  index: number;
264
  author: string;
265
  when_ms: number;
266
}
267
268
export interface ShowInfo extends LogEntry {
269
  files: { path: string; kind: string; from: string | null }[];
270
}
271
272
export interface RemoteInfo {
273
  name: string;
274
  url: string;
275
  bookmarks: { name: string; heads: string[] }[];
276
}
277
278
export interface FetchInfo {
279
  remote: string;
280
  refs: { name: string; heads: string[]; new: number }[];
281
  new_tags: number;
282
  unverified: boolean;
283
}
284
285
export interface PullInfo {
286
  remote: string;
287
  heads: string[];
288
  changed: boolean;
289
  checked_out: boolean;
290
  conflicted: boolean;
291
}
292
293
export interface PushResult {
294
  name: string;
295
  ok: boolean;
296
  reason: string;
297
}
298
299
export interface HunkInfo {
300
  index: number;
301
  path: string;
302
  text: string;
303
}
304
305
export interface OperationInfo {
306
  id: string;
307
  when_ms: number;
308
  command: string;
309
  snapshot: boolean;
310
  current: boolean;
311
}
312
313
// ---------------------------------------------------------------- types
314
315
export interface VersionInfo {
316
  version: string;
317
  protocol: number;
318
  format: number;
319
}
320
321
export interface RootInfo {
322
  root: string;
323
  workspace: string;
324
  bookmark: string;
325
}
326
327
export type PathStatus = "Added" | "Modified" | "Removed" | "Renamed" | "ModeChanged" | "ConflictEdited" | "Resolved";
328
329
export interface StatusInfo {
330
  bookmark: string;
331
  working_copy: string;
332
  heads: number;
333
  clean: boolean;
334
  conflicted: boolean;
335
  paths: { path: string; status: PathStatus }[];
336
  conflicts: ConflictInfo[];
337
}
338
339
export interface ConflictInfo {
340
  kind: string;
341
  path?: string;
342
  node?: string;
343
  nodes?: string[];
344
  changes?: string[];
345
  resolved?: boolean | "markers";
346
}
347
348
export interface LogEntry {
349
  hash: string;
350
  change_id: string;
351
  author: string;
352
  author_key: string;
353
  when_ms: number;
354
  message: string;
355
  kind: string;
356
  deps: string[];
357
  head: boolean;
358
  working_copy: boolean;
359
  signature: { status: string; key?: string; name?: string; revoked?: boolean; reason?: string } | null;
360
}
361
362
export interface LogInfo {
363
  bookmark: string;
364
  total: number;
365
  changes: LogEntry[];
366
}
367
368
export interface BookmarkInfo {
369
  name: string;
370
  heads: string[];
371
  current: boolean;
372
  ahead: number | null;
373
  behind: number | null;
374
}