@indigoai-us/hq-cloud 6.1.0 → 6.2.1

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.
Files changed (61) hide show
  1. package/dist/bin/sync-runner.d.ts.map +1 -1
  2. package/dist/bin/sync-runner.js +18 -0
  3. package/dist/bin/sync-runner.js.map +1 -1
  4. package/dist/cli/index.d.ts +2 -2
  5. package/dist/cli/index.d.ts.map +1 -1
  6. package/dist/cli/index.js +2 -2
  7. package/dist/cli/index.js.map +1 -1
  8. package/dist/cli/reindex.d.ts +4 -11
  9. package/dist/cli/reindex.d.ts.map +1 -1
  10. package/dist/cli/reindex.js +336 -30
  11. package/dist/cli/reindex.js.map +1 -1
  12. package/dist/cli/reindex.test.d.ts +3 -3
  13. package/dist/cli/reindex.test.js +36 -11
  14. package/dist/cli/reindex.test.js.map +1 -1
  15. package/dist/cli/rescue-core.d.ts +36 -0
  16. package/dist/cli/rescue-core.d.ts.map +1 -0
  17. package/dist/cli/rescue-core.js +1589 -0
  18. package/dist/cli/rescue-core.js.map +1 -0
  19. package/dist/cli/rescue-drift-reconcile.test.js +33 -10
  20. package/dist/cli/rescue-drift-reconcile.test.js.map +1 -1
  21. package/dist/cli/rescue-journal-reconcile.test.d.ts +2 -0
  22. package/dist/cli/rescue-journal-reconcile.test.d.ts.map +1 -0
  23. package/dist/cli/rescue-journal-reconcile.test.js +135 -0
  24. package/dist/cli/rescue-journal-reconcile.test.js.map +1 -0
  25. package/dist/cli/rescue-mtime-preserve.test.js +36 -12
  26. package/dist/cli/rescue-mtime-preserve.test.js.map +1 -1
  27. package/dist/cli/rescue.d.ts +4 -10
  28. package/dist/cli/rescue.d.ts.map +1 -1
  29. package/dist/cli/rescue.js +14 -37
  30. package/dist/cli/rescue.js.map +1 -1
  31. package/dist/cli/rescue.reindex.test.js +9 -8
  32. package/dist/cli/rescue.reindex.test.js.map +1 -1
  33. package/dist/cli/rescue.test.js +1 -10
  34. package/dist/cli/rescue.test.js.map +1 -1
  35. package/dist/index.d.ts +2 -2
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +2 -2
  38. package/dist/index.js.map +1 -1
  39. package/dist/lib/conflict-index.d.ts +40 -0
  40. package/dist/lib/conflict-index.d.ts.map +1 -1
  41. package/dist/lib/conflict-index.js +121 -0
  42. package/dist/lib/conflict-index.js.map +1 -1
  43. package/dist/lib/conflict.test.js +145 -1
  44. package/dist/lib/conflict.test.js.map +1 -1
  45. package/package.json +1 -1
  46. package/src/bin/sync-runner.ts +18 -0
  47. package/src/cli/index.ts +2 -2
  48. package/src/cli/reindex.test.ts +45 -12
  49. package/src/cli/reindex.ts +345 -36
  50. package/src/cli/rescue-core.ts +1719 -0
  51. package/src/cli/rescue-drift-reconcile.test.ts +33 -12
  52. package/src/cli/rescue-journal-reconcile.test.ts +156 -0
  53. package/src/cli/rescue-mtime-preserve.test.ts +36 -15
  54. package/src/cli/rescue.reindex.test.ts +9 -8
  55. package/src/cli/rescue.test.ts +1 -11
  56. package/src/cli/rescue.ts +15 -40
  57. package/src/index.ts +2 -2
  58. package/src/lib/conflict-index.ts +146 -0
  59. package/src/lib/conflict.test.ts +171 -0
  60. package/scripts/reindex.sh +0 -318
  61. package/scripts/replace-rescue.sh +0 -1522
