OXYGENHUB
# d2c9a5843004 oxygen-vscode / src / oxy.ts
14.2 KiB · last changed Milestone 0.6: Subrepos group, Bisect and Trust views, verify, tags, add workspace, clone 1 month ago source blame history raw
bed495a9 Andy McCall ↶ 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
  }
35d56b58 Andy McCall ↶ 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
  }
c8f95589 Andy McCall ↶ 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
  }
ab81294a Andy McCall ↶ 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
  }
d2c9a584 Andy McCall ↶ 257
258
  // ---------------------------------------------------------------- subrepos, bisect, trust, tags, workspaces
259
260
  submodules(root: string): Promise<SubrepoInfo[]> {
261
    return this.json<SubrepoInfo[]>(root, ["submodule", "status"]);
262
  }
263
264
  submoduleUpdate(root: string, init: boolean, paths: string[] = []): Promise<string> {
265
    return this.check(root, ["submodule", "update", ...(init ? ["--init"] : []), ...paths], [0, 1]).then((r) => r.stdout.toString("utf8"));
266
  }
267
268
  bisectStatus(root: string): Promise<BisectStatus | null> {
269
    return this.json<BisectStatus | null>(root, ["bisect", "status"]);
270
  }
271
272
  bisectStart(root: string): Promise<void> {
273
    return this.check(root, ["bisect", "start"]).then(() => undefined);
274
  }
275
276
  bisectMark(root: string, verdict: "good" | "bad" | "skip", changes: string[] = []): Promise<string> {
277
    return this.check(root, ["bisect", verdict, ...changes]).then((r) => r.stdout.toString("utf8").trim());
278
  }
279
280
  bisectRun(root: string, command: string[]): Promise<string> {
281
    return this.check(root, ["bisect", "run", ...command], [0, 1]).then((r) => r.stdout.toString("utf8").trim());
282
  }
283
284
  bisectReset(root: string): Promise<void> {
285
    return this.check(root, ["bisect", "reset"]).then(() => undefined);
286
  }
287
288
  policyShow(root: string): Promise<PolicyInfo | null> {
289
    return this.json<PolicyInfo | null>(root, ["policy", "show"]);
290
  }
291
292
  verify(root: string, change?: string): Promise<VerifyInfo[]> {
293
    return this.json<VerifyInfo[]>(root, ["verify", ...(change ? [change] : [])], [0, 1]);
294
  }
295
296
  tags(root: string): Promise<TagInfo[]> {
297
    return this.json<TagInfo[]>(root, ["tag", "list"]);
298
  }
299
300
  workspaces(root: string): Promise<WorkspaceInfo[]> {
301
    return this.json<WorkspaceInfo[]>(root, ["workspace", "list"]);
302
  }
303
304
  workspaceAdd(root: string, path: string, changes: string[] = []): Promise<void> {
305
    return this.check(root, ["workspace", "add", path, ...changes]).then(() => undefined);
306
  }
307
}
308
309
export interface SubrepoInfo {
310
  path: string;
311
  pin: PinInfo;
312
  actual: PinInfo | null;
313
  at_pin: boolean;
314
  dirty: boolean;
315
  url: string | null;
316
}
317
318
export type PinInfo = { kind: "oxy"; repo: string; heads: string[]; bookmark: string } | { kind: "git"; sha: string; branch: string };
319
320
export type BisectStatus =
321
  | { state: "need-marks"; good: number; bad: number }
322
  | { state: "testing"; heads: string[]; remaining: number; steps_left: number }
323
  | { state: "found"; culprit: string }
324
  | { state: "inconclusive"; candidates: string[] };
325
326
export interface PolicyInfo {
327
  hash: string;
328
  serial: number;
329
  expires_ms: number;
330
  expired: boolean;
331
  root_threshold: number;
332
  roots: { key: string; name: string; email: string }[];
333
  rules: { pattern: string; keys: { key: string; name: string; email: string }[]; threshold: number; require: string; allow_conflicts: boolean }[];
334
  revoked: { key: string; name: string; email: string }[];
335
}
336
337
export interface VerifyInfo {
338
  hash: string;
339
  change_id: string;
340
  status: string;
341
  key: string | null;
342
  name: string | null;
343
  revoked: boolean;
344
  reason: string | null;
345
}
346
347
export interface TagInfo {
348
  name: string;
349
  state: string;
350
  heads: string[];
351
  message: string;
352
  signer: string | null;
353
}
354
355
export interface WorkspaceInfo {
356
  id: string;
357
  path: string;
358
  current: boolean;
359
  working_copy: string | null;
ab81294a Andy McCall ↶ 360
}
361
362
export interface BlameLine {
363
  line: number;
364
  change_id: string;
365
  hash: string;
366
  index: number;
367
  author: string;
368
  when_ms: number;
369
}
370
371
export interface ShowInfo extends LogEntry {
372
  files: { path: string; kind: string; from: string | null }[];
c8f95589 Andy McCall ↶ 373
}
374
375
export interface RemoteInfo {
376
  name: string;
377
  url: string;
378
  bookmarks: { name: string; heads: string[] }[];
379
}
380
381
export interface FetchInfo {
382
  remote: string;
383
  refs: { name: string; heads: string[]; new: number }[];
384
  new_tags: number;
385
  unverified: boolean;
386
}
387
388
export interface PullInfo {
389
  remote: string;
390
  heads: string[];
391
  changed: boolean;
392
  checked_out: boolean;
393
  conflicted: boolean;
394
}
395
396
export interface PushResult {
397
  name: string;
398
  ok: boolean;
399
  reason: string;
35d56b58 Andy McCall ↶ 400
}
401
402
export interface HunkInfo {
403
  index: number;
404
  path: string;
405
  text: string;
406
}
407
408
export interface OperationInfo {
409
  id: string;
410
  when_ms: number;
411
  command: string;
412
  snapshot: boolean;
413
  current: boolean;
bed495a9 Andy McCall ↶ 414
}
415
416
// ---------------------------------------------------------------- types
417
418
export interface VersionInfo {
419
  version: string;
420
  protocol: number;
421
  format: number;
422
}
423
424
export interface RootInfo {
425
  root: string;
426
  workspace: string;
427
  bookmark: string;
428
}
429
430
export type PathStatus = "Added" | "Modified" | "Removed" | "Renamed" | "ModeChanged" | "ConflictEdited" | "Resolved";
431
432
export interface StatusInfo {
433
  bookmark: string;
434
  working_copy: string;
435
  heads: number;
436
  clean: boolean;
437
  conflicted: boolean;
438
  paths: { path: string; status: PathStatus }[];
439
  conflicts: ConflictInfo[];
440
}
441
442
export interface ConflictInfo {
443
  kind: string;
444
  path?: string;
445
  node?: string;
446
  nodes?: string[];
447
  changes?: string[];
448
  resolved?: boolean | "markers";
449
}
450
451
export interface LogEntry {
452
  hash: string;
453
  change_id: string;
454
  author: string;
455
  author_key: string;
456
  when_ms: number;
457
  message: string;
458
  kind: string;
459
  deps: string[];
460
  head: boolean;
461
  working_copy: boolean;
462
  signature: { status: string; key?: string; name?: string; revoked?: boolean; reason?: string } | null;
463
}
464
465
export interface LogInfo {
466
  bookmark: string;
467
  total: number;
468
  changes: LogEntry[];
469
}
470
471
export interface BookmarkInfo {
472
  name: string;
473
  heads: string[];
474
  current: boolean;
475
  ahead: number | null;
476
  behind: number | null;
477
}