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