@bli-cockpit/cli 0.2.11 → 0.2.12

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/dist/autostart.js CHANGED
@@ -2,6 +2,7 @@ import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths } from "./local-state.js";
5
+ import { savedDiscoveryLimitArgs } from "./discovery-limits.js";
5
6
  /** launchd LaunchAgent label; matches docs/runbooks/cockpit-launchd-sync.md. */
6
7
  export const AUTOSTART_LABEL = "com.bli.cockpit.sync";
7
8
  export const WINDOWS_AUTOSTART_TASK_NAME = "BLI Cockpit Sync";
@@ -53,6 +54,7 @@ export async function installAutostartAgent(options) {
53
54
  await mkdir(path.dirname(plistPath), { recursive: true });
54
55
  await mkdir(paths.state_dir, { recursive: true });
55
56
  await writeFile(plistPath, renderPlist({
57
+ discoveryArgs: await savedDiscoveryLimitArgs(homeDir),
56
58
  workDir,
57
59
  workDirs: resolvedWorkDirs,
58
60
  dashboardUrl,
@@ -127,6 +129,7 @@ export async function autostartStatus(options) {
127
129
  const cliEntryPoint = path.resolve(options.cliEntryPoint ?? process.argv[1] ?? "");
128
130
  const plist = await readFile(plistPath, "utf8").catch(() => "");
129
131
  const registrationProblems = darwinAgentRegistrationProblems(plist, {
132
+ discoveryArgs: await savedDiscoveryLimitArgs(homeDir),
130
133
  workDirs: resolvedWorkDirs,
131
134
  dashboardUrl,
132
135
  intervalSeconds,
@@ -165,6 +168,7 @@ async function installWindowsTask(options) {
165
168
  const cliEntryPoint = path.win32.resolve(options.cliEntryPoint ?? process.argv[1] ?? "");
166
169
  await mkdir(path.dirname(scriptPath), { recursive: true });
167
170
  await writeFile(scriptPath, `${UTF8_BOM}${renderWindowsSyncScript({
171
+ discoveryArgs: await savedDiscoveryLimitArgs(homeDir),
168
172
  workDirs: resolvedWorkDirs,
169
173
  dashboardUrl,
170
174
  nodeExecutable,
@@ -290,6 +294,7 @@ async function windowsTaskStatus(options) {
290
294
  }
291
295
  else if (options.repoRoots && options.repoRoots.length > 0) {
292
296
  const expectedScript = `${UTF8_BOM}${renderWindowsSyncScript({
297
+ discoveryArgs: await savedDiscoveryLimitArgs(options.homeDir),
293
298
  workDirs: normalizeWindowsWorkDirs(options.repoRoots),
294
299
  dashboardUrl: options.dashboardUrl ?? DEFAULT_DASHBOARD_URL,
295
300
  nodeExecutable: path.win32.resolve(options.nodeExecutable ?? process.execPath),
@@ -337,9 +342,10 @@ function renderWindowsSyncScript(options) {
337
342
  const dashboardArgs = options.dashboardUrl === DEFAULT_DASHBOARD_URL
338
343
  ? ""
339
344
  : ` --dashboard-url ${powershellLiteral(options.dashboardUrl)}`;
345
+ const discoveryArgs = options.discoveryArgs.length > 0 ? ` ${options.discoveryArgs.join(" ")}` : "";
340
346
  const commands = options.workDirs.flatMap((root) => [
341
347
  "try {",
342
- ` & $nodeExecutable $cliEntryPoint sync --workspace ${powershellLiteral(root)}${dashboardArgs} --json`,
348
+ ` & $nodeExecutable $cliEntryPoint sync --workspace ${powershellLiteral(root)}${dashboardArgs}${discoveryArgs} --json`,
343
349
  " if ($LASTEXITCODE -ne 0) { $exitCode = $LASTEXITCODE }",
344
350
  "} catch {",
345
351
  " [Console]::Error.WriteLine($_.Exception.Message)",
@@ -532,6 +538,7 @@ function renderPlist(options) {
532
538
  dashboardUrl: options.dashboardUrl,
533
539
  nodeExecutable: options.nodeExecutable,
534
540
  cliEntryPoint: options.cliEntryPoint,
541
+ discoveryArgs: options.discoveryArgs,
535
542
  });
536
543
  return [
537
544
  '<?xml version="1.0" encoding="UTF-8"?>',
@@ -569,7 +576,8 @@ function renderDarwinSyncCommand(options) {
569
576
  const dashboardArg = options.dashboardUrl === DEFAULT_DASHBOARD_URL
570
577
  ? ""
571
578
  : ` --dashboard-url ${shellQuote(options.dashboardUrl)}`;
572
- const commands = options.workDirs.map((root) => `${shellQuote(options.nodeExecutable)} ${shellQuote(options.cliEntryPoint)} sync --workspace ${shellQuote(root)}${dashboardArg} --json || exit_code=1`);
579
+ const discoveryArg = options.discoveryArgs.length > 0 ? ` ${options.discoveryArgs.join(" ")}` : "";
580
+ const commands = options.workDirs.map((root) => `${shellQuote(options.nodeExecutable)} ${shellQuote(options.cliEntryPoint)} sync --workspace ${shellQuote(root)}${dashboardArg}${discoveryArg} --json || exit_code=1`);
573
581
  return ["exit_code=0", ...commands, 'exit "$exit_code"'].join("; ");
574
582
  }
575
583
  function darwinAgentRegistrationProblems(plist, expected) {
@@ -583,6 +591,7 @@ function darwinAgentRegistrationProblems(plist, expected) {
583
591
  problems.push(`cadence is not ${expected.intervalSeconds} seconds`);
584
592
  }
585
593
  const expectedCommand = renderDarwinSyncCommand({
594
+ discoveryArgs: expected.discoveryArgs,
586
595
  workDirs: expected.workDirs,
587
596
  dashboardUrl: expected.dashboardUrl,
588
597
  nodeExecutable: expected.nodeExecutable,
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { autostartStatus, installAutostartAgent } from "../autostart.js";
4
+ import { savedDiscoveryLimitArgs } from "../discovery-limits.js";
4
5
  import { inspectBackfillLock } from "../backfill-lock.js";
5
6
  import { backfillCompletionCovers, readBackfillCompletionMarker, readBackfillCursor, } from "../cursors/backfill-cursor.js";
6
7
  import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, LOCAL_COLLECTOR_VERSION, readLocalCollectorConfig, readLocalCollectorSessionFile, } from "../local-state.js";
@@ -314,8 +315,12 @@ async function fixSyncState(context) {
314
315
  if (roots.length === 0) {
315
316
  return fail("sync-fresh", "no_roots", "sync has no saved workspace roots");
316
317
  }
318
+ // The remembered limits have to ride along, or the doctor's own verification
319
+ // sync scans differently from every other run and can fail closed on a
320
+ // machine the operator already fixed by hand (BLI-2362).
321
+ const discoveryArgs = await savedDiscoveryLimitArgs(context.command.homeDir);
317
322
  for (const repoRoot of roots) {
318
- const args = ["sync", "--json", "--workspace", repoRoot];
323
+ const args = ["sync", "--json", "--workspace", repoRoot, ...discoveryArgs];
319
324
  if (context.command.dashboardUrl !== DEFAULT_DASHBOARD_URL) {
320
325
  args.push("--dashboard-url", context.command.dashboardUrl);
321
326
  }
@@ -15,7 +15,8 @@ import { CODEX_ATTRIBUTION_SCAN_WINDOW_SESSION_LIMIT, CODEX_ATTRIBUTION_SCAN_WIN
15
15
  import { scanAndAttributeClaudeSessions } from "../adapters/claude-attribution.js";
16
16
  import { backfillCompletionCovers, emptyBackfillCursorState, prepareBackfillCursorForScope, readBackfillCompletionMarker, readBackfillCursor, } from "../cursors/backfill-cursor.js";
17
17
  import { acquireSyncLock } from "../sync-lock.js";
18
- import { DEFAULT_DISCOVERY_MAX_DEPTH, DEFAULT_DISCOVERY_MAX_REPOS, collectionRootPathAliases, discoverGitWorktreesInRootsWithStatus, } from "../repo-identity.js";
18
+ import { collectionRootPathAliases, discoverGitWorktreesInRootsWithStatus, } from "../repo-identity.js";
19
+ import { resolveDiscoveryLimits, saveDiscoveryLimits, } from "../discovery-limits.js";
19
20
  import { runAttributedWorktreeSync, matchesLiveSyncWorktree, } from "./session-sync.js";
20
21
  import { COLLECTION_ROOT_REQUIRED, missingCollectionRootMessage, normalizeRootsDetailed, resolveOnboardingRoots, rootRejectionExplanation, } from "../onboarding-roots.js";
21
22
  import { rawEvidenceGcSummary, runRawEvidenceLocalGc, } from "../raw-evidence-gc.js";
@@ -58,6 +59,10 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
58
59
  writeLine(io.stderr, localCommandHelp());
59
60
  return 1;
60
61
  }
62
+ // A limit typed on the command line is remembered for every later run,
63
+ // including the scheduled one nobody types into (BLI-2362). Done here so it
64
+ // applies to whichever command carried the flag.
65
+ await rememberDiscoveryLimits(command);
61
66
  try {
62
67
  switch (command.kind) {
63
68
  case "install":
@@ -1430,6 +1435,7 @@ async function runOnboard(command, io) {
1430
1435
  const worktrees = await discoverCommandWorktrees(collectionRoots, {
1431
1436
  maxDepth: command.maxDepth,
1432
1437
  maxRepos: command.maxRepos,
1438
+ homeDir: command.homeDir,
1433
1439
  allowEmpty: true,
1434
1440
  }, io);
1435
1441
  if (worktrees.length > 1) {
@@ -1782,14 +1788,22 @@ function cursorStatusLine(sync) {
1782
1788
  const ALL_SESSION_SCAN_WINDOW_MINUTES = 20 * 365 * 24 * 60;
1783
1789
  const SESSION_SCAN_OVERRIDE_LIMIT = 10_000;
1784
1790
  async function discoverCommandWorktrees(repoRoot, discovery = {}, io) {
1785
- const maxWorktrees = discovery.maxRepos ?? DEFAULT_DISCOVERY_MAX_REPOS;
1791
+ // What the operator typed this run, else what they typed some previous run,
1792
+ // else the built-in defaults (BLI-2362).
1793
+ const limits = await resolveDiscoveryLimits(discovery, discovery.homeDir);
1794
+ const maxWorktrees = limits.maxRepos;
1786
1795
  const roots = Array.isArray(repoRoot)
1787
1796
  ? repoRoot
1788
1797
  : [repoRoot ?? process.cwd()];
1789
1798
  const result = await discoverGitWorktreesInRootsWithStatus(roots, {
1790
- maxDepth: discovery.maxDepth ?? DEFAULT_DISCOVERY_MAX_DEPTH,
1799
+ maxDepth: limits.maxDepth,
1791
1800
  maxWorktrees,
1792
1801
  });
1802
+ if (io && result.unreadable_dirs.length > 0) {
1803
+ // Never silently dropped: anything under these folders is missing from the
1804
+ // scan, so say so even when the run otherwise succeeds.
1805
+ writeLine(io.stderr, unreadableDirectoriesMessage(result.unreadable_dirs));
1806
+ }
1793
1807
  const worktrees = result.worktrees;
1794
1808
  if (!result.complete) {
1795
1809
  // Sync fails closed here ON PURPOSE, and that is not the bug. Advancing a
@@ -1801,7 +1815,7 @@ async function discoverCommandWorktrees(repoRoot, discovery = {}, io) {
1801
1815
  const message = incompleteDiscoveryMessage({
1802
1816
  result,
1803
1817
  roots,
1804
- maxDepth: discovery.maxDepth ?? DEFAULT_DISCOVERY_MAX_DEPTH,
1818
+ maxDepth: limits.maxDepth,
1805
1819
  maxRepos: maxWorktrees,
1806
1820
  found: worktrees.length,
1807
1821
  });
@@ -1814,6 +1828,31 @@ async function discoverCommandWorktrees(repoRoot, discovery = {}, io) {
1814
1828
  }
1815
1829
  return worktrees;
1816
1830
  }
1831
+ /**
1832
+ * Persists `--max-depth` / `--max-repos` when a command carried them, so the
1833
+ * number survives into the background sync and the doctor's own sync — neither
1834
+ * of which has anywhere to type one (BLI-2362). Best-effort: failing to record
1835
+ * a preference must never fail the command the operator actually asked for.
1836
+ */
1837
+ async function rememberDiscoveryLimits(command) {
1838
+ const limits = command;
1839
+ if (limits.maxDepth === undefined && limits.maxRepos === undefined)
1840
+ return;
1841
+ await saveDiscoveryLimits({ maxDepth: limits.maxDepth, maxRepos: limits.maxRepos }, limits.homeDir).catch(() => undefined);
1842
+ }
1843
+ /**
1844
+ * Says which folders could not be opened, and therefore what the scan could not
1845
+ * see. Reported without failing the run — an unreadable folder cannot be fixed
1846
+ * by retrying, so blocking on one would strand the machine (BLI-2362).
1847
+ */
1848
+ function unreadableDirectoriesMessage(unreadable) {
1849
+ return [
1850
+ `WARNING: ${unreadable.length} folder(s) could not be opened, so anything inside them was not scanned:`,
1851
+ ...unreadable.map((dir) => ` ${dir.path} (${dir.code})`),
1852
+ "Collection continued for everything else. If a repo is missing from Cockpit,",
1853
+ "check the permissions on the folders above.",
1854
+ ].join("\n");
1855
+ }
1817
1856
  /**
1818
1857
  * Names the roots that could not be covered and hands back a command that
1819
1858
  * actually fixes it, with this machine's numbers already filled in.
@@ -2039,7 +2078,7 @@ async function runLogout(command, io) {
2039
2078
  return 0;
2040
2079
  }
2041
2080
  async function runStart(command, io) {
2042
- const worktrees = await discoverCommandWorktrees(command.repoRoot, { maxDepth: command.maxDepth, maxRepos: command.maxRepos }, io);
2081
+ const worktrees = await discoverCommandWorktrees(command.repoRoot, { maxDepth: command.maxDepth, maxRepos: command.maxRepos, homeDir: command.homeDir }, io);
2043
2082
  if (worktrees.length > 1) {
2044
2083
  const contexts = await Promise.all(worktrees.map((worktree) => startLocalWorkContext({
2045
2084
  homeDir: command.homeDir,
@@ -2230,6 +2269,7 @@ async function runSyncLocked(command, io) {
2230
2269
  const worktrees = await discoverCommandWorktrees(collectionRoots, {
2231
2270
  maxDepth: command.maxDepth,
2232
2271
  maxRepos: command.maxRepos,
2272
+ homeDir: command.homeDir,
2233
2273
  allowEmpty: true,
2234
2274
  }, io);
2235
2275
  const run = await runAttributedWorktreeSync({
@@ -2472,7 +2512,7 @@ async function runSyncRawEvidenceGc(command, io) {
2472
2512
  }
2473
2513
  async function runStatus(command, io) {
2474
2514
  const backfillCursor = await inspectBackfillCursor(command.homeDir);
2475
- const worktrees = await discoverCommandWorktrees(command.repoRoot, { maxDepth: command.maxDepth, maxRepos: command.maxRepos }, io);
2515
+ const worktrees = await discoverCommandWorktrees(command.repoRoot, { maxDepth: command.maxDepth, maxRepos: command.maxRepos, homeDir: command.homeDir }, io);
2476
2516
  if (worktrees.length > 1) {
2477
2517
  const statuses = await Promise.all(worktrees.map(async (worktree) => ({
2478
2518
  ...(await inspectLocalCollectorStatus({
@@ -2703,7 +2743,7 @@ function sessionsWindowLine(window) {
2703
2743
  async function runSessions(command, io) {
2704
2744
  const now = new Date();
2705
2745
  const homeDir = command.homeDir ?? os.homedir();
2706
- const worktrees = await discoverCommandWorktrees(command.repoRoot, { maxDepth: command.maxDepth, maxRepos: command.maxRepos }, io);
2746
+ const worktrees = await discoverCommandWorktrees(command.repoRoot, { maxDepth: command.maxDepth, maxRepos: command.maxRepos, homeDir: command.homeDir }, io);
2707
2747
  const window = await sessionsScanWindow(command, now);
2708
2748
  const wantCodex = command.source !== "claude";
2709
2749
  const wantClaude = command.source !== "codex";
@@ -15,7 +15,7 @@ export async function runCockpitCli(argv, io) {
15
15
  }
16
16
 
17
17
  if (command === "--version" || command === "-V" || command === "version") {
18
- writeLine(io?.stdout ?? process.stdout, "0.2.11");
18
+ writeLine(io?.stdout ?? process.stdout, "0.2.12");
19
19
  return 0;
20
20
  }
21
21
 
@@ -0,0 +1,89 @@
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { DEFAULT_DISCOVERY_MAX_DEPTH, DEFAULT_DISCOVERY_MAX_REPOS, } from "./repo-identity.js";
5
+ import { getCollectorRuntimePaths } from "./local-state.js";
6
+ const SCHEMA_VERSION = "cockpit-discovery-limits.v1";
7
+ // A depth beyond this is a typo rather than a workspace, and an unbounded walk
8
+ // on a huge tree is its own outage. Same for the repo cap.
9
+ const MAX_ALLOWED_DEPTH = 64;
10
+ const MAX_ALLOWED_REPOS = 5000;
11
+ function discoveryLimitsFile(homeDir) {
12
+ return path.join(getCollectorRuntimePaths(homeDir).state_dir, "discovery-limits.json");
13
+ }
14
+ function sanitizeLimit(value, ceiling) {
15
+ if (typeof value !== "number" || !Number.isSafeInteger(value))
16
+ return undefined;
17
+ if (value < 1 || value > ceiling)
18
+ return undefined;
19
+ return value;
20
+ }
21
+ /**
22
+ * Reads the remembered limits. Never throws: a corrupt file falls back to the
23
+ * defaults rather than taking collection down, because failing to read a tuning
24
+ * knob is not a reason to stop collecting.
25
+ */
26
+ export async function readSavedDiscoveryLimits(homeDir = os.homedir()) {
27
+ try {
28
+ const raw = await fs.readFile(discoveryLimitsFile(homeDir), "utf8");
29
+ const parsed = JSON.parse(raw);
30
+ return {
31
+ max_depth: sanitizeLimit(parsed.max_depth, MAX_ALLOWED_DEPTH),
32
+ max_repos: sanitizeLimit(parsed.max_repos, MAX_ALLOWED_REPOS),
33
+ };
34
+ }
35
+ catch {
36
+ return {};
37
+ }
38
+ }
39
+ /**
40
+ * Remembers limits an operator passed explicitly. Only writes the ones actually
41
+ * supplied, so raising the depth does not quietly reset a repo cap someone set
42
+ * earlier for their own reasons.
43
+ */
44
+ export async function saveDiscoveryLimits(limits, homeDir = os.homedir()) {
45
+ const maxDepth = sanitizeLimit(limits.maxDepth, MAX_ALLOWED_DEPTH);
46
+ const maxRepos = sanitizeLimit(limits.maxRepos, MAX_ALLOWED_REPOS);
47
+ if (maxDepth === undefined && maxRepos === undefined)
48
+ return;
49
+ const existing = await readSavedDiscoveryLimits(homeDir);
50
+ const next = {
51
+ schema_version: SCHEMA_VERSION,
52
+ max_depth: maxDepth ?? existing.max_depth,
53
+ max_repos: maxRepos ?? existing.max_repos,
54
+ updated_at: new Date().toISOString(),
55
+ };
56
+ const file = discoveryLimitsFile(homeDir);
57
+ await fs.mkdir(path.dirname(file), { recursive: true });
58
+ await fs.writeFile(file, `${JSON.stringify(next, null, 2)}\n`, {
59
+ encoding: "utf8",
60
+ mode: 0o600,
61
+ });
62
+ }
63
+ /**
64
+ * The limits a scan should actually use: what the operator typed this run,
65
+ * else what they typed some previous run, else the built-in defaults.
66
+ */
67
+ export async function resolveDiscoveryLimits(command, homeDir = os.homedir()) {
68
+ const saved = await readSavedDiscoveryLimits(homeDir);
69
+ return {
70
+ maxDepth: command.maxDepth ?? saved.max_depth ?? DEFAULT_DISCOVERY_MAX_DEPTH,
71
+ maxRepos: command.maxRepos ?? saved.max_repos ?? DEFAULT_DISCOVERY_MAX_REPOS,
72
+ };
73
+ }
74
+ /**
75
+ * The flags a generated background command needs so a scheduled run scans the
76
+ * same way an operator's manual run did. Empty when the machine is on the
77
+ * defaults, which keeps the common plist/Task Scheduler command unchanged.
78
+ */
79
+ export async function savedDiscoveryLimitArgs(homeDir = os.homedir()) {
80
+ const saved = await readSavedDiscoveryLimits(homeDir);
81
+ const args = [];
82
+ if (saved.max_depth !== undefined) {
83
+ args.push("--max-depth", String(saved.max_depth));
84
+ }
85
+ if (saved.max_repos !== undefined) {
86
+ args.push("--max-repos", String(saved.max_repos));
87
+ }
88
+ return args;
89
+ }
@@ -31,6 +31,12 @@ const SKIPPED_DIR_NAMES = new Set([
31
31
  */
32
32
  export const DEFAULT_DISCOVERY_MAX_DEPTH = 20;
33
33
  export const DEFAULT_DISCOVERY_MAX_REPOS = 200;
34
+ /** Caps the reported list so one pathological tree cannot dominate a receipt. */
35
+ const MAX_REPORTED_UNREADABLE_DIRS = 25;
36
+ function errorCode(error) {
37
+ const code = error?.code;
38
+ return typeof code === "string" ? code : "UNKNOWN";
39
+ }
34
40
  export async function resolveRepoWorktreeIdentity(repoRoot) {
35
41
  const requestedPath = path.resolve(repoRoot);
36
42
  const gitRoot = await runGit(["rev-parse", "--show-toplevel"], requestedPath);
@@ -89,6 +95,7 @@ export async function discoverGitWorktreesWithStatus(root, options = {}) {
89
95
  const stack = [{ dir: resolvedRoot, depth: 0 }];
90
96
  const visited = new Set();
91
97
  const incompleteReasons = new Set();
98
+ const unreadableDirs = [];
92
99
  while (stack.length > 0) {
93
100
  if (discovered.size >= maxWorktrees) {
94
101
  incompleteReasons.add("max_worktrees_reached");
@@ -107,7 +114,18 @@ export async function discoverGitWorktreesWithStatus(root, options = {}) {
107
114
  discovered.set(identity.worktree_fingerprint, identity);
108
115
  continue;
109
116
  }
110
- const entries = await fs.readdir(current.dir, { withFileTypes: true }).catch(() => []);
117
+ // A failed read is recorded, never swallowed: anything beneath this folder
118
+ // is missing from the scan, and the operator has to be able to see that.
119
+ let entries;
120
+ try {
121
+ entries = await fs.readdir(current.dir, { withFileTypes: true });
122
+ }
123
+ catch (error) {
124
+ if (unreadableDirs.length < MAX_REPORTED_UNREADABLE_DIRS) {
125
+ unreadableDirs.push({ path: current.dir, code: errorCode(error) });
126
+ }
127
+ continue;
128
+ }
111
129
  if (current.depth >= maxDepth) {
112
130
  if (entries.some((entry) => entry.isDirectory() && !shouldSkipDirectory(entry.name))) {
113
131
  incompleteReasons.add("max_depth_reached");
@@ -130,6 +148,7 @@ export async function discoverGitWorktreesWithStatus(root, options = {}) {
130
148
  incomplete_reasons: [...incompleteReasons].sort(),
131
149
  // Single-root scanner: the only root in play is the one it was handed.
132
150
  incomplete_roots: incompleteReasons.size === 0 ? [] : [path.resolve(root)],
151
+ unreadable_dirs: unreadableDirs,
133
152
  };
134
153
  }
135
154
  /**
@@ -147,6 +166,7 @@ export async function discoverGitWorktreesInRootsWithStatus(roots, options = {})
147
166
  const discovered = new Map();
148
167
  const incompleteReasons = new Set();
149
168
  const incompleteRoots = new Set();
169
+ const unreadableDirs = [];
150
170
  for (const root of roots) {
151
171
  const result = await discoverGitWorktreesWithStatus(root, {
152
172
  ...options,
@@ -156,6 +176,11 @@ export async function discoverGitWorktreesInRootsWithStatus(roots, options = {})
156
176
  incompleteReasons.add(reason);
157
177
  incompleteRoots.add(root);
158
178
  }
179
+ for (const dir of result.unreadable_dirs) {
180
+ if (unreadableDirs.length >= MAX_REPORTED_UNREADABLE_DIRS)
181
+ break;
182
+ unreadableDirs.push(dir);
183
+ }
159
184
  for (const worktree of result.worktrees) {
160
185
  if (discovered.size >= maxWorktrees) {
161
186
  if (!discovered.has(worktree.worktree_fingerprint)) {
@@ -174,6 +199,7 @@ export async function discoverGitWorktreesInRootsWithStatus(roots, options = {})
174
199
  complete: incompleteReasons.size === 0,
175
200
  incomplete_reasons: [...incompleteReasons].sort(),
176
201
  incomplete_roots: [...incompleteRoots].sort(),
202
+ unreadable_dirs: unreadableDirs,
177
203
  };
178
204
  }
179
205
  /**
@@ -242,6 +268,9 @@ async function expandLinkedWorktrees(identities, maxWorktrees, allowedRoots) {
242
268
  worktrees,
243
269
  complete: incompleteReasons.length === 0,
244
270
  incomplete_reasons: incompleteReasons,
271
+ // Expansion asks git for its own worktree list; it never walks folders, so
272
+ // it has no unreadable directories of its own to report.
273
+ unreadable_dirs: [],
245
274
  // Linked-worktree expansion is not scoped to one root; callers merge this
246
275
  // into a result that already knows which roots were involved.
247
276
  incomplete_roots: [],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.11",
3
+ "version": "0.2.12",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -26,6 +26,6 @@
26
26
  "test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
27
27
  },
28
28
  "dependencies": {
29
- "@bli-cockpit/telemetry-core": "0.1.15"
29
+ "@bli-cockpit/telemetry-core": "0.1.16"
30
30
  }
31
31
  }