OXYGENHUB
# bed495a96408 oxygen-vscode / src / oxy.ts
6.0 KiB · last changed Milestone 0.1: source control view, diffs over the oxy: scheme, quick diff, decorations, status bar, commit, quick-pick log; process wrapper with a Node test; packaging 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
144
// ---------------------------------------------------------------- types
145
146
export interface VersionInfo {
147
  version: string;
148
  protocol: number;
149
  format: number;
150
}
151
152
export interface RootInfo {
153
  root: string;
154
  workspace: string;
155
  bookmark: string;
156
}
157
158
export type PathStatus = "Added" | "Modified" | "Removed" | "Renamed" | "ModeChanged" | "ConflictEdited" | "Resolved";
159
160
export interface StatusInfo {
161
  bookmark: string;
162
  working_copy: string;
163
  heads: number;
164
  clean: boolean;
165
  conflicted: boolean;
166
  paths: { path: string; status: PathStatus }[];
167
  conflicts: ConflictInfo[];
168
}
169
170
export interface ConflictInfo {
171
  kind: string;
172
  path?: string;
173
  node?: string;
174
  nodes?: string[];
175
  changes?: string[];
176
  resolved?: boolean | "markers";
177
}
178
179
export interface LogEntry {
180
  hash: string;
181
  change_id: string;
182
  author: string;
183
  author_key: string;
184
  when_ms: number;
185
  message: string;
186
  kind: string;
187
  deps: string[];
188
  head: boolean;
189
  working_copy: boolean;
190
  signature: { status: string; key?: string; name?: string; revoked?: boolean; reason?: string } | null;
191
}
192
193
export interface LogInfo {
194
  bookmark: string;
195
  total: number;
196
  changes: LogEntry[];
197
}
198
199
export interface BookmarkInfo {
200
  name: string;
201
  heads: string[];
202
  current: boolean;
203
  ahead: number | null;
204
  behind: number | null;
205
}