@indigoai-us/hq-cloud 6.14.6 → 6.14.8

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 (97) hide show
  1. package/dist/backup-prune.d.ts +68 -0
  2. package/dist/backup-prune.d.ts.map +1 -0
  3. package/dist/backup-prune.js +196 -0
  4. package/dist/backup-prune.js.map +1 -0
  5. package/dist/backup-prune.test.d.ts +2 -0
  6. package/dist/backup-prune.test.d.ts.map +1 -0
  7. package/dist/backup-prune.test.js +89 -0
  8. package/dist/backup-prune.test.js.map +1 -0
  9. package/dist/bin/backup-prune-runner.d.ts +3 -0
  10. package/dist/bin/backup-prune-runner.d.ts.map +1 -0
  11. package/dist/bin/backup-prune-runner.js +50 -0
  12. package/dist/bin/backup-prune-runner.js.map +1 -0
  13. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
  14. package/dist/bin/sync-runner-watch-loop.js +28 -0
  15. package/dist/bin/sync-runner-watch-loop.js.map +1 -1
  16. package/dist/bin/sync-runner.d.ts +2 -0
  17. package/dist/bin/sync-runner.d.ts.map +1 -1
  18. package/dist/bin/sync-runner.js.map +1 -1
  19. package/dist/cli/reindex.d.ts.map +1 -1
  20. package/dist/cli/reindex.js +5 -3
  21. package/dist/cli/reindex.js.map +1 -1
  22. package/dist/cli/rescue-classify-ordering.test.js +105 -0
  23. package/dist/cli/rescue-classify-ordering.test.js.map +1 -1
  24. package/dist/cli/rescue-core.d.ts +7 -0
  25. package/dist/cli/rescue-core.d.ts.map +1 -1
  26. package/dist/cli/rescue-core.js +509 -282
  27. package/dist/cli/rescue-core.js.map +1 -1
  28. package/dist/cli/rescue-snapshot.d.ts +14 -0
  29. package/dist/cli/rescue-snapshot.d.ts.map +1 -0
  30. package/dist/cli/rescue-snapshot.js +39 -0
  31. package/dist/cli/rescue-snapshot.js.map +1 -0
  32. package/dist/cli/rescue-snapshot.test.d.ts +2 -0
  33. package/dist/cli/rescue-snapshot.test.d.ts.map +1 -0
  34. package/dist/cli/rescue-snapshot.test.js +46 -0
  35. package/dist/cli/rescue-snapshot.test.js.map +1 -0
  36. package/dist/cli/rescue.test.js +24 -0
  37. package/dist/cli/rescue.test.js.map +1 -1
  38. package/dist/cli/share.d.ts.map +1 -1
  39. package/dist/cli/share.js +12 -11
  40. package/dist/cli/share.js.map +1 -1
  41. package/dist/cli/sync.d.ts.map +1 -1
  42. package/dist/cli/sync.js +41 -6
  43. package/dist/cli/sync.js.map +1 -1
  44. package/dist/cli/sync.test.js +51 -0
  45. package/dist/cli/sync.test.js.map +1 -1
  46. package/dist/local-path-codec.d.ts +19 -0
  47. package/dist/local-path-codec.d.ts.map +1 -0
  48. package/dist/local-path-codec.js +69 -0
  49. package/dist/local-path-codec.js.map +1 -0
  50. package/dist/object-io.d.ts +19 -2
  51. package/dist/object-io.d.ts.map +1 -1
  52. package/dist/object-io.js +168 -32
  53. package/dist/object-io.js.map +1 -1
  54. package/dist/object-io.test.js +157 -1
  55. package/dist/object-io.test.js.map +1 -1
  56. package/dist/s3.d.ts +8 -0
  57. package/dist/s3.d.ts.map +1 -1
  58. package/dist/s3.js +52 -22
  59. package/dist/s3.js.map +1 -1
  60. package/dist/scope-shrink.d.ts.map +1 -1
  61. package/dist/scope-shrink.js +4 -3
  62. package/dist/scope-shrink.js.map +1 -1
  63. package/dist/signals/get.test.js +21 -1
  64. package/dist/signals/get.test.js.map +1 -1
  65. package/dist/signals/list.test.js +21 -1
  66. package/dist/signals/list.test.js.map +1 -1
  67. package/dist/sources/get.test.js +21 -1
  68. package/dist/sources/get.test.js.map +1 -1
  69. package/dist/sources/list.test.js +21 -1
  70. package/dist/sources/list.test.js.map +1 -1
  71. package/package.json +3 -2
  72. package/scripts/presign-transport-e2e.mjs +57 -10
  73. package/scripts/vault-rebaseline.sh +49 -1
  74. package/scripts/vault-rescue.sh +42 -1
  75. package/src/backup-prune.test.ts +98 -0
  76. package/src/backup-prune.ts +182 -0
  77. package/src/bin/backup-prune-runner.ts +33 -0
  78. package/src/bin/sync-runner-watch-loop.ts +18 -0
  79. package/src/bin/sync-runner.ts +2 -0
  80. package/src/cli/reindex.ts +5 -3
  81. package/src/cli/rescue-classify-ordering.test.ts +121 -0
  82. package/src/cli/rescue-core.ts +311 -89
  83. package/src/cli/rescue-snapshot.test.ts +57 -0
  84. package/src/cli/rescue-snapshot.ts +51 -0
  85. package/src/cli/rescue.test.ts +34 -0
  86. package/src/cli/share.ts +12 -11
  87. package/src/cli/sync.test.ts +62 -0
  88. package/src/cli/sync.ts +51 -7
  89. package/src/local-path-codec.ts +95 -0
  90. package/src/object-io.test.ts +175 -0
  91. package/src/object-io.ts +213 -32
  92. package/src/s3.ts +61 -12
  93. package/src/scope-shrink.ts +4 -3
  94. package/src/signals/get.test.ts +26 -2
  95. package/src/signals/list.test.ts +26 -2
  96. package/src/sources/get.test.ts +26 -2
  97. package/src/sources/list.test.ts +26 -2
@@ -28,6 +28,8 @@ import { spawnSync } from "child_process";
28
28
  import * as fs from "fs";
29
29
  import * as os from "os";
30
30
  import * as path from "path";
31
+ import { assertRescuePathTypeUnchanged, isRescueTransientDirectory, } from "./rescue-snapshot.js";
32
+ import { applyBackupPrune, canonicalRootHash, inventoryManagedSnapshots, } from "../backup-prune.js";
31
33
  // Paths always preserved across wipe+overlay (shuttled out, restored after).
