@bli-cockpit/cli 0.2.57 → 0.2.58

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.
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Every place this package shells out to `git`, plus the two ways a branch
3
+ * is read when git itself cannot be asked (a `.git` file's HEAD, for a
4
+ * linked worktree whose git process is unavailable). Sibling of
5
+ * `repo-identity.ts`, named in its header.
6
+ */
7
+ import { execFile } from "node:child_process";
8
+ import fs from "node:fs/promises";
9
+ import path from "node:path";
10
+ import { promisify } from "node:util";
11
+ import { describeError, isMissingFileFailure } from "./health-detail.js";
12
+ const execFileAsync = promisify(execFile);
13
+ export async function runGit(args, cwd) {
14
+ const { stdout } = await execFileAsync("git", args, {
15
+ cwd,
16
+ timeout: 2_000,
17
+ maxBuffer: 1024 * 1024,
18
+ });
19
+ return stdout;
20
+ }
21
+ export async function hasGitMarker(dir) {
22
+ return fs.stat(path.join(dir, ".git")).then((stat) => stat.isDirectory() || stat.isFile(), () => false);
23
+ }
24
+ export async function resolveGitBranchWithGit(repoRoot) {
25
+ const branch = await runGit(["rev-parse", "--abbrev-ref", "HEAD"], repoRoot).then((value) => value.trim(), () => "");
26
+ if (branch && branch !== "HEAD")
27
+ return branch;
28
+ const head = await runGit(["rev-parse", "--short=12", "HEAD"], repoRoot).then((value) => value.trim(), () => "");
29
+ return head ? `detached:${head}` : "unknown";
30
+ }
31
+ export async function resolveBranchFromHead(repoRoot) {
32
+ try {
33
+ const gitPath = path.join(repoRoot, ".git");
34
+ const stat = await fs.stat(gitPath);
35
+ const headPath = stat.isFile()
36
+ ? path.join(await resolveLinkedGitDir(gitPath), "HEAD")
37
+ : path.join(gitPath, "HEAD");
38
+ const head = (await fs.readFile(headPath, "utf8")).trim();
39
+ if (head.startsWith("ref: refs/heads/")) {
40
+ return head.slice("ref: refs/heads/".length);
41
+ }
42
+ return head ? `detached:${head.slice(0, 12)}` : "unknown";
43
+ }
44
+ catch (error) {
45
+ // Not a repo → quiet, that is an ordinary approved folder. A `.git` that
46
+ // exists and will not read → every session from this worktree is labelled
47
+ // branch `unknown` and, until BLI-3238, nothing said why.
48
+ if (!isMissingFileFailure(error)) {
49
+ console.error("[repo-identity] could not read HEAD, branch recorded as unknown", JSON.stringify({
50
+ reason: "git_head_unreadable",
51
+ ...describeError(error),
52
+ }));
53
+ }
54
+ return "unknown";
55
+ }
56
+ }
57
+ async function resolveLinkedGitDir(gitFile) {
58
+ const raw = await fs.readFile(gitFile, "utf8");
59
+ const match = raw.match(/^gitdir:\s*(.+)$/m);
60
+ if (!match)
61
+ return path.dirname(gitFile);
62
+ const gitDir = match[1].trim();
63
+ return path.isAbsolute(gitDir) ? gitDir : path.resolve(path.dirname(gitFile), gitDir);
64
+ }
65
+ export async function listLinkedWorktreePaths(repoRoot) {
66
+ const porcelain = await runGit(["worktree", "list", "--porcelain"], repoRoot).catch(() => "");
67
+ const paths = [];
68
+ for (const line of porcelain.split("\n")) {
69
+ if (line.startsWith("worktree ")) {
70
+ const value = line.slice("worktree ".length).trim();
71
+ if (value)
72
+ paths.push(value);
73
+ }
74
+ }
75
+ return paths;
76
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Adding a discovered repo's linked worktrees (Claude Code isolation
3
+ * worktrees, `git worktree add` checkouts) as first-class candidates.
4
+ * Sibling of `repo-identity.ts`, named in its header.
5
+ */
6
+ import path from "node:path";
7
+ import { containsPath, isCodexWorktreePath } from "./root-normalization.js";
8
+ import { listLinkedWorktreePaths } from "./repo-identity-git.js";
9
+ import { compareIdentity, resolveRepoWorktreeIdentity } from "./repo-identity.js";
10
+ /**
11
+ * Directory discovery skips dot-dirs, so Claude Code's isolation worktrees
12
+ * (`<repo>/.claude/worktrees/<name>`) and any other linked worktree are never
13
+ * found by walking the filesystem. A session whose cwd sits inside one would
14
+ * otherwise satisfy `isPathWithin(cwd, parentRoot)` and attribute confidently
15
+ * to the PARENT with the wrong branch and worktree fingerprint. Enumerating
16
+ * `git worktree list --porcelain` for each discovered repo adds the linked
17
+ * worktrees as first-class candidates with their own branch/fingerprint; the
18
+ * deepest-root tie-break (attribution-core D6) then attributes nested-worktree
19
+ * sessions to the correct linked worktree. Benefits Codex sessions identically.
20
+ */
21
+ export async function expandLinkedWorktrees(identities, maxWorktrees, allowedRoots) {
22
+ const byFingerprint = new Map(identities.map((identity) => [identity.worktree_fingerprint, identity]));
23
+ const seenRoots = new Set(identities.map((identity) => localPathKey(identity.repo_root)));
24
+ let maxWorktreesReached = identities.length > maxWorktrees;
25
+ for (const identity of identities.slice(0, maxWorktrees)) {
26
+ // One `git worktree list` from any worktree returns every worktree of that
27
+ // repo, so a single call per already-discovered repo covers its linked set.
28
+ for (const worktreePath of await listLinkedWorktreePaths(identity.repo_root)) {
29
+ const resolved = path.resolve(worktreePath);
30
+ const rootKey = localPathKey(resolved);
31
+ if (seenRoots.has(rootKey))
32
+ continue;
33
+ seenRoots.add(rootKey);
34
+ const linked = await resolveRepoWorktreeIdentity(resolved).catch(() => null);
35
+ if (!linked ||
36
+ byFingerprint.has(linked.worktree_fingerprint) ||
37
+ !isLinkedWorktreeWithinCollectionScope(linked, identities, allowedRoots)) {
38
+ continue;
39
+ }
40
+ if (byFingerprint.size >= maxWorktrees) {
41
+ maxWorktreesReached = true;
42
+ break;
43
+ }
44
+ byFingerprint.set(linked.worktree_fingerprint, linked);
45
+ }
46
+ }
47
+ const worktrees = [...byFingerprint.values()]
48
+ .sort(compareIdentity)
49
+ .slice(0, maxWorktrees);
50
+ const incompleteReasons = maxWorktreesReached
51
+ ? ["max_worktrees_reached"]
52
+ : [];
53
+ return {
54
+ worktrees,
55
+ complete: incompleteReasons.length === 0,
56
+ incomplete_reasons: incompleteReasons,
57
+ // Expansion asks git for its own worktree list; it never walks folders, so
58
+ // it has no unreadable directories of its own to report.
59
+ unreadable_dirs: [],
60
+ // Linked-worktree expansion is not scoped to one root; callers merge this
61
+ // into a result that already knows which roots were involved.
62
+ incomplete_roots: [],
63
+ };
64
+ }
65
+ function isLinkedWorktreeWithinCollectionScope(linked, discoveredFromApprovedRoots, allowedRoots) {
66
+ if (allowedRoots.some((root) => containsPath(root, linked.repo_root))) {
67
+ return true;
68
+ }
69
+ // Codex isolation worktrees live under ~/.codex/worktrees, outside the
70
+ // approved workspace parent. They remain in scope only when Git proves they
71
+ // belong to a clone discovered inside an approved root. Arbitrary sibling or
72
+ // personal linked worktrees do not inherit that consent.
73
+ if (!isCodexWorktreePath(linked.repo_root))
74
+ return false;
75
+ return discoveredFromApprovedRoots.some((identity) => identity.repo_fingerprint === linked.repo_fingerprint &&
76
+ allowedRoots.some((root) => containsPath(root, identity.repo_root)));
77
+ }
78
+ function localPathKey(value) {
79
+ const resolved = path.resolve(value);
80
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
81
+ }
@@ -1,11 +1,9 @@
1
- import { execFile } from "node:child_process";
2
- import crypto from "node:crypto";
3
1
  import fs from "node:fs/promises";