@@ -0,0 +1,1719 @@
1
+ /**
2
+ * hq rescue — drift-preserving re-sync of a local HQ tree to an upstream
3
+ * hq-core release (or staging branch). This is the native-TypeScript port of
4
+ * the former scripts/replace-rescue.sh (removed). It is an *orchestration*
5
+ * port: the control flow, three-way drift classification, routing, YAML
6
+ * stamping, mtime restoration, backup snapshot and summary are implemented in
7
+ * TypeScript, while the heavy primitives the bash itself delegated to —
8
+ * `git` (clone/checkout/hash-object/cat-file/ls-tree/show/log), `rsync` (the
9
+ * overlay), and the YAML editor (`yq`/`python3`) — are still invoked as
10
+ * subprocesses. Reimplementing git's blob hashing or rsync's include/exclude
11
+ * filter engine in pure JS would *increase* the risk of behavioural drift, so
12
+ * the port reuses the exact same tools with the exact same argument vectors.
13
+ *
14
+ * Behaviour (classification outcomes, on-disk results, log lines, exit codes)
15
+ * is preserved 1:1 with the legacy script. See the original script's header in
16
+ * git history for the full algorithm narrative.
17
+ *
18
+ * Three-way classification per file under each wipe-set top-level entry:
19
+ * USER-ONLY -> leave in place (overlay runs without --delete).
20
+ * UNCHANGED -> delete (overlay re-lays from source) OR, when identical to
21
+ * upstream HEAD, leave in place + protect from the overlay so
22
+ * its mtime survives (Layer 1).
23
+ * USER-EDIT -> rescue into personal/ (or diff-append for .claude/CLAUDE.md),
24
+ * quarantine into .hq-conflicts/ (conflict class), or silently
25
+ * overwrite (overwrite-safe class).
26
+ */
27
+ import { spawnSync } from "child_process";
28
+ import * as fs from "fs";
29
+ import * as os from "os";
30
+ import * as path from "path";
31
+ import {
32
+ readJournal,
33
+ writeJournal,
34
+ hashFile,
35
+ updateEntry,
36
+ PERSONAL_VAULT_JOURNAL_SLUG,
37
+ } from "../journal.js";
38
+
39
+ export interface RunRescueResult {
40
+ status: number;
41
+ }
42
+
43
+ // Paths always preserved across wipe+overlay (shuttled out, restored after).
44
+ const CARVE_OUT_PATHS = [
45
+ "core/packages",
46
+ "packages",
47
+ ".claude/state",
48
+ "core/workers/registry.yaml",
49
+ ".claude/settings.local.json",
50
+ ];
51
+
52
+ // Preserve set that is redundant to pass via --preserve (always preserved).
53
+ const ALWAYS_PRESERVED_NAMES = [
54
+ ".git",
55
+ "companies",
56
+ "personal",
57
+ "workspace",
58
+ "repos",
59
+ ".github",
60
+ ".leak-scan",
61
+ ".hq-sync-journal.json",
62
+ ".hq",
63
+ ".hq-conflicts",
64
+ ];
65
+
66
+ interface Config {
67
+ ref: string;
68
+ sourceRepo: string;
69
+ dryRun: boolean;
70
+ assumeYes: boolean;
71
+ cloudUpdate: boolean;
72
+ extraPreserve: string[];
73
+ preserveSubpaths: string[];
74
+ narrowPaths: string[];
75
+ hqRootOverride: string;
76
+ historyCheck: boolean;
77
+ floorShaOverride: string;
78
+ doBackup: boolean;
79
+ backupRoot: string;
80
+ backupRetentionDays: string;
81
+ }
82
+
83
+ class ExitError extends Error {
84
+ constructor(public code: number) {
85
+ super(`exit ${code}`);
86
+ }
87
+ }
88
+
89
+ /** Run a subprocess synchronously, inheriting the parent environment by default. */
90
+ function run(
91
+ cmd: string,
92
+ args: string[],
93
+ opts: { cwd?: string; env?: NodeJS.ProcessEnv; input?: string } = {},
94
+ ): { status: number; stdout: string; stderr: string } {
95
+ const res = spawnSync(cmd, args, {
96
+ cwd: opts.cwd,
97
+ env: opts.env ?? process.env,
98
+ input: opts.input,
99
+ encoding: "utf-8",
100
+ });
101
+ return {
102
+ status: res.status ?? (res.error ? 127 : 1),
103
+ stdout: res.stdout ?? "",
104
+ stderr: res.stderr ?? "",
105
+ };
106
+ }
107
+
108
+ /** `command -v <bin>` — is a binary on PATH? */
109
+ function hasCmd(bin: string, env: NodeJS.ProcessEnv): boolean {
110
+ const r = spawnSync(bin, ["--version"], { env, stdio: "ignore" });
111
+ return !r.error;
112
+ }
113
+
114
+ // fs predicates mirroring bash test operators.
115
+ function isDir(p: string): boolean {
116
+ try {
117
+ return fs.statSync(p).isDirectory();
118
+ } catch {
119
+ return false;
120
+ }
121
+ }
122
+ function isFileFollow(p: string): boolean {
123
+ try {
124
+ return fs.statSync(p).isFile();
125
+ } catch {
126
+ return false;
127
+ }
128
+ }
129
+ function existsFollow(p: string): boolean {
130
+ try {
131
+ fs.statSync(p);
132
+ return true;
133
+ } catch {
134
+ return false;
135
+ }
136
+ }
137
+ function lstatOrNull(p: string): fs.Stats | null {
138
+ try {
139
+ return fs.lstatSync(p);
140
+ } catch {
141
+ return null;
142
+ }
143
+ }
144
+ function lexists(p: string): boolean {
145
+ return lstatOrNull(p) !== null;
146
+ }
147
+
148
+ /** `cmp -s a b` — byte-identical regular-file comparison. */
149
+ function bytesEqual(a: string, b: string): boolean {
150
+ try {
151
+ const sa = fs.statSync(a);
152
+ const sb = fs.statSync(b);
153
+ if (sa.size !== sb.size) return false;
154
+ return fs.readFileSync(a).equals(fs.readFileSync(b));
155
+ } catch {
156
+ return false;
157
+ }
158
+ }
159
+
160
+ function utcStamp(d: Date, sep: "colon" | "dash"): string {
161
+ const p = (n: number) => String(n).padStart(2, "0");
162
+ const date = `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
163
+ const h = p(d.getUTCHours());
164
+ const m = p(d.getUTCMinutes());
165
+ const s = p(d.getUTCSeconds());
166
+ return sep === "colon" ? `${date}T${h}:${m}:${s}Z` : `${date}T${h}-${m}-${s}Z`;
167
+ }
168
+
169
+ /**
170
+ * Parse the rescue argv exactly as the bash `while case` loop did, including
171
+ * validation and the same exit codes (1 unknown/usage, 2 bad option). Throws
172
+ * ExitError on any validation failure.
173
+ */
174
+ export function parseRescueArgs(argv: string[]): Config {
175
+ const cfg: Config = {
176
+ ref: "main",
177
+ sourceRepo: "indigoai-us/hq-core-staging",
178
+ dryRun: false,
179
+ assumeYes: false,
180
+ cloudUpdate: false,
181
+ extraPreserve: [],
182
+ preserveSubpaths: [],
183
+ narrowPaths: [],
184
+ hqRootOverride: "",
185
+ historyCheck: true,
186
+ floorShaOverride: "",
187
+ doBackup: process.env.HQ_RESCUE_NO_BACKUP === "1" ? false : true,
188
+ backupRoot: process.env.HQ_BACKUP_DIR || path.join(os.homedir(), ".hq", "backups"),
189
+ backupRetentionDays: process.env.HQ_BACKUP_RETENTION_DAYS || "7",
190
+ };
191
+
192
+ const usage = (): never => {
193
+ // The legacy script printed its comment header here; we emit a concise
194
+ // equivalent. Exit code (1) is preserved.
195
+ process.stderr.write(
196
+ "usage: hq rescue [--ref REF] [--source OWNER/REPO] [--paths P1,P2,...]\n" +
197
+ " [--preserve PATH]... [--preserve-subpath REL]...\n" +
198
+ " [--hq-root DIR] [--no-history-check] [--floor-sha SHA]\n" +
199
+ " [--no-backup] [--backup-dir DIR] [--cloud-update]\n" +
200
+ " [--dry-run] [--yes]\n",
201
+ );
202
+ throw new ExitError(1);
203
+ };
204
+
205
+ let narrowCsv = "";
206
+ let i = 0;
207
+ const need = (flag: string): string => {
208
+ // bash `shift 2` with a missing operand would expand to empty under set -u
209
+ // off for positional; here we treat a missing operand as empty string,
210
+ // matching `"$2"` when absent.
211
+ return i + 1 < argv.length ? argv[++i] : "";
212
+ };
213
+ for (; i < argv.length; i++) {
214
+ const a = argv[i];
215
+ switch (a) {
216
+ case "--ref":
217
+ cfg.ref = need(a);
218
+ break;
219
+ case "--source":
220
+ cfg.sourceRepo = need(a);
221
+ break;
222
+ case "--preserve":
223
+ cfg.extraPreserve.push(need(a));
224
+ break;
225
+ case "--preserve-subpath":
226
+ cfg.preserveSubpaths.push(need(a));
227
+ break;
228
+ case "--paths":
229
+ narrowCsv = need(a);
230
+ break;
231
+ case "--hq-root":
232
+ cfg.hqRootOverride = need(a);
233
+ break;
234
+ case "--no-history-check":
235
+ cfg.historyCheck = false;
236
+ break;
237
+ case "--floor-sha": {
238
+ cfg.floorShaOverride = need(a);
239
+ if (!/^[0-9a-f]{40}$/.test(cfg.floorShaOverride)) {
240
+ process.stderr.write(
241
+ `error: --floor-sha must be a 40-char lowercase hex SHA, got: ${cfg.floorShaOverride}\n`,
242
+ );
243
+ throw new ExitError(2);
244
+ }
245
+ break;
246
+ }
247
+ case "--no-backup":
248
+ cfg.doBackup = false;
249
+ break;
250
+ case "--backup-dir":
251
+ cfg.backupRoot = need(a);
252
+ break;
253
+ case "--dry-run":
254
+ cfg.dryRun = true;
255
+ break;
256
+ case "--cloud-update":
257
+ cfg.cloudUpdate = true;
258
+ break;
259
+ case "--yes":
260
+ case "-y":
261
+ cfg.assumeYes = true;
262
+ break;
263
+ case "-h":
264
+ case "--help":
265
+ usage();
266
+ break;
267
+ default:
268
+ process.stderr.write(`unknown arg: ${a}\n`);
269
+ usage();
270
+ }
271
+ }
272
+
273
+ // --preserve redundancy check.
274
+ for (const p of cfg.extraPreserve) {
275
+ const stripped = p.replace(/\/$/, "");
276
+ if (ALWAYS_PRESERVED_NAMES.includes(stripped)) {
277
+ process.stderr.write(
278
+ `error: --preserve ${p} is redundant ('${p}' is always preserved). Remove the flag.\n`,
279
+ );
280
+ throw new ExitError(2);
281
+ }
282
+ }
283
+
284
+ // --preserve-subpath traversal check.
285
+ for (const sp of cfg.preserveSubpaths) {
286
+ if (sp.startsWith("/") || sp.includes("..")) {
287
+ process.stderr.write(
288
+ `error: --preserve-subpath ${sp} must be a relative path with no '..' segments.\n`,
289
+ );
290
+ throw new ExitError(2);
291
+ }
292
+ }
293
+
294
+ // Append always-on carve-outs to preserveSubpaths (dedup).
295
+ for (const cp of CARVE_OUT_PATHS) {
296
+ if (!cfg.preserveSubpaths.includes(cp)) cfg.preserveSubpaths.push(cp);
297
+ }
298
+
299
+ // Narrow paths parsing + validation.
300
+ if (narrowCsv) {
301
+ for (const raw of narrowCsv.split(",")) {
302
+ const name = raw.trim();
303
+ if (name === "") continue;
304
+ if (name.includes("/") || name === ".." || name === ".") {
305
+ process.stderr.write(
306
+ `error: --paths entry '${name}' must be a single top-level name (no slashes, no '.' or '..').\n`,
307
+ );
308
+ throw new ExitError(2);
309
+ }
310
+ cfg.narrowPaths.push(name);
311
+ }
312
+ if (cfg.narrowPaths.length === 0) {
313
+ process.stderr.write("error: --paths was passed but resolved to an empty list.\n");
314
+ throw new ExitError(2);
315
+ }
316
+ if (cfg.extraPreserve.length !== 0) {
317
+ process.stderr.write(
318
+ "error: --paths and --preserve are mutually exclusive. Use --preserve-subpath for sub-paths inside the listed top-level entries.\n",
319
+ );
320
+ throw new ExitError(2);
321
+ }
322
+ }
323
+
324
+ return cfg;
325
+ }
326
+
327
+ /**
328
+ * Execute a fully-parsed rescue. `argv` are the same flags the bash script
329
+ * accepted; `env` defaults to process.env (GH_TOKEN is read from it). Returns
330
+ * the process-equivalent exit status. Never throws for normal control-flow
331
+ * exits — those are mapped to the returned status.
332
+ */
333
+ export function runRescue(
334
+ argv: string[],
335
+ opts: { env?: NodeJS.ProcessEnv } = {},
336
+ ): RunRescueResult {
337
+ const env = opts.env ?? process.env;
338
+ const out = (s: string) => process.stdout.write(s);
339
+ const err = (s: string) => process.stderr.write(s);
340
+
341
+ let cfg: Config;
342
+ try {
343
+ cfg = parseRescueArgs(argv);
344
+ } catch (e) {
345
+ if (e instanceof ExitError) return { status: e.code };
346
+ throw e;
347
+ }
348
+
349
+ let tmpdir: string | null = null;
350
+ const cleanup = () => {
351
+ if (tmpdir) {
352
+ try {
353
+ fs.rmSync(tmpdir, { recursive: true, force: true });
354
+ } catch {
355
+ /* best-effort */
356
+ }
357
+ }
358
+ };
359
+
360
+ try {
361
+ return doRescue(cfg, env, out, err, (d) => {
362
+ tmpdir = d;
363
+ });
364
+ } catch (e) {
365
+ if (e instanceof ExitError) return { status: e.code };
366
+ throw e;
367
+ } finally {
368
+ cleanup();
369
+ }
370
+ }
371
+
372
+ function doRescue(
373
+ cfg: Config,
374
+ env: NodeJS.ProcessEnv,
375
+ out: (s: string) => void,
376
+ err: (s: string) => void,
377
+ setTmp: (d: string) => void,
378
+ ): RunRescueResult {
379
+ // --- Resolve HQ root ---
380
+ let hqRoot: string;
381
+ if (cfg.hqRootOverride) {
382
+ if (!isDir(cfg.hqRootOverride)) {
383
+ // bash `cd` failure under set -e would abort; mirror as a generic error.
384
+ err(`error: --hq-root ${cfg.hqRootOverride} is not a directory.\n`);
385
+ throw new ExitError(1);
386
+ }
387
+ hqRoot = fs.realpathSync(cfg.hqRootOverride);
388
+ } else {
389
+ // Legacy default assumed the script lived at personal/skills/<skill>/.
390
+ // The package's programmatic callers always pass --hq-root; fall back to cwd.
391
+ hqRoot = fs.realpathSync(process.cwd());
392
+ }
393
+
394
+ if (!isDir(path.join(hqRoot, "companies")) || !isDir(path.join(hqRoot, "personal"))) {
395
+ err(
396
+ `error: ${hqRoot} does not look like an HQ root (missing companies/ or personal/). Aborting.\n`,
397
+ );
398
+ err(" pass --hq-root <dir> if the script is not at personal/skills/<skill>/.\n");
399
+ throw new ExitError(3);
400
+ }
401
+
402
+ const RUN_TS = utcStamp(new Date(), "dash");
403
+
404
+ // --- Read prior sync-point metadata (BEFORE the wipe) ---
405
+ let prevSyncSha = "";
406
+ let prevSyncSource = "";
407
+ let prevSyncRef = "";
408
+ let prevSyncAt = "";
409
+ const coreYaml = path.join(hqRoot, "core", "core.yaml");
410
+ const yqAvailable = hasCmd("yq", env);
411
+ if (isFileFollow(coreYaml) && yqAvailable) {
412
+ const yq = (expr: string) =>
413
+ run("yq", ["-r", expr, coreYaml], { env }).stdout.trim();
414
+ prevSyncSha = yq(
415
+ ".replaced_from_source.last_sync_sha // .replaced_from_staging.last_sync_sha // \"\"",
416
+ );
417
+ prevSyncSource = yq(
418
+ ".replaced_from_source.source // .replaced_from_staging.source // \"\"",
419
+ );
420
+ prevSyncRef = yq(".replaced_from_source.ref // .replaced_from_staging.ref // \"\"");
421
+ prevSyncAt = yq(
422
+ ".replaced_from_source.last_sync_at // .replaced_from_staging.last_sync_at // \"\"",
423
+ );
424
+ }
425
+
426
+ // Caller override (--floor-sha) wins only when the on-disk stamp is empty.
427
+ if (!prevSyncSha && cfg.floorShaOverride) {
428
+ prevSyncSha = cfg.floorShaOverride;
429
+ prevSyncSource = cfg.sourceRepo;
430
+ prevSyncRef = "(caller-supplied floor)";
431
+ prevSyncAt = "(unstamped install)";
432
+ }
433
+
434
+ out(`==> HQ root: ${hqRoot}\n`);
435
+ out(`==> Source: https://github.com/${cfg.sourceRepo} @ ${cfg.ref}\n`);
436
+ if (prevSyncSha) {
437
+ if (prevSyncSource === cfg.sourceRepo) {
438
+ out(
439
+ `==> Prior sync: ${prevSyncSha} from ${prevSyncSource}@${prevSyncRef} (${prevSyncAt}) — will use as history floor\n`,
440
+ );
441
+ } else {
442
+ out(
443
+ `==> Prior sync: ${prevSyncSha} from ${prevSyncSource} (different source — ignoring as floor)\n`,
444
+ );
445
+ }
446
+ }
447
+ if (cfg.narrowPaths.length !== 0) {
448
+ out("==> Mode: narrow (--paths)\n");
449
+ out(`==> Wipe set: ${cfg.narrowPaths.join(" ")}\n`);
450
+ } else {
451
+ out("==> Mode: preserve-list (default)\n");
452
+ const extra = cfg.extraPreserve.length ? `, ${cfg.extraPreserve.join(" ")}` : "";
453
+ out(
454
+ `==> Preserved: .git, companies (except companies/_template), personal, workspace, repos, .github, .leak-scan, .hq-sync-journal.json, .hq, .hq-conflicts${extra}\n`,
455
+ );
456
+ }
457
+ if (cfg.preserveSubpaths.length !== 0) {
458
+ out("==> Preserved subpaths (backed up + restored across the overlay):\n");
459
+ for (const sp of cfg.preserveSubpaths) {
460
+ const isCarve = CARVE_OUT_PATHS.includes(sp);
461
+ out(isCarve ? ` - ${sp} (always-on carve-out)\n` : ` - ${sp}\n`);
462
+ }
463
+ }
464
+ out(
465
+ `==> Drift policy: rescue user-edited files to personal/; route .agents|.codex|.obsidian|MIGRATION.md user-edits to .hq-conflicts/rescue-${RUN_TS}/; silently overwrite AGENTS.md|USER-GUIDE.md|core/policies/_digest.md (regenerable); leave user-only files untouched; drop reindex symlinks (regenerated by reindex.sh)\n`,
466
+ );
467
+ if (cfg.cloudUpdate) {
468
+ out(
469
+ "==> Cloud-update mode: ON (recognize `hq-symlink:<target>` flat files as upstream-symlink-equivalent — reconciled as UNCHANGED)\n",
470
+ );
471
+ }
472
+ if (cfg.historyCheck) {
473
+ out("==> History gate: ON (skip drift if local matches any past staging blob at that path)\n");
474
+ } else {
475
+ out("==> History gate: OFF (--no-history-check; every diff rescued)\n");
476
+ }
477
+ if (cfg.doBackup) {
478
+ out(
479
+ `==> Safety backup: ON -> ${cfg.backupRoot}/pre-update-${RUN_TS} (retention ${cfg.backupRetentionDays}d)\n`,
480
+ );
481
+ } else {
482
+ out("==> Safety backup: OFF (--no-backup / HQ_RESCUE_NO_BACKUP=1)\n");
483
+ }
484
+ if (cfg.dryRun) out("==> DRY RUN (no destructive operations will run)\n");
485
+
486
+ // --- Confirmation prompt ---
487
+ if (!cfg.assumeYes && !cfg.dryRun) {
488
+ let backupLine = " * NO pre-op backup (--no-backup),";
489
+ if (cfg.doBackup)
490
+ backupLine = ` * snapshot the wipe set to ${cfg.backupRoot}/pre-update-${RUN_TS} first,`;
491
+ let cloudLine = "";
492
+ if (cfg.cloudUpdate)
493
+ cloudLine =
494
+ "\n * reconcile `hq-symlink:<target>` flat files against upstream symlinks (cloud-update mode),";
495
+ out(
496
+ `\nThis will:\n${backupLine}\n` +
497
+ " * rescue user-edited files (vs the last sync) into personal/,\n" +
498
+ ` * route user-edited .agents/.codex/.obsidian/MIGRATION.md into .hq-conflicts/rescue-${RUN_TS}/ for review,\n` +
499
+ " * silently overwrite AGENTS.md / USER-GUIDE.md / core/policies/_digest.md (regenerable from upstream or reindex — no copy preserved)," +
500
+ cloudLine +
501
+ "\n * drop reindex-generated symlinks (regenerated by reindex.sh on next Stop hook),\n" +
502
+ " * leave user-only files (not in upstream) untouched,\n" +
503
+ ` * delete upstream files unchanged since last sync, then unpack ${cfg.sourceRepo}@${cfg.ref} on top.\n` +
504
+ "Type 'yes' to proceed: ",
505
+ );
506
+ const confirm = readLineSync();
507
+ if (confirm !== "yes") {
508
+ out("Aborted.\n");
509
+ throw new ExitError(4);
510
+ }
511
+ }
512
+
513
+ const tmpdir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-replace-rescue-"));
514
+ setTmp(tmpdir);
515
+ const srcDir = path.join(tmpdir, "src");
516
+ const rescueLog = path.join(tmpdir, "rescue-actions.log");
517
+ fs.writeFileSync(rescueLog, "");
518
+ const unchangedList = path.join(tmpdir, "unchanged-paths.txt");
519
+ fs.writeFileSync(unchangedList, "");
520
+
521
+ const appendLog = (line: string) => {
522
+ try {
523
+ fs.appendFileSync(rescueLog, line);
524
+ } catch {
525
+ /* best-effort, matches `|| true` */
526
+ }
527
+ };
528
+
529
+ // --- Build clone URL (GH_TOKEN basic-auth injection) ---
530
+ const ghToken = env.GH_TOKEN || "";
531
+ let cloneUrl: string;
532
+ let cloneUrlDisplay: string;
533
+ if (ghToken) {
534
+ cloneUrl = `https://x-access-token:${ghToken}@github.com/${cfg.sourceRepo}.git`;
535
+ cloneUrlDisplay = `https://x-access-token:***@github.com/${cfg.sourceRepo}.git`;
536
+ } else {
537
+ cloneUrl = `https://github.com/${cfg.sourceRepo}.git`;
538
+ cloneUrlDisplay = cloneUrl;
539
+ }
540
+
541
+ out("\n");
542
+ if (cfg.historyCheck) {
543
+ out(`==> Cloning ${cloneUrlDisplay} @${cfg.ref} (full history, blob:none filter) ...\n`);
544
+ if (run("git", ["clone", "--filter=blob:none", cloneUrl, srcDir], { env }).status !== 0) {
545
+ err("error: clone failed\n");
546
+ throw new ExitError(5);
547
+ }
548
+ if (run("git", ["checkout", cfg.ref], { cwd: srcDir, env }).status !== 0) {
549
+ err(`error: could not check out ref '${cfg.ref}' from ${cfg.sourceRepo}\n`);
550
+ throw new ExitError(5);
551
+ }
552
+ } else {
553
+ out(`==> Cloning ${cloneUrlDisplay} @${cfg.ref} (shallow) ...\n`);
554
+ if (
555
+ run("git", ["clone", "--depth", "1", "--branch", cfg.ref, cloneUrl, srcDir], { env })
556
+ .status !== 0
557
+ ) {
558
+ out(" (shallow branch clone failed; trying full clone + checkout)\n");
559
+ if (run("git", ["clone", cloneUrl, srcDir], { env }).status !== 0) {
560
+ err("error: clone failed\n");
561
+ throw new ExitError(5);
562
+ }
563
+ if (run("git", ["checkout", cfg.ref], { cwd: srcDir, env }).status !== 0) {
564
+ err(`error: could not check out ref '${cfg.ref}' from ${cfg.sourceRepo}\n`);
565
+ throw new ExitError(5);
566
+ }
567
+ }
568
+ }
569
+
570
+ const srcSha = run("git", ["rev-parse", "HEAD"], { cwd: srcDir, env }).stdout.trim();
571
+ out(`==> Source SHA: ${srcSha}\n`);
572
+
573
+ // --- Restore file mtimes from git history ---
574
+ restoreMtimesFromGit(srcDir, env, out);
575
+
576
+ // --- Resolve history floor ---
577
+ let historyFloor = "";
578
+ let baselineMode: "history_floor" | "head_compare" = "head_compare";
579
+ if (cfg.historyCheck && prevSyncSha && prevSyncSource === cfg.sourceRepo) {
580
+ if (run("git", ["cat-file", "-e", prevSyncSha], { cwd: srcDir, env }).status === 0) {
581
+ historyFloor = prevSyncSha;
582
+ baselineMode = "history_floor";
583
+ } else {
584
+ out(
585
+ ` prior-sync SHA ${prevSyncSha} not reachable in clone (rebased/dropped?); falling back to head_compare\n`,
586
+ );
587
+ }
588
+ }
589
+ if (baselineMode === "history_floor") {
590
+ out(`==> Baseline: ${historyFloor} (last-sync floor reachable)\n`);
591
+ } else {
592
+ out("==> Baseline: HEAD compare (no usable last-sync stamp; first-ever run or stamp mismatch)\n");
593
+ }
594
+
595
+ // --- Build wipe/overlay arg sets per mode ---
596
+ const pruneNames: string[] = [];
597
+ let rsyncExcludes: string[] = [];
598
+ if (cfg.narrowPaths.length !== 0) {
599
+ for (const n of cfg.narrowPaths) {
600
+ rsyncExcludes.push(`--include=/${n}`);
601
+ rsyncExcludes.push(`--include=/${n}/***`);
602
+ }
603
+ rsyncExcludes.push("--exclude=/*");
604
+ } else {
605
+ pruneNames.push(...ALWAYS_PRESERVED_NAMES);
606
+ for (const p of cfg.extraPreserve) pruneNames.push(p);
607
+ rsyncExcludes = [
608
+ "--exclude=.git",
609
+ "--exclude=personal",
610
+ "--exclude=workspace",
611
+ "--exclude=repos",
612
+ "--exclude=.github",
613
+ "--exclude=.leak-scan",
614
+ "--exclude=.hq-sync-journal.json",
615
+ "--exclude=.hq",
616
+ "--exclude=.hq-conflicts",
617
+ "--include=/companies/",
618
+ "--include=/companies/_template/***",
619
+ "--exclude=/companies/*",
620
+ ];
621
+ for (const p of cfg.extraPreserve) rsyncExcludes.push(`--exclude=${p}`);
622
+ }
623
+
624
+ // --- Compute the wipe-set top-level roots ---
625
+ const wipeToplevel: string[] = [];
626
+ if (cfg.narrowPaths.length !== 0) {
627
+ for (const n of cfg.narrowPaths) {
628
+ if (lexists(path.join(hqRoot, n))) wipeToplevel.push(n);
629
+ }
630
+ } else {
631
+ const preserveSet = new Set(pruneNames);
632
+ let entries: string[] = [];
633
+ try {
634
+ entries = fs.readdirSync(hqRoot);
635
+ } catch {
636
+ entries = [];
637
+ }
638
+ entries.sort();
639
+ for (const name of entries) {
640
+ if (preserveSet.has(name)) continue;
641
+ wipeToplevel.push(name);
642
+ }
643
+ if (isDir(path.join(hqRoot, "companies", "_template"))) {
644
+ wipeToplevel.push("companies/_template");
645
+ }
646
+ }
647
+
648
+ // --- Classification state ---
649
+ const counts = {
650
+ userOnly: 0,
651
+ unchanged: 0,
652
+ userEdit: 0,
653
+ userConflict: 0,
654
+ userOverwrite: 0,
655
+ claudeDiffAppend: 0,
656
+ symlinkDropped: 0,
657
+ cloudSymlinkReconciled: 0,
658
+ driftReconciled: 0,
659
+ };
660
+
661
+ const ctx: WalkCtx = {
662
+ cfg,
663
+ env,
664
+ hqRoot,
665
+ srcDir,
666
+ historyFloor,
667
+ baselineMode,
668
+ runTs: RUN_TS,
669
+ srcSha,
670
+ counts,
671
+ unchangedList,
672
+ appendLog,
673
+ out,
674
+ };
675
+
676
+ // --- Pre-operation safety snapshot (BEFORE any destructive op) ---
677
+ let backupDir = "";
678
+ if (cfg.doBackup && !cfg.dryRun && wipeToplevel.length !== 0) {
679
+ backupDir = path.join(cfg.backupRoot, `pre-update-${RUN_TS}`);
680
+ out("\n");
681
+ out(`==> Safety snapshot -> ${backupDir}\n`);
682
+ fs.mkdirSync(backupDir, { recursive: true });
683
+ const rsyncAvailable = hasCmd("rsync", env);
684
+ for (const rootRel of wipeToplevel) {
685
+ const srcAbs = path.join(hqRoot, rootRel);
686
+ if (!lexists(srcAbs)) continue;
687
+ const parentRel = path.dirname(rootRel);
688
+ const destParent = path.join(backupDir, parentRel);
689
+ fs.mkdirSync(destParent, { recursive: true });
690
+ if (rsyncAvailable) {
691
+ const r = run(
692
+ "rsync",
693
+ ["-a", "--exclude=node_modules/", "--exclude=.git/", srcAbs, destParent + "/"],
694
+ { env },
695
+ );
696
+ if (r.status !== 0) cpA(srcAbs, destParent);
697
+ } else {
698
+ cpA(srcAbs, destParent);
699
+ }
700
+ }
701
+ out(
702
+ ` snapshot complete (restore any file: cp "${backupDir}/<relpath>" "${hqRoot}/<relpath>")\n`,
703
+ );
704
+ }
705
+
706
+ // --- Walk + classify ---
707
+ out("\n");
708
+ if (wipeToplevel.length === 0) {
709
+ out("==> Wipe set is empty; nothing to process or overlay.\n");
710
+ } else {
711
+ out(`==> Walking wipe set, classifying vs. ${cfg.sourceRepo}@${cfg.ref} ...\n`);
712
+ for (const rootRel of wipeToplevel) {
713
+ walkAndProcess(ctx, rootRel);
714
+ }
715
+ }
716
+
717
+ // --- DRY RUN: report + exit ---
718
+ if (cfg.dryRun) {
719
+ out("\n");
720
+ out("==> DRY RUN classification summary:\n");
721
+ out(` user-only (leave in place): ${counts.userOnly} files\n`);
722
+ out(` unchanged (preserved/re-laid): ${counts.unchanged} files\n`);
723
+ out(` user-edit (rescue / diff-append): ${counts.userEdit} files\n`);
724
+ out(` user-edit (conflict quarantine): ${counts.userConflict} files\n`);
725
+ out(` user-edit (overwrite-safe): ${counts.userOverwrite} files\n`);
726
+ out(` cloud-symlink reconciled: ${counts.cloudSymlinkReconciled} files\n`);
727
+ out(` drift reconciled (== upstream): ${counts.driftReconciled} files\n`);
728
+ out(` reindex symlinks dropped: ${counts.symlinkDropped} entries\n`);
729
+ if (counts.claudeDiffAppend > 0) {
730
+ out(" of which .claude/CLAUDE.md would diff-append to personal/CLAUDE.md\n");
731
+ }
732
+ if (counts.userConflict > 0) {
733
+ out(` conflict bucket: .hq-conflicts/rescue-${RUN_TS}/\n`);
734
+ }
735
+ if (cfg.preserveSubpaths.length !== 0) {
736
+ out("\n");
737
+ out("==> DRY RUN: would back up + restore these sub-paths across the overlay:\n");
738
+ for (const sp of cfg.preserveSubpaths) {
739
+ if (lexists(path.join(hqRoot, sp))) {
740
+ out(` ~ ./${sp} (present — will survive)\n`);
741
+ } else {
742
+ out(` ~ ./${sp} (not present locally — no-op)\n`);
743
+ }
744
+ }
745
+ }
746
+ out("\n");
747
+ out("==> DRY RUN: would copy these top-level entries from source on overlay:\n");
748
+ if (cfg.narrowPaths.length !== 0) {
749
+ for (const n of cfg.narrowPaths) {
750
+ if (lexists(path.join(srcDir, n))) out(` + ./${n}\n`);
751
+ }
752
+ } else {
753
+ const preserveSet = new Set(pruneNames);
754
+ let srcEntries: string[] = [];
755
+ try {
756
+ srcEntries = fs.readdirSync(srcDir);
757
+ } catch {
758
+ srcEntries = [];
759
+ }
760
+ srcEntries.sort();
761
+ for (const name of srcEntries) {
762
+ if (preserveSet.has(name)) continue;
763
+ out(` + ${name}\n`);
764
+ }
765
+ if (isDir(path.join(srcDir, "companies", "_template"))) {
766
+ out(` + ./companies/_template (carve-out from ${cfg.sourceRepo}@${cfg.ref})\n`);
767
+ }
768
+ }
769
+ out("\n");
770
+ out("==> DRY RUN complete. No filesystem changes made.\n");
771
+ return { status: 0 };
772
+ }
773
+
774
+ // --- Back up preserve-subpaths to a mktemp shuttle ---
775
+ const shuttle = path.join(tmpdir, "preserve");
776
+ fs.mkdirSync(shuttle, { recursive: true });
777
+ const preserveMap: { id: number; rel: string }[] = [];
778
+ let shuttleId = 0;
779
+ for (const sp of cfg.preserveSubpaths) {
780
+ const src = path.join(hqRoot, sp);
781
+ if (lexists(src)) {
782
+ shuttleId += 1;
783
+ cpA(src, shuttle, String(shuttleId));
784
+ preserveMap.push({ id: shuttleId, rel: sp });
785
+ out(`==> Backed up ${sp} -> shuttle/${shuttleId}\n`);
786
+ }
787
+ }
788
+
789
+ out("\n");
790
+ out(
791
+ `==> Walk complete (user-only: ${counts.userOnly}, unchanged: ${counts.unchanged}, user-edit-rescued: ${counts.userEdit}, conflict-quarantined: ${counts.userConflict}, overwrite-safe: ${counts.userOverwrite}, cloud-symlink-reconciled: ${counts.cloudSymlinkReconciled}, symlinks-dropped: ${counts.symlinkDropped})\n`,
792
+ );
793
+
794
+ // --- Overlay source onto HQ root ---
795
+ out("==> Overlaying source onto HQ root ...\n");
796
+ const overlayProtect: string[] = [];
797
+ if (fs.statSync(unchangedList).size > 0) {
798
+ overlayProtect.push(`--exclude-from=${unchangedList}`);
799
+ }
800
+ const overlay = run(
801
+ "rsync",
802
+ ["-ai", ...overlayProtect, ...rsyncExcludes, srcDir + "/", hqRoot + "/"],
803
+ { env },
804
+ );
805
+ if (overlay.status !== 0) {
806
+ // bash runs under set -e: a failed rsync aborts with its status.
807
+ err(overlay.stderr);
808
+ throw new ExitError(overlay.status || 1);
809
+ }
810
+
811
+ // --- Restore preserved sub-paths ---
812
+ if (preserveMap.length > 0) {
813
+ out("==> Restoring preserved sub-paths ...\n");
814
+ for (const { id, rel } of preserveMap) {
815
+ const dest = path.join(hqRoot, rel);
816
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
817
+ const shuttleItem = path.join(shuttle, String(id));
818
+ if (isDir(shuttleItem)) {
819
+ fs.rmSync(dest, { recursive: true, force: true });
820
+ cpATo(shuttleItem, dest);
821
+ } else {
822
+ cpATo(shuttleItem, dest);
823
+ }
824
+ out(` restored ${rel}\n`);
825
+ }
826
+ }
827
+
828
+ // --- Reconcile the sync-journal baseline for re-laid files ---
829
+ // The overlay rewrote scaffold files from upstream, but their personal-vault
830
+ // sync-journal entries still carry the PRE-rescue hash. The next sync would
831
+ // then read localHash != journal.hash ("local changed") and — when the vault
832
+ // also moved — mint a false `.conflict-*` mirror (the root cause of the
833
+ // conflict-file litter). Re-stamp the baseline to the freshly laid-down
834
+ // content so `localChanged` is false: the common single-machine case
835
+ // converges silently, and a genuinely-divergent vault degrades to a clean
836
+ // pull instead of a conflict mirror.
837
+ //
838
+ // Scoped STRICTLY to the paths rsync reported transferring (`-i` itemize) —
839
+ // never a blanket journal rewrite — so a user's unsynced pending edit (which
840
+ // the walk already rescued to personal/ rather than overlaying) is never
841
+ // silently marked as synced. Best-effort: a reconcile failure must never
842
+ // fail the rescue.
843
+ try {
844
+ const relaid: string[] = [];
845
+ for (const line of overlay.stdout.split("\n")) {
846
+ // itemize line: 11-char change code, space, path. 2nd col `f` = file.
847
+ const m = /^[<>ch.]f\S{9} (.+)$/.exec(line);
848
+ if (m) relaid.push(m[1]);
849
+ }
850
+ if (relaid.length > 0) {
851
+ const journal = readJournal(PERSONAL_VAULT_JOURNAL_SLUG);
852
+ let restamped = 0;
853
+ for (const rel of relaid) {
854
+ const prev = journal.files[rel];
855
+ if (!prev) continue; // only re-stamp paths already in the synced baseline
856
+ const abs = path.join(hqRoot, rel);
857
+ let st: fs.Stats;
858
+ try {
859
+ st = fs.lstatSync(abs);
860
+ } catch {
861
+ continue;
862
+ }
863
+ if (st.isSymbolicLink() || !st.isFile()) continue;
864
+ updateEntry(
865
+ journal,
866
+ rel,
867
+ hashFile(abs),
868
+ st.size,
869
+ prev.direction,
870
+ prev.remoteEtag,
871
+ st.mtimeMs,
872
+ );
873
+ restamped++;
874
+ }
875
+ if (restamped > 0) {
876
+ writeJournal(PERSONAL_VAULT_JOURNAL_SLUG, journal);
877
+ out(
878
+ `==> Reconciled sync-journal baseline for ${restamped} re-laid file(s)\n`,
879
+ );
880
+ }
881
+ }
882
+ } catch (e) {
883
+ out(
884
+ ` (sync-journal reconcile skipped: ${
885
+ e instanceof Error ? e.message : String(e)
886
+ })\n`,
887
+ );
888
+ }
889
+
890
+ // --- Stamp sync-point provenance into core/core.yaml ---
891
+ if (cfg.narrowPaths.length === 0 && isFileFollow(coreYaml)) {
892
+ const nowUtc = utcStamp(new Date(), "colon");
893
+ if (yqAvailable) {
894
+ const stampEnv: NodeJS.ProcessEnv = {
895
+ ...env,
896
+ SHA: srcSha,
897
+ SOURCE: cfg.sourceRepo,
898
+ THE_REF: cfg.ref,
899
+ AT: nowUtc,
900
+ };
901
+ run(
902
+ "yq",
903
+ [
904
+ "-i",
905
+ ".replaced_from_source.source = strenv(SOURCE) |\n" +
906
+ " .replaced_from_source.ref = strenv(THE_REF) |\n" +
907
+ " .replaced_from_source.last_sync_sha = strenv(SHA) |\n" +
908
+ " .replaced_from_source.last_sync_at = strenv(AT) |\n" +
909
+ " del(.replaced_from_staging)",
910
+ coreYaml,
911
+ ],
912
+ { env: stampEnv },
913
+ );
914
+ out(`==> Stamped core/core.yaml: replaced_from_source.last_sync_sha=${srcSha}\n`);
915
+ } else if (
916
+ hasCmd("python3", env) &&
917
+ run("python3", ["-c", "import yaml"], { env }).status === 0
918
+ ) {
919
+ const pyEnv: NodeJS.ProcessEnv = {
920
+ ...env,
921
+ SHA: srcSha,
922
+ SOURCE: cfg.sourceRepo,
923
+ THE_REF: cfg.ref,
924
+ AT: nowUtc,
925
+ CORE: coreYaml,
926
+ };
927
+ run(
928
+ "python3",
929
+ [
930
+ "-c",
931
+ [
932
+ "import os, yaml",
933
+ 'path = os.environ["CORE"]',
934
+ "try:",
935
+ " with open(path) as f:",
936
+ " d = yaml.safe_load(f) or {}",
937
+ "except FileNotFoundError:",
938
+ " d = {}",
939
+ 'd["replaced_from_source"] = {',
940
+ ' "source": os.environ["SOURCE"],',
941
+ ' "ref": os.environ["THE_REF"],',
942
+ ' "last_sync_sha": os.environ["SHA"],',
943
+ ' "last_sync_at": os.environ["AT"],',
944
+ "}",
945
+ 'd.pop("replaced_from_staging", None)',
946
+ 'with open(path, "w") as f:',
947
+ " yaml.safe_dump(d, f, default_flow_style=False, sort_keys=False)",
948
+ ].join("\n"),
949
+ ],
950
+ { env: pyEnv },
951
+ );
952
+ out(`==> Stamped core/core.yaml: replaced_from_source.last_sync_sha=${srcSha}\n`);
953
+ } else {
954
+ err(" WARN: neither yq nor python3+PyYAML available — skipping core/core.yaml stamp\n");
955
+ }
956
+ }
957
+
958
+ // --- File count summary ---
959
+ out("\n");
960
+ out("==> File count summary:\n");
961
+ for (const rootRel of wipeToplevel) {
962
+ const abs = path.join(hqRoot, rootRel);
963
+ if (lexists(abs)) {
964
+ const n = countFiles(abs);
965
+ out(` ${rootRel}: ${n} files\n`);
966
+ }
967
+ }
968
+ out("\n");
969
+ out("==> Classification:\n");
970
+ out(` user-only (left in place): ${counts.userOnly} files\n`);
971
+ out(` unchanged (preserved/re-laid): ${counts.unchanged} files\n`);
972
+ out(` user-edits (rescued): ${counts.userEdit} files\n`);
973
+ out(` user-edits (conflict quarantine): ${counts.userConflict} files\n`);
974
+ out(` user-edits (overwrite-safe): ${counts.userOverwrite} files\n`);
975
+ out(` cloud-symlink reconciled: ${counts.cloudSymlinkReconciled} files\n`);
976
+ out(` drift reconciled (== upstream): ${counts.driftReconciled} files\n`);
977
+ out(` reindex symlinks dropped: ${counts.symlinkDropped} entries\n`);
978
+ if (counts.claudeDiffAppend > 0) {
979
+ out(" of which .claude/CLAUDE.md diff-appended to personal/CLAUDE.md\n");
980
+ }
981
+ if (counts.userConflict > 0) {
982
+ out(` conflict bucket: .hq-conflicts/rescue-${RUN_TS}/\n`);
983
+ }
984
+ if (baselineMode === "history_floor") {
985
+ out(` baseline: last-sync floor ${historyFloor}\n`);
986
+ } else {
987
+ out(" baseline: upstream HEAD (no stamp; first run / floor unreachable)\n");
988
+ }
989
+
990
+ // --- Write recovery manifest into the snapshot + prune old snapshots ---
991
+ if (cfg.doBackup && backupDir && isDir(backupDir)) {
992
+ const lines: string[] = [];
993
+ lines.push("# HQ pre-update safety snapshot", "");
994
+ lines.push(`Created: ${RUN_TS}`);
995
+ lines.push(`HQ root: ${hqRoot}`);
996
+ lines.push(`Source: ${cfg.sourceRepo}@${cfg.ref} (${srcSha})`);
997
+ if (baselineMode === "history_floor") {
998
+ lines.push(`Baseline: history_floor (${historyFloor})`);
999
+ } else {
1000
+ lines.push("Baseline: head_compare (no last-sync stamp; first run or floor unreachable)");
1001
+ }
1002
+ lines.push("", "## What the rescue did");
1003
+ lines.push(` user-only (left in place): ${counts.userOnly}`);
1004
+ lines.push(` unchanged (preserved/re-laid): ${counts.unchanged}`);
1005
+ lines.push(` user-edit (rescued into personal/): ${counts.userEdit}`);
1006
+ lines.push(` user-edit (conflict quarantine): ${counts.userConflict}`);
1007
+ lines.push(` user-edit (overwrite-safe): ${counts.userOverwrite}`);
1008
+ lines.push(` cloud-symlink reconciled: ${counts.cloudSymlinkReconciled}`);
1009
+ lines.push(` drift reconciled (== upstream HEAD): ${counts.driftReconciled}`);
1010
+ lines.push(` reindex symlinks dropped: ${counts.symlinkDropped}`);
1011
+ if (counts.userConflict > 0) {
1012
+ lines.push(` conflict bucket: .hq-conflicts/rescue-${RUN_TS}/`);
1013
+ }
1014
+ lines.push("", "## Moved / deleted files (tab-separated: action, path, detail)");
1015
+ let rescueLogContent = "";
1016
+ try {
1017
+ rescueLogContent = fs.readFileSync(rescueLog, "utf-8");
1018
+ } catch {
1019
+ rescueLogContent = "";
1020
+ }
1021
+ if (rescueLogContent.length > 0) {
1022
+ // `cat` includes the trailing content verbatim.
1023
+ lines.push(rescueLogContent.replace(/\n$/, ""));
1024
+ } else {
1025
+ lines.push(" (no files were moved or deleted)");
1026
+ }
1027
+ lines.push("", "## Restore");
1028
+ lines.push("This directory holds the wipe set exactly as it was before the update.");
1029
+ lines.push("Restore a single file:");
1030
+ lines.push(` cp "${backupDir}/<relpath>" "${hqRoot}/<relpath>"`);
1031
+ lines.push("Restore everything (overwrites current scaffold — use with care):");
1032
+ lines.push(` rsync -a "${backupDir}/" "${hqRoot}/"`);
1033
+ lines.push("", `Auto-pruned after ${cfg.backupRetentionDays} days (HQ_BACKUP_RETENTION_DAYS).`);
1034
+ try {
1035
+ fs.writeFileSync(path.join(backupDir, "RECOVERY.md"), lines.join("\n") + "\n");
1036
+ } catch {
1037
+ /* best-effort, matches `|| true` */
1038
+ }
1039
+ out("\n");
1040
+ out(`==> Pre-update snapshot + recovery manifest: ${backupDir}\n`);
1041
+ pruneOldBackups(cfg, out);
1042
+ }
1043
+
1044
+ out("\n");
1045
+ out(`==> Done. Source: ${cfg.sourceRepo}@${cfg.ref} (${srcSha})\n`);
1046
+ out(" User-edited files were rescued under personal/ (see scan output above).\n");
1047
+ out(" User-only files (created by you, unknown to upstream) were left untouched.\n");
1048
+ if (cfg.doBackup && backupDir) {
1049
+ out(` A full pre-update snapshot is at ${backupDir} (RECOVERY.md explains restore).\n`);
1050
+ }
1051
+
1052
+ return { status: 0 };
1053
+ }
1054
+
1055
+ interface WalkCtx {
1056
+ cfg: Config;
1057
+ env: NodeJS.ProcessEnv;
1058
+ hqRoot: string;
1059
+ srcDir: string;
1060
+ historyFloor: string;
1061
+ baselineMode: "history_floor" | "head_compare";
1062
+ runTs: string;
1063
+ srcSha: string;
1064
+ counts: {
1065
+ userOnly: number;
1066
+ unchanged: number;
1067
+ userEdit: number;
1068
+ userConflict: number;
1069
+ userOverwrite: number;
1070
+ claudeDiffAppend: number;
1071
+ symlinkDropped: number;
1072
+ cloudSymlinkReconciled: number;
1073
+ driftReconciled: number;
1074
+ };
1075
+ unchangedList: string;
1076
+ appendLog: (line: string) => void;
1077
+ out: (s: string) => void;
1078
+ }
1079
+
1080
+ // --- Rescue-target mapping ---
1081
+ function mapRescueTarget(rel: string): string {
1082
+ if (rel === ".claude/CLAUDE.md") return "personal/CLAUDE.md";
1083
+ let rest: string;
1084
+ if (rel.startsWith(".claude/")) rest = rel.slice(".claude/".length);
1085
+ else if (rel.startsWith("core/")) rest = rel.slice("core/".length);
1086
+ else rest = rel;
1087
+ return `personal/${rest}`;
1088
+ }
1089
+
1090
+ function isConflictClass(rel: string): boolean {
1091
+ return (
1092
+ rel === ".agents" ||
1093
+ rel.startsWith(".agents/") ||
1094
+ rel === ".codex" ||
1095
+ rel.startsWith(".codex/") ||
1096
+ rel === ".obsidian" ||
1097
+ rel.startsWith(".obsidian/") ||
1098
+ rel === "MIGRATION.md"
1099
+ );
1100
+ }
1101
+
1102
+ function isOverwriteSafe(rel: string): boolean {
1103
+ return (
1104
+ rel === "AGENTS.md" ||
1105
+ rel === "USER-GUIDE.md" ||
1106
+ rel === "core/docs/hq/USER-GUIDE.md" ||
1107
+ rel === "core/policies/_digest.md"
1108
+ );
1109
+ }
1110
+
1111
+ function isMasterSyncSymlink(localPath: string): boolean {
1112
+ const st = lstatOrNull(localPath);
1113
+ if (!st || !st.isSymbolicLink()) return false;
1114
+ let tgt = "";
1115
+ try {
1116
+ tgt = fs.readlinkSync(localPath);
1117
+ } catch {
1118
+ return false;
1119
+ }
1120
+ return tgt.includes("/personal/") || tgt.startsWith("personal/");
1121
+ }
1122
+
1123
+ function isUnderPreserve(cfg: Config, rel: string): boolean {
1124
+ for (const sp of cfg.preserveSubpaths) {
1125
+ if (rel === sp || rel.startsWith(sp + "/")) return true;
1126
+ }
1127
+ return false;
1128
+ }
1129
+
1130
+ /** Cloud-flattened `hq-symlink:<target>` equivalence check (--cloud-update). */
1131
+ function isCloudFlattenedSymlinkEquiv(ctx: WalkCtx, rel: string, localPath: string): boolean {
1132
+ if (!ctx.cfg.cloudUpdate) return false;
1133
+ if (!isFileFollow(localPath)) return false;
1134
+ let buf: Buffer;
1135
+ try {
1136
+ buf = fs.readFileSync(localPath);
1137
+ } catch {
1138
+ return false;
1139
+ }
1140
+ const head = buf.subarray(0, 11).toString("utf-8");
1141
+ if (head !== "hq-symlink:") return false;
1142
+ // First line, strip prefix, strip newlines.
1143
+ const firstLine = buf.toString("utf-8").split("\n")[0] ?? "";
1144
+ const localTarget = firstLine.replace(/^hq-symlink:/, "").replace(/\n/g, "");
1145
+ if (!localTarget) return false;
1146
+
1147
+ const ref = ctx.baselineMode === "history_floor" ? ctx.historyFloor : "HEAD";
1148
+ const lsTree = run("git", ["ls-tree", ref, "--", rel], { cwd: ctx.srcDir, env: ctx.env }).stdout;
1149
+ const mode = lsTree.trim().split(/\s+/)[0] ?? "";
1150
+ if (mode !== "120000") return false;
1151
+
1152
+ const showRef = ctx.baselineMode === "history_floor" ? `${ctx.historyFloor}:${rel}` : `HEAD:${rel}`;
1153
+ const upstreamTarget = run("git", ["show", showRef], { cwd: ctx.srcDir, env: ctx.env }).stdout;
1154
+ if (!upstreamTarget) return false;
1155
+ return localTarget === upstreamTarget;
1156
+ }
1157
+
1158
+ // --- diff-append .claude/CLAUDE.md user additions into personal/CLAUDE.md ---
1159
+ function diffAppendClaudeMd(ctx: WalkCtx): void {
1160
+ const localFile = path.join(ctx.hqRoot, ".claude/CLAUDE.md");
1161
+ const personalFile = path.join(ctx.hqRoot, "personal/CLAUDE.md");
1162
+ const baselineFile = path.join(
1163
+ fs.mkdtempSync(path.join(os.tmpdir(), "claude-md-baseline-")),
1164
+ "baseline",
1165
+ );
1166
+
1167
+ let wroteBaseline = false;
1168
+ if (ctx.baselineMode === "history_floor") {
1169
+ const show = run("git", ["show", `${ctx.historyFloor}:.claude/CLAUDE.md`], {
1170
+ cwd: ctx.srcDir,
1171
+ env: ctx.env,
1172
+ });
1173
+ if (show.status === 0) {
1174
+ fs.writeFileSync(baselineFile, show.stdout);
1175
+ wroteBaseline = true;
1176
+ }
1177
+ }
1178
+ const srcClaude = path.join(ctx.srcDir, ".claude/CLAUDE.md");
1179
+ const baselineNonEmpty = () => {
1180
+ try {
1181
+ return fs.statSync(baselineFile).size > 0;
1182
+ } catch {
1183
+ return false;
1184
+ }
1185
+ };
1186
+ if (!baselineNonEmpty() && isFileFollow(srcClaude)) {
1187
+ fs.copyFileSync(srcClaude, baselineFile);
1188
+ wroteBaseline = true;
1189
+ }
1190
+
1191
+ fs.mkdirSync(path.join(ctx.hqRoot, "personal"), { recursive: true });
1192
+
1193
+ const personalNonEmpty = () => {
1194
+ try {
1195
+ return fs.statSync(personalFile).size > 0;
1196
+ } catch {
1197
+ return false;
1198
+ }
1199
+ };
1200
+
1201
+ if (!baselineNonEmpty()) {
1202
+ // No baseline — record the whole local file under a marker.
1203
+ try {
1204
+ if (wroteBaseline) fs.rmSync(baselineFile, { force: true });
1205
+ } catch {
1206
+ /* ignore */
1207
+ }
1208
+ let block = "";
1209
+ if (personalNonEmpty()) block += "\n";
1210
+ block += `<!-- drift-append from .claude/CLAUDE.md @ ${ctx.runTs} (source ${ctx.srcSha}; no baseline available) -->\n`;
1211
+ block += readFileOrEmpty(localFile);
1212
+ fs.appendFileSync(personalFile, block);
1213
+ ctx.out(" diff-appended (full local, no baseline): .claude/CLAUDE.md -> personal/CLAUDE.md\n");
1214
+ ctx.counts.claudeDiffAppend += 1;
1215
+ return;
1216
+ }
1217
+
1218
+ // Extract additions via `diff -u` + the sed filter.
1219
+ const diff = run("diff", ["-u", baselineFile, localFile], { env: ctx.env });
1220
+ try {
1221
+ fs.rmSync(baselineFile, { force: true });
1222
+ } catch {
1223
+ /* ignore */
1224
+ }
1225
+ const additions = diff.stdout
1226
+ .split("\n")
1227
+ .filter((l) => !l.startsWith("+++"))
1228
+ .filter((l) => l.startsWith("+"))
1229
+ .map((l) => l.slice(1));
1230
+ // sed prints each matched line; reconstruct with trailing newline per line.
1231
+ const additionsText = additions.length ? additions.join("\n") + "\n" : "";
1232
+
1233
+ if (!additionsText) {
1234
+ ctx.out(" no user edits to .claude/CLAUDE.md (skipped diff-append)\n");
1235
+ return;
1236
+ }
1237
+
1238
+ let block = "";
1239
+ if (personalNonEmpty()) block += "\n";
1240
+ block += `<!-- drift-append from .claude/CLAUDE.md @ ${ctx.runTs} (source ${ctx.srcSha}) -->\n`;
1241
+ block += additionsText;
1242
+ fs.appendFileSync(personalFile, block);
1243
+ ctx.out(" diff-appended: .claude/CLAUDE.md additions -> personal/CLAUDE.md\n");
1244
+ ctx.counts.claudeDiffAppend += 1;
1245
+ }
1246
+
1247
+ function readFileOrEmpty(p: string): string {
1248
+ try {
1249
+ return fs.readFileSync(p, "utf-8");
1250
+ } catch {
1251
+ return "";
1252
+ }
1253
+ }
1254
+
1255
+ // --- rescue a single USER-EDIT file into personal/ ---
1256
+ function rescueOne(ctx: WalkCtx, rel: string): void {
1257
+ const localPath = path.join(ctx.hqRoot, rel);
1258
+ ctx.counts.userEdit += 1;
1259
+
1260
+ if (rel === ".claude/CLAUDE.md") {
1261
+ diffAppendClaudeMd(ctx);
1262
+ fs.rmSync(localPath, { force: true });
1263
+ ctx.appendLog(`rescued\t${rel}\t-> personal/CLAUDE.md (diff-append)\n`);
1264
+ return;
1265
+ }
1266
+
1267
+ const target = mapRescueTarget(rel);
1268
+ let dest = path.join(ctx.hqRoot, target);
1269
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
1270
+ if (lexists(dest)) {
1271
+ dest = `${dest}.drift-${Math.floor(Date.now() / 1000)}-${process.pid}`;
1272
+ }
1273
+ fs.renameSync(localPath, dest);
1274
+ const destRel = dest.startsWith(ctx.hqRoot + "/") ? dest.slice(ctx.hqRoot.length + 1) : dest;
1275
+ ctx.out(` rescued: ${rel} -> ${destRel}\n`);
1276
+ ctx.appendLog(`rescued\t${rel}\t-> ${destRel}\n`);
1277
+ }
1278
+
1279
+ // --- quarantine a single USER-EDIT file into .hq-conflicts/ ---
1280
+ function conflictOne(ctx: WalkCtx, rel: string): void {
1281
+ const localPath = path.join(ctx.hqRoot, rel);
1282
+ ctx.counts.userConflict += 1;
1283
+
1284
+ const target = `.hq-conflicts/rescue-${ctx.runTs}/${rel}`;
1285
+ let dest = path.join(ctx.hqRoot, target);
1286
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
1287
+ if (lexists(dest)) {
1288
+ dest = `${dest}.drift-${Math.floor(Date.now() / 1000)}-${process.pid}`;
1289
+ }
1290
+ fs.renameSync(localPath, dest);
1291
+ const destRel = dest.startsWith(ctx.hqRoot + "/") ? dest.slice(ctx.hqRoot.length + 1) : dest;
1292
+ ctx.out(` conflicted: ${rel} -> ${destRel}\n`);
1293
+ ctx.appendLog(`conflicted\t${rel}\t-> ${destRel}\n`);
1294
+ }
1295
+
1296
+ // --- classify + act on one file (the per-file workhorse) ---
1297
+ function processOne(ctx: WalkCtx, rel: string): void {
1298
+ const { cfg } = ctx;
1299
+ const localPath = path.join(ctx.hqRoot, rel);
1300
+ const srcPath = path.join(ctx.srcDir, rel);
1301
+
1302
+ if (isUnderPreserve(cfg, rel)) return;
1303
+
1304
+ // Conflict-resolution artifacts (`<name>.conflict-<ts>-<peer>.<ext>`).
1305
+ const base = rel.includes("/") ? rel.slice(rel.lastIndexOf("/") + 1) : rel;
1306
+ if (/\.conflict-/.test(base)) {
1307
+ if (cfg.dryRun) {
1308
+ ctx.out(` drop conflict artifact: ${rel}\n`);
1309
+ } else {
1310
+ fs.rmSync(localPath, { force: true });
1311
+ }
1312
+ return;
1313
+ }
1314
+
1315
+ // Script-managed files: core/core.yaml + legacy core.yaml.
1316
+ if (rel === "core/core.yaml" || rel === "core.yaml") {
1317
+ if (cfg.dryRun) {
1318
+ ctx.out(` skip script-managed (rewrites at stamp step): ${rel}\n`);
1319
+ } else {
1320
+ fs.rmSync(localPath, { force: true });
1321
+ }
1322
+ return;
1323
+ }
1324
+
1325
+ // Symlinks (mid-tree).
1326
+ const lst = lstatOrNull(localPath);
1327
+ if (lst && lst.isSymbolicLink()) {
1328
+ if (isMasterSyncSymlink(localPath)) {
1329
+ ctx.counts.symlinkDropped += 1;
1330
+ if (cfg.dryRun) {
1331
+ let tgt = "";
1332
+ try {
1333
+ tgt = fs.readlinkSync(localPath);
1334
+ } catch {
1335
+ /* ignore */
1336
+ }
1337
+ ctx.out(` drop reindex symlink: ${rel} -> ${tgt}\n`);
1338
+ } else {
1339
+ fs.rmSync(localPath, { force: true });
1340
+ ctx.appendLog(`symlink-dropped\t${rel}\t(reindex regenerable)\n`);
1341
+ }
1342
+ }
1343
+ return;
1344
+ }
1345
+
1346
+ // Is path in upstream HEAD?
1347
+ const inHead = existsFollow(srcPath) ? 1 : 0;
1348
+
1349
+ // Is path in last-sync floor?
1350
+ let inFloor = 0;
1351
+ if (ctx.baselineMode === "history_floor") {
1352
+ if (
1353
+ run("git", ["cat-file", "-e", `${ctx.historyFloor}:${rel}`], {
1354
+ cwd: ctx.srcDir,
1355
+ env: ctx.env,
1356
+ }).status === 0
1357
+ ) {
1358
+ inFloor = 1;
1359
+ }
1360
+ }
1361
+
1362
+ // USER-ONLY: unknown to upstream (HEAD AND floor both lack it).
1363
+ if (inHead === 0 && inFloor === 0) {
1364
+ ctx.counts.userOnly += 1;
1365
+ if (cfg.dryRun) ctx.out(` user-only (leave in place): ${rel}\n`);
1366
+ return;
1367
+ }
1368
+
1369
+ // Path is/was in upstream. Determine if user edited it.
1370
+ let userEdited = 0;
1371
+ if (ctx.baselineMode === "history_floor" && inFloor === 1) {
1372
+ const localSha = run("git", ["hash-object", localPath], { env: ctx.env }).stdout.trim();
1373
+ const baselineSha = run("git", ["rev-parse", `${ctx.historyFloor}:${rel}`], {
1374
+ cwd: ctx.srcDir,
1375
+ env: ctx.env,
1376
+ }).stdout.trim();
1377
+ if (!localSha || !baselineSha || localSha !== baselineSha) userEdited = 1;
1378
+ } else if (inHead === 1) {
1379
+ if (!bytesEqual(localPath, srcPath)) userEdited = 1;
1380
+ } else {
1381
+ // in_floor=1, in_head=0: upstream removed since last sync — rescue.
1382
+ userEdited = 1;
1383
+ }
1384
+
1385
+ // Cloud-update reclassification.
1386
+ if (userEdited === 1 && isCloudFlattenedSymlinkEquiv(ctx, rel, localPath)) {
1387
+ ctx.counts.cloudSymlinkReconciled += 1;
1388
+ if (cfg.dryRun) {
1389
+ ctx.out(
1390
+ ` cloud-symlink reconciled (unchanged): ${rel} (hq-symlink: marker matches upstream target)\n`,
1391
+ );
1392
+ } else {
1393
+ fs.rmSync(localPath, { force: true });
1394
+ ctx.appendLog(
1395
+ `cloud-symlink-reconciled\t${rel}\t(hq-symlink: marker matches upstream target; overlay re-lays symlink)\n`,
1396
+ );
1397
+ }
1398
+ return;
1399
+ }
1400
+
1401
+ // Convergence guard: drifted from floor but identical to upstream HEAD.
1402
+ if (userEdited === 1 && inHead === 1 && bytesEqual(localPath, srcPath)) {
1403
+ ctx.counts.driftReconciled += 1;
1404
+ if (cfg.dryRun) {
1405
+ ctx.out(` drift reconciled (identical to upstream HEAD; no rescue): ${rel}\n`);
1406
+ } else {
1407
+ fs.rmSync(localPath, { force: true });
1408
+ ctx.appendLog(
1409
+ `drift-reconciled\t${rel}\t(identical to upstream HEAD; drifted from floor only — no personal/ copy)\n`,
1410
+ );
1411
+ }
1412
+ return;
1413
+ }
1414
+
1415
+ if (userEdited === 1) {
1416
+ if (cfg.dryRun) {
1417
+ if (rel === ".claude/CLAUDE.md") {
1418
+ ctx.out(` user-edit (diff-append): ${rel} -> personal/CLAUDE.md\n`);
1419
+ ctx.counts.userEdit += 1;
1420
+ } else if (isOverwriteSafe(rel)) {
1421
+ ctx.out(` user-edit (overwrite-safe): ${rel} -> upstream wins (no copy preserved)\n`);
1422
+ ctx.counts.userOverwrite += 1;
1423
+ } else if (isConflictClass(rel)) {
1424
+ ctx.out(` user-edit (conflict): ${rel} -> .hq-conflicts/rescue-${ctx.runTs}/${rel}\n`);
1425
+ ctx.counts.userConflict += 1;
1426
+ } else {
1427
+ ctx.out(` user-edit (rescue): ${rel} -> ${mapRescueTarget(rel)}\n`);
1428
+ ctx.counts.userEdit += 1;
1429
+ }
1430
+ } else {
1431
+ if (rel === ".claude/CLAUDE.md") {
1432
+ rescueOne(ctx, rel);
1433
+ } else if (isOverwriteSafe(rel)) {
1434
+ fs.rmSync(localPath, { force: true });
1435
+ ctx.counts.userOverwrite += 1;
1436
+ ctx.appendLog(`overwritten\t${rel}\t(overwrite-safe; upstream wins, no copy preserved)\n`);
1437
+ } else if (isConflictClass(rel)) {
1438
+ conflictOne(ctx, rel);
1439
+ } else {
1440
+ rescueOne(ctx, rel);
1441
+ }
1442
+ }
1443
+ } else {
1444
+ // user_edited=0: matches floor baseline. Split on byte-equality to HEAD.
1445
+ ctx.counts.unchanged += 1;
1446
+ if (bytesEqual(localPath, path.join(ctx.srcDir, rel))) {
1447
+ if (cfg.dryRun) {
1448
+ ctx.out(` unchanged (preserved in place): ${rel}\n`);
1449
+ } else {
1450
+ fs.appendFileSync(ctx.unchangedList, `/${rel}\n`);
1451
+ ctx.appendLog(`unchanged\t${rel}\t(identical to upstream; left in place, mtime preserved)\n`);
1452
+ }
1453
+ } else {
1454
+ if (cfg.dryRun) {
1455
+ ctx.out(` unchanged (delete + replace): ${rel}\n`);
1456
+ } else {
1457
+ fs.rmSync(localPath, { force: true });
1458
+ ctx.appendLog(`deleted\t${rel}\t(unchanged vs baseline; re-laid by overlay if still upstream)\n`);
1459
+ }
1460
+ }
1461
+ }
1462
+ }
1463
+
1464
+ // --- walk a wipe-set root ---
1465
+ function walkAndProcess(ctx: WalkCtx, rootRel: string): void {
1466
+ const { cfg } = ctx;
1467
+ const rootAbs = path.join(ctx.hqRoot, rootRel);
1468
+ if (!lexists(rootAbs)) return;
1469
+
1470
+ // companies/_template — wholesale-replace.
1471
+ if (rootRel === "companies/_template") {
1472
+ if (cfg.dryRun) {
1473
+ ctx.out(" wholesale-replace: companies/_template (template carve-out)\n");
1474
+ } else {
1475
+ fs.rmSync(path.join(ctx.hqRoot, "companies", "_template"), { recursive: true, force: true });
1476
+ }
1477
+ return;
1478
+ }
1479
+
1480
+ const lst = lstatOrNull(rootAbs);
1481
+
1482
+ // Top-level symlink.
1483
+ if (lst && lst.isSymbolicLink()) {
1484
+ if (isMasterSyncSymlink(rootAbs)) {
1485
+ ctx.counts.symlinkDropped += 1;
1486
+ if (cfg.dryRun) {
1487
+ let tgt = "";
1488
+ try {
1489
+ tgt = fs.readlinkSync(rootAbs);
1490
+ } catch {
1491
+ /* ignore */
1492
+ }
1493
+ ctx.out(` drop reindex symlink: ${rootRel} -> ${tgt}\n`);
1494
+ } else {
1495
+ fs.rmSync(rootAbs, { force: true });
1496
+ ctx.appendLog(`symlink-dropped\t${rootRel}\t(reindex regenerable)\n`);
1497
+ }
1498
+ }
1499
+ return;
1500
+ }
1501
+
1502
+ // Top-level regular file.
1503
+ if (lst && lst.isFile()) {
1504
+ processOne(ctx, rootRel);
1505
+ return;
1506
+ }
1507
+
1508
+ // Top-level directory: recursive walk, pruning node_modules + nested .git.
1509
+ for (const rel of findFilesAndSymlinks(rootAbs, ctx.hqRoot)) {
1510
+ processOne(ctx, rel);
1511
+ }
1512
+ }
1513
+
1514
+ /**
1515
+ * Mirror of `find "$root" \( -type d \( -name node_modules -o -name .git \)
1516
+ * -prune \) -o \( \( -type f -o -type l \) -print0 \)`: yield repo-relative
1517
+ * paths of regular files and symlinks, pruning node_modules/.git dirs and not
1518
+ * descending into directory symlinks (find -P default). Deterministic order.
1519
+ */
1520
+ function findFilesAndSymlinks(rootAbs: string, hqRoot: string): string[] {
1521
+ const found: string[] = [];
1522
+ const walk = (dir: string) => {
1523
+ let entries: fs.Dirent[];
1524
+ try {
1525
+ entries = fs.readdirSync(dir, { withFileTypes: true });
1526
+ } catch {
1527
+ return;
1528
+ }
1529
+ entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
1530
+ for (const ent of entries) {
1531
+ const abs = path.join(dir, ent.name);
1532
+ if (ent.isSymbolicLink()) {
1533
+ // Surfaced as an entry (type l); never descended into.
1534
+ found.push(abs.slice(hqRoot.length + 1));
1535
+ continue;
1536
+ }
1537
+ if (ent.isDirectory()) {
1538
+ if (ent.name === "node_modules" || ent.name === ".git") continue; // prune
1539
+ walk(abs);
1540
+ continue;
1541
+ }
1542
+ if (ent.isFile()) {
1543
+ found.push(abs.slice(hqRoot.length + 1));
1544
+ }
1545
+ }
1546
+ };
1547
+ walk(rootAbs);
1548
+ return found;
1549
+ }
1550
+
1551
+ // --- restore file mtimes from git commit history ---
1552
+ function restoreMtimesFromGit(
1553
+ srcDir: string,
1554
+ env: NodeJS.ProcessEnv,
1555
+ out: (s: string) => void,
1556
+ ): void {
1557
+ // Deepen a shallow clone so per-file commit times are real.
1558
+ const isShallow =
1559
+ run("git", ["rev-parse", "--is-shallow-repository"], { cwd: srcDir, env }).stdout.trim() ===
1560
+ "true";
1561
+ if (isShallow) {
1562
+ out("==> Deepening clone for per-file mtime history (blob:none) ...\n");
1563
+ const r = run("git", ["fetch", "--unshallow", "--filter=blob:none"], { cwd: srcDir, env });
1564
+ if (r.status !== 0) {
1565
+ out(" (unshallow failed; mtimes fall back to release tip-commit time)\n");
1566
+ }
1567
+ }
1568
+
1569
+ out("==> Restoring file mtimes from git commit history ...\n");
1570
+ const log = run(
1571
+ "git",
1572
+ ["log", "--no-renames", "--pretty=format:C:%ct", "--name-only", "--diff-filter=ACMR"],
1573
+ { cwd: srcDir, env },
1574
+ );
1575
+ if (log.status !== 0) return;
1576
+
1577
+ let ts = "";
1578
+ const seen = new Set<string>();
1579
+ for (const line of log.stdout.split("\n")) {
1580
+ if (line.startsWith("C:")) {
1581
+ ts = line.slice(2);
1582
+ continue;
1583
+ }
1584
+ if (line.length === 0) continue;
1585
+ if (seen.has(line)) continue;
1586
+ seen.add(line);
1587
+ const f = path.join(srcDir, line);
1588
+ const st = lstatOrNull(f);
1589
+ if (!st || st.isSymbolicLink() || !st.isFile()) continue;
1590
+ const epoch = Number(ts);
1591
+ if (!Number.isFinite(epoch)) continue;
1592
+ try {
1593
+ fs.utimesSync(f, epoch, epoch);
1594
+ } catch {
1595
+ /* best-effort, matches perl `|| true` */
1596
+ }
1597
+ }
1598
+ }
1599
+
1600
+ // --- prune old pre-update-* snapshots past retention ---
1601
+ function pruneOldBackups(cfg: Config, out: (s: string) => void): void {
1602
+ if (!isDir(cfg.backupRoot)) return;
1603
+ if (!/^[0-9]+$/.test(cfg.backupRetentionDays)) return;
1604
+ const days = Number(cfg.backupRetentionDays);
1605
+ if (!(days > 0)) return;
1606
+ // `find -mtime +N`: strictly older than (N+1)*24h by mtime.
1607
+ const cutoffMs = Date.now() - (days + 1) * 24 * 60 * 60 * 1000;
1608
+ let entries: string[] = [];
1609
+ try {
1610
+ entries = fs.readdirSync(cfg.backupRoot);
1611
+ } catch {
1612
+ return;
1613
+ }
1614
+ for (const name of entries) {
1615
+ if (!name.startsWith("pre-update-")) continue;
1616
+ const abs = path.join(cfg.backupRoot, name);
1617
+ const st = lstatOrNull(abs);
1618
+ if (!st || !st.isDirectory()) continue;
1619
+ if (st.mtimeMs < cutoffMs) {
1620
+ out(` pruned old snapshot (> ${cfg.backupRetentionDays}d): ${name}\n`);
1621
+ try {
1622
+ fs.rmSync(abs, { recursive: true, force: true });
1623
+ } catch {
1624
+ /* best-effort */
1625
+ }
1626
+ }
1627
+ }
1628
+ }
1629
+
1630
+ // --- recursive file count (`find <dir> -type f | wc -l`) ---
1631
+ function countFiles(dir: string): number {
1632
+ let n = 0;
1633
+ const st = lstatOrNull(dir);
1634
+ if (!st) return 0;
1635
+ if (st.isFile()) return 1;
1636
+ if (!st.isDirectory() || st.isSymbolicLink()) return 0;
1637
+ const walk = (d: string) => {
1638
+ let entries: fs.Dirent[];
1639
+ try {
1640
+ entries = fs.readdirSync(d, { withFileTypes: true });
1641
+ } catch {
1642
+ return;
1643
+ }
1644
+ for (const ent of entries) {
1645
+ const abs = path.join(d, ent.name);
1646
+ if (ent.isDirectory()) walk(abs);
1647
+ else if (ent.isFile()) n += 1;
1648
+ }
1649
+ };
1650
+ walk(dir);
1651
+ return n;
1652
+ }
1653
+
1654
+ /**
1655
+ * `cp -a <src> <destDir>/[<asName>]` — recursive copy preserving symlinks,
1656
+ * mtimes and modes. Mirrors the bash `cp -a "$src" "$destDir/"` (copies the
1657
+ * source basename into destDir) and the shuttle form (`cp -a "$src"
1658
+ * "$SHUTTLE/$id"`, an explicit destination name).
1659
+ */
1660
+ function cpA(src: string, destDir: string, asName?: string): void {
1661
+ const dest = asName ? path.join(destDir, asName) : path.join(destDir, path.basename(src));
1662
+ cpATo(src, dest);
1663
+ }
1664
+
1665
+ /** `cp -a <src> <dest>` with an explicit destination path. */
1666
+ function cpATo(src: string, dest: string): void {
1667
+ const st = lstatOrNull(src);
1668
+ if (!st) return;
1669
+ if (st.isSymbolicLink()) {
1670
+ const link = fs.readlinkSync(src);
1671
+ try {
1672
+ fs.rmSync(dest, { force: true });
1673
+ } catch {
1674
+ /* ignore */
1675
+ }
1676
+ fs.symlinkSync(link, dest);
1677
+ return;
1678
+ }
1679
+ if (st.isDirectory()) {
1680
+ fs.mkdirSync(dest, { recursive: true });
1681
+ for (const name of fs.readdirSync(src)) {
1682
+ cpATo(path.join(src, name), path.join(dest, name));
1683
+ }
1684
+ try {
1685
+ fs.utimesSync(dest, st.atime, st.mtime);
1686
+ } catch {
1687
+ /* ignore */
1688
+ }
1689
+ return;
1690
+ }
1691
+ // Regular file.
1692
+ fs.copyFileSync(src, dest);
1693
+ try {
1694
+ fs.chmodSync(dest, st.mode);
1695
+ fs.utimesSync(dest, st.atime, st.mtime);
1696
+ } catch {
1697
+ /* ignore */
1698
+ }
1699
+ }
1700
+
1701
+ /** Synchronous single-line read from stdin (mirrors bash `read -r`). */
1702
+ function readLineSync(): string {
1703
+ const fd = 0;
1704
+ const buf = Buffer.alloc(1);
1705
+ let line = "";
1706
+ for (;;) {
1707
+ let bytes = 0;
1708
+ try {
1709
+ bytes = fs.readSync(fd, buf, 0, 1, null);
1710
+ } catch {
1711
+ break; // EOF / not readable
1712
+ }
1713
+ if (bytes === 0) break;
1714
+ const ch = buf.toString("utf-8");
1715
+ if (ch === "\n") break;
1716
+ line += ch;
1717
+ }
1718
+ return line.replace(/\r$/, "");
1719
+ }