nawabari 0.1.0 → 0.3.0

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.
Files changed (41) hide show
  1. package/README.md +347 -5
  2. package/dist/cli.js +770 -51
  3. package/dist/cli.js.map +1 -1
  4. package/dist/contract.d.ts +9 -0
  5. package/dist/contract.js +291 -0
  6. package/dist/contract.js.map +1 -0
  7. package/dist/domain/doctor.d.ts +1 -1
  8. package/dist/domain/doctor.js +99 -90
  9. package/dist/domain/doctor.js.map +1 -1
  10. package/dist/domain/errors.d.ts +1 -1
  11. package/dist/domain/errors.js +5 -1
  12. package/dist/domain/errors.js.map +1 -1
  13. package/dist/domain/session-backend.d.ts +15 -3
  14. package/dist/domain/session-backend.js +348 -7
  15. package/dist/domain/session-backend.js.map +1 -1
  16. package/dist/domain/session.d.ts +247 -2
  17. package/dist/domain/session.js +42 -0
  18. package/dist/domain/session.js.map +1 -1
  19. package/dist/errors.d.ts +2 -2
  20. package/dist/errors.js.map +1 -1
  21. package/dist/git.d.ts +102 -0
  22. package/dist/git.js +714 -30
  23. package/dist/git.js.map +1 -1
  24. package/dist/operation-authorization.d.ts +76 -0
  25. package/dist/operation-authorization.js +41 -0
  26. package/dist/operation-authorization.js.map +1 -0
  27. package/dist/output-budget.d.ts +16 -0
  28. package/dist/output-budget.js +58 -0
  29. package/dist/output-budget.js.map +1 -0
  30. package/dist/presentation.js +76 -22
  31. package/dist/presentation.js.map +1 -1
  32. package/dist/repository-evidence.d.ts +74 -0
  33. package/dist/repository-evidence.js +7 -0
  34. package/dist/repository-evidence.js.map +1 -0
  35. package/dist/resource-claims.d.ts +87 -0
  36. package/dist/resource-claims.js +450 -0
  37. package/dist/resource-claims.js.map +1 -0
  38. package/dist/session-registry.d.ts +251 -1
  39. package/dist/session-registry.js +2094 -257
  40. package/dist/session-registry.js.map +1 -1
  41. package/package.json +2 -1
