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.
- package/README.md +347 -5
- package/dist/cli.js +770 -51
- package/dist/cli.js.map +1 -1
- package/dist/contract.d.ts +9 -0
- package/dist/contract.js +291 -0
- package/dist/contract.js.map +1 -0
- package/dist/domain/doctor.d.ts +1 -1
- package/dist/domain/doctor.js +99 -90
- package/dist/domain/doctor.js.map +1 -1
- package/dist/domain/errors.d.ts +1 -1
- package/dist/domain/errors.js +5 -1
- package/dist/domain/errors.js.map +1 -1
- package/dist/domain/session-backend.d.ts +15 -3
- package/dist/domain/session-backend.js +348 -7
- package/dist/domain/session-backend.js.map +1 -1
- package/dist/domain/session.d.ts +247 -2
- package/dist/domain/session.js +42 -0
- package/dist/domain/session.js.map +1 -1
- package/dist/errors.d.ts +2 -2
- package/dist/errors.js.map +1 -1
- package/dist/git.d.ts +102 -0
- package/dist/git.js +714 -30
- package/dist/git.js.map +1 -1
- package/dist/operation-authorization.d.ts +76 -0
- package/dist/operation-authorization.js +41 -0
- package/dist/operation-authorization.js.map +1 -0
- package/dist/output-budget.d.ts +16 -0
- package/dist/output-budget.js +58 -0
- package/dist/output-budget.js.map +1 -0
- package/dist/presentation.js +76 -22
- package/dist/presentation.js.map +1 -1
- package/dist/repository-evidence.d.ts +74 -0
- package/dist/repository-evidence.js +7 -0
- package/dist/repository-evidence.js.map +1 -0
- package/dist/resource-claims.d.ts +87 -0
- package/dist/resource-claims.js +450 -0
- package/dist/resource-claims.js.map +1 -0
- package/dist/session-registry.d.ts +251 -1
- package/dist/session-registry.js +2094 -257
- package/dist/session-registry.js.map +1 -1
- 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
|
-
|
|
6
|
-
|
|
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(
|
|
28
|
+
return String(execFileSync(executable, [...args], {
|
|
9
29
|
cwd,
|
|
10
30
|
encoding: "utf8",
|
|
31
|
+
maxBuffer: maxOutputBytes,
|
|
11
32
|
stdio: ["ignore", "pipe", "pipe"],
|
|
12
|
-
timeout:
|
|
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
|
-
})
|
|
40
|
+
}));
|
|
19
41
|
}
|
|
20
42
|
catch (error) {
|
|
21
|
-
throw
|
|
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
|
|
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("
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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`, {
|
|
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
|
|
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("
|
|
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
|
|
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
|
-
|
|
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
|