4
2
  import path from "node:path";
5
- import { promisify } from "node:util";
6
- import { containsPath, isCodexWorktreePath } from "./root-normalization.js";
7
- import { describeError, isMissingFileFailure } from "./health-detail.js";
8
- const execFileAsync = promisify(execFile);
3
+ import { hasGitMarker, resolveGitBranchWithGit, resolveBranchFromHead, runGit } from "./repo-identity-git.js";
4
+ import { repoFingerprintFromLocalRoot, repoFingerprintFromOrigin, repoLabelFromOrigin, normalizeGitOrigin, sha256, stableWorktreeFingerprint, stableWorktreeRoot, } from "./repo-identity-fingerprint.js";
5
+ import { expandLinkedWorktrees } from "./repo-identity-linked-worktrees.js";
6
+ export { canonicalizeCollectionRootPaths, collectionRootPathAliases, normalizeGitOrigin, repoFingerprintFromLocalRoot, repoFingerprintFromOrigin, repoLabelFromOrigin, stableWorktreeFingerprint, stableWorktreeRoot, } from "./repo-identity-fingerprint.js";
9
7
  const SKIPPED_DIR_NAMES = new Set([
10
8
  ".cache",
11
9
  ".git",
@@ -203,112 +201,6 @@ export async function discoverGitWorktreesInRootsWithStatus(roots, options = {})
203
201
  unreadable_dirs: unreadableDirs,
204
202
  };
205
203
  }
