@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 +11 -2
- package/dist/commands/doctor.js +6 -1
- package/dist/commands/local.js +47 -7
- package/dist/commands/public-root.js +1 -1
- package/dist/discovery-limits.js +89 -0
- package/dist/repo-identity.js +30 -1
- package/package.json +2 -2
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
|
|
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,
|
package/dist/commands/doctor.js
CHANGED
|
@@ -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
|
}
|
package/dist/commands/local.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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.
|
|
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
|
+
}
|
package/dist/repo-identity.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
29
|
+
"@bli-cockpit/telemetry-core": "0.1.16"
|
|
30
30
|
}
|
|
31
31
|
}
|