@indigoai-us/hq-cli 5.17.0 → 5.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,262 @@
1
+ /**
2
+ * Unit tests for `buildNarrowPlan` (local-tree-diff.ts).
3
+ *
4
+ * Uses a tmpdir fixture (same pattern as cloud-demote.test.ts) — vitest +
5
+ * real fs — so the symmetric-diff + dirty-classification logic is exercised
6
+ * end-to-end against an actual journal + filesystem.
7
+ */
8
+
9
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
10
+ import * as fs from "node:fs";
11
+ import * as os from "node:os";
12
+ import * as path from "node:path";
13
+ import * as crypto from "node:crypto";
14
+
15
+ import {
16
+ buildNarrowPlan,
17
+ formatBytes,
18
+ formatNarrowPlanSummary,
19
+ } from "./local-tree-diff.js";
20
+ import type { SyncJournal } from "@indigoai-us/hq-cloud";
21
+
22
+ // ── Fixture helpers ─────────────────────────────────────────────────────────
23
+
24
+ let tmpRoot: string;
25
+
26
+ function writeFile(rel: string, contents: string): { abs: string; rel: string } {
27
+ const abs = path.join(tmpRoot, rel);
28
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
29
+ fs.writeFileSync(abs, contents);
30
+ return { abs, rel };
31
+ }
32
+
33
+ function sha256(s: string): string {
34
+ return crypto.createHash("sha256").update(s).digest("hex");
35
+ }
36
+
37
+ /** Build a journal that "knows about" the given files at their current hash + mtime. */
38
+ function journalFromFiles(
39
+ files: Array<{ rel: string; contents: string; syncedAt?: string }>,
40
+ ): SyncJournal {
41
+ const j: SyncJournal = {
42
+ version: "2",
43
+ lastSync: new Date().toISOString(),
44
+ files: {},
45
+ pulls: [],
46
+ };
47
+ for (const f of files) {
48
+ j.files[f.rel] = {
49
+ hash: sha256(f.contents),
50
+ size: Buffer.byteLength(f.contents),
51
+ // Stamp syncedAt slightly in the future so on-disk mtime <= syncedAt
52
+ // (the clean path needs `mtime <= syncedAt + 1s` and mkdtemp file
53
+ // mtimes are essentially "now").
54
+ syncedAt: f.syncedAt ?? new Date(Date.now() + 5000).toISOString(),
55
+ direction: "down",
56
+ };
57
+ }
58
+ return j;
59
+ }
60
+
61
+ beforeEach(() => {
62
+ tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-narrow-plan-"));
63
+ });
64
+
65
+ afterEach(() => {
66
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
67
+ });
68
+
69
+ // ── Happy-path partitioning ─────────────────────────────────────────────────
70
+
71
+ describe("buildNarrowPlan — partition by prefix coverage", () => {
72
+ it("separates files into staying / clean / dirty buckets", () => {
73
+ // Staying — covered by prospective prefix.
74
+ const staying = writeFile(
75
+ "companies/acme/meetings/2026/notes.md",
76
+ "stays here",
77
+ );
78
+ // Clean orphan — outside prospective, matches journal.
79
+ const cleanOrphan = writeFile(
80
+ "companies/acme/scratch/old.md",
81
+ "clean content",
82
+ );
83
+ // Dirty orphan — outside prospective, hash mismatch.
84
+ const dirtyOrphan = writeFile(
85
+ "companies/acme/scratch/dirty.md",
86
+ "current local content",
87
+ );
88
+
89
+ const journal = journalFromFiles([
90
+ { rel: staying.rel, contents: "stays here" },
91
+ { rel: cleanOrphan.rel, contents: "clean content" },
92
+ // Journal saw an earlier version → hash mismatch with the on-disk
93
+ // "current local content" → dirty.
94
+ { rel: dirtyOrphan.rel, contents: "JOURNAL VERSION DIFFERENT" },
95
+ ]);
96
+
97
+ const plan = buildNarrowPlan({
98
+ hqRoot: tmpRoot,
99
+ companySlug: "acme",
100
+ prospectivePrefixSet: ["companies/acme/meetings/"],
101
+ journal,
102
+ });
103
+
104
+ expect(plan.staying.map((f) => f.relPath)).toEqual([staying.rel]);
105
+ expect(plan.clean.map((f) => f.relPath)).toEqual([cleanOrphan.rel]);
106
+ expect(plan.dirty.map((f) => f.relPath)).toEqual([dirtyOrphan.rel]);
107
+ expect(plan.dirty[0].reason).toBe("hash-mismatch");
108
+
109
+ expect(plan.totalStayingCount).toBe(1);
110
+ expect(plan.totalCleanCount).toBe(1);
111
+ expect(plan.totalDirtyCount).toBe(1);
112
+ expect(plan.totalCleanBytes).toBe(Buffer.byteLength("clean content"));
113
+ });
114
+
115
+ it("classifies files with no journal entry as dirty (not-in-journal)", () => {
116
+ const orphan = writeFile("companies/acme/scratch/local-draft.md", "never synced");
117
+ const plan = buildNarrowPlan({
118
+ hqRoot: tmpRoot,
119
+ companySlug: "acme",
120
+ prospectivePrefixSet: ["companies/acme/meetings/"],
121
+ journal: journalFromFiles([]),
122
+ });
123
+ expect(plan.dirty.map((f) => f.relPath)).toEqual([orphan.rel]);
124
+ expect(plan.dirty[0].reason).toBe("not-in-journal");
125
+ });
126
+
127
+ it("treats tombstoned journal entries as not-in-journal (dirty)", () => {
128
+ const orphan = writeFile("companies/acme/scratch/tombstoned.md", "still here");
129
+ const journal = journalFromFiles([
130
+ { rel: orphan.rel, contents: "still here" },
131
+ ]);
132
+ // Mark the entry tombstoned post-hoc.
133
+ journal.files[orphan.rel]!.removedAt = new Date().toISOString();
134
+ journal.files[orphan.rel]!.removedReason = "scope_shrink";
135
+
136
+ const plan = buildNarrowPlan({
137
+ hqRoot: tmpRoot,
138
+ companySlug: "acme",
139
+ prospectivePrefixSet: ["companies/acme/meetings/"],
140
+ journal,
141
+ });
142
+ expect(plan.dirty.map((f) => f.relPath)).toEqual([orphan.rel]);
143
+ expect(plan.dirty[0].reason).toBe("not-in-journal");
144
+ });
145
+
146
+ it("flags modified-after-sync via mtime past syncedAt", () => {
147
+ const orphan = writeFile("companies/acme/scratch/touched.md", "v1");
148
+ const journal = journalFromFiles([
149
+ { rel: orphan.rel, contents: "v1", syncedAt: new Date(0).toISOString() },
150
+ ]);
151
+ const plan = buildNarrowPlan({
152
+ hqRoot: tmpRoot,
153
+ companySlug: "acme",
154
+ prospectivePrefixSet: ["companies/acme/meetings/"],
155
+ journal,
156
+ });
157
+ expect(plan.dirty.map((f) => f.relPath)).toEqual([orphan.rel]);
158
+ expect(plan.dirty[0].reason).toBe("modified-after-sync");
159
+ });
160
+
161
+ it("handles nested prefixes (broader covers narrower) by trusting the caller-coalesced set", () => {
162
+ const a = writeFile("companies/acme/meetings/a.md", "a");
163
+ const b = writeFile("companies/acme/meetings/2026/b.md", "b");
164
+ const c = writeFile("companies/acme/scratch/c.md", "c");
165
+
166
+ const plan = buildNarrowPlan({
167
+ hqRoot: tmpRoot,
168
+ companySlug: "acme",
169
+ prospectivePrefixSet: ["companies/acme/meetings/"],
170
+ journal: journalFromFiles([
171
+ { rel: a.rel, contents: "a" },
172
+ { rel: b.rel, contents: "b" },
173
+ { rel: c.rel, contents: "c" },
174
+ ]),
175
+ });
176
+
177
+ const stayingPaths = plan.staying.map((f) => f.relPath).sort();
178
+ expect(stayingPaths).toEqual([a.rel, b.rel].sort());
179
+ expect(plan.clean.map((f) => f.relPath)).toEqual([c.rel]);
180
+ });
181
+
182
+ it("returns an empty plan when the company folder is missing", () => {
183
+ const plan = buildNarrowPlan({
184
+ hqRoot: tmpRoot,
185
+ companySlug: "ghost",
186
+ prospectivePrefixSet: ["companies/ghost/"],
187
+ journal: journalFromFiles([]),
188
+ });
189
+ expect(plan.totalStayingCount).toBe(0);
190
+ expect(plan.totalCleanCount).toBe(0);
191
+ expect(plan.totalDirtyCount).toBe(0);
192
+ });
193
+
194
+ it("everything is clean when the prefix set is empty (entire tree is orphan)", () => {
195
+ const a = writeFile("companies/acme/a.md", "aa");
196
+ const b = writeFile("companies/acme/sub/b.md", "bb");
197
+ const plan = buildNarrowPlan({
198
+ hqRoot: tmpRoot,
199
+ companySlug: "acme",
200
+ prospectivePrefixSet: [],
201
+ journal: journalFromFiles([
202
+ { rel: a.rel, contents: "aa" },
203
+ { rel: b.rel, contents: "bb" },
204
+ ]),
205
+ });
206
+ expect(plan.totalStayingCount).toBe(0);
207
+ expect(plan.totalCleanCount).toBe(2);
208
+ expect(plan.totalDirtyCount).toBe(0);
209
+ });
210
+
211
+ it("everything stays when the prospective prefix covers the whole company folder", () => {
212
+ const a = writeFile("companies/acme/a.md", "aa");
213
+ const b = writeFile("companies/acme/sub/b.md", "bb");
214
+ const plan = buildNarrowPlan({
215
+ hqRoot: tmpRoot,
216
+ companySlug: "acme",
217
+ prospectivePrefixSet: ["companies/acme/"],
218
+ journal: journalFromFiles([]),
219
+ });
220
+ expect(plan.totalStayingCount).toBe(2);
221
+ expect(plan.totalCleanCount).toBe(0);
222
+ expect(plan.totalDirtyCount).toBe(0);
223
+ // both are staying, regardless of journal state
224
+ expect(
225
+ plan.staying.map((f) => f.relPath).sort(),
226
+ ).toEqual([a.rel, b.rel].sort());
227
+ });
228
+ });
229
+
230
+ // ── Formatters ──────────────────────────────────────────────────────────────
231
+
232
+ describe("formatBytes", () => {
233
+ it("formats bytes in a fixed unit ladder", () => {
234
+ expect(formatBytes(0)).toBe("0 B");
235
+ expect(formatBytes(512)).toBe("512 B");
236
+ expect(formatBytes(2048)).toBe("2.00 KB");
237
+ expect(formatBytes(5 * 1024 * 1024)).toBe("5.00 MB");
238
+ });
239
+
240
+ it("clamps negative + non-finite to 0 B", () => {
241
+ expect(formatBytes(-1)).toBe("0 B");
242
+ expect(formatBytes(Number.NaN)).toBe("0 B");
243
+ });
244
+ });
245
+
246
+ describe("formatNarrowPlanSummary", () => {
247
+ it("renders staying / clean / dirty counts with byte totals", () => {
248
+ const out = formatNarrowPlanSummary({
249
+ staying: [],
250
+ clean: [],
251
+ dirty: [],
252
+ totalStayingCount: 5,
253
+ totalCleanCount: 3,
254
+ totalCleanBytes: 2048,
255
+ totalDirtyCount: 1,
256
+ totalDirtyBytes: 1024,
257
+ });
258
+ expect(out).toContain("files staying: 5");
259
+ expect(out).toContain("clean orphans: 3 (2.00 KB)");
260
+ expect(out).toContain("dirty orphans: 1 (1.00 KB)");
261
+ });
262
+ });
@@ -0,0 +1,330 @@
1
+ /**
2
+ * Pure helper for `hq sync narrow` (US-007).
3
+ *
4
+ * Walks a local directory tree under `<hqRoot>/companies/{slug}/`, classifies
5
+ * each file against a prospective `shared`-mode prefix set, and groups the
6
+ * results into three buckets:
7
+ *
8
+ * - **staying** — file is covered by the prospective prefix set; survives.
9
+ * - **clean** — file is NOT covered (orphan) AND is provably unchanged
10
+ * since the last journal sync (safe to delete).
11
+ * - **dirty** — file is NOT covered (orphan) AND has been locally
12
+ * modified since the last sync (sacred — abort without
13
+ * `--force`).
14
+ *
15
+ * The clean-vs-dirty rule mirrors hq-cloud's implicit-shrink path
16
+ * (`scope-shrink.ts::classifyOrphan`) verbatim so the two narrowing paths —
17
+ * implicit (next pull after sync-mode flip) and explicit (`hq sync narrow
18
+ * --apply`) — agree on what "dirty" means. See US-000 Task 3 in
19
+ * `companies/indigo/projects/hq-sync-browse-vs-sync/references.md`.
20
+ *
21
+ * Pure: no network. Reads the journal off `SyncJournal` (caller passes it
22
+ * in), stats + hashes files on disk — no journal mutations and no remote
23
+ * calls happen here. The CLI orchestrator (`sync-narrow.ts`) is responsible
24
+ * for the destructive side effects (delete, tombstone, PUT sync-config).
25
+ */
26
+
27
+ import * as fs from "node:fs";
28
+ import * as path from "node:path";
29
+
30
+ import {
31
+ hashFile,
32
+ isCoveredByAny,
33
+ type JournalEntry,
34
+ type SyncJournal,
35
+ } from "@indigoai-us/hq-cloud";
36
+
37
+ // ── Types ───────────────────────────────────────────────────────────────────
38
+
39
+ export type DirtyReason =
40
+ | "modified-after-sync"
41
+ | "hash-mismatch"
42
+ | "not-in-journal"
43
+ | "stat-error";
44
+
45
+ export interface NarrowFile {
46
+ /** Path relative to `hqRoot` (matches journal key + S3 key naming). */
47
+ relPath: string;
48
+ /** Absolute path on disk (convenience for the CLI delete loop). */
49
+ absPath: string;
50
+ /** Size in bytes from the local stat. 0 for symlinks (lstat size is the link, not target). */
51
+ bytes: number;
52
+ }
53
+
54
+ export interface NarrowDirtyFile extends NarrowFile {
55
+ reason: DirtyReason;
56
+ }
57
+
58
+ export interface NarrowPlan {
59
+ /** Files covered by `prospectivePrefixSet` — survive the narrow. */
60
+ staying: NarrowFile[];
61
+ /** Orphan files that are journal-clean — safe to delete on `--apply`. */
62
+ clean: NarrowFile[];
63
+ /** Orphan files that are locally dirty — block `--apply` unless `--force`. */
64
+ dirty: NarrowDirtyFile[];
65
+
66
+ // Convenience aggregates for the CLI summary table.
67
+ totalStayingCount: number;
68
+ totalCleanCount: number;
69
+ totalCleanBytes: number;
70
+ totalDirtyCount: number;
71
+ totalDirtyBytes: number;
72
+ }
73
+
74
+ export interface BuildNarrowPlanInput {
75
+ /** Absolute HQ root path (the dir containing `companies/`). */
76
+ hqRoot: string;
77
+ /** Company slug — used to derive the walk root `<hqRoot>/companies/<slug>/`. */
78
+ companySlug: string;
79
+ /**
80
+ * Coalesced prospective `shared`-mode prefix set (the result of running
81
+ * the caller's explicit grants through `coalescePrefixes`). Prefixes are
82
+ * hq-root-relative (e.g. `companies/indigo/meetings/`).
83
+ */
84
+ prospectivePrefixSet: readonly string[];
85
+ /**
86
+ * Active per-company journal. Hash + mtime comparisons key off
87
+ * `journal.files[relPath]`.
88
+ */
89
+ journal: SyncJournal;
90
+ }
91
+
92
+ // ── Pure entry point ────────────────────────────────────────────────────────
93
+
94
+ /**
95
+ * Build a NarrowPlan by walking `<hqRoot>/companies/<slug>/` and classifying
96
+ * each regular file against `prospectivePrefixSet` + `journal`.
97
+ *
98
+ * Walk semantics:
99
+ * - Recursive `fs.readdirSync(... withFileTypes)`.
100
+ * - Symlinks are RECORDED but never followed (`lstat`, not `stat`), matching
101
+ * the `share()`/sync engine's contract.
102
+ * - Hidden files + `.DS_Store` + `node_modules` are walked as-is — the
103
+ * caller is expected to point this at HQ content, where `.hqignore`
104
+ * filtering happens upstream of the journal. The journal already
105
+ * reflects what's been pushed/pulled, so `not-in-journal` is the
106
+ * classifier signal for "we didn't put this here".
107
+ * - If the walk root is missing entirely, returns an empty plan (rather
108
+ * than throwing) — a freshly-flipped membership may not have synced any
109
+ * files yet.
110
+ */
111
+ export function buildNarrowPlan(input: BuildNarrowPlanInput): NarrowPlan {
112
+ const { hqRoot, companySlug, prospectivePrefixSet, journal } = input;
113
+ const walkRoot = path.join(hqRoot, "companies", companySlug);
114
+
115
+ const staying: NarrowFile[] = [];
116
+ const clean: NarrowFile[] = [];
117
+ const dirty: NarrowDirtyFile[] = [];
118
+
119
+ if (!fs.existsSync(walkRoot)) {
120
+ return emptyPlan();
121
+ }
122
+
123
+ walkLocal(walkRoot, hqRoot, (file) => {
124
+ if (isCoveredByAny(file.relPath, prospectivePrefixSet)) {
125
+ staying.push(file);
126
+ return;
127
+ }
128
+ const verdict = classifyAgainstJournal(file, journal);
129
+ if (verdict.clean) {
130
+ clean.push(file);
131
+ } else {
132
+ dirty.push({ ...file, reason: verdict.reason });
133
+ }
134
+ });
135
+
136
+ return {
137
+ staying,
138
+ clean,
139
+ dirty,
140
+ totalStayingCount: staying.length,
141
+ totalCleanCount: clean.length,
142
+ totalCleanBytes: clean.reduce((sum, f) => sum + f.bytes, 0),
143
+ totalDirtyCount: dirty.length,
144
+ totalDirtyBytes: dirty.reduce((sum, f) => sum + f.bytes, 0),
145
+ };
146
+ }
147
+
148
+ // ── Internal: walk + classify ───────────────────────────────────────────────
149
+
150
+ function emptyPlan(): NarrowPlan {
151
+ return {
152
+ staying: [],
153
+ clean: [],
154
+ dirty: [],
155
+ totalStayingCount: 0,
156
+ totalCleanCount: 0,
157
+ totalCleanBytes: 0,
158
+ totalDirtyCount: 0,
159
+ totalDirtyBytes: 0,
160
+ };
161
+ }
162
+
163
+ /**
164
+ * Recursive readdir walk yielding regular files (and dangling-target
165
+ * symlinks — those are recorded as files for narrow-plan purposes; the
166
+ * delete path uses `fs.unlinkSync` which handles symlinks correctly).
167
+ *
168
+ * Uses `lstat` rather than `stat` so a symlink's size doesn't follow the
169
+ * target chain (matches the share-engine convention).
170
+ */
171
+ function walkLocal(
172
+ dir: string,
173
+ hqRoot: string,
174
+ emit: (file: NarrowFile) => void,
175
+ ): void {
176
+ let entries: fs.Dirent[];
177
+ try {
178
+ entries = fs.readdirSync(dir, { withFileTypes: true });
179
+ } catch (err) {
180
+ // Permission-denied or ENOENT mid-walk — skip silently; the CLI
181
+ // surfaces the higher-level "company folder missing" message earlier.
182
+ const code = (err as NodeJS.ErrnoException).code;
183
+ if (code === "ENOENT" || code === "EACCES") return;
184
+ throw err;
185
+ }
186
+
187
+ for (const entry of entries) {
188
+ const absPath = path.join(dir, entry.name);
189
+ const relPath = path.relative(hqRoot, absPath);
190
+
191
+ if (entry.isSymbolicLink()) {
192
+ // Record the link as a file-like entry. Don't descend — narrow is
193
+ // about pruning files that the LOCAL tree has materialized here; a
194
+ // symlink to a knowledge repo is one such "file" from the
195
+ // walk's POV.
196
+ let bytes = 0;
197
+ try {
198
+ bytes = fs.lstatSync(absPath).size;
199
+ } catch {
200
+ // pass — dangling link still gets emitted with size 0
201
+ }
202
+ emit({ relPath, absPath, bytes });
203
+ continue;
204
+ }
205
+
206
+ if (entry.isDirectory()) {
207
+ walkLocal(absPath, hqRoot, emit);
208
+ continue;
209
+ }
210
+
211
+ if (entry.isFile()) {
212
+ let bytes = 0;
213
+ try {
214
+ bytes = fs.lstatSync(absPath).size;
215
+ } catch {
216
+ // pass
217
+ }
218
+ emit({ relPath, absPath, bytes });
219
+ }
220
+ }
221
+ }
222
+
223
+ interface JournalVerdict {
224
+ clean: boolean;
225
+ reason: DirtyReason;
226
+ }
227
+
228
+ /**
229
+ * Mirror of `scope-shrink.ts::classifyOrphan`:
230
+ *
231
+ * clean = (entry exists AND hash(local) === entry.hash AND mtime <= syncedAt + 1s)
232
+ * OR (local file missing)
233
+ *
234
+ * Differences from the implicit path:
235
+ * - If there is NO journal entry at all, classify as **dirty**
236
+ * (`not-in-journal`). The implicit path never sees those files (it
237
+ * iterates the journal); here we walk the local tree so an
238
+ * un-journaled file is a real risk — it could be an unsynced local
239
+ * draft.
240
+ * - We don't honor `direction: "up"` differently. For an explicit
241
+ * narrow migration the user is asking "wipe everything outside the
242
+ * shared scope" — push-only authorship deserves the same dirty-file
243
+ * protection as pulled content (otherwise `--apply` could nuke local
244
+ * drafts).
245
+ */
246
+ function classifyAgainstJournal(
247
+ file: NarrowFile,
248
+ journal: SyncJournal,
249
+ ): JournalVerdict {
250
+ const entry: JournalEntry | undefined = journal.files[file.relPath];
251
+ if (!entry) {
252
+ return { clean: false, reason: "not-in-journal" };
253
+ }
254
+ // Tombstoned entries — the file shouldn't be here at all; treat as
255
+ // not-in-journal for safety so `--apply` doesn't silently delete it.
256
+ if (entry.removedAt) {
257
+ return { clean: false, reason: "not-in-journal" };
258
+ }
259
+
260
+ let stat: fs.Stats;
261
+ try {
262
+ stat = fs.lstatSync(file.absPath);
263
+ } catch (err) {
264
+ const code = (err as NodeJS.ErrnoException).code;
265
+ if (code === "ENOENT") {
266
+ // Already gone — clean (the apply path will skip ENOENT on unlink).
267
+ return { clean: true, reason: "stat-error" };
268
+ }
269
+ return { clean: false, reason: "stat-error" };
270
+ }
271
+
272
+ // mtime guard with 1s grace for filesystem clock jitter, matching
273
+ // hq-cloud `scope-shrink.ts`.
274
+ const syncedAtMs = Date.parse(entry.syncedAt);
275
+ if (!Number.isNaN(syncedAtMs) && stat.mtimeMs > syncedAtMs + 1000) {
276
+ return { clean: false, reason: "modified-after-sync" };
277
+ }
278
+
279
+ // Hash check — final word. Symlinks: hashFile reads contents which is
280
+ // wrong for symlinks (it would follow the target), so on a symlink we
281
+ // skip the hash check and trust mtime alone. This matches the
282
+ // engine's behavior in the symlink-orphan case.
283
+ if (stat.isSymbolicLink()) {
284
+ return { clean: true, reason: "stat-error" };
285
+ }
286
+
287
+ let actualHash: string;
288
+ try {
289
+ actualHash = hashFile(file.absPath);
290
+ } catch {
291
+ return { clean: false, reason: "stat-error" };
292
+ }
293
+ if (actualHash !== entry.hash) {
294
+ return { clean: false, reason: "hash-mismatch" };
295
+ }
296
+ return { clean: true, reason: "stat-error" };
297
+ }
298
+
299
+ // ── Formatting helper (separable for tests) ────────────────────────────────
300
+
301
+ /**
302
+ * Render the dry-run summary as plain text (no chalk — keep it pure). The
303
+ * CLI wrapper can colorize lines afterwards if desired.
304
+ */
305
+ export function formatNarrowPlanSummary(plan: NarrowPlan): string {
306
+ const lines: string[] = [];
307
+ lines.push("Narrow plan summary:");
308
+ lines.push(` files staying: ${plan.totalStayingCount}`);
309
+ lines.push(
310
+ ` clean orphans: ${plan.totalCleanCount} (${formatBytes(plan.totalCleanBytes)})`,
311
+ );
312
+ lines.push(
313
+ ` dirty orphans: ${plan.totalDirtyCount} (${formatBytes(plan.totalDirtyBytes)})`,
314
+ );
315
+ return lines.join("\n");
316
+ }
317
+
318
+ /** Human-readable byte counts with a fixed unit ladder. */
319
+ export function formatBytes(n: number): string {
320
+ if (!Number.isFinite(n) || n < 0) return "0 B";
321
+ if (n < 1024) return `${n} B`;
322
+ const units = ["KB", "MB", "GB", "TB"];
323
+ let v = n / 1024;
324
+ let i = 0;
325
+ while (v >= 1024 && i < units.length - 1) {
326
+ v /= 1024;
327
+ i++;
328
+ }
329
+ return `${v.toFixed(2)} ${units[i]}`;
330
+ }