206
- /**
207
- * Canonicalizes existing collection roots through the filesystem so transcript
208
- * paths and consent roots use the same spelling (for example `/private/var`
209
- * versus the `/var` symlink on macOS). Missing roots remain resolved as typed.
210
- */
211
- export async function canonicalizeCollectionRootPaths(roots) {
212
- const canonical = await Promise.all(roots.map(stableWorktreeRoot));
213
- return [...new Set(canonical)];
214
- }
215
- /**
216
- * Returns the operator-entered resolved paths plus filesystem-canonical aliases.
217
- * Both are needed for absent child repos because the child itself cannot be
218
- * realpathed after deletion, while its transcript may retain either spelling.
219
- */
220
- export async function collectionRootPathAliases(roots) {
221
- const resolved = roots.map((root) => path.resolve(root));
222
- const canonical = await canonicalizeCollectionRootPaths(resolved);
223
- return [...new Set([...resolved, ...canonical])];
224
- }
225
- /**
226
- * Directory discovery skips dot-dirs, so Claude Code's isolation worktrees
227
- * (`<repo>/.claude/worktrees/<name>`) and any other linked worktree are never
228
- * found by walking the filesystem. A session whose cwd sits inside one would
229
- * otherwise satisfy `isPathWithin(cwd, parentRoot)` and attribute confidently
230
- * to the PARENT with the wrong branch and worktree fingerprint. Enumerating
231
- * `git worktree list --porcelain` for each discovered repo adds the linked
232
- * worktrees as first-class candidates with their own branch/fingerprint; the
233
- * deepest-root tie-break (attribution-core D6) then attributes nested-worktree
234
- * sessions to the correct linked worktree. Benefits Codex sessions identically.
235
- */
236
- async function expandLinkedWorktrees(identities, maxWorktrees, allowedRoots) {
237
- const byFingerprint = new Map(identities.map((identity) => [identity.worktree_fingerprint, identity]));
238
- const seenRoots = new Set(identities.map((identity) => localPathKey(identity.repo_root)));
239
- let maxWorktreesReached = identities.length > maxWorktrees;
240
- for (const identity of identities.slice(0, maxWorktrees)) {
241
- // One `git worktree list` from any worktree returns every worktree of that
242
- // repo, so a single call per already-discovered repo covers its linked set.
243
- for (const worktreePath of await listLinkedWorktreePaths(identity.repo_root)) {
244
- const resolved = path.resolve(worktreePath);
245
- const rootKey = localPathKey(resolved);
246
- if (seenRoots.has(rootKey))
247
- continue;
248
- seenRoots.add(rootKey);
249
- const linked = await resolveRepoWorktreeIdentity(resolved).catch(() => null);
250
- if (!linked ||
251
- byFingerprint.has(linked.worktree_fingerprint) ||
252
- !isLinkedWorktreeWithinCollectionScope(linked, identities, allowedRoots)) {
253
- continue;
254
- }
255
- if (byFingerprint.size >= maxWorktrees) {
256
- maxWorktreesReached = true;
257
- break;
258
- }
259
- byFingerprint.set(linked.worktree_fingerprint, linked);
260
- }
261
- }
262
- const worktrees = [...byFingerprint.values()]
263
- .sort(compareIdentity)
264
- .slice(0, maxWorktrees);
265
- const incompleteReasons = maxWorktreesReached
266
- ? ["max_worktrees_reached"]
267
- : [];
268
- return {
269
- worktrees,
270
- complete: incompleteReasons.length === 0,
271
- incomplete_reasons: incompleteReasons,
272
- // Expansion asks git for its own worktree list; it never walks folders, so
273
- // it has no unreadable directories of its own to report.
274
- unreadable_dirs: [],
275
- // Linked-worktree expansion is not scoped to one root; callers merge this
276
- // into a result that already knows which roots were involved.
277
- incomplete_roots: [],
278
- };
279
- }
280
- function isLinkedWorktreeWithinCollectionScope(linked, discoveredFromApprovedRoots, allowedRoots) {
281
- if (allowedRoots.some((root) => containsPath(root, linked.repo_root))) {
282
- return true;
283
- }
284
- // Codex isolation worktrees live under ~/.codex/worktrees, outside the
285
- // approved workspace parent. They remain in scope only when Git proves they
286
- // belong to a clone discovered inside an approved root. Arbitrary sibling or
287
- // personal linked worktrees do not inherit that consent.
288
- if (!isCodexWorktreePath(linked.repo_root))
289
- return false;
290
- return discoveredFromApprovedRoots.some((identity) => identity.repo_fingerprint === linked.repo_fingerprint &&
291
- allowedRoots.some((root) => containsPath(root, identity.repo_root)));
292
- }
293
- function localPathKey(value) {
294
- const resolved = path.resolve(value);
295
- return process.platform === "win32" ? resolved.toLowerCase() : resolved;
296
- }
297
- async function listLinkedWorktreePaths(repoRoot) {
298
- const porcelain = await runGit(["worktree", "list", "--porcelain"], repoRoot).catch(() => "");
299
- const paths = [];
300
- for (const line of porcelain.split("\n")) {
301
- if (line.startsWith("worktree ")) {
302
- const value = line.slice("worktree ".length).trim();
303
- if (value)
304
- paths.push(value);
305
- }
306
- }
307
- return paths;
308
- }
309
- async function hasGitMarker(dir) {
310
- return fs.stat(path.join(dir, ".git")).then((stat) => stat.isDirectory() || stat.isFile(), () => false);
311
- }
312
204
  async function fallbackFilesystemIdentity(repoRoot) {
313
205
  const resolvedRoot = await stableWorktreeRoot(repoRoot);
314
206
  const repoLabel = path.basename(resolvedRoot) || "repo";
@@ -326,120 +218,11 @@ async function fallbackFilesystemIdentity(repoRoot) {
326
218
  worktree_is_primary: true,
327
219
  };
328
220
  }