package/dist/git.js CHANGED
@@ -2,44 +2,100 @@ import { execFileSync } from "node:child_process";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { SessionRegistryError } from "./errors.js";
5
- export const defaultGit = {
6
- run(args, cwd) {
5
+ import { CHECKPOINT_MAX_PATHS } from "./operation-authorization.js";
6
+ import { canonicalizeConcretePath } from "./resource-claims.js";
7
+ export const GIT_COMMAND_TIMEOUT_MS = 10_000;
8
+ export const GIT_COMMAND_MAX_OUTPUT_BYTES = 64 * 1024;
9
+ export const REPOSITORY_EVIDENCE_SCHEMA_VERSION = 1;
10
+ export const DIFF_EVIDENCE_SCHEMA_VERSION = 1;
11
+ export const EVIDENCE_MAX_DIFF_PATHS = 64;
12
+ export const EVIDENCE_MAX_DIFF_BYTES = GIT_COMMAND_MAX_OUTPUT_BYTES;
13
+ export const EVIDENCE_MAX_DIFF_HUNKS = 128;
14
+ const MAX_ERROR_DETAIL_LENGTH = 4_096;
15
+ export function createGitCommandRunner(options = {}) {
16
+ const executable = options.executable ?? "git";
17
+ const timeoutMs = options.timeoutMs ?? GIT_COMMAND_TIMEOUT_MS;
18
+ const maxOutputBytes = options.maxOutputBytes ?? GIT_COMMAND_MAX_OUTPUT_BYTES;
19
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) {
20
+ throw new RangeError("Git command timeout must be a positive safe integer");
21
+ }
22
+ if (!Number.isSafeInteger(maxOutputBytes) || maxOutputBytes < 1) {
23
+ throw new RangeError("Git command output limit must be a positive safe integer");
24
+ }
25
+ const execute = (args, cwd) => {
26
+ const command = args.map((argument) => boundedDetail(argument)).join(" ");
7
27
  try {
8
- return execFileSync("git", [...args], {
28
+ return String(execFileSync(executable, [...args], {
9
29
  cwd,
10
30
  encoding: "utf8",
31
+ maxBuffer: maxOutputBytes,
11
32
  stdio: ["ignore", "pipe", "pipe"],
12
- timeout: 10_000,
33
+ timeout: timeoutMs,
13
34
  env: {
14
35
  ...process.env,
36
+ ...options.env,
15
37
  GIT_TERMINAL_PROMPT: "0",
16
38
  GIT_OPTIONAL_LOCKS: "0",
17
39
  },
18
- }).trim();
40
+ }));
19
41
  }
20
42
  catch (error) {
21
- throw new SessionRegistryError("GIT_COMMAND_FAILED", `git ${args.join(" ")} failed in ${cwd}`, { command: args.join(" "), cwd }, error);
43
+ throw gitProcessError(error, command, cwd);
22
44
  }
23
- },
24
- };
45
+ };
46
+ return Object.freeze({
47
+ run(args, cwd) {
48
+ return execute(args, cwd).trim();
49
+ },
50
+ runRaw(args, cwd) {
51
+ return execute(args, cwd);
52
+ },
53
+ });
54
+ }
55
+ export const defaultGit = createGitCommandRunner();
25
56
  export function resolveRepositoryContext(options = {}) {
26
- const cwd = canonicalDirectory(options.cwd ?? process.cwd(), "REPOSITORY_IDENTITY_AMBIGUOUS");
57
+ const cwd = canonicalDirectory(options.cwd ?? process.cwd(), "REPOSITORY_IDENTITY_AMBIGUOUS", "MISSING_WORKTREE");
27
58
  const git = options.git ?? defaultGit;
28
59
  let worktreePath;
29
60
  let commonGitDirectory;
30
61
  try {
31
62
  worktreePath = canonicalDirectory(git.run(["rev-parse", "--show-toplevel"], cwd), "REPOSITORY_IDENTITY_AMBIGUOUS");
32
63
  const commonGitDirectoryOutput = git.run(["rev-parse", "--git-common-dir"], cwd);
64
+ if (commonGitDirectoryOutput.length === 0) {
65
+ throw new SessionRegistryError("REPOSITORY_IDENTITY_AMBIGUOUS", "Git returned an empty common directory");
66
+ }
33
67
  const commonGitDirectoryPath = path.isAbsolute(commonGitDirectoryOutput)
34
68
  ? commonGitDirectoryOutput
35
69
  : path.resolve(cwd, commonGitDirectoryOutput);
36
70
  commonGitDirectory = canonicalDirectory(commonGitDirectoryPath, "REPOSITORY_IDENTITY_AMBIGUOUS");
71
+ const gitDirectoryOutput = git.run(["rev-parse", "--git-dir"], cwd);
72
+ if (gitDirectoryOutput.length === 0) {
73
+ throw new SessionRegistryError("REPOSITORY_IDENTITY_AMBIGUOUS", "Git returned an empty Git directory");
74
+ }
75
+ const gitDirectoryPath = path.isAbsolute(gitDirectoryOutput)
76
+ ? gitDirectoryOutput
77
+ : path.resolve(cwd, gitDirectoryOutput);
78
+ const gitDirectory = canonicalDirectory(gitDirectoryPath, "REPOSITORY_IDENTITY_AMBIGUOUS");
79
+ if (!isPathInside(commonGitDirectory, gitDirectory)) {
80
+ throw new SessionRegistryError("REPOSITORY_IDENTITY_AMBIGUOUS", "Git worktree metadata is outside the repository common directory", { commonGitDirectory, gitDirectory });
81
+ }
37
82
  }
38
83
  catch (error) {
39
- if (error instanceof SessionRegistryError && error.code === "REPOSITORY_IDENTITY_AMBIGUOUS") {
84
+ if (error instanceof SessionRegistryError) {
85
+ if (error.code === "REPOSITORY_IDENTITY_AMBIGUOUS" ||
86
+ error.code === "GIT_SPAWN_FAILED" ||
87
+ error.code === "GIT_TIMEOUT" ||
88
+ error.code === "GIT_OUTPUT_LIMIT" ||
89
+ error.code === "PHYSICAL_OBSERVATION_UNAVAILABLE") {
90
+ throw error;
91
+ }
92
+ if (error.code === "GIT_COMMAND_FAILED" &&
93
+ (error.details.exitCode === undefined || error.details.exitCode === 128)) {
94
+ throw new SessionRegistryError("NOT_A_GIT_REPOSITORY", `Could not resolve a repository from ${cwd}`, { cwd }, error);
95
+ }
40
96
  throw error;
41
97
  }
42
- throw new SessionRegistryError("NOT_A_GIT_REPOSITORY", `Could not resolve a repository from ${cwd}`, { cwd }, error);
98
+ throw new SessionRegistryError("PHYSICAL_OBSERVATION_UNAVAILABLE", `Could not observe repository identity from ${cwd}`, { cwd }, error);
43
99
  }
44
100
  return Object.freeze({
45
101
  repositoryId: commonGitDirectory,
@@ -47,47 +103,549 @@ export function resolveRepositoryContext(options = {}) {
47
103
  worktreePath,
48
104
  });
49
105
  }
106
+ function readBranchOrDetached(git, cwd) {
107
+ try {
108
+ return readCurrentBranch(git, cwd);
109
+ }
110
+ catch (error) {
111
+ if (error instanceof SessionRegistryError &&
112
+ error.code === "WORKTREE_IDENTITY_AMBIGUOUS" &&
113
+ error.details.reason === "detached-head") {
114
+ throw new SessionRegistryError("DETACHED_HEAD", `The worktree at ${cwd} has no branch`, { worktree: cwd }, error);
115
+ }
116
+ throw error;
117
+ }
118
+ }
50
119
  export function resolveWorktreeIdentity(options = {}) {
51
- const repository = options.repository ?? resolveRepositoryContext(options);
120
+ let context;
121
+ try {
122
+ context = verifyPhysicalExecutionContext(options);
123
+ }
124
+ catch (error) {
125
+ // Keep the pre-#37 public resolver code while the reusable verifier exposes
126
+ // the more precise detached-head reason to governed callers.
127
+ if (error instanceof SessionRegistryError && error.code === "DETACHED_HEAD") {
128
+ throw new SessionRegistryError("WORKTREE_IDENTITY_AMBIGUOUS", error.message, error.details, error);
129
+ }
130
+ throw error;
131
+ }
132
+ return Object.freeze({
133
+ worktreeId: context.worktreeId,
134
+ worktreePath: context.worktreePath,
135
+ branchId: context.branchId,
136
+ branchName: context.branchName,
137
+ headId: context.headId,
138
+ });
139
+ }
140
+ /**
141
+ * Verify the physical repository/worktree facts used by governed operations.
142
+ * Every identity in the returned value is observed from Git or canonicalized
143
+ * filesystem state; caller values are expectations only.
144
+ */
145
+ export function verifyPhysicalExecutionContext(options = {}) {
52
146
  const git = options.git ?? defaultGit;
53
- const branchName = options.branchName ?? readCurrentBranch(git, repository.worktreePath);
147
+ const targetPath = options.worktreePath ?? options.cwd ?? options.repository?.worktreePath ?? process.cwd();
148
+ const expectedWorktreePath = options.expectedWorktreePath ??
149
+ options.worktreePath ??
150
+ (options.cwd === undefined && options.repository !== undefined ? options.repository.worktreePath : undefined);
151
+ assertDirectoryPath(targetPath);
152
+ const repository = resolveRepositoryContext({ cwd: targetPath, git });
153
+ if (options.repository !== undefined && repository.repositoryId !== options.repository.repositoryId) {
154
+ throw new SessionRegistryError("REPOSITORY_MISMATCH", "The observed Git common directory does not match the expected repository", {
155
+ expectedRepositoryId: options.repository.repositoryId,
156
+ actualRepositoryId: repository.repositoryId,
157
+ });
158
+ }
159
+ if (expectedWorktreePath !== undefined) {
160
+ const expected = canonicalDirectory(expectedWorktreePath, "WORKTREE_IDENTITY_AMBIGUOUS", "MISSING_WORKTREE");
161
+ if (expected !== repository.worktreePath) {
162
+ throw new SessionRegistryError("WORKTREE_MISMATCH", "The observed Git worktree does not match the expected worktree", { expectedWorktree: expected, actualWorktree: repository.worktreePath });
163
+ }
164
+ }
165
+ const branchName = readBranchOrDetached(git, repository.worktreePath);
54
166
  const branchId = normalizeBranchId(branchName);
167
+ const headId = readCurrentHead(git, repository.worktreePath);
168
+ const worktrees = listGitWorktrees(git, repository.worktreePath);
169
+ const matches = worktrees.filter((worktree) => worktree.worktreePath === repository.worktreePath);
170
+ if (matches.length === 0) {
171
+ throw new SessionRegistryError("MISSING_WORKTREE", "Git did not report the current worktree", {
172
+ worktree: repository.worktreePath,
173
+ });
174
+ }
175
+ if (matches.length > 1) {
176
+ throw new SessionRegistryError("GIT_STATE_AMBIGUOUS", "Git reported multiple entries for the current worktree", {
177
+ worktree: repository.worktreePath,
178
+ });
179
+ }
180
+ const worktree = matches[0];
181
+ if (worktree.prunable) {
182
+ throw new SessionRegistryError("MISSING_WORKTREE", "Git marks the current worktree as prunable", {
183
+ worktree: repository.worktreePath,
184
+ prunable: true,
185
+ });
186
+ }
187
+ if (worktree.branchName !== branchName) {
188
+ throw new SessionRegistryError("GIT_STATE_AMBIGUOUS", "Git worktree inventory disagrees with the current branch", {
189
+ worktree: repository.worktreePath,
190
+ inventoryBranch: worktree.branchName ?? "<detached>",
191
+ currentBranch: branchName,
192
+ });
193
+ }
194
+ try {
195
+ normalizeBranchId(worktree.branchName);
196
+ }
197
+ catch (error) {
198
+ throw new SessionRegistryError("GIT_STATE_AMBIGUOUS", "Git worktree inventory contains an invalid branch identity", { worktree: repository.worktreePath, branch: worktree.branchName }, error);
199
+ }
200
+ const finalBranchName = readBranchOrDetached(git, repository.worktreePath);
201
+ const finalHeadId = readCurrentHead(git, repository.worktreePath);
202
+ if (finalBranchName !== branchName || finalHeadId !== headId) {
203
+ throw new SessionRegistryError("GIT_STATE_AMBIGUOUS", "The repository/worktree identity changed during physical observation", {
204
+ worktree: repository.worktreePath,
205
+ initialBranch: branchName,
206
+ finalBranch: finalBranchName,
207
+ initialHead: headId,
208
+ finalHead: finalHeadId,
209
+ });
210
+ }
211
+ if (options.branchName !== undefined && normalizeBranchId(options.branchName) !== branchId) {
212
+ throw new SessionRegistryError("BRANCH_MISMATCH", "The observed Git branch does not match the expected branch", {
213
+ expectedBranch: options.branchName,
214
+ actualBranch: branchName,
215
+ });
216
+ }
55
217
  return Object.freeze({
218
+ repositoryId: repository.repositoryId,
219
+ commonGitDirectory: repository.commonGitDirectory,
56
220
  worktreeId: repository.worktreePath,
57
221
  worktreePath: repository.worktreePath,
58
222
  branchId,
59
- branchName: branchId.slice("refs/heads/".length),
223
+ branchName,
224
+ headId,
225
+ worktree,
226
+ worktrees,
60
227
  });
61
228
  }
62
229
  /** Read the repository's local worktree inventory without consulting a remote. */
63
230
  export function listGitWorktrees(git, cwd) {
64
- const output = git.run(["worktree", "list", "--porcelain"], cwd);
231
+ let output;
232
+ try {
233
+ output = git.run(["worktree", "list", "--porcelain"], cwd);
234
+ }
235
+ catch (error) {
236
+ if (error instanceof SessionRegistryError)
237
+ throw error;
238
+ throw new SessionRegistryError("PHYSICAL_OBSERVATION_UNAVAILABLE", "Could not observe Git worktrees", { cwd }, error);
239
+ }
65
240
  const entries = [];
66
241
  let currentPath;
67
242
  let currentBranch = null;
243
+ let currentPrunable = false;
68
244
  const flush = () => {
69
245
  if (currentPath === undefined)
70
246
  return;
71
- entries.push(Object.freeze({ worktreePath: canonicalListedPath(currentPath), branchName: currentBranch }));
247
+ const worktreePath = canonicalListedPath(currentPath);
248
+ if (entries.some((entry) => entry.worktreePath === worktreePath)) {
249
+ throw new SessionRegistryError("GIT_STATE_AMBIGUOUS", `Git reported the worktree more than once: ${worktreePath}`, {
250
+ worktree: worktreePath,
251
+ });
252
+ }
253
+ entries.push(Object.freeze({
254
+ worktreePath,
255
+ branchName: currentBranch,
256
+ prunable: currentPrunable,
257
+ }));
72
258
  currentPath = undefined;
73
259
  currentBranch = null;
260
+ currentPrunable = false;
74
261
  };
75
262
  for (const line of output.split(/\r?\n/u)) {
76
263
  if (line.startsWith("worktree ")) {
77
264
  flush();
78
265
  currentPath = line.slice("worktree ".length);
266
+ if (currentPath.length === 0) {
267
+ throw new SessionRegistryError("GIT_STATE_AMBIGUOUS", "Git reported an empty worktree path");
268
+ }
79
269
  continue;
80
270
  }
81
271
  if (line.startsWith("branch ")) {
82
272
  const branchId = line.slice("branch ".length);
83
273
  if (branchId.startsWith("refs/heads/")) {
274
+ if (currentBranch !== null) {
275
+ throw new SessionRegistryError("GIT_STATE_AMBIGUOUS", "Git reported multiple branches for one worktree");
276
+ }
84
277
  currentBranch = branchId.slice("refs/heads/".length);
85
278
  }
279
+ continue;
280
+ }
281
+ if (line === "prunable" || line.startsWith("prunable ")) {
282
+ currentPrunable = true;
86
283
  }
87
284
  }
88
285
  flush();
89
286
  return entries;
90
287
  }
288
+ /**
289
+ * Capture only the paths Git currently exposes as changed, staged, unstaged,
290
+ * or untracked. This is evidence, not an OS-level write monitor.
291
+ */
292
+ export function captureGitCheckpoint(git, cwd) {
293
+ let output;
294
+ try {
295
+ const run = git.runRaw ?? git.run;
296
+ output = run(["status", "--porcelain=v1", "--untracked-files=all", "--ignored=no", "-z"], cwd);
297
+ }
298
+ catch (error) {
299
+ if (error instanceof SessionRegistryError)
300
+ throw error;
301
+ throw new SessionRegistryError("PHYSICAL_OBSERVATION_UNAVAILABLE", "Could not observe Git checkpoint paths", {
302
+ cwd,
303
+ }, error);
304
+ }
305
+ const changed = new Set();
306
+ const staged = new Set();
307
+ const unstaged = new Set();
308
+ const untracked = new Set();
309
+ const records = output.split("\u0000");
310
+ for (let index = 0; index < records.length; index += 1) {
311
+ const record = records[index];
312
+ if (record.length === 0)
313
+ continue;
314
+ if (record.length < 4 || record[2] !== " ") {
315
+ throw new SessionRegistryError("GIT_STATE_AMBIGUOUS", "Git returned an invalid status record", { cwd });
316
+ }
317
+ const indexStatus = record[0];
318
+ const worktreeStatus = record[1];
319
+ const paths = [record.slice(3)];
320
+ if (indexStatus === "R" || indexStatus === "C") {
321
+ const source = records[index + 1];
322
+ if (source === undefined || source.length === 0) {
323
+ throw new SessionRegistryError("GIT_STATE_AMBIGUOUS", "Git returned an incomplete rename status record", {
324
+ cwd,
325
+ });
326
+ }
327
+ index += 1;
328
+ paths.push(source);
329
+ }
330
+ for (const resource of paths) {
331
+ if (resource.length === 0 || resource.includes("\u0000")) {
332
+ throw new SessionRegistryError("GIT_STATE_AMBIGUOUS", "Git returned an invalid changed path", { cwd });
333
+ }
334
+ changed.add(resource);
335
+ if (indexStatus !== " " && indexStatus !== "?")
336
+ staged.add(resource);
337
+ if (worktreeStatus !== " " && worktreeStatus !== "?")
338
+ unstaged.add(resource);
339
+ if (indexStatus === "?" && worktreeStatus === "?")
340
+ untracked.add(resource);
341
+ if (changed.size > CHECKPOINT_MAX_PATHS) {
342
+ throw new SessionRegistryError("GIT_OUTPUT_LIMIT", "Git checkpoint contains too many paths", {
343
+ cwd,
344
+ maxPaths: CHECKPOINT_MAX_PATHS,
345
+ });
346
+ }
347
+ }
348
+ }
349
+ return Object.freeze({
350
+ changed: sortGitPaths(changed),
351
+ staged: sortGitPaths(staged),
352
+ unstaged: sortGitPaths(unstaged),
353
+ untracked: sortGitPaths(untracked),
354
+ });
355
+ }
356
+ /**
357
+ * Read the exact set of paths a resulting commit actually changed, directly
358
+ * from Git rather than from pre-commit staging intent. Bounded and NUL-safe,
359
+ * mirroring captureGitCheckpoint's evidence guarantees.
360
+ */
361
+ export function readCommitChangedPaths(git, cwd, commitSha) {
362
+ let output;
363
+ try {
364
+ const run = git.runRaw ?? git.run;
365
+ output = run(["diff-tree", "--no-commit-id", "--name-only", "-r", "--root", "-z", commitSha], cwd);
366
+ }
367
+ catch (error) {
368
+ if (error instanceof SessionRegistryError)
369
+ throw error;
370
+ throw new SessionRegistryError("PHYSICAL_OBSERVATION_UNAVAILABLE", "Could not observe the resulting commit's changed paths", { cwd, commitSha }, error);
371
+ }
372
+ const paths = new Set();
373
+ const records = output.endsWith("\n") ? output.slice(0, -1).split("\u0000") : output.split("\u0000");
374
+ for (const record of records) {
375
+ if (record.length === 0)
376
+ continue;
377
+ paths.add(record);
378
+ if (paths.size > CHECKPOINT_MAX_PATHS) {
379
+ throw new SessionRegistryError("GIT_OUTPUT_LIMIT", "Committed path set exceeds the bounded evidence limit", {
380
+ cwd,
381
+ commitSha,
382
+ maxPaths: CHECKPOINT_MAX_PATHS,
383
+ });
384
+ }
385
+ }
386
+ return sortGitPaths(paths);
387
+ }
388
+ /**
389
+ * Canonicalize concrete paths reported by Git against the physical worktree.
390
+ * Git-observed paths are not glob patterns, so literal `*` and `?` characters
391
+ * remain valid filename characters. Any path that cannot be represented as a
392
+ * canonical repository resource fails closed as ambiguous Git state.
393
+ */
394
+ export function canonicalizeGitObservedPaths(paths, worktreePath) {
395
+ const canonical = new Set();
396
+ for (const resource of paths) {
397
+ try {
398
+ canonical.add(canonicalizeConcretePath(resource, worktreePath));
399
+ }
400
+ catch (error) {
401
+ throw new SessionRegistryError("GIT_STATE_AMBIGUOUS", "Git reported a path that cannot be represented as a canonical repository resource", { path: resource }, error);
402
+ }
403
+ }
404
+ return sortGitPaths(canonical);
405
+ }
406
+ /** Observe and canonicalize all bounded Git checkpoint path sets. */
407
+ export function observeGitCheckpoint(git, cwd) {
408
+ const observed = captureGitCheckpoint(git, cwd);
409
+ return Object.freeze({
410
+ changed: canonicalizeGitObservedPaths(observed.changed, cwd),
411
+ staged: canonicalizeGitObservedPaths(observed.staged, cwd),
412
+ unstaged: canonicalizeGitObservedPaths(observed.unstaged, cwd),
413
+ untracked: canonicalizeGitObservedPaths(observed.untracked, cwd),
414
+ });
415
+ }
416
+ /** Observe the canonical changed/staged sets used by governed mutations. */
417
+ export function observeGitMutationPaths(git, cwd) {
418
+ const observed = observeGitCheckpoint(git, cwd);
419
+ return Object.freeze({ changed: observed.changed, staged: observed.staged });
420
+ }
421
+ /** Read and canonicalize the exact paths changed by a resulting commit. */
422
+ export function readCanonicalCommitChangedPaths(git, cwd, commitSha) {
423
+ return canonicalizeGitObservedPaths(readCommitChangedPaths(git, cwd, commitSha), cwd);
424
+ }
425
+ /**
426
+ * Read bounded per-path Git statistics. The caller must provide explicit
427
+ * concrete paths; an empty path selection is rejected so this cannot become a
428
+ * repository-wide diff browser.
429
+ */
430
+ export function readGitPathStats(git, cwd, paths, from = "HEAD", to) {
431
+ const canonicalPaths = canonicalizeDiffPaths(paths, cwd);
432
+ const range = resolveDiffRange(git, cwd, from, to);
433
+ return readGitPathStatsForRange(git, cwd, canonicalPaths, range.fromRevision, range.toRevision);
434
+ }
435
+ /** Read a selected, byte- and hunk-bounded diff plus its canonical path stats. */
436
+ export function readBoundedGitDiff(git, cwd, options) {
437
+ const canonicalPaths = canonicalizeDiffPaths(options.paths, cwd);
438
+ const range = resolveDiffRange(git, cwd, options.from ?? "HEAD", options.to);
439
+ const stats = readGitPathStatsForRange(git, cwd, canonicalPaths, range.fromRevision, range.toRevision);
440
+ const includePatch = options.includePatch === true;
441
+ const maxBytes = options.maxBytes ?? EVIDENCE_MAX_DIFF_BYTES;
442
+ const maxHunks = options.maxHunks ?? EVIDENCE_MAX_DIFF_HUNKS;
443
+ assertDiffBound(maxBytes, EVIDENCE_MAX_DIFF_BYTES, "maxBytes");
444
+ assertDiffBound(maxHunks, EVIDENCE_MAX_DIFF_HUNKS, "maxHunks");
445
+ if (!includePatch) {
446
+ return Object.freeze({
447
+ schemaVersion: DIFF_EVIDENCE_SCHEMA_VERSION,
448
+ fromRevision: range.fromRevision,
449
+ toRevision: range.toRevision,
450
+ paths: canonicalPaths,
451
+ stats,
452
+ patch: null,
453
+ patchBytes: 0,
454
+ hunkCount: 0,
455
+ });
456
+ }
457
+ let patch;
458
+ try {
459
+ const run = git.runRaw ?? git.run;
460
+ patch = run(diffArguments(range.fromRevision, range.toRevision, canonicalPaths, ["--unified=0", "--patch"]), cwd);
461
+ }
462
+ catch (error) {
463
+ if (error instanceof SessionRegistryError)
464
+ throw error;
465
+ throw new SessionRegistryError("PHYSICAL_OBSERVATION_UNAVAILABLE", "Could not observe the selected Git diff", {
466
+ cwd,
467
+ }, error);
468
+ }
469
+ const patchBytes = Buffer.byteLength(patch, "utf8");
470
+ if (patchBytes > maxBytes) {
471
+ throw new SessionRegistryError("GIT_OUTPUT_LIMIT", "Selected Git diff exceeds the byte bound", {
472
+ cwd,
473
+ maxBytes,
474
+ patchBytes,
475
+ });
476
+ }
477
+ const hunkCount = (patch.match(/^@@ /gmu) ?? []).length;
478
+ if (hunkCount > maxHunks) {
479
+ throw new SessionRegistryError("GIT_OUTPUT_LIMIT", "Selected Git diff exceeds the hunk bound", {
480
+ cwd,
481
+ maxHunks,
482
+ hunkCount,
483
+ });
484
+ }
485
+ return Object.freeze({
486
+ schemaVersion: DIFF_EVIDENCE_SCHEMA_VERSION,
487
+ fromRevision: range.fromRevision,
488
+ toRevision: range.toRevision,
489
+ paths: canonicalPaths,
490
+ stats,
491
+ patch,
492
+ patchBytes,
493
+ hunkCount,
494
+ });
495
+ }
496
+ function canonicalizeDiffPaths(paths, cwd) {
497
+ if (paths.length === 0) {
498
+ throw new SessionRegistryError("GIT_STATE_AMBIGUOUS", "A bounded diff requires at least one explicit path", {
499
+ cwd,
500
+ });
501
+ }
502
+ if (paths.length > EVIDENCE_MAX_DIFF_PATHS) {
503
+ throw new SessionRegistryError("GIT_OUTPUT_LIMIT", "The selected diff contains too many paths", {
504
+ cwd,
505
+ maxPaths: EVIDENCE_MAX_DIFF_PATHS,
506
+ pathCount: paths.length,
507
+ });
508
+ }
509
+ const canonical = new Set();
510
+ for (const resource of paths) {
511
+ try {
512
+ canonical.add(canonicalizeConcretePath(resource, cwd));
513
+ }
514
+ catch (error) {
515
+ throw new SessionRegistryError("GIT_STATE_AMBIGUOUS", "The selected diff path cannot be represented as a canonical repository resource", { path: resource }, error);
516
+ }
517
+ }
518
+ return sortGitPaths(canonical);
519
+ }
520
+ function resolveDiffRange(git, cwd, from, to) {
521
+ const fromRevision = resolveDiffRevision(git, cwd, from, "from");
522
+ const toRevision = to === undefined ? null : resolveDiffRevision(git, cwd, to, "to");
523
+ return { fromRevision, toRevision };
524
+ }
525
+ function resolveDiffRevision(git, cwd, ref, side) {
526
+ if (ref.length === 0 || ref !== ref.trim() || ref.startsWith("-") || ref.includes("\u0000")) {
527
+ throw new SessionRegistryError("GIT_STATE_AMBIGUOUS", `Invalid ${side} revision selector`, { side, ref });
528
+ }
529
+ try {
530
+ const revision = git.run(["rev-parse", "--verify", `${ref}^{commit}`], cwd);
531
+ if (!/^[0-9a-f]{40,64}$/u.test(revision)) {
532
+ throw new SessionRegistryError("GIT_STATE_AMBIGUOUS", `Git returned an invalid ${side} revision`, {
533
+ side,
534
+ ref,
535
+ revision,
536
+ });
537
+ }
538
+ return revision;
539
+ }
540
+ catch (error) {
541
+ if (error instanceof SessionRegistryError && error.code !== "GIT_COMMAND_FAILED")
542
+ throw error;
543
+ throw new SessionRegistryError("GIT_STATE_AMBIGUOUS", `Could not resolve the ${side} diff revision`, {
544
+ side,
545
+ ref,
546
+ }, error);
547
+ }
548
+ }
549
+ function readGitPathStatsForRange(git, cwd, paths, fromRevision, toRevision) {
550
+ let output;
551
+ try {
552
+ const run = git.runRaw ?? git.run;
553
+ output = run(diffArguments(fromRevision, toRevision, paths, ["--numstat", "--no-renames"]), cwd);
554
+ }
555
+ catch (error) {
556
+ if (error instanceof SessionRegistryError)
557
+ throw error;
558
+ throw new SessionRegistryError("PHYSICAL_OBSERVATION_UNAVAILABLE", "Could not observe Git path statistics", {
559
+ cwd,
560
+ }, error);
561
+ }
562
+ const observed = new Map();
563
+ const records = output.endsWith("\n") ? output.slice(0, -1).split("\u0000") : output.split("\u0000");
564
+ for (const record of records) {
565
+ if (record.length === 0)
566
+ continue;
567
+ const firstTab = record.indexOf("\t");
568
+ const secondTab = firstTab === -1 ? -1 : record.indexOf("\t", firstTab + 1);
569
+ if (firstTab <= 0 || secondTab <= firstTab + 1 || secondTab === record.length - 1) {
570
+ throw new SessionRegistryError("GIT_STATE_AMBIGUOUS", "Git returned an invalid numstat record", { cwd });
571
+ }
572
+ const additionsText = record.slice(0, firstTab);
573
+ const deletionsText = record.slice(firstTab + 1, secondTab);
574
+ const rawPath = record.slice(secondTab + 1);
575
+ let canonicalPath;
576
+ try {
577
+ canonicalPath = canonicalizeConcretePath(rawPath, cwd);
578
+ }
579
+ catch (error) {
580
+ throw new SessionRegistryError("GIT_STATE_AMBIGUOUS", "Git returned an unrepresentable stat path", {
581
+ cwd,
582
+ path: rawPath,
583
+ }, error);
584
+ }
585
+ if (!paths.includes(canonicalPath) || observed.has(canonicalPath)) {
586
+ throw new SessionRegistryError("GIT_STATE_AMBIGUOUS", "Git returned an unexpected or duplicate stat path", {
587
+ cwd,
588
+ path: canonicalPath,
589
+ });
590
+ }
591
+ const binary = additionsText === "-" || deletionsText === "-";
592
+ const additions = binary ? null : parseStatCount(additionsText, cwd, canonicalPath);
593
+ const deletions = binary ? null : parseStatCount(deletionsText, cwd, canonicalPath);
594
+ observed.set(canonicalPath, Object.freeze({
595
+ path: canonicalPath,
596
+ additions,
597
+ deletions,
598
+ binary,
599
+ available: true,
600
+ }));
601
+ }
602
+ return Object.freeze(paths.map((resource) => observed.get(resource) ??
603
+ Object.freeze({
604
+ path: resource,
605
+ additions: null,
606
+ deletions: null,
607
+ binary: null,
608
+ available: false,
609
+ })));
610
+ }
611
+ function parseStatCount(value, cwd, resource) {
612
+ if (!/^\d+$/u.test(value)) {
613
+ throw new SessionRegistryError("GIT_STATE_AMBIGUOUS", "Git returned an invalid numstat count", {
614
+ cwd,
615
+ path: resource,
616
+ value,
617
+ });
618
+ }
619
+ const count = Number(value);
620
+ if (!Number.isSafeInteger(count)) {
621
+ throw new SessionRegistryError("GIT_OUTPUT_LIMIT", "Git returned an unbounded numstat count", {
622
+ cwd,
623
+ path: resource,
624
+ });
625
+ }
626
+ return count;
627
+ }
628
+ function diffArguments(fromRevision, toRevision, paths, flags) {
629
+ return [
630
+ "diff",
631
+ "--no-color",
632
+ "--no-ext-diff",
633
+ "--no-textconv",
634
+ ...flags,
635
+ fromRevision,
636
+ ...(toRevision === null ? [] : [toRevision]),
637
+ "--",
638
+ ...paths.map((resource) => `:(literal)${resource}`),
639
+ ];
640
+ }
641
+ function assertDiffBound(value, max, name) {
642
+ if (!Number.isSafeInteger(value) || value < 1 || value > max) {
643
+ throw new SessionRegistryError("GIT_OUTPUT_LIMIT", `${name} is outside the bounded diff limit`, {
644
+ [name]: value,
645
+ max,
646
+ });
647
+ }
648
+ }
91
649
  export function normalizeBranchId(branchName) {
92
650
  const trimmed = branchName.trim();
93
651
  if (trimmed !== branchName) {
@@ -102,8 +660,10 @@ export function normalizeBranchId(branchName) {
102
660
  if (shortName.length === 0 ||
103
661
  shortName.startsWith("/") ||
104
662
  shortName.endsWith("/") ||
663
+ shortName.startsWith("-") ||
105
664
  shortName.includes("..") ||
106
665
  shortName === "@" ||
666
+ shortName.endsWith("@") ||
107
667
  shortName.includes("@{") ||
108
668
  shortName.endsWith(".") ||
109
669
  shortName.endsWith(".lock") ||
@@ -114,35 +674,87 @@ export function normalizeBranchId(branchName) {
114
674
  }
115
675
  return normalized;
116
676
  }
117
- function canonicalListedPath(candidate) {
118
- const resolved = path.resolve(candidate);
119
- try {
120
- return fs.realpathSync.native(resolved);
121
- }
122
- catch {
123
- return resolved;
124
- }
125
- }
126
677
  export function readCurrentBranch(git, cwd) {
127
678
  try {
128
679
  const branchName = git.run(["symbolic-ref", "--quiet", "--short", "HEAD"], cwd);
129
680
  if (branchName.length === 0) {
130
- throw new SessionRegistryError("WORKTREE_IDENTITY_AMBIGUOUS", `The worktree at ${cwd} has no branch`, { cwd });
681
+ throw new SessionRegistryError("WORKTREE_IDENTITY_AMBIGUOUS", `The worktree at ${cwd} has no branch`, {
682
+ cwd,
683
+ reason: "detached-head",
684
+ });
131
685
  }
132
686
  return branchName;
133
687
  }
134
688
  catch (error) {
135
- if (error instanceof SessionRegistryError && error.code === "WORKTREE_IDENTITY_AMBIGUOUS") {
689
+ if (error instanceof SessionRegistryError) {
690
+ if (error.code === "GIT_SPAWN_FAILED" || error.code === "GIT_TIMEOUT" || error.code === "GIT_OUTPUT_LIMIT") {
691
+ throw error;
692
+ }
693
+ if (error.code === "WORKTREE_IDENTITY_AMBIGUOUS")
694
+ throw error;
695
+ if (error.code === "GIT_COMMAND_FAILED" &&
696
+ (error.details.exitCode === undefined || error.details.exitCode === 1 || error.details.exitCode === 128)) {
697
+ throw new SessionRegistryError("WORKTREE_IDENTITY_AMBIGUOUS", `Could not resolve the current branch for ${cwd}`, { cwd, reason: "detached-head" }, error);
698
+ }
136
699
  throw error;
137
700
  }
138
- throw new SessionRegistryError("WORKTREE_IDENTITY_AMBIGUOUS", `Could not resolve the current branch for ${cwd}`, { cwd }, error);
701
+ throw new SessionRegistryError("PHYSICAL_OBSERVATION_UNAVAILABLE", `Could not observe the current branch for ${cwd}`, {
702
+ cwd,
703
+ }, error);
139
704
  }
140
705
  }
141
- function canonicalDirectory(candidate, errorCode) {
706
+ export function readCurrentHead(git, cwd) {
707
+ try {
708
+ const head = git.run(["rev-parse", "--verify", "HEAD"], cwd);
709
+ if (!/^[0-9a-f]{40,64}$/u.test(head)) {
710
+ throw new SessionRegistryError("GIT_STATE_AMBIGUOUS", `Git returned an invalid HEAD for ${cwd}`, { cwd });
711
+ }
712
+ return head;
713
+ }
714
+ catch (error) {
715
+ if (error instanceof SessionRegistryError) {
716
+ if (error.code === "GIT_SPAWN_FAILED" || error.code === "GIT_TIMEOUT" || error.code === "GIT_OUTPUT_LIMIT") {
717
+ throw error;
718
+ }
719
+ if (error.code === "GIT_STATE_AMBIGUOUS")
720
+ throw error;
721
+ if (error.code === "GIT_COMMAND_FAILED" &&
722
+ (error.details.exitCode === undefined || error.details.exitCode === 128)) {
723
+ throw new SessionRegistryError("GIT_STATE_AMBIGUOUS", `Could not resolve HEAD for ${cwd}`, { cwd, reason: "head-unavailable" }, error);
724
+ }
725
+ throw error;
726
+ }
727
+ throw new SessionRegistryError("PHYSICAL_OBSERVATION_UNAVAILABLE", `Could not observe HEAD for ${cwd}`, { cwd }, error);
728
+ }
729
+ }
730
+ function canonicalListedPath(candidate) {
731
+ let resolved;
732
+ try {
733
+ resolved = path.resolve(candidate);
734
+ }
735
+ catch (error) {
736
+ throw new SessionRegistryError("GIT_STATE_AMBIGUOUS", "Git reported an invalid worktree path", {}, error);
737
+ }
738
+ try {
739
+ return fs.realpathSync.native(resolved);
740
+ }
741
+ catch (error) {
742
+ if (isNodeError(error) && error.code === "ENOENT")
743
+ return resolved;
744
+ throw new SessionRegistryError("PHYSICAL_OBSERVATION_UNAVAILABLE", `Could not canonicalize the Git worktree path: ${resolved}`, { worktree: resolved }, error);
745
+ }
746
+ }
747
+ function canonicalDirectory(candidate, errorCode, missingCode) {
142
748
  if (candidate.trim().length === 0) {
143
749
  throw new SessionRegistryError(errorCode, "Git returned an empty directory identity");
144
750
  }
145
- const resolved = path.resolve(candidate);
751
+ let resolved;
752
+ try {
753
+ resolved = path.resolve(candidate);
754
+ }
755
+ catch (error) {
756
+ throw new SessionRegistryError(errorCode, "Git returned an invalid directory identity", {}, error);
757
+ }
146
758
  try {
147
759
  const stat = fs.statSync(resolved);
148
760
  if (!stat.isDirectory()) {
@@ -151,7 +763,79 @@ function canonicalDirectory(candidate, errorCode) {
151
763
  return fs.realpathSync.native(resolved);
152
764
  }
153
765
  catch (error) {
766
+ if (missingCode !== undefined && isNodeError(error) && error.code === "ENOENT") {
767
+ throw new SessionRegistryError(missingCode, `The worktree path is missing: ${resolved}`, { worktree: resolved }, error);
768
+ }
154
769
  throw new SessionRegistryError(errorCode, `Could not resolve directory identity: ${resolved}`, { path: resolved }, error);
155
770
  }
156
771
  }
772
+ function isNodeError(error) {
773
+ return error instanceof Error && "code" in error && typeof error.code === "string";
774
+ }
775
+ function assertDirectoryPath(candidate) {
776
+ let resolved;
777
+ try {
778
+ resolved = path.resolve(candidate);
779
+ }
780
+ catch (error) {
781
+ throw new SessionRegistryError("PHYSICAL_OBSERVATION_UNAVAILABLE", "Could not resolve the worktree path", { worktree: boundedDetail(candidate) }, error);
782
+ }
783
+ try {
784
+ const stat = fs.statSync(resolved);
785
+ if (!stat.isDirectory()) {
786
+ throw new SessionRegistryError("MISSING_WORKTREE", `The worktree path is not a directory: ${resolved}`, {
787
+ worktree: resolved,
788
+ });
789
+ }
790
+ }
791
+ catch (error) {
792
+ if (error instanceof SessionRegistryError)
793
+ throw error;
794
+ if (isNodeError(error) && (error.code === "ENOENT" || error.code === "ENOTDIR")) {
795
+ throw new SessionRegistryError("MISSING_WORKTREE", `The worktree path is missing: ${resolved}`, { worktree: resolved }, error);
796
+ }
797
+ throw new SessionRegistryError("PHYSICAL_OBSERVATION_UNAVAILABLE", `Could not inspect the worktree path: ${resolved}`, { worktree: resolved }, error);
798
+ }
799
+ }
800
+ function isPathInside(root, candidate) {
801
+ return candidate === root || candidate.startsWith(`${root}${path.sep}`);
802
+ }
803
+ function sortGitPaths(paths) {
804
+ return Object.freeze([...paths].sort((left, right) => (left < right ? -1 : left > right ? 1 : 0)));
805
+ }
806
+ function boundedDetail(value) {
807
+ const text = typeof value === "string" ? value : String(value);
808
+ return text.length > MAX_ERROR_DETAIL_LENGTH ? `${text.slice(0, MAX_ERROR_DETAIL_LENGTH)}…` : text;
809
+ }
810
+ function gitProcessError(error, command, cwd) {
811
+ const candidate = error;
812
+ const details = {
813
+ command: boundedDetail(command),
814
+ cwd: boundedDetail(cwd),
815
+ };
816
+ if (typeof candidate.status === "number")
817
+ details.exitCode = candidate.status;
818
+ if (typeof candidate.signal === "string")
819
+ details.signal = candidate.signal;
820
+ if (typeof candidate.stderr === "string" && candidate.stderr.length > 0) {
821
+ details.stderr = boundedDetail(candidate.stderr);
822
+ }
823
+ let code;
824
+ if (candidate.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" || candidate.code === "ENOBUFS") {
825
+ code = "GIT_OUTPUT_LIMIT";
826
+ }
827
+ else if (candidate.code === "ETIMEDOUT") {
828
+ code = "GIT_TIMEOUT";
829
+ }
830
+ else if (candidate.killed === true) {
831
+ code = "GIT_TIMEOUT";
832
+ }
833
+ else if ((candidate.status === undefined || candidate.status === null) &&
834
+ (candidate.code === "ENOENT" || candidate.code === "EACCES" || candidate.code === "ENOTDIR")) {
835
+ code = "GIT_SPAWN_FAILED";
836
+ }
837
+ else
838
+ code = "GIT_COMMAND_FAILED";
839
+ return new SessionRegistryError(code, `git ${command} failed in ${cwd}`, details, error);
840
+ }
157
841
  //# sourceMappingURL=git.js.map