32
34
  const CARVE_OUT_PATHS = [
33
35
  "core/packages",
@@ -55,6 +57,13 @@ const ALWAYS_PRESERVED_NAMES = [
55
57
  // these too (hq-cloud DEFAULT_IGNORES).
56
58
  ".pnpm-store",
57
59
  "node_modules",
60
+ // Local worktrees and updater/build sandboxes are reproducible, often huge,
61
+ // and never owned by an hq-core release. Keep these root-only names outside
62
+ // both the wipe set and the safety snapshot.
63
+ "worktrees",
64
+ ".worktrees",
65
+ ".sst-sandbox-home",
66
+ ".tmp-codex-asar",
58
67
  ];
59
68
  class ExitError extends Error {
60
69
  code;
@@ -77,6 +86,21 @@ function run(cmd, args, opts = {}) {
77
86
  stderr: res.stderr ?? "",
78
87
  };
79
88
  }
89
+ /**
90
+ * Cygwin's rsync is a POSIX program even when Node gave us a native Win32
91
+ * path. Keep UNC paths in Cygwin's documented `//server/share` form and map
92
+ * drive roots explicitly instead of relying on Cygwin's native-path fallback.
93
+ */
94
+ export function toCygwinRsyncPath(localPath) {
95
+ const slashPath = localPath.replaceAll("\\", "/");
96
+ if (/^\/\/[^/]+\/[^/]+(?:\/|$)/.test(slashPath))
97
+ return slashPath;
98
+ const drivePath = /^([a-z]):\/(.*)$/i.exec(slashPath);
99
+ if (drivePath) {
100
+ return `/cygdrive/${drivePath[1].toLowerCase()}/${drivePath[2]}`;
101
+ }
102
+ return localPath;
103
+ }
80
104
  function redactGitDiagnostic(output, ghToken) {
81
105
  let redacted = output;
82
106
  if (ghToken)
@@ -88,7 +112,18 @@ function redactGitDiagnostic(output, ghToken) {
88
112
  /** `command -v <bin>` — is a binary on PATH? */
89
113
  function hasCmd(bin, env) {
90
114
  const r = spawnSync(bin, ["--version"], { env, stdio: "ignore" });
91
- return !r.error;
115
+ return !r.error && r.status === 0;
116
+ }
117
+ /** The overlay requires rsync; do not allocate a recovery copy when it cannot run. */
118
+ function requireWorkingRsync(env, err) {
119
+ const probe = run("rsync", ["--version"], { env });
120
+ const output = `${probe.stdout}\n${probe.stderr}`;
121
+ if (probe.status === 0 && /\brsync\b.*\bversion\b/i.test(output))
122
+ return;
123
+ err("error: rsync preflight failed before any safety snapshot was allocated.\n");
124
+ err(` resolved PATH: ${env.PATH ?? "(unset)"}\n`);
125
+ err(" Install or repair rsync, then retry the HQ update.\n");
126
+ throw new ExitError(probe.status || 127);
92
127
  }
93
128
  // fs predicates mirroring bash test operators.
94
129
  function isDir(p) {
@@ -124,6 +159,18 @@ function lstatOrNull(p) {
124
159
  return null;
125
160
  }
126
161
  }
162
+ function rescuePathKind(p) {
163
+ const st = lstatOrNull(p);
164
+ if (!st)
165
+ return "missing";
166
+ if (st.isSymbolicLink())
167
+ return "symlink";
168
+ if (st.isFile())
169
+ return "file";
170
+ if (st.isDirectory())
171
+ return "directory";
172
+ return "other";
173
+ }
127
174
  function lexists(p) {
128
175
  return lstatOrNull(p) !== null;
129
176
  }
@@ -169,6 +216,7 @@ export function parseRescueArgs(argv) {
169
216
  doBackup: process.env.HQ_RESCUE_NO_BACKUP === "1" ? false : true,
170
217
  backupRoot: process.env.HQ_BACKUP_DIR || path.join(os.homedir(), ".hq", "backups"),
171
218
  backupRetentionDays: process.env.HQ_BACKUP_RETENTION_DAYS || "7",
219
+ backupRetentionCount: process.env.HQ_BACKUP_RETENTION_COUNT || "2",
172
220
  };
173
221
  const usage = () => {
174
222
  // The legacy script printed its comment header here; we emit a concise
@@ -366,6 +414,10 @@ function doRescue(cfg, env, out, err, setTmp) {
366
414
  throw new ExitError(3);
367
415
  }
368
416
  const RUN_TS = utcStamp(new Date(), "dash");
417
+ // Both the release overlay and a restorable snapshot require rsync. A shim
418
+ // which merely starts but exits non-zero used to trigger an enormous cp -a
419
+ // fallback, then fail later at overlay. Stop before classification/allocation.
420
+ requireWorkingRsync(env, err);
369
421
  // --- Read prior sync-point metadata (BEFORE the wipe) ---
370
422
  let prevSyncSha = "";
371
423
  let prevSyncSource = "";
@@ -404,7 +456,7 @@ function doRescue(cfg, env, out, err, setTmp) {
404
456
  else {
405
457
  out("==> Mode: preserve-list (default)\n");
406
458
  const extra = cfg.extraPreserve.length ? `, ${cfg.extraPreserve.join(" ")}` : "";
407
- out(`==> Preserved: .git, companies (except companies/_template), personal, workspace, repos, .github, .leak-scan, .hq-sync-journal.json, .hq, .hq-conflicts${extra}\n`);
459
+ out(`==> Preserved: .git, companies (except companies/_template), personal, workspace, repos, worktrees, .worktrees, .sst-sandbox-home, .tmp-codex-asar, .github, .leak-scan, .hq-sync-journal.json, .hq, .hq-conflicts${extra}\n`);
408
460
  }
409
461
  if (cfg.preserveSubpaths.length !== 0) {
410
462
  out("==> Preserved subpaths (backed up + restored across the overlay):\n");
@@ -424,7 +476,7 @@ function doRescue(cfg, env, out, err, setTmp) {
424
476
  out("==> History gate: OFF (--no-history-check; every diff rescued)\n");
425
477
  }
426
478
  if (cfg.doBackup) {
427
- out(`==> Safety backup: ON -> ${cfg.backupRoot}/pre-update-${RUN_TS} (retention ${cfg.backupRetentionDays}d)\n`);
479
+ out(`==> Safety backup: ON -> ${cfg.backupRoot}/pre-update-${RUN_TS} (retention ${cfg.backupRetentionDays}d / ${cfg.backupRetentionCount} completed)\n`);
428
480
  }
429
481
  else {
430
482
  out("==> Safety backup: OFF (--no-backup / HQ_RESCUE_NO_BACKUP=1)\n");
@@ -435,7 +487,7 @@ function doRescue(cfg, env, out, err, setTmp) {
435
487
  if (!cfg.assumeYes && !cfg.dryRun) {
436
488
  let backupLine = " * NO pre-op backup (--no-backup),";
437
489
  if (cfg.doBackup)
438
- backupLine = ` * snapshot the wipe set to ${cfg.backupRoot}/pre-update-${RUN_TS} first,`;
490
+ backupLine = ` * snapshot the release-managed wipe set (excluding worktrees/build caches) to ${cfg.backupRoot}/pre-update-${RUN_TS} first,`;
439
491
  let cloudLine = "";
440
492
  if (cfg.cloudUpdate)
441
493
  cloudLine =
@@ -579,6 +631,10 @@ function doRescue(cfg, env, out, err, setTmp) {
579
631
  "--exclude=.hq-sync-journal.json",
580
632
  "--exclude=.hq",
581
633
  "--exclude=.hq-conflicts",
634
+ "--exclude=/worktrees",
635
+ "--exclude=/.worktrees",
636
+ "--exclude=/.sst-sandbox-home",
637
+ "--exclude=/.tmp-codex-asar",
582
638
  "--include=/companies/",
583
639
  "--include=/companies/_template/***",
584
640
  "--exclude=/companies/*",
@@ -641,32 +697,6 @@ function doRescue(cfg, env, out, err, setTmp) {
641
697
  decisions: [],
642
698
  actions: [],
643
699
  };
644
- // --- Pre-operation safety snapshot (BEFORE any destructive op) ---
645
- let backupDir = "";
646
- if (cfg.doBackup && !cfg.dryRun && wipeToplevel.length !== 0) {
647
- backupDir = path.join(cfg.backupRoot, `pre-update-${RUN_TS}`);
648
- out("\n");
649
- out(`==> Safety snapshot -> ${backupDir}\n`);
650
- fs.mkdirSync(backupDir, { recursive: true });
651
- const rsyncAvailable = hasCmd("rsync", env);
652
- for (const rootRel of wipeToplevel) {
653
- const srcAbs = path.join(hqRoot, rootRel);
654
- if (!lexists(srcAbs))
655
- continue;
656
- const parentRel = path.dirname(rootRel);
657
- const destParent = path.join(backupDir, parentRel);
658
- fs.mkdirSync(destParent, { recursive: true });
659
- if (rsyncAvailable) {
660
- const r = run("rsync", ["-a", "--exclude=node_modules/", "--exclude=.git/", srcAbs, destParent + "/"], { env });
661
- if (r.status !== 0)
662
- cpA(srcAbs, destParent);
663
- }
664
- else {
665
- cpA(srcAbs, destParent);
666
- }
667
- }
668
- out(` snapshot complete (restore any file: cp "${backupDir}/<relpath>" "${hqRoot}/<relpath>")\n`);
669
- }
670
700
  // --- Walk + classify (PHASE 1: read-only) ---
671
701
  // Classification mutates nothing on disk: every outcome is recorded as a
672
702
  // typed RescueDecision. Destructive ops (delete, rescue-move,
@@ -760,237 +790,317 @@ function doRescue(cfg, env, out, err, setTmp) {
760
790
  // Actions run in classification (walk) order — the same order the old
761
791
  // interleaved walk mutated in — so per-file output and on-disk results are
762
792
  // unchanged versus before the two-phase split.
763
- ctx.actions = buildRescueActions(ctx);
764
- applyClassifiedActions(ctx, backupDir);
765
- // --- Back up preserve-subpaths to a mktemp shuttle ---
766
- const shuttle = path.join(tmpdir, "preserve");
767
- fs.mkdirSync(shuttle, { recursive: true });
768
- const preserveMap = [];
769
- let shuttleId = 0;
770
- for (const sp of cfg.preserveSubpaths) {
771
- const src = path.join(hqRoot, sp);
772
- if (lexists(src)) {
773
- shuttleId += 1;
774
- cpA(src, shuttle, String(shuttleId));
775
- preserveMap.push({ id: shuttleId, rel: sp });
776
- out(`==> Backed up ${sp} -> shuttle/${shuttleId}\n`);
777
- }
778
- }
779
- out("\n");
780
- out(`==> 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`);
781
- // --- Overlay source onto HQ root ---
782
- out("==> Overlaying source onto HQ root ...\n");
783
793
  const overlayProtect = [];
784
794
  if (fs.statSync(unchangedList).size > 0) {
785
795
  overlayProtect.push(`--exclude-from=${unchangedList}`);
786
796
  }
787
- const overlay = run("rsync", ["-a", ...overlayProtect, ...rsyncExcludes, srcDir + "/", hqRoot + "/"], { env });
788
- if (overlay.status !== 0) {
789
- // bash runs under set -e: a failed rsync aborts with its status.
790
- err(overlay.stderr);
791
- throw new ExitError(overlay.status || 1);
792
- }
793
- // --- Restore preserved sub-paths ---
794
- if (preserveMap.length > 0) {
795
- out("==> Restoring preserved sub-paths ...\n");
796
- for (const { id, rel } of preserveMap) {
797
- const dest = path.join(hqRoot, rel);
798
- fs.mkdirSync(path.dirname(dest), { recursive: true });
799
- const shuttleItem = path.join(shuttle, String(id));
800
- if (isDir(shuttleItem)) {
801
- fs.rmSync(dest, { recursive: true, force: true });
802
- cpATo(shuttleItem, dest);
803
- }
804
- else {
805
- cpATo(shuttleItem, dest);
797
+ const overlayArgs = [
798
+ "-a",
799
+ ...overlayProtect,
800
+ ...rsyncExcludes,
801
+ toCygwinRsyncPath(srcDir + "/"),
802
+ toCygwinRsyncPath(hqRoot + "/"),
803
+ ];
804
+ // Exercise the exact overlay command before any classified delete/move. A
805
+ // Cygwin path conversion or filter failure therefore leaves the root intact.
806
+ const overlayPreflight = run("rsync", ["--dry-run", ...overlayArgs], { env });
807
+ if (overlayPreflight.status !== 0) {
808
+ err(overlayPreflight.stderr);
809
+ throw new ExitError(overlayPreflight.status || 1);
810
+ }
811
+ ctx.actions = buildRescueActions(ctx);
812
+ // Snapshot allocation is deliberately after the entire read-only walk and
813
+ // action-plan build. A no-op (or a classifier fault above) cannot consume
814
+ // backup space.
815
+ let backupDir = "";
816
+ let mutationStarted = false;
817
+ const hasDestructivePaths = ctx.actions.some((action) => action.affectedRels.length > 0);
818
+ if (cfg.doBackup && !cfg.dryRun && wipeToplevel.length !== 0 && hasDestructivePaths) {
819
+ const prePlan = pruneManagedBackups(cfg, hqRoot, out, false);
820
+ const unresolved = prePlan.protected.filter((x) => x.reason === "recovery-required-no-later-good");
821
+ if (unresolved.length >= 2) {
822
+ err("error: safety snapshot circuit breaker is open: two unresolved recovery-required snapshots exist.\n");
823
+ err(" Inspect/restore those snapshots and repair the release source before retrying (operator override required).\n");
824
+ throw new ExitError(1);
825
+ }
826
+ requireBackupCapacity(cfg, hqRoot, wipeToplevel, env, err);
827
+ fs.mkdirSync(cfg.backupRoot, { recursive: true });
828
+ const runId = `${process.pid}-${Date.now().toString(36)}`;
829
+ backupDir = allocateSnapshotDir(cfg.backupRoot, RUN_TS, canonicalRootHash(hqRoot), runId);
830
+ out("\n");
831
+ out(`==> Safety snapshot -> ${backupDir}\n`);
832
+ try {
833
+ fs.mkdirSync(backupDir, { recursive: false });
834
+ writeSnapshotManifest(backupDir, snapshotManifest("copying", hqRoot, runId, cfg, srcSha, wipeToplevel));
835
+ for (const rootRel of wipeToplevel) {
836
+ const srcAbs = path.join(hqRoot, rootRel);
837
+ if (!lexists(srcAbs) || isRescueTransientDirectory(rootRel))
838
+ continue;
839
+ cpAToFiltered(srcAbs, path.join(backupDir, rootRel), rootRel);
806
840
  }
807
- out(` restored ${rel}\n`);
808
- }
809
- }
810
- // --- Restore executable bit on shipped shebang scripts (defense-in-depth) ---
811
- // rsync -a already propagated each file's upstream git tree mode; this guards
812
- // the case where upstream itself committed an executable script as 0644 -- a
813
- // hook the runtime exec's then dies with EACCES and is silently disabled. A
814
- // shebang is an unambiguous "meant to be run" marker. See restoreShebangExecBits.
815
- const execBitFixes = restoreShebangExecBits(srcDir, hqRoot, env);
816
- if (execBitFixes > 0) {
817
- out(`==> Restored executable bit on ${execBitFixes} shipped shebang script(s) lacking +x\n`);
818
- }
819
- // --- Stamp sync-point provenance into core/core.yaml ---
820
- // `last_sync_at` = the source commit's committer time, NOT wall-clock now:
821
- // the stamp must be a pure function of srcSha so every machine rescuing the
822
- // same release writes byte-identical core.yaml (see srcCommitIsoRaw). An
823
- // unparseable commit timestamp skips the stamp entirely — a nondeterministic
824
- // stamp is worse than no stamp (it re-creates cross-machine conflict mirrors).
825
- const srcCommitDate = new Date(srcCommitIsoRaw);
826
- if (cfg.narrowPaths.length === 0 && isFileFollow(coreYaml) && Number.isNaN(srcCommitDate.getTime())) {
827
- err(` WARN: could not parse source commit timestamp (${JSON.stringify(srcCommitIsoRaw)}); skipping core/core.yaml stamp to keep it deterministic\n`);
828
- }
829
- if (cfg.narrowPaths.length === 0 &&
830
- isFileFollow(coreYaml) &&
831
- !Number.isNaN(srcCommitDate.getTime())) {
832
- const nowUtc = utcStamp(srcCommitDate, "colon");
833
- if (yqAvailable) {
834
- const stampEnv = {
835
- ...env,
836
- SHA: srcSha,
837
- SOURCE: cfg.sourceRepo,
838
- THE_REF: cfg.ref,
839
- AT: nowUtc,
840
- };
841
- run("yq", [
842
- "-i",
843
- ".replaced_from_source.source = strenv(SOURCE) |\n" +
844
- " .replaced_from_source.ref = strenv(THE_REF) |\n" +
845
- " .replaced_from_source.last_sync_sha = strenv(SHA) |\n" +
846
- " .replaced_from_source.last_sync_at = strenv(AT) |\n" +
847
- " del(.replaced_from_staging)",
848
- coreYaml,
849
- ], { env: stampEnv });
850
- out(`==> Stamped core/core.yaml: replaced_from_source.last_sync_sha=${srcSha}\n`);
851
- }
852
- else if (hasCmd("python3", env) &&
853
- run("python3", ["-c", "import yaml"], { env }).status === 0) {
854
- const pyEnv = {
855
- ...env,
856
- SHA: srcSha,
857
- SOURCE: cfg.sourceRepo,
858
- THE_REF: cfg.ref,
859
- AT: nowUtc,
860
- CORE: coreYaml,
861
- };
862
- run("python3", [
863
- "-c",
864
- [
865
- "import os, yaml",
866
- 'path = os.environ["CORE"]',
867
- "try:",
868
- " with open(path) as f:",
869
- " d = yaml.safe_load(f) or {}",
870
- "except FileNotFoundError:",
871
- " d = {}",
872
- 'd["replaced_from_source"] = {',
873
- ' "source": os.environ["SOURCE"],',
874
- ' "ref": os.environ["THE_REF"],',
875
- ' "last_sync_sha": os.environ["SHA"],',
876
- ' "last_sync_at": os.environ["AT"],',
877
- "}",
878
- 'd.pop("replaced_from_staging", None)',
879
- 'with open(path, "w") as f:',
880
- " yaml.safe_dump(d, f, default_flow_style=False, sort_keys=False)",
881
- ].join("\n"),
882
- ], { env: pyEnv });
883
- out(`==> Stamped core/core.yaml: replaced_from_source.last_sync_sha=${srcSha}\n`);
841
+ updateSnapshotStatus(backupDir, "ready");
884
842
  }
885
- else {
886
- err(" WARN: neither yq nor python3+PyYAML available — skipping core/core.yaml stamp\n");
887
- }
888
- }
889
- // NOTE (6.2.4): the post-rescue "sync-journal baseline reconcile" that lived
890
- // here (6.2.1 itemize-parse, 6.2.3 hash-diff) is intentionally GONE. Masking
891
- // rescue-changed scaffold as already-synced starved the push leg — the vault
892
- // silently kept stale scaffold bytes while the journal claimed convergence.
893
- // Correct semantics: leave rescue-changed files visible as "local changed";
894
- // the next bidirectional sync pushes them up (vault converges to the rescue
895
- // output), and the pull-side byte-identical convergence probe (6.2.0)
896
- // absorbs cross-machine races without minting `.conflict-*` mirrors.
897
- // --- File count summary ---
898
- out("\n");
899
- out("==> File count summary:\n");
900
- for (const rootRel of wipeToplevel) {
901
- const abs = path.join(hqRoot, rootRel);
902
- if (lexists(abs)) {
903
- const n = countFiles(abs);
904
- out(` ${rootRel}: ${n} files\n`);
843
+ catch (error) {
844
+ updateSnapshotStatus(backupDir, "failed-pre-mutation");
845
+ throw error;
905
846
  }
847
+ pruneManagedBackups(cfg, hqRoot, out, true);
848
+ out(` snapshot ready (restore any file: cp "${backupDir}/<relpath>" "${hqRoot}/<relpath>")\n`);
906
849
  }
907
- out("\n");
908
- out("==> Classification:\n");
909
- out(` user-only (left in place): ${counts.userOnly} files\n`);
910
- out(` unchanged (preserved/re-laid): ${counts.unchanged} files\n`);
911
- out(` user-edits (rescued): ${counts.userEdit} files\n`);
912
- out(` user-edits (conflict quarantine): ${counts.userConflict} files\n`);
913
- out(` user-edits (overwrite-safe): ${counts.userOverwrite} files\n`);
914
- out(` cloud-symlink reconciled: ${counts.cloudSymlinkReconciled} files\n`);
915
- out(` drift reconciled (== upstream): ${counts.driftReconciled} files\n`);
916
- out(` reindex symlinks dropped: ${counts.symlinkDropped} entries\n`);
917
- if (counts.claudeDiffAppend > 0) {
918
- out(" of which .claude/CLAUDE.md diff-appended to personal/CLAUDE.md\n");
919
- }
920
- if (counts.userConflict > 0) {
921
- out(` conflict bucket: .hq-conflicts/rescue-${RUN_TS}/\n`);
922
- }
923
- if (baselineMode === "history_floor") {
924
- out(` baseline: last-sync floor ${historyFloor}\n`);
850
+ else if (cfg.doBackup && !cfg.dryRun && wipeToplevel.length !== 0) {
851
+ out("\n==> Safety snapshot skipped (classification found no destructive paths).\n");
925
852
  }
926
- else {
927
- out(" baseline: upstream HEAD (no stamp; first run / floor unreachable)\n");
928
- }
929
- // --- Write recovery manifest into the snapshot + prune old snapshots ---
930
- if (cfg.doBackup && backupDir && isDir(backupDir)) {
931
- const lines = [];
932
- lines.push("# HQ pre-update safety snapshot", "");
933
- lines.push(`Created: ${RUN_TS}`);
934
- lines.push(`HQ root: ${hqRoot}`);
935
- lines.push(`Source: ${cfg.sourceRepo}@${cfg.ref} (${srcSha})`);
936
- if (baselineMode === "history_floor") {
937
- lines.push(`Baseline: history_floor (${historyFloor})`);
853
+ try {
854
+ mutationStarted = true;
855
+ applyClassifiedActions(ctx, backupDir);
856
+ // --- Back up preserve-subpaths to a mktemp shuttle ---
857
+ const shuttle = path.join(tmpdir, "preserve");
858
+ fs.mkdirSync(shuttle, { recursive: true });
859
+ const preserveMap = [];
860
+ let shuttleId = 0;
861
+ for (const sp of cfg.preserveSubpaths) {
862
+ const src = path.join(hqRoot, sp);
863
+ if (lexists(src)) {
864
+ shuttleId += 1;
865
+ cpA(src, shuttle, String(shuttleId));
866
+ preserveMap.push({ id: shuttleId, rel: sp });
867
+ out(`==> Backed up ${sp} -> shuttle/${shuttleId}\n`);
868
+ }
938
869
  }
939
- else {
940
- lines.push("Baseline: head_compare (no last-sync stamp; first run or floor unreachable)");
941
- }
942
- lines.push("", "## What the rescue did");
943
- lines.push(` user-only (left in place): ${counts.userOnly}`);
944
- lines.push(` unchanged (preserved/re-laid): ${counts.unchanged}`);
945
- lines.push(` user-edit (rescued into personal/): ${counts.userEdit}`);
946
- lines.push(` user-edit (conflict quarantine): ${counts.userConflict}`);
947
- lines.push(` user-edit (overwrite-safe): ${counts.userOverwrite}`);
948
- lines.push(` cloud-symlink reconciled: ${counts.cloudSymlinkReconciled}`);
949
- lines.push(` drift reconciled (== upstream HEAD): ${counts.driftReconciled}`);
950
- lines.push(` reindex symlinks dropped: ${counts.symlinkDropped}`);
951
- if (counts.userConflict > 0) {
952
- lines.push(` conflict bucket: .hq-conflicts/rescue-${RUN_TS}/`);
870
+ out("\n");
871
+ out(`==> 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`);
872
+ // --- Overlay source onto HQ root ---
873
+ out("==> Overlaying source onto HQ root ...\n");
874
+ // `unchangedList` is only populated during applyClassifiedActions above, so
875
+ // re-derive the --exclude-from protect list HERE. Building overlayArgs before
876
+ // classification left the list empty, so unchanged files were overlaid and
877
+ // their mtimes reset to clone time (rescue-mtime-preserve regression). The
878
+ // early overlayArgs remain correct for the pre-classification dry-run.
879
+ const overlayRunArgs = fs.statSync(unchangedList).size > 0
880
+ ? [
881
+ "-a",
882
+ `--exclude-from=${unchangedList}`,
883
+ ...rsyncExcludes,
884
+ toCygwinRsyncPath(srcDir + "/"),
885
+ toCygwinRsyncPath(hqRoot + "/"),
886
+ ]
887
+ : overlayArgs;
888
+ const overlay = run("rsync", overlayRunArgs, { env });
889
+ if (overlay.status !== 0) {
890
+ // bash runs under set -e: a failed rsync aborts with its status.
891
+ err(overlay.stderr);
892
+ throw new ExitError(overlay.status || 1);
893
+ }
894
+ // --- Restore preserved sub-paths ---
895
+ if (preserveMap.length > 0) {
896
+ out("==> Restoring preserved sub-paths ...\n");
897
+ for (const { id, rel } of preserveMap) {
898
+ const dest = path.join(hqRoot, rel);
899
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
900
+ const shuttleItem = path.join(shuttle, String(id));
901
+ if (isDir(shuttleItem)) {
902
+ fs.rmSync(dest, { recursive: true, force: true });
903
+ cpATo(shuttleItem, dest);
904
+ }
905
+ else {
906
+ cpATo(shuttleItem, dest);
907
+ }
908
+ out(` restored ${rel}\n`);
909
+ }
953
910
  }
954
- lines.push("", "## Moved / deleted files (tab-separated: action, path, detail)");
955
- let rescueLogContent = "";
956
- try {
957
- rescueLogContent = fs.readFileSync(rescueLog, "utf-8");
911
+ // --- Restore executable bit on shipped shebang scripts (defense-in-depth) ---
912
+ // rsync -a already propagated each file's upstream git tree mode; this guards
913
+ // the case where upstream itself committed an executable script as 0644 -- a
914
+ // hook the runtime exec's then dies with EACCES and is silently disabled. A
915
+ // shebang is an unambiguous "meant to be run" marker. See restoreShebangExecBits.
916
+ const execBitFixes = restoreShebangExecBits(srcDir, hqRoot, env);
917
+ if (execBitFixes > 0) {
918
+ out(`==> Restored executable bit on ${execBitFixes} shipped shebang script(s) lacking +x\n`);
919
+ }
920
+ // --- Stamp sync-point provenance into core/core.yaml ---
921
+ // `last_sync_at` = the source commit's committer time, NOT wall-clock now:
922
+ // the stamp must be a pure function of srcSha so every machine rescuing the
923
+ // same release writes byte-identical core.yaml (see srcCommitIsoRaw). An
924
+ // unparseable commit timestamp skips the stamp entirely — a nondeterministic
925
+ // stamp is worse than no stamp (it re-creates cross-machine conflict mirrors).
926
+ const srcCommitDate = new Date(srcCommitIsoRaw);
927
+ if (cfg.narrowPaths.length === 0 && isFileFollow(coreYaml) && Number.isNaN(srcCommitDate.getTime())) {
928
+ err(` WARN: could not parse source commit timestamp (${JSON.stringify(srcCommitIsoRaw)}); skipping core/core.yaml stamp to keep it deterministic\n`);
929
+ }
930
+ if (cfg.narrowPaths.length === 0 &&
931
+ isFileFollow(coreYaml) &&
932
+ !Number.isNaN(srcCommitDate.getTime())) {
933
+ const nowUtc = utcStamp(srcCommitDate, "colon");
934
+ if (yqAvailable) {
935
+ const stampEnv = {
936
+ ...env,
937
+ SHA: srcSha,
938
+ SOURCE: cfg.sourceRepo,
939
+ THE_REF: cfg.ref,
940
+ AT: nowUtc,
941
+ };
942
+ run("yq", [
943
+ "-i",
944
+ ".replaced_from_source.source = strenv(SOURCE) |\n" +
945
+ " .replaced_from_source.ref = strenv(THE_REF) |\n" +
946
+ " .replaced_from_source.last_sync_sha = strenv(SHA) |\n" +
947
+ " .replaced_from_source.last_sync_at = strenv(AT) |\n" +
948
+ " del(.replaced_from_staging)",
949
+ coreYaml,
950
+ ], { env: stampEnv });
951
+ out(`==> Stamped core/core.yaml: replaced_from_source.last_sync_sha=${srcSha}\n`);
952
+ }
953
+ else if (hasCmd("python3", env) &&
954
+ run("python3", ["-c", "import yaml"], { env }).status === 0) {
955
+ const pyEnv = {
956
+ ...env,
957
+ SHA: srcSha,
958
+ SOURCE: cfg.sourceRepo,
959
+ THE_REF: cfg.ref,
960
+ AT: nowUtc,
961
+ CORE: coreYaml,
962
+ };
963
+ run("python3", [
964
+ "-c",
965
+ [
966
+ "import os, yaml",
967
+ 'path = os.environ["CORE"]',
968
+ "try:",
969
+ " with open(path) as f:",
970
+ " d = yaml.safe_load(f) or {}",
971
+ "except FileNotFoundError:",
972
+ " d = {}",
973
+ 'd["replaced_from_source"] = {',
974
+ ' "source": os.environ["SOURCE"],',
975
+ ' "ref": os.environ["THE_REF"],',
976
+ ' "last_sync_sha": os.environ["SHA"],',
977
+ ' "last_sync_at": os.environ["AT"],',
978
+ "}",
979
+ 'd.pop("replaced_from_staging", None)',
980
+ 'with open(path, "w") as f:',
981
+ " yaml.safe_dump(d, f, default_flow_style=False, sort_keys=False)",
982
+ ].join("\n"),
983
+ ], { env: pyEnv });
984
+ out(`==> Stamped core/core.yaml: replaced_from_source.last_sync_sha=${srcSha}\n`);
985
+ }
986
+ else {
987
+ err(" WARN: neither yq nor python3+PyYAML available — skipping core/core.yaml stamp\n");
988
+ }
958
989
  }
959
- catch {
960
- rescueLogContent = "";
990
+ // NOTE (6.2.4): the post-rescue "sync-journal baseline reconcile" that lived
991
+ // here (6.2.1 itemize-parse, 6.2.3 hash-diff) is intentionally GONE. Masking
992
+ // rescue-changed scaffold as already-synced starved the push leg — the vault
993
+ // silently kept stale scaffold bytes while the journal claimed convergence.
994
+ // Correct semantics: leave rescue-changed files visible as "local changed";
995
+ // the next bidirectional sync pushes them up (vault converges to the rescue
996
+ // output), and the pull-side byte-identical convergence probe (6.2.0)
997
+ // absorbs cross-machine races without minting `.conflict-*` mirrors.
998
+ // --- File count summary ---
999
+ out("\n");
1000
+ out("==> File count summary:\n");
1001
+ for (const rootRel of wipeToplevel) {
1002
+ const abs = path.join(hqRoot, rootRel);
1003
+ if (lexists(abs)) {
1004
+ const n = countFiles(abs);
1005
+ out(` ${rootRel}: ${n} files\n`);
1006
+ }
961
1007
  }
962
- if (rescueLogContent.length > 0) {
963
- // `cat` includes the trailing content verbatim.
964
- lines.push(rescueLogContent.replace(/\n$/, ""));
1008
+ out("\n");
1009
+ out("==> Classification:\n");
1010
+ out(` user-only (left in place): ${counts.userOnly} files\n`);
1011
+ out(` unchanged (preserved/re-laid): ${counts.unchanged} files\n`);
1012
+ out(` user-edits (rescued): ${counts.userEdit} files\n`);
1013
+ out(` user-edits (conflict quarantine): ${counts.userConflict} files\n`);
1014
+ out(` user-edits (overwrite-safe): ${counts.userOverwrite} files\n`);
1015
+ out(` cloud-symlink reconciled: ${counts.cloudSymlinkReconciled} files\n`);
1016
+ out(` drift reconciled (== upstream): ${counts.driftReconciled} files\n`);
1017
+ out(` reindex symlinks dropped: ${counts.symlinkDropped} entries\n`);
1018
+ if (counts.claudeDiffAppend > 0) {
1019
+ out(" of which .claude/CLAUDE.md diff-appended to personal/CLAUDE.md\n");
965
1020
  }
966
- else {
967
- lines.push(" (no files were moved or deleted)");
968
- }
969
- lines.push("", "## Restore");
970
- lines.push("This directory holds the wipe set exactly as it was before the update.");
971
- lines.push("Restore a single file:");
972
- lines.push(` cp "${backupDir}/<relpath>" "${hqRoot}/<relpath>"`);
973
- lines.push("Restore everything (overwrites current scaffold — use with care):");
974
- lines.push(` rsync -a "${backupDir}/" "${hqRoot}/"`);
975
- lines.push("", `Auto-pruned after ${cfg.backupRetentionDays} days (HQ_BACKUP_RETENTION_DAYS).`);
976
- try {
977
- fs.writeFileSync(path.join(backupDir, "RECOVERY.md"), lines.join("\n") + "\n");
1021
+ if (counts.userConflict > 0) {
1022
+ out(` conflict bucket: .hq-conflicts/rescue-${RUN_TS}/\n`);
978
1023
  }
979
- catch {
980
- /* best-effort, matches `|| true` */
1024
+ if (baselineMode === "history_floor") {
1025
+ out(` baseline: last-sync floor ${historyFloor}\n`);
1026
+ }
1027
+ else {
1028
+ out(" baseline: upstream HEAD (no stamp; first run / floor unreachable)\n");
1029
+ }
1030
+ // --- Write recovery manifest into the snapshot + prune old snapshots ---
1031
+ if (cfg.doBackup && backupDir && isDir(backupDir)) {
1032
+ const lines = [];
1033
+ lines.push("# HQ pre-update safety snapshot", "");
1034
+ lines.push(`Created: ${RUN_TS}`);
1035
+ lines.push(`HQ root: ${hqRoot}`);
1036
+ lines.push(`Source: ${cfg.sourceRepo}@${cfg.ref} (${srcSha})`);
1037
+ if (baselineMode === "history_floor") {
1038
+ lines.push(`Baseline: history_floor (${historyFloor})`);
1039
+ }
1040
+ else {
1041
+ lines.push("Baseline: head_compare (no last-sync stamp; first run or floor unreachable)");
1042
+ }
1043
+ lines.push("", "## What the rescue did");
1044
+ lines.push(` user-only (left in place): ${counts.userOnly}`);
1045
+ lines.push(` unchanged (preserved/re-laid): ${counts.unchanged}`);
1046
+ lines.push(` user-edit (rescued into personal/): ${counts.userEdit}`);
1047
+ lines.push(` user-edit (conflict quarantine): ${counts.userConflict}`);
1048
+ lines.push(` user-edit (overwrite-safe): ${counts.userOverwrite}`);
1049
+ lines.push(` cloud-symlink reconciled: ${counts.cloudSymlinkReconciled}`);
1050
+ lines.push(` drift reconciled (== upstream HEAD): ${counts.driftReconciled}`);
1051
+ lines.push(` reindex symlinks dropped: ${counts.symlinkDropped}`);
1052
+ if (counts.userConflict > 0) {
1053
+ lines.push(` conflict bucket: .hq-conflicts/rescue-${RUN_TS}/`);
1054
+ }
1055
+ lines.push("", "## Moved / deleted files (tab-separated: action, path, detail)");
1056
+ let rescueLogContent = "";
1057
+ try {
1058
+ rescueLogContent = fs.readFileSync(rescueLog, "utf-8");
1059
+ }
1060
+ catch {
1061
+ rescueLogContent = "";
1062
+ }
1063
+ if (rescueLogContent.length > 0) {
1064
+ // `cat` includes the trailing content verbatim.
1065
+ lines.push(rescueLogContent.replace(/\n$/, ""));
1066
+ }
1067
+ else {
1068
+ lines.push(" (no files were moved or deleted)");
1069
+ }
1070
+ lines.push("", "## Restore");
1071
+ lines.push("This directory holds the release-managed wipe set as it was before the update; reproducible worktrees and build caches are intentionally excluded.");
1072
+ lines.push("Restore a single file:");
1073
+ lines.push(` cp "${backupDir}/<relpath>" "${hqRoot}/<relpath>"`);
1074
+ lines.push("Restore everything (overwrites current scaffold — use with care):");
1075
+ lines.push(` rsync -a "${backupDir}/" "${hqRoot}/"`);
1076
+ lines.push("", `Retention: ${cfg.backupRetentionDays} days and ${cfg.backupRetentionCount} completed snapshots (latest recovery floor retained).`);
1077
+ try {
1078
+ fs.writeFileSync(path.join(backupDir, "RECOVERY.md"), lines.join("\n") + "\n");
1079
+ }
1080
+ catch {
1081
+ /* best-effort, matches `|| true` */
1082
+ }
1083
+ updateSnapshotStatus(backupDir, "completed-good", RUN_TS);
1084
+ out("\n");
1085
+ out(`==> Pre-update snapshot + recovery manifest: ${backupDir}\n`);
1086
+ pruneManagedBackups(cfg, hqRoot, out, true);
981
1087
  }
982
1088
  out("\n");
983
- out(`==> Pre-update snapshot + recovery manifest: ${backupDir}\n`);
984
- pruneOldBackups(cfg, out);
1089
+ out(`==> Done. Source: ${cfg.sourceRepo}@${cfg.ref} (${srcSha})\n`);
1090
+ out(" User-edited files were rescued under personal/ (see scan output above).\n");
1091
+ out(" User-only files (created by you, unknown to upstream) were left untouched.\n");
1092
+ if (cfg.doBackup && backupDir) {
1093
+ out(` A filtered pre-update snapshot is at ${backupDir} (RECOVERY.md explains restore).\n`);
1094
+ }
1095
+ return { status: 0 };
985
1096
  }
986
- out("\n");
987
- out(`==> Done. Source: ${cfg.sourceRepo}@${cfg.ref} (${srcSha})\n`);
988
- out(" User-edited files were rescued under personal/ (see scan output above).\n");
989
- out(" User-only files (created by you, unknown to upstream) were left untouched.\n");
990
- if (cfg.doBackup && backupDir) {
991
- out(` A full pre-update snapshot is at ${backupDir} (RECOVERY.md explains restore).\n`);
1097
+ catch (error) {
1098
+ if (backupDir && isDir(backupDir)) {
1099
+ updateSnapshotStatus(backupDir, mutationStarted ? "recovery-required" : "failed-pre-mutation");
1100
+ pruneManagedBackups(cfg, hqRoot, out, true);
1101
+ }
1102
+ throw error;
992
1103
  }
993
- return { status: 0 };
994
1104
  }
995
1105
  function recordDecision(ctx, decision) {
996
1106
  ctx.decisions.push(decision);
@@ -1019,8 +1129,12 @@ function recordDecision(ctx, decision) {
1019
1129
  }
1020
1130
  function applyClassifiedActions(ctx, backupDir) {
1021
1131
  const completed = [];
1132
+ injectApplyPathChangeForTest(ctx);
1022
1133
  for (const action of ctx.actions) {
1023
1134
  try {
1135
+ for (const expected of action.expectedPaths) {
1136
+ assertRescuePathTypeUnchanged(expected.rel, expected.kind, rescuePathKind(path.join(ctx.hqRoot, expected.rel)));
1137
+ }
1024
1138
  action.run();
1025
1139
  completed.push(action);
1026
1140
  }
@@ -1039,6 +1153,21 @@ function applyClassifiedActions(ctx, backupDir) {
1039
1153
  }
1040
1154
  }
1041
1155
  }
1156
+ /** Deterministic seam for the real reindex-vs-rescue race; never set in production. */
1157
+ function injectApplyPathChangeForTest(ctx) {
1158
+ const rel = ctx.env.HQ_RESCUE_FAULT_BEFORE_APPLY_REL;
1159
+ const kind = ctx.env.HQ_RESCUE_FAULT_BEFORE_APPLY_KIND;
1160
+ if (!rel || !kind)
1161
+ return;
1162
+ const abs = path.join(ctx.hqRoot, rel);
1163
+ fs.rmSync(abs, { recursive: true, force: true });
1164
+ if (kind === "directory")
1165
+ fs.mkdirSync(abs, { recursive: true });
1166
+ else if (kind === "file") {
1167
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
1168
+ fs.writeFileSync(abs, "fault-injected path replacement\n");
1169
+ }
1170
+ }
1042
1171
  function rollbackCompletedActions(ctx, completed, backupDir) {
1043
1172
  if (!backupDir || !isDir(backupDir))
1044
1173
  return [];
@@ -1590,7 +1719,15 @@ function renderDryRunDecision(ctx, decision) {
1590
1719
  function buildRescueActions(ctx) {
1591
1720
  const actions = [];
1592
1721
  const add = (label, affectedRels, runAction) => {
1593
- actions.push({ label, affectedRels, run: runAction });
1722
+ actions.push({
1723
+ label,
1724
+ affectedRels,
1725
+ expectedPaths: affectedRels.map((rel) => ({
1726
+ rel,
1727
+ kind: rescuePathKind(path.join(ctx.hqRoot, rel)),
1728
+ })),
1729
+ run: runAction,
1730
+ });
1594
1731
  };
1595
1732
  for (const decision of ctx.decisions) {
1596
1733
  const localPath = path.join(ctx.hqRoot, decision.rel);
@@ -1789,16 +1926,15 @@ function walkAndProcess(ctx, rootRel) {
1789
1926
  recordDecision(ctx, classifyOne(ctx, rootRel));
1790
1927
  return;
1791
1928
  }
1792
- // Top-level directory: recursive walk, pruning node_modules + nested .git.
1929
+ // Top-level directory: recursive walk, pruning canonical transient state.
1793
1930
  for (const rel of findFilesAndSymlinks(rootAbs, ctx.hqRoot)) {
1794
1931
  recordDecision(ctx, classifyOne(ctx, rel));
1795
1932
  }
1796
1933
  }
1797
1934
  /**
1798
- * Mirror of `find "$root" \( -type d \( -name node_modules -o -name .git \)
1799
- * -prune \) -o \( \( -type f -o -type l \) -print0 \)`: yield repo-relative
1800
- * paths of regular files and symlinks, pruning node_modules/.git dirs and not
1801
- * descending into directory symlinks (find -P default). Deterministic order.
1935
+ * Yield repo-relative regular files and symlinks while pruning canonical
1936
+ * transient directories and never descending into directory symlinks. Order is
1937
+ * deterministic so classification and apply plans remain reproducible.
1802
1938
  */
1803
1939
  function findFilesAndSymlinks(rootAbs, hqRoot) {
1804
1940
  const found = [];
@@ -1826,11 +1962,9 @@ function findFilesAndSymlinks(rootAbs, hqRoot) {
1826
1962
  // - .claude/worktrees: Claude Code per-agent worktree sandboxes —
1827
1963
  // each holds a full nested HQ checkout, so not pruning them means
1828
1964
  // walking 100k+ throwaway files. The sync engine ignores them too.
1829
- if (ent.name === "node_modules" ||
1830
- ent.name === ".git" ||
1831
- ent.name === ".pnpm-store" ||
1832
- (ent.name === "worktrees" && dir.endsWith("/.claude")))
1833
- continue; // prune
1965
+ const rel = abs.slice(hqRoot.length + 1);
1966
+ if (isRescueTransientDirectory(rel))
1967
+ continue;
1834
1968
  walk(abs);
1835
1969
  continue;
1836
1970
  }
@@ -1885,41 +2019,98 @@ function restoreMtimesFromGit(srcDir, env, out) {
1885
2019
  }
1886
2020
  }
1887
2021
  }
1888
- // --- prune old pre-update-* snapshots past retention ---
1889
- function pruneOldBackups(cfg, out) {
1890
- if (!isDir(cfg.backupRoot))
1891
- return;
1892
- if (!/^[0-9]+$/.test(cfg.backupRetentionDays))
1893
- return;
1894
- const days = Number(cfg.backupRetentionDays);
1895
- if (!(days > 0))
1896
- return;
1897
- // `find -mtime +N`: strictly older than (N+1)*24h by mtime.
1898
- const cutoffMs = Date.now() - (days + 1) * 24 * 60 * 60 * 1000;
1899
- let entries = [];
2022
+ function allocateSnapshotDir(backupRoot, runTs, rootHash, runId) {
2023
+ return path.join(backupRoot, `pre-update-${runTs}-${rootHash}-${runId}`);
2024
+ }
2025
+ function snapshotManifest(status, hqRoot, runId, cfg, sourceSha, includedRoots) {
2026
+ const now = new Date().toISOString();
2027
+ return { schemaVersion: 1, status, canonicalHqRoot: hqRoot, rootHash: canonicalRootHash(hqRoot), runId,
2028
+ creatorPid: process.pid, creatorStartedAt: now, source: cfg.sourceRepo, ref: cfg.ref, sourceSha,
2029
+ createdAt: now, includedRoots, fileCount: 0, byteCount: 0 };
2030
+ }
2031
+ function readSnapshotManifest(backupDir) {
1900
2032
  try {
1901
- entries = fs.readdirSync(cfg.backupRoot);
2033
+ return JSON.parse(fs.readFileSync(path.join(backupDir, "SNAPSHOT.json"), "utf8"));
1902
2034
  }
1903
2035
  catch {
1904
- return;
2036
+ return null;
1905
2037
  }
1906
- for (const name of entries) {
1907
- if (!name.startsWith("pre-update-"))
1908
- continue;
1909
- const abs = path.join(cfg.backupRoot, name);
1910
- const st = lstatOrNull(abs);
1911
- if (!st || !st.isDirectory())
1912
- continue;
1913
- if (st.mtimeMs < cutoffMs) {
1914
- out(` pruned old snapshot (> ${cfg.backupRetentionDays}d): ${name}\n`);
1915
- try {
1916
- fs.rmSync(abs, { recursive: true, force: true });
1917
- }
1918
- catch {
1919
- /* best-effort */
1920
- }
2038
+ }
2039
+ /** Publish only complete JSON; a crash can leave a temp file but never a torn manifest. */
2040
+ function writeSnapshotManifest(backupDir, manifest) {
2041
+ const target = path.join(backupDir, "SNAPSHOT.json");
2042
+ const temp = `${target}.${process.pid}.tmp`;
2043
+ fs.writeFileSync(temp, JSON.stringify(manifest, null, 2) + "\n");
2044
+ fs.renameSync(temp, target);
2045
+ }
2046
+ function updateSnapshotStatus(backupDir, status, completedAt) {
2047
+ const manifest = readSnapshotManifest(backupDir);
2048
+ if (!manifest)
2049
+ return;
2050
+ const stats = estimateFiltered(backupDir, "");
2051
+ writeSnapshotManifest(backupDir, { ...manifest, status, fileCount: stats.files, byteCount: stats.bytes,
2052
+ ...(completedAt ? { completedAt: new Date().toISOString() } : {}) });
2053
+ }
2054
+ function pruneManagedBackups(cfg, hqRoot, out, apply) {
2055
+ const inventory = inventoryManagedSnapshots(cfg.backupRoot, hqRoot, (m) => {
2056
+ try {
2057
+ process.kill(m.creatorPid, 0);
2058
+ return true;
1921
2059
  }
2060
+ catch {
2061
+ return false;
2062
+ }
2063
+ }, { keepGood: Number(cfg.backupRetentionCount), maxAgeDays: Number(cfg.backupRetentionDays) });
2064
+ const plan = { candidates: inventory.candidates, protected: inventory.protected, unmanaged: inventory.unmanaged };
2065
+ out(`==> Backup retention dry-run: ${plan.candidates.length} candidate(s), ${plan.protected.length} protected, ${plan.unmanaged.length} unmanaged.\n`);
2066
+ for (const item of plan.candidates)
2067
+ out(` candidate ${item.entry.name}: ${item.reason}\n`);
2068
+ for (const item of plan.protected)
2069
+ out(` protected ${item.entry.name}: ${item.reason}\n`);
2070
+ if (apply && plan.candidates.length > 0) {
2071
+ const result = applyBackupPrune(cfg.backupRoot, hqRoot, plan, { keepGood: Number(cfg.backupRetentionCount), maxAgeDays: Number(cfg.backupRetentionDays) });
2072
+ for (const name of result.deleted)
2073
+ out(` deleted snapshot: ${name}\n`);
2074
+ for (const failure of result.failures)
2075
+ out(` retention delete failed (retained): ${failure}\n`);
2076
+ }
2077
+ return plan;
2078
+ }
2079
+ function estimateFiltered(abs, rel) {
2080
+ const st = lstatOrNull(abs);
2081
+ if (!st || (st.isDirectory() && rel && isRescueTransientDirectory(rel)))
2082
+ return { files: 0, bytes: 0 };
2083
+ if (st.isSymbolicLink())
2084
+ return { files: 1, bytes: Buffer.byteLength(fs.readlinkSync(abs)) };
2085
+ if (st.isFile())
2086
+ return { files: 1, bytes: st.size };
2087
+ if (!st.isDirectory())
2088
+ return { files: 0, bytes: 0 };
2089
+ let files = 0, bytes = 0;
2090
+ for (const name of fs.readdirSync(abs)) {
2091
+ const x = estimateFiltered(path.join(abs, name), path.posix.join(rel, name));
2092
+ files += x.files;
2093
+ bytes += x.bytes;
2094
+ }
2095
+ return { files, bytes };
2096
+ }
2097
+ function requireBackupCapacity(cfg, hqRoot, roots, _env, err) {
2098
+ try {
2099
+ const estimate = roots.reduce((total, root) => total + estimateFiltered(path.join(hqRoot, root), root).bytes, 0);
2100
+ const stat = fs.statfsSync(isDir(cfg.backupRoot) ? cfg.backupRoot : path.dirname(cfg.backupRoot));
2101
+ const free = Number(stat.bavail) * Number(stat.bsize);
2102
+ const capacity = Number(stat.blocks) * Number(stat.bsize);
2103
+ const required = estimate + Math.max(5 * 1024 ** 3, Math.ceil(capacity * 0.1));
2104
+ if (free >= required)
2105
+ return;
2106
+ err(`error: insufficient free space for safety snapshot (need ${required} bytes, have ${free}).\n`);
2107
+ }
2108
+ catch (error) {
2109
+ err(`error: could not measure snapshot size/free space (${error instanceof Error ? error.message : String(error)}).\n`);
1922
2110
  }
2111
+ if (cfg.cloudUpdate)
2112
+ throw new ExitError(1);
2113
+ err(" Continuing only because this is an interactive rescue; use --cloud-update to fail closed.\n");
1923
2114
  }
1924
2115
  // --- recursive file count (`find <dir> -type f | wc -l`) ---
1925
2116
  function countFiles(dir) {
@@ -1999,6 +2190,42 @@ function cpATo(src, dest) {
1999
2190
  /* ignore */
2000
2191
  }
2001
2192
  }
2193
+ /** Snapshot copy that preserves metadata while pruning canonical transient directories. */
2194
+ function cpAToFiltered(src, dest, rel) {
2195
+ const st = lstatOrNull(src);
2196
+ if (!st)
2197
+ return;
2198
+ if (st.isDirectory() && isRescueTransientDirectory(rel))
2199
+ return;
2200
+ if (st.isSymbolicLink()) {
2201
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
2202
+ fs.symlinkSync(fs.readlinkSync(src), dest);
2203
+ return;
2204
+ }
2205
+ if (st.isDirectory()) {
2206
+ fs.mkdirSync(dest, { recursive: true });
2207
+ for (const name of fs.readdirSync(src)) {
2208
+ cpAToFiltered(path.join(src, name), path.join(dest, name), path.posix.join(rel, name));
2209
+ }
2210
+ try {
2211
+ fs.chmodSync(dest, st.mode);
2212
+ fs.utimesSync(dest, st.atime, st.mtime);
2213
+ }
2214
+ catch {
2215
+ /* metadata preservation is best-effort */
2216
+ }
2217
+ return;
2218
+ }
2219
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
2220
+ fs.copyFileSync(src, dest);
2221
+ try {
2222
+ fs.chmodSync(dest, st.mode);
2223
+ fs.utimesSync(dest, st.atime, st.mtime);
2224
+ }
2225
+ catch {
2226
+ /* metadata preservation is best-effort */
2227
+ }
2228
+ }
2002
2229
  /** Synchronous single-line read from stdin (mirrors bash `read -r`). */
2003
2230
  function readLineSync() {
2004
2231
  const fd = 0;