329
- export async function stableWorktreeRoot(repoRoot) {
330
- const resolvedRoot = path.resolve(repoRoot);
331
- return fs.realpath(resolvedRoot).catch(() => resolvedRoot);
332
- }
333
- export function stableWorktreeFingerprint(repoRoot, pathApi = path) {
334
- return `wt-${sha256(`worktree:${normalizeFingerprintPath(repoRoot, pathApi)}`).slice(0, 24)}`;
335
- }
336
- export function repoFingerprintFromLocalRoot(root, pathApi = path) {
337
- return `repo-${sha256(`local:${normalizeFingerprintPath(root, pathApi)}`).slice(0, 24)}`;
338
- }
339
- export function repoFingerprintFromOrigin(origin) {
340
- const normalizedOrigin = normalizeGitOrigin(origin);
341
- return `repo-${sha256(`origin:${normalizedOrigin}`).slice(0, 24)}`;
342
- }
343
- async function resolveBranchFromHead(repoRoot) {
344
- try {
345
- const gitPath = path.join(repoRoot, ".git");
346
- const stat = await fs.stat(gitPath);
347
- const headPath = stat.isFile()
348
- ? path.join(await resolveLinkedGitDir(gitPath), "HEAD")
349
- : path.join(gitPath, "HEAD");
350
- const head = (await fs.readFile(headPath, "utf8")).trim();
351
- if (head.startsWith("ref: refs/heads/")) {
352
- return head.slice("ref: refs/heads/".length);
353
- }
354
- return head ? `detached:${head.slice(0, 12)}` : "unknown";
355
- }
356
- catch (error) {
357
- // Not a repo → quiet, that is an ordinary approved folder. A `.git` that
358
- // exists and will not read → every session from this worktree is labelled
359
- // branch `unknown` and, until BLI-3238, nothing said why.
360
- if (!isMissingFileFailure(error)) {
361
- console.error("[repo-identity] could not read HEAD, branch recorded as unknown", JSON.stringify({
362
- reason: "git_head_unreadable",
363
- ...describeError(error),
364
- }));
365
- }
366
- return "unknown";
367
- }
368
- }
369
- async function resolveLinkedGitDir(gitFile) {
370
- const raw = await fs.readFile(gitFile, "utf8");
371
- const match = raw.match(/^gitdir:\s*(.+)$/m);
372
- if (!match)
373
- return path.dirname(gitFile);
374
- const gitDir = match[1].trim();
375
- return path.isAbsolute(gitDir) ? gitDir : path.resolve(path.dirname(gitFile), gitDir);
376
- }
377
- export function normalizeGitOrigin(rawOrigin) {
378
- const trimmed = rawOrigin.trim();
379
- if (!trimmed)
380
- return "";
381
- const scpLike = trimmed.match(/^(?:[^@]+@)?([^:]+):(.+)$/);
382
- if (scpLike && !trimmed.includes("://")) {
383
- return normalizeOriginParts(scpLike[1] ?? "", scpLike[2] ?? "");
384
- }
385
- try {
386
- const url = new URL(trimmed);
387
- return normalizeOriginParts(url.hostname, url.pathname);
388
- }
389
- catch {
390
- // Deliberately silent (BLI-3238). `new URL` is being used as the test for
391
- // "is this origin URL-shaped?", and a plain path or an unusual remote form
392
- // failing to parse IS the answer — the fallback below is the intended
393
- // normalization for exactly that case, not a degradation.
394
- return trimmed
395
- .replace(/\.git$/i, "")
396
- .replace(/^\/+|\/+$/g, "")
397
- .toLowerCase();
398
- }
399
- }
400
- export function repoLabelFromOrigin(origin) {
401
- const segments = origin.split("/").filter(Boolean);
402
- return segments.at(-1) ?? origin;
403
- }
404
- function normalizeOriginParts(host, repoPath) {
405
- return [
406
- host.trim().toLowerCase(),
407
- repoPath
408
- .trim()
409
- .replace(/\.git$/i, "")
410
- .replace(/^\/+|\/+$/g, "")
411
- .toLowerCase(),
412
- ]
413
- .filter(Boolean)
414
- .join("/");
415
- }
416
221
  function shouldSkipDirectory(name) {
417
222
  return name.startsWith(".") || SKIPPED_DIR_NAMES.has(name);
418
223
  }
