@bli-cockpit/cli 0.2.5 → 0.2.7
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 +19 -7
- package/dist/adapters/attribution-core.js +152 -31
- package/dist/adapters/claude-attribution.js +14 -7
- package/dist/adapters/codex-attribution.js +8 -2
- package/dist/adapters/raw-evidence.js +33 -12
- package/dist/autostart.js +460 -20
- package/dist/commands/backfill.js +582 -87
- package/dist/commands/doctor.js +72 -60
- package/dist/commands/local-args.js +19 -2
- package/dist/commands/local.js +401 -229
- package/dist/commands/public-root.js +6 -1
- package/dist/commands/session-sync.js +259 -54
- package/dist/cursors/backfill-cursor.js +169 -1
- package/dist/cursors/raw-evidence-cursor.js +16 -2
- package/dist/evidence-upload-client.js +139 -107
- package/dist/local-state.js +71 -5
- package/dist/onboarding-roots.js +42 -17
- package/dist/process-runner.js +103 -0
- package/dist/raw-evidence-attribution-policy.js +25 -0
- package/dist/repo-identity.js +142 -26
- package/dist/root-normalization.js +29 -16
- package/dist/spool/local-spool.js +335 -58
- package/dist/upload.js +309 -66
- package/package.json +2 -2
package/dist/local-state.js
CHANGED
|
@@ -4,8 +4,8 @@ import { readFileSync } from "node:fs";
|
|
|
4
4
|
import fs from "node:fs/promises";
|
|
5
5
|
import os from "node:os";
|
|
6
6
|
import path from "node:path";
|
|
7
|
-
import { resolveRepoWorktreeIdentity, stableWorktreeFingerprint, stableWorktreeRoot, } from "./repo-identity.js";
|
|
8
|
-
import { normalizeCollectionRoots } from "./root-normalization.js";
|
|
7
|
+
import { normalizeGitOrigin, repoFingerprintFromLocalRoot, repoFingerprintFromOrigin, repoLabelFromOrigin, resolveRepoWorktreeIdentity, stableWorktreeFingerprint, stableWorktreeRoot, } from "./repo-identity.js";
|
|
8
|
+
import { isSamePath, normalizeCollectionRoots, } from "./root-normalization.js";
|
|
9
9
|
import { summarizeLocalUploadSpool } from "./spool/local-spool.js";
|
|
10
10
|
import { summarizeInstallEventOutbox } from "./spool/install-event-outbox.js";
|
|
11
11
|
const localCollectorPackage = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
@@ -139,6 +139,20 @@ export async function logoutLocalCollector(options = {}) {
|
|
|
139
139
|
return { removed, session_file: paths.session_file };
|
|
140
140
|
}
|
|
141
141
|
export async function startLocalWorkContext(options = {}) {
|
|
142
|
+
return writeLocalWorkContext(options);
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Starts a context for a transcript-attributed sync target that does not exist
|
|
146
|
+
* in the current git inventory, such as an approved wrapper folder or a
|
|
147
|
+
* deleted repo reconstructed from transcript provenance. This internal path
|
|
148
|
+
* validates the exact target identity before writing any context state.
|
|
149
|
+
*/
|
|
150
|
+
export async function startLocalWorkContextForAttributedTarget(options, attributedIdentity) {
|
|
151
|
+
const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
|
|
152
|
+
const identity = await validateAttributedTargetIdentity(repoRoot, attributedIdentity);
|
|
153
|
+
return writeLocalWorkContext(options, identity);
|
|
154
|
+
}
|
|
155
|
+
async function writeLocalWorkContext(options, attributedIdentity) {
|
|
142
156
|
if (options.activeTicketId && options.clearTicket) {
|
|
143
157
|
throw new Error("--ticket and --clear-ticket cannot be combined.");
|
|
144
158
|
}
|
|
@@ -152,8 +166,11 @@ export async function startLocalWorkContext(options = {}) {
|
|
|
152
166
|
operatorId: options.operatorId,
|
|
153
167
|
sessionId: options.sessionId,
|
|
154
168
|
});
|
|
155
|
-
const identity =
|
|
156
|
-
|
|
169
|
+
const identity = attributedIdentity ??
|
|
170
|
+
(await resolveIdentityOrFallback(repoRoot, options.branch));
|
|
171
|
+
const branch = attributedIdentity
|
|
172
|
+
? attributedIdentity.branch
|
|
173
|
+
: (options.branch ?? identity.branch);
|
|
157
174
|
const sessionId = options.sessionId ??
|
|
158
175
|
(session.session_state === "missing" ? `local-${crypto.randomUUID()}` : session.session_id);
|
|
159
176
|
const operatorId = options.operatorId ?? session.operator_id;
|
|
@@ -225,6 +242,55 @@ export async function startLocalWorkContext(options = {}) {
|
|
|
225
242
|
await writeJsonFile(paths.active_work_context_file, context);
|
|
226
243
|
return context;
|
|
227
244
|
}
|
|
245
|
+
async function validateAttributedTargetIdentity(requestedRepoRoot, identity) {
|
|
246
|
+
const canonicalRoot = await stableWorktreeRoot(requestedRepoRoot);
|
|
247
|
+
const identityRoot = await stableWorktreeRoot(identity.repo_root);
|
|
248
|
+
const identityRequestedPath = await stableWorktreeRoot(identity.requested_path);
|
|
249
|
+
if (!isSamePath(canonicalRoot, identityRoot) ||
|
|
250
|
+
!isSamePath(canonicalRoot, identityRequestedPath)) {
|
|
251
|
+
throw new Error("Attributed target identity paths do not match the requested repo root.");
|
|
252
|
+
}
|
|
253
|
+
const expectedWorktreeFingerprint = stableWorktreeFingerprint(canonicalRoot);
|
|
254
|
+
if (identity.worktree_fingerprint !== expectedWorktreeFingerprint) {
|
|
255
|
+
throw new Error("Attributed target worktree fingerprint does not match its repo root.");
|
|
256
|
+
}
|
|
257
|
+
const expectedWorktreeLabel = path.basename(canonicalRoot) || "workspace";
|
|
258
|
+
if (identity.worktree_label !== expectedWorktreeLabel) {
|
|
259
|
+
throw new Error("Attributed target worktree label does not match its repo root.");
|
|
260
|
+
}
|
|
261
|
+
if (identity.repo_origin_url) {
|
|
262
|
+
const normalizedOrigin = normalizeGitOrigin(identity.repo_origin_url);
|
|
263
|
+
if (normalizedOrigin !== identity.repo_origin_url) {
|
|
264
|
+
throw new Error("Attributed target repo origin is not normalized.");
|
|
265
|
+
}
|
|
266
|
+
if (identity.repo_fingerprint !== repoFingerprintFromOrigin(normalizedOrigin)) {
|
|
267
|
+
throw new Error("Attributed target repo fingerprint does not match its origin.");
|
|
268
|
+
}
|
|
269
|
+
if (identity.repo_label !== repoLabelFromOrigin(normalizedOrigin)) {
|
|
270
|
+
throw new Error("Attributed target repo label does not match its origin.");
|
|
271
|
+
}
|
|
272
|
+
if (identity.worktree_is_primary) {
|
|
273
|
+
throw new Error("Origin-derived attributed targets cannot claim a primary live worktree.");
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
if (identity.repo_fingerprint !==
|
|
278
|
+
repoFingerprintFromLocalRoot(canonicalRoot)) {
|
|
279
|
+
throw new Error("Attributed target repo fingerprint does not match its local root.");
|
|
280
|
+
}
|
|
281
|
+
if (identity.repo_label !== expectedWorktreeLabel) {
|
|
282
|
+
throw new Error("Attributed target repo label does not match its local root.");
|
|
283
|
+
}
|
|
284
|
+
if (!identity.worktree_is_primary) {
|
|
285
|
+
throw new Error("Local attributed targets must use their primary local identity.");
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return {
|
|
289
|
+
...identity,
|
|
290
|
+
requested_path: canonicalRoot,
|
|
291
|
+
repo_root: canonicalRoot,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
228
294
|
export async function inspectLocalCollectorStatus(options = {}) {
|
|
229
295
|
const now = options.now ?? new Date();
|
|
230
296
|
const homeDir = options.homeDir ?? os.homedir();
|
|
@@ -405,7 +471,7 @@ async function resolveIdentityOrFallback(repoRoot, branchOverride) {
|
|
|
405
471
|
return branchOverride ? { ...identity, branch: branchOverride } : identity;
|
|
406
472
|
}
|
|
407
473
|
const repoLabel = path.basename(resolvedRoot) || "workspace";
|
|
408
|
-
const repoFingerprint =
|
|
474
|
+
const repoFingerprint = repoFingerprintFromLocalRoot(resolvedRoot);
|
|
409
475
|
const worktreeFingerprint = stableWorktreeFingerprint(resolvedRoot);
|
|
410
476
|
const branch = branchOverride ?? (await resolveGitBranch(resolvedRoot));
|
|
411
477
|
return {
|
package/dist/onboarding-roots.js
CHANGED
|
@@ -2,7 +2,7 @@ import fs from "node:fs/promises";
|
|
|
2
2
|
import { realpathSync } from "node:fs";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
|
-
import { normalizeCollectionRoots } from "./root-normalization.js";
|
|
5
|
+
import { isSamePath, normalizeCollectionRoots, } from "./root-normalization.js";
|
|
6
6
|
export const COLLECTION_ROOT_REQUIRED = "collection_root_required";
|
|
7
7
|
export function homeRootConsentPrompt(homeDirInput) {
|
|
8
8
|
const homeDir = path.resolve(homeDirInput ?? os.homedir());
|
|
@@ -85,33 +85,53 @@ export function normalizeRoots(roots) {
|
|
|
85
85
|
return normalizeRootsDetailed(roots).roots;
|
|
86
86
|
}
|
|
87
87
|
export function normalizeRootsDetailed(roots, options = {}) {
|
|
88
|
+
const pathApi = options.pathApi ?? path;
|
|
88
89
|
const seen = new Set();
|
|
89
90
|
const candidates = [];
|
|
90
91
|
const rejected = [];
|
|
91
|
-
const homeDir =
|
|
92
|
+
const homeDir = pathApi.resolve(options.homeDir ?? os.homedir());
|
|
93
|
+
const canonicalHomeDir = canonicalPath(homeDir, pathApi, options.realpath);
|
|
92
94
|
for (const root of roots) {
|
|
93
95
|
const trimmed = root.trim();
|
|
94
96
|
if (!trimmed)
|
|
95
97
|
continue;
|
|
96
98
|
for (const candidate of splitRootInput(trimmed)) {
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
+
const expanded = expandHomePath(candidate, homeDir, pathApi);
|
|
100
|
+
const resolved = pathApi.resolve(expanded);
|
|
101
|
+
const canonicalResolved = canonicalPath(resolved, pathApi, options.realpath);
|
|
102
|
+
if (isSamePath(resolved, pathApi.parse(resolved).root, pathApi) ||
|
|
103
|
+
isSamePath(canonicalResolved, pathApi.parse(canonicalResolved).root, pathApi)) {
|
|
99
104
|
rejected.push({ input: candidate, reason: "fs_root" });
|
|
100
105
|
continue;
|
|
101
106
|
}
|
|
102
|
-
if (resolved
|
|
107
|
+
if (isSamePath(resolved, homeDir, pathApi) ||
|
|
108
|
+
isSamePath(canonicalResolved, canonicalHomeDir, pathApi)) {
|
|
103
109
|
if (!options.allowHomeRoot) {
|
|
104
110
|
rejected.push({ input: candidate, reason: "home_dir" });
|
|
105
111
|
continue;
|
|
106
112
|
}
|
|
107
113
|
}
|
|
108
|
-
|
|
114
|
+
const seenKey = pathApi.sep === "\\"
|
|
115
|
+
? canonicalResolved.toLowerCase()
|
|
116
|
+
: canonicalResolved;
|
|
117
|
+
if (seen.has(seenKey))
|
|
109
118
|
continue;
|
|
110
|
-
seen.add(
|
|
119
|
+
seen.add(seenKey);
|
|
111
120
|
candidates.push(resolved);
|
|
112
121
|
}
|
|
113
122
|
}
|
|
114
|
-
return {
|
|
123
|
+
return {
|
|
124
|
+
roots: normalizeCollectionRoots(candidates, pathApi),
|
|
125
|
+
rejected,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
export function expandHomePath(value, homeDir, pathApi = path) {
|
|
129
|
+
if (value === "~")
|
|
130
|
+
return pathApi.resolve(homeDir);
|
|
131
|
+
if (!/^~[\\/]/u.test(value))
|
|
132
|
+
return value;
|
|
133
|
+
const parts = value.slice(2).split(/[\\/]+/u).filter(Boolean);
|
|
134
|
+
return pathApi.resolve(homeDir, ...parts);
|
|
115
135
|
}
|
|
116
136
|
function splitRootInput(value) {
|
|
117
137
|
return value
|
|
@@ -241,7 +261,7 @@ async function promptForDeclinedHomeRoot(options, homeDir) {
|
|
|
241
261
|
throw new Error(`${COLLECTION_ROOT_REQUIRED}: ${HOME_ROOT_NO_FOLDER_CHOSEN_MESSAGE}`);
|
|
242
262
|
}
|
|
243
263
|
function resolveRootInputPath(input, homeDir, cwd) {
|
|
244
|
-
const expanded = input === "~" ||
|
|
264
|
+
const expanded = input === "~" || /^~[\\/]/u.test(input)
|
|
245
265
|
? path.join(homeDir, input.slice(2))
|
|
246
266
|
: input;
|
|
247
267
|
return path.resolve(cwd, expanded);
|
|
@@ -257,17 +277,17 @@ function resolvedRoots(options, roots, source, confirmed) {
|
|
|
257
277
|
// the home-root guard on the decline path (e.g. `~/home-link -> /Users/me`).
|
|
258
278
|
// Non-existent paths (or realpath errors) fall back to the lexical resolve so
|
|
259
279
|
// behavior is unchanged for the retry branch and for tests using fake homedirs.
|
|
260
|
-
function canonicalPath(input) {
|
|
261
|
-
const resolved =
|
|
280
|
+
function canonicalPath(input, pathApi = path, realpath = realpathSync) {
|
|
281
|
+
const resolved = pathApi.resolve(input);
|
|
262
282
|
try {
|
|
263
|
-
return
|
|
283
|
+
return pathApi.resolve(realpath(resolved));
|
|
264
284
|
}
|
|
265
285
|
catch {
|
|
266
286
|
return resolved;
|
|
267
287
|
}
|
|
268
288
|
}
|
|
269
289
|
function isHomeRoot(root, homeDir) {
|
|
270
|
-
return canonicalPath(root)
|
|
290
|
+
return isSamePath(canonicalPath(root), canonicalPath(homeDir ?? os.homedir()));
|
|
271
291
|
}
|
|
272
292
|
function homeRootTutorial(homeDirInput) {
|
|
273
293
|
const homeDir = path.resolve(homeDirInput ?? os.homedir());
|
|
@@ -305,12 +325,17 @@ export function missingCollectionRootMessage(options = {}) {
|
|
|
305
325
|
" 3) Stop and rerun with a flag: cockpit onboard --workspace ~/BLI",
|
|
306
326
|
].join("\n");
|
|
307
327
|
}
|
|
308
|
-
function likelyBliRootFromCwd(cwd) {
|
|
309
|
-
const
|
|
310
|
-
const
|
|
328
|
+
export function likelyBliRootFromCwd(cwd, pathApi = path) {
|
|
329
|
+
const root = pathApi.parse(cwd).root;
|
|
330
|
+
const parts = pathApi
|
|
331
|
+
.relative(root, cwd)
|
|
332
|
+
.split(pathApi.sep)
|
|
333
|
+
.filter(Boolean);
|
|
334
|
+
const comparableParts = pathApi.sep === "\\" ? parts.map((part) => part.toLowerCase()) : parts;
|
|
335
|
+
const index = comparableParts.lastIndexOf(pathApi.sep === "\\" ? "bli" : "BLI");
|
|
311
336
|
if (index < 0)
|
|
312
337
|
return null;
|
|
313
|
-
return
|
|
338
|
+
return pathApi.resolve(root, ...parts.slice(0, index + 1));
|
|
314
339
|
}
|
|
315
340
|
async function directoryExists(dir) {
|
|
316
341
|
return fs.stat(dir).then((stats) => stats.isDirectory(), () => false);
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { execFile, spawn } from "node:child_process";
|
|
2
|
+
const WINDOWS_CMD_SHIMS = new Set(["cockpit", "npm", "npx"]);
|
|
3
|
+
const CMD_META_CHARS = /([()\][%!^"`<>&|;, *?])/gu;
|
|
4
|
+
/**
|
|
5
|
+
* Resolves commands that npm installs as `.cmd` shims through ComSpec on
|
|
6
|
+
* Windows. Arguments are escaped before they enter cmd.exe so workspace paths
|
|
7
|
+
* cannot turn into extra shell commands.
|
|
8
|
+
*/
|
|
9
|
+
export function commandInvocation(command, args, options = {}) {
|
|
10
|
+
const platform = options.platform ?? process.platform;
|
|
11
|
+
const env = options.env ?? process.env;
|
|
12
|
+
if (platform !== "win32" || !isWindowsCmdShim(command)) {
|
|
13
|
+
return { command, args, windowsVerbatimArguments: false };
|
|
14
|
+
}
|
|
15
|
+
const comspec = env.ComSpec ?? env.COMSPEC ?? "cmd.exe";
|
|
16
|
+
return {
|
|
17
|
+
command: comspec,
|
|
18
|
+
args: ["/d", "/s", "/c", `"${windowsCmdShimLine(command, args)}"`],
|
|
19
|
+
windowsVerbatimArguments: true,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
/** A fully escaped command line suitable for cmd.exe's `/c` payload. */
|
|
23
|
+
export function windowsCmdShimLine(command, args) {
|
|
24
|
+
const shim = windowsShimName(command);
|
|
25
|
+
return [escapeWindowsCommand(shim), ...args.map(escapeWindowsCmdArgument)].join(" ");
|
|
26
|
+
}
|
|
27
|
+
export function createCapturedExecRunner(options = {}) {
|
|
28
|
+
return (command, args, runOptions) => new Promise((resolve) => {
|
|
29
|
+
const env = runOptions?.env ?? options.env ?? process.env;
|
|
30
|
+
const invocation = commandInvocation(command, args, {
|
|
31
|
+
platform: options.platform,
|
|
32
|
+
env,
|
|
33
|
+
});
|
|
34
|
+
execFile(invocation.command, invocation.args, {
|
|
35
|
+
encoding: "utf8",
|
|
36
|
+
env,
|
|
37
|
+
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
|
|
38
|
+
}, (error, stdout, stderr) => {
|
|
39
|
+
const code = error == null
|
|
40
|
+
? 0
|
|
41
|
+
: typeof error.code === "number"
|
|
42
|
+
? error.code
|
|
43
|
+
: 1;
|
|
44
|
+
resolve({
|
|
45
|
+
code,
|
|
46
|
+
stdout: stdout ?? "",
|
|
47
|
+
stderr: stderr || error?.message || "",
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
export function createInteractiveExecRunner(options = {}) {
|
|
53
|
+
return (command, args, runOptions) => new Promise((resolve) => {
|
|
54
|
+
const env = runOptions?.env ?? options.env ?? process.env;
|
|
55
|
+
const invocation = commandInvocation(command, args, {
|
|
56
|
+
platform: options.platform,
|
|
57
|
+
env,
|
|
58
|
+
});
|
|
59
|
+
const child = spawn(invocation.command, invocation.args, {
|
|
60
|
+
stdio: "inherit",
|
|
61
|
+
env,
|
|
62
|
+
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
|
|
63
|
+
});
|
|
64
|
+
let settled = false;
|
|
65
|
+
child.on("error", (error) => {
|
|
66
|
+
if (settled)
|
|
67
|
+
return;
|
|
68
|
+
settled = true;
|
|
69
|
+
resolve({ code: 1, stdout: "", stderr: error.message });
|
|
70
|
+
});
|
|
71
|
+
child.on("close", (code) => {
|
|
72
|
+
if (settled)
|
|
73
|
+
return;
|
|
74
|
+
settled = true;
|
|
75
|
+
resolve({ code: code ?? 1, stdout: "", stderr: "" });
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
function isWindowsCmdShim(command) {
|
|
80
|
+
const normalized = command.replace(/\\/gu, "/");
|
|
81
|
+
const basename = normalized.slice(normalized.lastIndexOf("/") + 1).toLowerCase();
|
|
82
|
+
return (basename.endsWith(".cmd") ||
|
|
83
|
+
basename.endsWith(".bat") ||
|
|
84
|
+
WINDOWS_CMD_SHIMS.has(basename));
|
|
85
|
+
}
|
|
86
|
+
function windowsShimName(command) {
|
|
87
|
+
return /\.(?:cmd|bat)$/iu.test(command) ? command : `${command}.cmd`;
|
|
88
|
+
}
|
|
89
|
+
function escapeWindowsCommand(value) {
|
|
90
|
+
return value.replace(CMD_META_CHARS, "^$1");
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Mirrors the escaping used by mature Windows process runners for `.cmd`
|
|
94
|
+
* shims. Meta characters are escaped twice because cmd.exe parses once and the
|
|
95
|
+
* npm-generated shim parses again.
|
|
96
|
+
*/
|
|
97
|
+
function escapeWindowsCmdArgument(value) {
|
|
98
|
+
let escaped = value.replace(/(?=(\\+?)?)\1"/gu, '$1$1\\"');
|
|
99
|
+
escaped = escaped.replace(/(?=(\\+?)?)\1$/gu, "$1$1");
|
|
100
|
+
escaped = `"${escaped}"`;
|
|
101
|
+
escaped = escaped.replace(CMD_META_CHARS, "^$1");
|
|
102
|
+
return escaped.replace(CMD_META_CHARS, "^$1");
|
|
103
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Historical backfill and attribution diagnostics may retain any deterministic
|
|
3
|
+
* fallback that produced a worktree identity. Ambiguous and unattributed
|
|
4
|
+
* sessions still need a separate quarantine lane so collection does not invent
|
|
5
|
+
* repo provenance.
|
|
6
|
+
*/
|
|
7
|
+
export function isRawEvidenceUploadableAttributionState(state) {
|
|
8
|
+
return state === "attributed" || state === "attributed_fallback";
|
|
9
|
+
}
|
|
10
|
+
const LIVE_WORKTREE_FALLBACK_REASONS = new Set([
|
|
11
|
+
"known_repo_primary_fallback",
|
|
12
|
+
"single_repo_folder_fallback",
|
|
13
|
+
"multi_repo_folder_workspace",
|
|
14
|
+
"transcript_origin_fallback",
|
|
15
|
+
]);
|
|
16
|
+
/**
|
|
17
|
+
* Continuous sync transports exact attribution plus the explicit fallback
|
|
18
|
+
* identities it knows how to validate and materialize as sync targets. Future
|
|
19
|
+
* fallback reasons stay fail-closed until their provenance contract is added.
|
|
20
|
+
*/
|
|
21
|
+
export function isLiveRawEvidenceSyncAttribution(state, reason) {
|
|
22
|
+
return (state === "attributed" ||
|
|
23
|
+
(state === "attributed_fallback" &&
|
|
24
|
+
LIVE_WORKTREE_FALLBACK_REASONS.has(reason)));
|
|
25
|
+
}
|
package/dist/repo-identity.js
CHANGED
|
@@ -3,6 +3,7 @@ import crypto from "node:crypto";
|
|
|
3
3
|
import fs from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
|
+
import { containsPath, isCodexWorktreePath } from "./root-normalization.js";
|
|
6
7
|
const execFileAsync = promisify(execFile);
|
|
7
8
|
const SKIPPED_DIR_NAMES = new Set([
|
|
8
9
|
".cache",
|
|
@@ -51,19 +52,33 @@ export async function resolveRepoWorktreeIdentity(repoRoot) {
|
|
|
51
52
|
};
|
|
52
53
|
}
|
|
53
54
|
export async function discoverGitWorktrees(root, options = {}) {
|
|
55
|
+
return (await discoverGitWorktreesWithStatus(root, options)).worktrees;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Discovers worktrees while retaining whether traversal limits could have
|
|
59
|
+
* hidden additional repositories. Callers that make collection-completeness
|
|
60
|
+
* claims must use this result instead of treating a truncated list as whole.
|
|
61
|
+
*/
|
|
62
|
+
export async function discoverGitWorktreesWithStatus(root, options = {}) {
|
|
54
63
|
const resolvedRoot = path.resolve(root);
|
|
55
|
-
const
|
|
64
|
+
const allowedRoots = [await stableWorktreeRoot(resolvedRoot)];
|
|
65
|
+
const maxWorktrees = Math.max(1, options.maxWorktrees ?? 50);
|
|
56
66
|
const direct = await resolveRepoWorktreeIdentity(resolvedRoot).catch(() => null);
|
|
57
67
|
if (direct)
|
|
58
|
-
return expandLinkedWorktrees([direct], maxWorktrees);
|
|
68
|
+
return expandLinkedWorktrees([direct], maxWorktrees, allowedRoots);
|
|
59
69
|
if (await hasGitMarker(resolvedRoot)) {
|
|
60
|
-
return expandLinkedWorktrees([await fallbackFilesystemIdentity(resolvedRoot)], maxWorktrees);
|
|
70
|
+
return expandLinkedWorktrees([await fallbackFilesystemIdentity(resolvedRoot)], maxWorktrees, allowedRoots);
|
|
61
71
|
}
|
|
62
72
|
const maxDepth = options.maxDepth ?? 2;
|
|
63
73
|
const discovered = new Map();
|
|
64
74
|
const stack = [{ dir: resolvedRoot, depth: 0 }];
|
|
65
75
|
const visited = new Set();
|
|
66
|
-
|
|
76
|
+
const incompleteReasons = new Set();
|
|
77
|
+
while (stack.length > 0) {
|
|
78
|
+
if (discovered.size >= maxWorktrees) {
|
|
79
|
+
incompleteReasons.add("max_worktrees_reached");
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
67
82
|
const current = stack.shift();
|
|
68
83
|
if (!current)
|
|
69
84
|
continue;
|
|
@@ -77,16 +92,85 @@ export async function discoverGitWorktrees(root, options = {}) {
|
|
|
77
92
|
discovered.set(identity.worktree_fingerprint, identity);
|
|
78
93
|
continue;
|
|
79
94
|
}
|
|
80
|
-
if (current.depth >= maxDepth)
|
|
81
|
-
continue;
|
|
82
95
|
const entries = await fs.readdir(current.dir, { withFileTypes: true }).catch(() => []);
|
|
96
|
+
if (current.depth >= maxDepth) {
|
|
97
|
+
if (entries.some((entry) => entry.isDirectory() && !shouldSkipDirectory(entry.name))) {
|
|
98
|
+
incompleteReasons.add("max_depth_reached");
|
|
99
|
+
}
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
83
102
|
for (const entry of entries) {
|
|
84
103
|
if (!entry.isDirectory() || shouldSkipDirectory(entry.name))
|
|
85
104
|
continue;
|
|
86
105
|
stack.push({ dir: path.join(current.dir, entry.name), depth: current.depth + 1 });
|
|
87
106
|
}
|
|
88
107
|
}
|
|
89
|
-
|
|
108
|
+
const expanded = await expandLinkedWorktrees([...discovered.values()].sort(compareIdentity), maxWorktrees, allowedRoots);
|
|
109
|
+
for (const reason of expanded.incomplete_reasons) {
|
|
110
|
+
incompleteReasons.add(reason);
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
worktrees: expanded.worktrees,
|
|
114
|
+
complete: incompleteReasons.size === 0,
|
|
115
|
+
incomplete_reasons: [...incompleteReasons].sort(),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Discovers and deduplicates git worktrees across the exact roots supplied by
|
|
120
|
+
* the caller. An approved root with no current git checkout intentionally
|
|
121
|
+
* contributes zero candidates: transcript attribution may still reconstruct a
|
|
122
|
+
* deleted-repo identity, while discovery itself never invents a broader or
|
|
123
|
+
* unrelated root.
|
|
124
|
+
*/
|
|
125
|
+
export async function discoverGitWorktreesInRoots(roots, options = {}) {
|
|
126
|
+
return (await discoverGitWorktreesInRootsWithStatus(roots, options)).worktrees;
|
|
127
|
+
}
|
|
128
|
+
export async function discoverGitWorktreesInRootsWithStatus(roots, options = {}) {
|
|
129
|
+
const maxWorktrees = Math.max(1, options.maxWorktrees ?? 50);
|
|
130
|
+
const discovered = new Map();
|
|
131
|
+
const incompleteReasons = new Set();
|
|
132
|
+
for (const root of roots) {
|
|
133
|
+
const result = await discoverGitWorktreesWithStatus(root, {
|
|
134
|
+
...options,
|
|
135
|
+
maxWorktrees,
|
|
136
|
+
});
|
|
137
|
+
for (const reason of result.incomplete_reasons) {
|
|
138
|
+
incompleteReasons.add(reason);
|
|
139
|
+
}
|
|
140
|
+
for (const worktree of result.worktrees) {
|
|
141
|
+
if (discovered.size >= maxWorktrees) {
|
|
142
|
+
if (!discovered.has(worktree.worktree_fingerprint)) {
|
|
143
|
+
incompleteReasons.add("max_worktrees_reached");
|
|
144
|
+
}
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
discovered.set(worktree.worktree_fingerprint, worktree);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
worktrees: [...discovered.values()].sort(compareIdentity),
|
|
152
|
+
complete: incompleteReasons.size === 0,
|
|
153
|
+
incomplete_reasons: [...incompleteReasons].sort(),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Canonicalizes existing collection roots through the filesystem so transcript
|
|
158
|
+
* paths and consent roots use the same spelling (for example `/private/var`
|
|
159
|
+
* versus the `/var` symlink on macOS). Missing roots remain resolved as typed.
|
|
160
|
+
*/
|
|
161
|
+
export async function canonicalizeCollectionRootPaths(roots) {
|
|
162
|
+
const canonical = await Promise.all(roots.map(stableWorktreeRoot));
|
|
163
|
+
return [...new Set(canonical)];
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Returns the operator-entered resolved paths plus filesystem-canonical aliases.
|
|
167
|
+
* Both are needed for absent child repos because the child itself cannot be
|
|
168
|
+
* realpathed after deletion, while its transcript may retain either spelling.
|
|
169
|
+
*/
|
|
170
|
+
export async function collectionRootPathAliases(roots) {
|
|
171
|
+
const resolved = roots.map((root) => path.resolve(root));
|
|
172
|
+
const canonical = await canonicalizeCollectionRootPaths(resolved);
|
|
173
|
+
return [...new Set([...resolved, ...canonical])];
|
|
90
174
|
}
|
|
91
175
|
/**
|
|
92
176
|
* Directory discovery skips dot-dirs, so Claude Code's isolation worktrees
|
|
@@ -99,28 +183,60 @@ export async function discoverGitWorktrees(root, options = {}) {
|
|
|
99
183
|
* deepest-root tie-break (attribution-core D6) then attributes nested-worktree
|
|
100
184
|
* sessions to the correct linked worktree. Benefits Codex sessions identically.
|
|
101
185
|
*/
|
|
102
|
-
async function expandLinkedWorktrees(identities, maxWorktrees) {
|
|
186
|
+
async function expandLinkedWorktrees(identities, maxWorktrees, allowedRoots) {
|
|
103
187
|
const byFingerprint = new Map(identities.map((identity) => [identity.worktree_fingerprint, identity]));
|
|
104
|
-
const seenRoots = new Set(identities.map((identity) =>
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
break;
|
|
188
|
+
const seenRoots = new Set(identities.map((identity) => localPathKey(identity.repo_root)));
|
|
189
|
+
let maxWorktreesReached = identities.length > maxWorktrees;
|
|
190
|
+
for (const identity of identities.slice(0, maxWorktrees)) {
|
|
108
191
|
// One `git worktree list` from any worktree returns every worktree of that
|
|
109
192
|
// repo, so a single call per already-discovered repo covers its linked set.
|
|
110
193
|
for (const worktreePath of await listLinkedWorktreePaths(identity.repo_root)) {
|
|
111
|
-
if (byFingerprint.size >= maxWorktrees)
|
|
112
|
-
break;
|
|
113
194
|
const resolved = path.resolve(worktreePath);
|
|
114
|
-
|
|
195
|
+
const rootKey = localPathKey(resolved);
|
|
196
|
+
if (seenRoots.has(rootKey))
|
|
115
197
|
continue;
|
|
116
|
-
seenRoots.add(
|
|
198
|
+
seenRoots.add(rootKey);
|
|
117
199
|
const linked = await resolveRepoWorktreeIdentity(resolved).catch(() => null);
|
|
118
|
-
if (
|
|
119
|
-
byFingerprint.
|
|
200
|
+
if (!linked ||
|
|
201
|
+
byFingerprint.has(linked.worktree_fingerprint) ||
|
|
202
|
+
!isLinkedWorktreeWithinCollectionScope(linked, identities, allowedRoots)) {
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (byFingerprint.size >= maxWorktrees) {
|
|
206
|
+
maxWorktreesReached = true;
|
|
207
|
+
break;
|
|
120
208
|
}
|
|
209
|
+
byFingerprint.set(linked.worktree_fingerprint, linked);
|
|
121
210
|
}
|
|
122
211
|
}
|
|
123
|
-
|
|
212
|
+
const worktrees = [...byFingerprint.values()]
|
|
213
|
+
.sort(compareIdentity)
|
|
214
|
+
.slice(0, maxWorktrees);
|
|
215
|
+
const incompleteReasons = maxWorktreesReached
|
|
216
|
+
? ["max_worktrees_reached"]
|
|
217
|
+
: [];
|
|
218
|
+
return {
|
|
219
|
+
worktrees,
|
|
220
|
+
complete: incompleteReasons.length === 0,
|
|
221
|
+
incomplete_reasons: incompleteReasons,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
function isLinkedWorktreeWithinCollectionScope(linked, discoveredFromApprovedRoots, allowedRoots) {
|
|
225
|
+
if (allowedRoots.some((root) => containsPath(root, linked.repo_root))) {
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
// Codex isolation worktrees live under ~/.codex/worktrees, outside the
|
|
229
|
+
// approved workspace parent. They remain in scope only when Git proves they
|
|
230
|
+
// belong to a clone discovered inside an approved root. Arbitrary sibling or
|
|
231
|
+
// personal linked worktrees do not inherit that consent.
|
|
232
|
+
if (!isCodexWorktreePath(linked.repo_root))
|
|
233
|
+
return false;
|
|
234
|
+
return discoveredFromApprovedRoots.some((identity) => identity.repo_fingerprint === linked.repo_fingerprint &&
|
|
235
|
+
allowedRoots.some((root) => containsPath(root, identity.repo_root)));
|
|
236
|
+
}
|
|
237
|
+
function localPathKey(value) {
|
|
238
|
+
const resolved = path.resolve(value);
|
|
239
|
+
return process.platform === "win32" ? resolved.toLowerCase() : resolved;
|
|
124
240
|
}
|
|
125
241
|
async function listLinkedWorktreePaths(repoRoot) {
|
|
126
242
|
const porcelain = await runGit(["worktree", "list", "--porcelain"], repoRoot).catch(() => "");
|
|
@@ -158,11 +274,11 @@ export async function stableWorktreeRoot(repoRoot) {
|
|
|
158
274
|
const resolvedRoot = path.resolve(repoRoot);
|
|
159
275
|
return fs.realpath(resolvedRoot).catch(() => resolvedRoot);
|
|
160
276
|
}
|
|
161
|
-
export function stableWorktreeFingerprint(repoRoot) {
|
|
162
|
-
return `wt-${sha256(`worktree:${normalizeFingerprintPath(repoRoot)}`).slice(0, 24)}`;
|
|
277
|
+
export function stableWorktreeFingerprint(repoRoot, pathApi = path) {
|
|
278
|
+
return `wt-${sha256(`worktree:${normalizeFingerprintPath(repoRoot, pathApi)}`).slice(0, 24)}`;
|
|
163
279
|
}
|
|
164
|
-
export function repoFingerprintFromLocalRoot(root) {
|
|
165
|
-
return `repo-${sha256(`local:${
|
|
280
|
+
export function repoFingerprintFromLocalRoot(root, pathApi = path) {
|
|
281
|
+
return `repo-${sha256(`local:${normalizeFingerprintPath(root, pathApi)}`).slice(0, 24)}`;
|
|
166
282
|
}
|
|
167
283
|
export function repoFingerprintFromOrigin(origin) {
|
|
168
284
|
const normalizedOrigin = normalizeGitOrigin(origin);
|
|
@@ -251,9 +367,9 @@ async function runGit(args, cwd) {
|
|
|
251
367
|
});
|
|
252
368
|
return stdout;
|
|
253
369
|
}
|
|
254
|
-
function normalizeFingerprintPath(repoRoot) {
|
|
255
|
-
const resolved =
|
|
256
|
-
return
|
|
370
|
+
function normalizeFingerprintPath(repoRoot, pathApi = path) {
|
|
371
|
+
const resolved = pathApi.resolve(repoRoot);
|
|
372
|
+
return pathApi.sep === "\\" ? resolved.toLowerCase() : resolved;
|
|
257
373
|
}
|
|
258
374
|
function sha256(value) {
|
|
259
375
|
return crypto.createHash("sha256").update(value, "utf8").digest("hex");
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
-
export function collapseAncestorRoots(roots) {
|
|
2
|
+
export function collapseAncestorRoots(roots, pathApi = path) {
|
|
3
3
|
const collapsed = [];
|
|
4
4
|
for (const root of roots) {
|
|
5
|
-
const resolved =
|
|
6
|
-
if (collapsed.some((existing) => containsPath(existing, resolved)))
|
|
5
|
+
const resolved = pathApi.resolve(root);
|
|
6
|
+
if (collapsed.some((existing) => containsPath(existing, resolved, pathApi))) {
|
|
7
7
|
continue;
|
|
8
|
+
}
|
|
8
9
|
for (let index = collapsed.length - 1; index >= 0; index -= 1) {
|
|
9
|
-
if (containsPath(resolved, collapsed[index])) {
|
|
10
|
+
if (containsPath(resolved, collapsed[index], pathApi)) {
|
|
10
11
|
collapsed.splice(index, 1);
|
|
11
12
|
}
|
|
12
13
|
}
|
|
@@ -14,21 +15,33 @@ export function collapseAncestorRoots(roots) {
|
|
|
14
15
|
}
|
|
15
16
|
return collapsed;
|
|
16
17
|
}
|
|
17
|
-
export function normalizeCollectionRoots(roots) {
|
|
18
|
-
const collapsed = collapseAncestorRoots(roots);
|
|
19
|
-
if (!collapsed.some(isBliWorkspaceRoot))
|
|
18
|
+
export function normalizeCollectionRoots(roots, pathApi = path) {
|
|
19
|
+
const collapsed = collapseAncestorRoots(roots, pathApi);
|
|
20
|
+
if (!collapsed.some((root) => isBliWorkspaceRoot(root, pathApi))) {
|
|
20
21
|
return collapsed;
|
|
21
|
-
|
|
22
|
+
}
|
|
23
|
+
return collapsed.filter((root) => !isCodexWorktreePath(root, pathApi));
|
|
22
24
|
}
|
|
23
|
-
function containsPath(parent, candidate) {
|
|
24
|
-
const relative =
|
|
25
|
+
export function containsPath(parent, candidate, pathApi = path) {
|
|
26
|
+
const relative = pathApi.relative(parent, candidate);
|
|
25
27
|
return (relative === "" ||
|
|
26
|
-
(!!relative &&
|
|
28
|
+
(!!relative &&
|
|
29
|
+
relative !== ".." &&
|
|
30
|
+
!relative.startsWith(`..${pathApi.sep}`) &&
|
|
31
|
+
!pathApi.isAbsolute(relative)));
|
|
32
|
+
}
|
|
33
|
+
export function isSamePath(left, right, pathApi = path) {
|
|
34
|
+
return pathApi.relative(left, right) === "";
|
|
35
|
+
}
|
|
36
|
+
function comparableSegment(value, pathApi) {
|
|
37
|
+
return pathApi.sep === "\\" ? value.toLowerCase() : value;
|
|
27
38
|
}
|
|
28
|
-
function isBliWorkspaceRoot(root) {
|
|
29
|
-
return
|
|
39
|
+
function isBliWorkspaceRoot(root, pathApi) {
|
|
40
|
+
return comparableSegment(pathApi.basename(root), pathApi) ===
|
|
41
|
+
comparableSegment("BLI", pathApi);
|
|
30
42
|
}
|
|
31
|
-
function isCodexWorktreePath(root) {
|
|
32
|
-
const parts = root.split(
|
|
33
|
-
return parts.some((part, index) => part === ".codex" &&
|
|
43
|
+
export function isCodexWorktreePath(root, pathApi = path) {
|
|
44
|
+
const parts = root.split(pathApi.sep).filter(Boolean);
|
|
45
|
+
return parts.some((part, index) => comparableSegment(part, pathApi) === ".codex" &&
|
|
46
|
+
comparableSegment(parts[index + 1] ?? "", pathApi) === "worktrees");
|
|
34
47
|
}
|