OXYGENHUB
# 5779e5c731a6 oxygen-vscode / src / oxy.ts
7.3 KiB · last changed Milestone 0.2: discard, commit selected paths, commit chosen hunks, amend, undo, Oxygen Operations view with undo to here 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
  }
175
}
176
177
export interface HunkInfo {
178
  index: number;
179
  path: string;
180
  text: string;
181
}
182
183
export interface OperationInfo {
184
  id: string;
185
  when_ms: number;
186
  command: string;
187
  snapshot: boolean;
188
  current: boolean;
bed495a9 Andy McCall ↶ 189
}
190
191
// ---------------------------------------------------------------- types
192
193
export interface VersionInfo {
194
  version: string;
195
  protocol: number;
196
  format: number;
197
}
198
199
export interface RootInfo {
200
  root: string;
201
  workspace: string;
202
  bookmark: string;
203
}
204
205
export type PathStatus = "Added" | "Modified" | "Removed" | "Renamed" | "ModeChanged" | "ConflictEdited" | "Resolved";
206
207
export interface StatusInfo {
208
  bookmark: string;
209
  working_copy: string;
210
  heads: number;
211
  clean: boolean;
212
  conflicted: boolean;
213
  paths: { path: string; status: PathStatus }[];
214
  conflicts: ConflictInfo[];
215
}
216
217
export interface ConflictInfo {
218
  kind: string;
219
  path?: string;
220
  node?: string;
221
  nodes?: string[];
222
  changes?: string[];
223
  resolved?: boolean | "markers";
224
}
225
226
export interface LogEntry {
227
  hash: string;
228
  change_id: string;
229
  author: string;
230
  author_key: string;
231
  when_ms: number;
232
  message: string;
233
  kind: string;
234
  deps: string[];
235
  head: boolean;
236
  working_copy: boolean;
237
  signature: { status: string; key?: string; name?: string; revoked?: boolean; reason?: string } | null;
238
}
239
240
export interface LogInfo {
241
  bookmark: string;
242
  total: number;
243
  changes: LogEntry[];
244
}
245
246
export interface BookmarkInfo {
247
  name: string;
248
  heads: string[];
249
  current: boolean;
250
  ahead: number | null;
251
  behind: number | null;
252
}