419
- function compareIdentity(a, b) {
224
+ export function compareIdentity(a, b) {
420
225
  return (a.repo_label.localeCompare(b.repo_label) ||
421
226
  Number(b.worktree_is_primary) - Number(a.worktree_is_primary) ||
422
227
  a.worktree_label.localeCompare(b.worktree_label));
423
- }
424
- async function resolveGitBranchWithGit(repoRoot) {
425
- const branch = await runGit(["rev-parse", "--abbrev-ref", "HEAD"], repoRoot).then((value) => value.trim(), () => "");
426
- if (branch && branch !== "HEAD")
427
- return branch;
428
- const head = await runGit(["rev-parse", "--short=12", "HEAD"], repoRoot).then((value) => value.trim(), () => "");
429
- return head ? `detached:${head}` : "unknown";
430
- }
431
- async function runGit(args, cwd) {
432
- const { stdout } = await execFileAsync("git", args, {
433
- cwd,
434
- timeout: 2_000,
435
- maxBuffer: 1024 * 1024,
436
- });
437
- return stdout;
438
- }
439
- function normalizeFingerprintPath(repoRoot, pathApi = path) {
440
- const resolved = pathApi.resolve(repoRoot);
441
- return pathApi.sep === "\\" ? resolved.toLowerCase() : resolved;
442
- }
443
- function sha256(value) {
444
- return crypto.createHash("sha256").update(value, "utf8").digest("hex");
445
228
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.57",
3
+ "version": "0.2.58",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -18,17 +18,17 @@
18
18
  "node": ">=20"
19
19
  },
20
20
  "scripts": {
21
- "prebuild": "npm run build --workspace=@bli-cockpit/local-collector",
21
+ "prebuild": "node ../../scripts/build-workspace-dep.mjs @bli-cockpit/local-collector",
22
22
  "build": "node ../../scripts/build-public-cli.mjs",
23
23
  "prepack": "npm run build",
24
- "pretypecheck": "npm run build",
24
+ "pretypecheck": "node ../../scripts/build-workspace-dep.mjs @bli-cockpit/cli",
25
25
  "typecheck": "node -e \"await import('./dist/commands/public-root.js')\"",
26
- "pretest": "npm run build",
26
+ "pretest": "node ../../scripts/build-workspace-dep.mjs @bli-cockpit/cli",
27
27
  "test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-cli-runtime-files.mjs && node ../../scripts/assert-public-cli-no-fleet-posts.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
28
28
  },
29
29
  "dependencies": {
30
- "@bli-cockpit/memory-mcp": "0.1.5",
31
- "@bli-cockpit/mcp": "0.1.0",
30
+ "@bli-cockpit/memory-mcp": "0.1.6",
31
+ "@bli-cockpit/mcp": "0.1.1",
32
32
  "@bli-cockpit/telemetry-core": "0.1.28"
33
33
  }
34
34
  }