@oh-my-pi/pi-coding-agent 15.8.0 → 15.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/utils/git.ts CHANGED
@@ -189,11 +189,7 @@ function formatCommandFailure(
189
189
  return `git ${args.join(" ")} failed with exit code ${result.exitCode}`;
190
190
  }
191
191
 
192
- async function runCommand(
193
- cwd: string,
194
- args: readonly string[],
195
- options: CommandOptions = {},
196
- ): Promise<GitCommandResult> {
192
+ async function git(cwd: string, args: readonly string[], options: CommandOptions = {}): Promise<GitCommandResult> {
197
193
  const commandArgs = withShortLivedGitConfig(options.readOnly ? withNoOptionalLocks(args) : [...args]);
198
194
  const child = Bun.spawn(["git", ...commandArgs], {
199
195
  cwd,
@@ -248,7 +244,7 @@ async function runChecked(
248
244
  options: CommandOptions = {},
249
245
  ): Promise<GitCommandResult> {
250
246
  ensureAvailable();
251
- const result = await runCommand(cwd, args, options);
247
+ const result = await git(cwd, args, options);
252
248
  if (result.exitCode !== 0) {
253
249
  throw new GitCommandError(args, result);
254
250
  }
@@ -269,7 +265,7 @@ async function tryText(
269
265
  options: CommandOptions = {},
270
266
  ): Promise<string | undefined> {
271
267
  ensureAvailable();
272
- const result = await runCommand(cwd, args, options);
268
+ const result = await git(cwd, args, options);
273
269
  if (result.exitCode !== 0) return undefined;
274
270
  return result.stdout;
275
271
  }
@@ -709,7 +705,7 @@ export const diff = Object.assign(
709
705
  async function diff(cwd: string, options: DiffOptions = {}): Promise<string> {
710
706
  const args = buildDiffArgs(options);
711
707
  if (options.allowFailure) {
712
- return (await runCommand(cwd, args, { env: options.env, readOnly: true, signal: options.signal })).stdout;
708
+ return (await git(cwd, args, { env: options.env, readOnly: true, signal: options.signal })).stdout;
713
709
  }
714
710
  return runText(cwd, args, { env: options.env, readOnly: true, signal: options.signal });
715
711
  },
@@ -741,7 +737,7 @@ export const diff = Object.assign(
741
737
  if (options.cached) args.push("--cached");
742
738
  args.push("--quiet");
743
739
  if (options.files?.length) args.push("--", ...options.files);
744
- const result = await runCommand(cwd, args, { readOnly: true, signal: options.signal });
740
+ const result = await git(cwd, args, { readOnly: true, signal: options.signal });
745
741
  if (result.exitCode === 0) return false;
746
742
  if (result.exitCode === 1) return true;
747
743
  throw new GitCommandError(args, result);
@@ -757,7 +753,7 @@ export const diff = Object.assign(
757
753
  if (options.binary) args.push("--binary");
758
754
  args.push(base, headRef);
759
755
  if (options.allowFailure) {
760
- return (await runCommand(cwd, args, { readOnly: true, signal: options.signal })).stdout;
756
+ return (await git(cwd, args, { readOnly: true, signal: options.signal })).stdout;
761
757
  }
762
758
  return runText(cwd, args, { readOnly: true, signal: options.signal });
763
759
  },
@@ -789,7 +785,7 @@ export const status = Object.assign(
789
785
  {
790
786
  /** Parsed status counts (staged, unstaged, untracked). */
791
787
  async summary(cwd: string, signal?: AbortSignal): Promise<GitStatusSummary | null> {
792
- const result = await runCommand(cwd, ["status", "--porcelain"], { readOnly: true, signal });
788
+ const result = await git(cwd, ["status", "--porcelain"], { readOnly: true, signal });
793
789
  if (result.exitCode !== 0) return null;
794
790
  return parseStatusPorcelain(result.stdout);
795
791
  },
@@ -950,7 +946,7 @@ export const branch = {
950
946
  async current(cwd: string, signal?: AbortSignal): Promise<string | null> {
951
947
  const headState = await resolveHead(cwd);
952
948
  if (headState?.kind === "ref") return headState.branchName ?? headState.ref;
953
- const result = await runCommand(cwd, ["symbolic-ref", "--short", "HEAD"], { readOnly: true, signal });
949
+ const result = await git(cwd, ["symbolic-ref", "--short", "HEAD"], { readOnly: true, signal });
954
950
  if (result.exitCode !== 0) return null;
955
951
  return result.stdout.trim() || null;
956
952
  },
@@ -966,7 +962,7 @@ export const branch = {
966
962
  }
967
963
  }
968
964
  for (const remoteRef of ["origin/HEAD", "upstream/HEAD"]) {
969
- const result = await runCommand(cwd, ["rev-parse", "--abbrev-ref", remoteRef], { readOnly: true, signal });
965
+ const result = await git(cwd, ["rev-parse", "--abbrev-ref", remoteRef], { readOnly: true, signal });
970
966
  if (result.exitCode !== 0) continue;
971
967
  const branchName = stripRemotePrefix(result.stdout.trim());
972
968
  if (branchName) return branchName;
@@ -995,7 +991,7 @@ export const branch = {
995
991
  name: string,
996
992
  options: { force?: boolean; signal?: AbortSignal } = {},
997
993
  ): Promise<boolean> {
998
- const result = await runCommand(cwd, ["branch", options.force === false ? "-d" : "-D", name], {
994
+ const result = await git(cwd, ["branch", options.force === false ? "-d" : "-D", name], {
999
995
  signal: options.signal,
1000
996
  });
1001
997
  return result.exitCode === 0;
@@ -1038,7 +1034,7 @@ export const remote = {
1038
1034
  * needs to resolve, not paper over.
1039
1035
  */
1040
1036
  async add(cwd: string, name: string, url: string, signal?: AbortSignal): Promise<void> {
1041
- const result = await runCommand(cwd, ["remote", "add", name, url], { signal });
1037
+ const result = await git(cwd, ["remote", "add", name, url], { signal });
1042
1038
  if (result.exitCode === 0) return;
1043
1039
  if (REMOTE_ALREADY_EXISTS.test(result.stderr)) {
1044
1040
  const existing = await remote.url(cwd, name, signal);
@@ -1059,7 +1055,7 @@ export const ref = {
1059
1055
  if (refName === "HEAD") return (await head.sha(cwd, signal)) !== null;
1060
1056
  const repository = await resolveRepository(cwd);
1061
1057
  if (repository && refName.startsWith("refs/")) return (await readRef(repository, refName)) !== null;
1062
- const result = await runCommand(cwd, ["show-ref", "--verify", "--quiet", refName], { readOnly: true, signal });
1058
+ const result = await git(cwd, ["show-ref", "--verify", "--quiet", refName], { readOnly: true, signal });
1063
1059
  return result.exitCode === 0;
1064
1060
  },
1065
1061
 
@@ -1068,7 +1064,7 @@ export const ref = {
1068
1064
  if (refName === "HEAD") return head.sha(cwd, signal);
1069
1065
  const repository = await resolveRepository(cwd);
1070
1066
  if (repository && refName.startsWith("refs/")) return readRef(repository, refName);
1071
- const result = await runCommand(cwd, ["rev-parse", refName], { readOnly: true, signal });
1067
+ const result = await git(cwd, ["rev-parse", refName], { readOnly: true, signal });
1072
1068
  if (result.exitCode !== 0) return null;
1073
1069
  return result.stdout.trim() || null;
1074
1070
  },
@@ -1150,7 +1146,7 @@ export const worktree = {
1150
1146
  const args = ["worktree", "remove"];
1151
1147
  if (options.force ?? true) args.push("-f");
1152
1148
  args.push(worktreePath);
1153
- const result = await runCommand(cwd, args, { signal: options.signal });
1149
+ const result = await git(cwd, args, { signal: options.signal });
1154
1150
  return result.exitCode === 0;
1155
1151
  },
1156
1152
 
@@ -1186,7 +1182,7 @@ export const patch = {
1186
1182
 
1187
1183
  /** Check if a patch file can be applied cleanly. */
1188
1184
  async canApply(cwd: string, patchPath: string, options: Omit<PatchOptions, "check"> = {}): Promise<boolean> {
1189
- const result = await runCommand(cwd, buildApplyArgs(patchPath, { ...options, check: true }), {
1185
+ const result = await git(cwd, buildApplyArgs(patchPath, { ...options, check: true }), {
1190
1186
  env: options.env,
1191
1187
  readOnly: true,
1192
1188
  signal: options.signal,
@@ -1346,7 +1342,7 @@ export const ls = {
1346
1342
 
1347
1343
  /** List submodule paths (recursive). */
1348
1344
  async submodules(cwd: string, signal?: AbortSignal): Promise<string[]> {
1349
- const output = await runCommand(cwd, ["submodule", "--quiet", "foreach", "--recursive", "echo $sm_path"], {
1345
+ const output = await git(cwd, ["submodule", "--quiet", "foreach", "--recursive", "echo $sm_path"], {
1350
1346
  readOnly: true,
1351
1347
  signal,
1352
1348
  });
@@ -1381,14 +1377,14 @@ export const head = {
1381
1377
  async sha(cwd: string, signal?: AbortSignal): Promise<string | null> {
1382
1378
  const headState = await head.resolve(cwd);
1383
1379
  if (headState?.commit) return headState.commit;
1384
- const result = await runCommand(cwd, ["rev-parse", "HEAD"], { readOnly: true, signal });
1380
+ const result = await git(cwd, ["rev-parse", "HEAD"], { readOnly: true, signal });
1385
1381
  if (result.exitCode !== 0) return null;
1386
1382
  return result.stdout.trim() || null;
1387
1383
  },
1388
1384
 
1389
1385
  /** Abbreviated HEAD commit SHA. */
1390
1386
  async short(cwd: string, length = 7, signal?: AbortSignal): Promise<string | null> {
1391
- const result = await runCommand(cwd, ["rev-parse", `--short=${length}`, "HEAD"], { readOnly: true, signal });
1387
+ const result = await git(cwd, ["rev-parse", `--short=${length}`, "HEAD"], { readOnly: true, signal });
1392
1388
  if (result.exitCode !== 0) return null;
1393
1389
  return result.stdout.trim() || null;
1394
1390
  },
@@ -1403,7 +1399,7 @@ export const repo = {
1403
1399
  async root(cwd: string, signal?: AbortSignal): Promise<string | null> {
1404
1400
  const repository = await resolveRepository(cwd);
1405
1401
  if (repository) return repository.repoRoot;
1406
- const result = await runCommand(cwd, ["rev-parse", "--show-toplevel"], { readOnly: true, signal });
1402
+ const result = await git(cwd, ["rev-parse", "--show-toplevel"], { readOnly: true, signal });
1407
1403
  if (result.exitCode !== 0) return null;
1408
1404
  return result.stdout.trim() || null;
1409
1405
  },
@@ -0,0 +1,248 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import { $which } from "@oh-my-pi/pi-utils";
4
+ import { LRUCache } from "lru-cache/raw";
5
+
6
+ // ════════════════════════════════════════════════════════════════════════════
7
+ // Types
8
+ // ════════════════════════════════════════════════════════════════════════════
9
+
10
+ /** Result from a completed `jj` subprocess invocation. */
11
+ export interface JjCommandResult {
12
+ /** Process exit code reported by `jj`. */
13
+ exitCode: number;
14
+ /** Captured standard output as UTF-8 text. */
15
+ stdout: string;
16
+ /** Captured standard error as UTF-8 text. */
17
+ stderr: string;
18
+ }
19
+
20
+ /** Resolved Jujutsu workspace metadata. */
21
+ export interface JjRepository {
22
+ /** Root directory containing the `.jj` workspace metadata. */
23
+ repoRoot: string;
24
+ /** Path to the shared workspace store directory, resolved through `.jj/repo`'s file indirection for non-default workspaces. */
25
+ storeDir: string;
26
+ }
27
+
28
+ /** Options for `jj diff` invocations. */
29
+ export interface DiffOptions {
30
+ /** Optional file paths to restrict the diff with `-- <files>`. */
31
+ readonly files?: readonly string[];
32
+ /** Return only changed file names instead of Git-format diff text. */
33
+ readonly nameOnly?: boolean;
34
+ /** Optional abort signal passed to the spawned `jj` process. */
35
+ readonly signal?: AbortSignal;
36
+ }
37
+
38
+ interface CommandOptions {
39
+ readonly signal?: AbortSignal;
40
+ }
41
+
42
+ // ════════════════════════════════════════════════════════════════════════════
43
+ // Error
44
+ // ════════════════════════════════════════════════════════════════════════════
45
+
46
+ /** Error thrown when a checked `jj` command exits non-zero. */
47
+ export class JjCommandError extends Error {
48
+ /** Arguments passed after the common `jj --no-pager --color=never` prefix. */
49
+ readonly args: readonly string[];
50
+ /** Captured command result that caused the failure. */
51
+ readonly result: JjCommandResult;
52
+
53
+ /** Create an error for a failed checked `jj` command. */
54
+ constructor(args: readonly string[], result: JjCommandResult) {
55
+ super(formatCommandFailure(args, result));
56
+ this.name = "JjCommandError";
57
+ this.args = [...args];
58
+ this.result = result;
59
+ }
60
+ }
61
+
62
+ // ════════════════════════════════════════════════════════════════════════════
63
+ // Internal: Core execution
64
+ // ════════════════════════════════════════════════════════════════════════════
65
+
66
+ function ensureAvailable(): void {
67
+ if (!$which("jj")) {
68
+ throw new Error("jj is not installed.");
69
+ }
70
+ }
71
+
72
+ function formatCommandFailure(
73
+ args: readonly string[],
74
+ result: Pick<JjCommandResult, "exitCode" | "stdout" | "stderr">,
75
+ ): string {
76
+ const stderr = result.stderr.trim();
77
+ if (stderr) return stderr;
78
+ const stdout = result.stdout.trim();
79
+ if (stdout) return stdout;
80
+ return `jj ${args.join(" ")} failed with exit code ${result.exitCode}`;
81
+ }
82
+
83
+ async function jj(cwd: string, args: readonly string[], options: CommandOptions = {}): Promise<JjCommandResult> {
84
+ const child = Bun.spawn(["jj", "--no-pager", "--color=never", ...args], {
85
+ cwd,
86
+ signal: options.signal,
87
+ stdin: "ignore",
88
+ stdout: "pipe",
89
+ stderr: "pipe",
90
+ windowsHide: true,
91
+ });
92
+
93
+ if (!child.stdout || !child.stderr) {
94
+ throw new Error("Failed to capture jj command output.");
95
+ }
96
+
97
+ const [stdout, stderr, exitCode] = await Promise.all([
98
+ new Response(child.stdout).text(),
99
+ new Response(child.stderr).text(),
100
+ child.exited,
101
+ ]);
102
+
103
+ return { exitCode: exitCode ?? 0, stdout, stderr };
104
+ }
105
+
106
+ async function runChecked(
107
+ cwd: string,
108
+ args: readonly string[],
109
+ options: CommandOptions = {},
110
+ ): Promise<JjCommandResult> {
111
+ ensureAvailable();
112
+ const result = await jj(cwd, args, options);
113
+ if (result.exitCode !== 0) {
114
+ throw new JjCommandError(args, result);
115
+ }
116
+ return result;
117
+ }
118
+
119
+ async function runText(cwd: string, args: readonly string[], options: CommandOptions = {}): Promise<string> {
120
+ return (await runChecked(cwd, args, options)).stdout;
121
+ }
122
+
123
+ function splitLines(text: string): string[] {
124
+ return text
125
+ .split("\n")
126
+ .map(line => line.trim())
127
+ .filter(Boolean);
128
+ }
129
+
130
+ function buildDiffArgs(options: DiffOptions): string[] {
131
+ const args = ["diff"];
132
+ args.push(options.nameOnly ? "--name-only" : "--git");
133
+ if (options.files?.length) args.push("--", ...options.files);
134
+ return args;
135
+ }
136
+
137
+ // ════════════════════════════════════════════════════════════════════════════
138
+ // Internal: Repository resolution
139
+ // ════════════════════════════════════════════════════════════════════════════
140
+
141
+ interface WorkspaceRootCacheEntry {
142
+ readonly root?: string;
143
+ }
144
+
145
+ const WORKSPACE_ROOT_CACHE_MAX_ENTRIES = 256;
146
+ const workspaceRootCache = new LRUCache<string, WorkspaceRootCacheEntry>({ max: WORKSPACE_ROOT_CACHE_MAX_ENTRIES });
147
+
148
+ async function hasJjWorkspaceMetadata(dir: string): Promise<boolean> {
149
+ // jj marks a directory as a workspace via `.jj/repo`. In the default workspace
150
+ // it is a directory (containing `store/`, …); in a workspace created by
151
+ // `jj workspace add` it is a FILE whose contents point at the shared repo dir
152
+ // of the default workspace. Either form is a real workspace, so match on
153
+ // `.jj/repo` presence rather than the inner `store/` directory.
154
+ try {
155
+ await fs.stat(path.join(dir, ".jj", "repo"));
156
+ return true;
157
+ } catch {
158
+ return false;
159
+ }
160
+ }
161
+
162
+ function parentOf(dir: string): string | undefined {
163
+ const parent = path.dirname(dir);
164
+ return parent === dir ? undefined : parent;
165
+ }
166
+
167
+ async function findWorkspaceRoot(cwd: string): Promise<string | undefined> {
168
+ const key = path.resolve(cwd);
169
+ if (workspaceRootCache.has(key)) return workspaceRootCache.get(key)?.root;
170
+
171
+ for (let dir: string | undefined = key; dir; dir = parentOf(dir)) {
172
+ if (await hasJjWorkspaceMetadata(dir)) {
173
+ workspaceRootCache.set(key, { root: dir });
174
+ return dir;
175
+ }
176
+ }
177
+
178
+ workspaceRootCache.set(key, {});
179
+ return undefined;
180
+ }
181
+
182
+ /**
183
+ * Resolve the `.jj/repo` directory backing a workspace root, following the file
184
+ * indirection used by non-default workspaces. `jj workspace add` writes a FILE at
185
+ * `.jj/repo` whose contents are a path — relative to `.jj` — to the shared repo
186
+ * directory of the default workspace; the default workspace keeps `.jj/repo` as a
187
+ * directory.
188
+ */
189
+ async function resolveRepoDir(root: string): Promise<string> {
190
+ const jjDir = path.join(root, ".jj");
191
+ const repoPath = path.join(jjDir, "repo");
192
+ if ((await fs.stat(repoPath)).isFile()) {
193
+ const target = (await fs.readFile(repoPath, "utf8")).trim();
194
+ return path.resolve(jjDir, target);
195
+ }
196
+ return repoPath;
197
+ }
198
+
199
+ async function repositoryFromRoot(root: string): Promise<JjRepository> {
200
+ return {
201
+ repoRoot: root,
202
+ storeDir: path.join(await resolveRepoDir(root), "store"),
203
+ };
204
+ }
205
+
206
+ // ════════════════════════════════════════════════════════════════════════════
207
+ // API: diff
208
+ // ════════════════════════════════════════════════════════════════════════════
209
+
210
+ /** Run `jj diff --git` for the current workspace commit and return the raw Git-format diff text. */
211
+ export const diff = Object.assign(
212
+ async function diff(cwd: string, options: DiffOptions = {}): Promise<string> {
213
+ return runText(cwd, buildDiffArgs(options), { signal: options.signal });
214
+ },
215
+ {
216
+ /** List changed file paths. */
217
+ async changedFiles(cwd: string, options: Pick<DiffOptions, "files" | "signal"> = {}): Promise<string[]> {
218
+ return splitLines(await diff(cwd, { ...options, nameOnly: true }));
219
+ },
220
+ },
221
+ );
222
+
223
+ // ════════════════════════════════════════════════════════════════════════════
224
+ // API: repo
225
+ // ════════════════════════════════════════════════════════════════════════════
226
+
227
+ export const repo = {
228
+ /** Clear cached workspace roots. Intended for tests that mutate JJ metadata under an existing path. */
229
+ clearRootCache(): void {
230
+ workspaceRootCache.clear();
231
+ },
232
+
233
+ /** Resolve the current Jujutsu workspace root, or `null` when `cwd` is not in a JJ repository. */
234
+ async root(cwd: string): Promise<string | null> {
235
+ return (await findWorkspaceRoot(cwd)) ?? null;
236
+ },
237
+
238
+ /** Full Jujutsu workspace metadata. */
239
+ async resolve(cwd: string): Promise<JjRepository | null> {
240
+ const root = await repo.root(cwd);
241
+ return root ? await repositoryFromRoot(root) : null;
242
+ },
243
+
244
+ /** Check whether `cwd` is inside a Jujutsu repository. */
245
+ async is(cwd: string): Promise<boolean> {
246
+ return (await repo.root(cwd)) !== null;
247
+ },
248
+ };