@indigoai-us/hq-cloud 6.14.7 → 6.14.9

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 (104) 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-company.d.ts.map +1 -1
  14. package/dist/bin/sync-runner-company.js +8 -0
  15. package/dist/bin/sync-runner-company.js.map +1 -1
  16. package/dist/bin/sync-runner-planning.d.ts +1 -1
  17. package/dist/bin/sync-runner-planning.d.ts.map +1 -1
  18. package/dist/bin/sync-runner-planning.js +15 -1
  19. package/dist/bin/sync-runner-planning.js.map +1 -1
  20. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
  21. package/dist/bin/sync-runner-watch-loop.js +28 -0
  22. package/dist/bin/sync-runner-watch-loop.js.map +1 -1
  23. package/dist/bin/sync-runner.d.ts +14 -0
  24. package/dist/bin/sync-runner.d.ts.map +1 -1
  25. package/dist/bin/sync-runner.js +26 -0
  26. package/dist/bin/sync-runner.js.map +1 -1
  27. package/dist/bin/sync-runner.test.js +68 -1
  28. package/dist/bin/sync-runner.test.js.map +1 -1
  29. package/dist/cli/rescue-classify-ordering.test.js +105 -0
  30. package/dist/cli/rescue-classify-ordering.test.js.map +1 -1
  31. package/dist/cli/rescue-core.d.ts +1 -0
  32. package/dist/cli/rescue-core.d.ts.map +1 -1
  33. package/dist/cli/rescue-core.js +478 -300
  34. package/dist/cli/rescue-core.js.map +1 -1
  35. package/dist/cli/rescue-snapshot.d.ts +14 -0
  36. package/dist/cli/rescue-snapshot.d.ts.map +1 -0
  37. package/dist/cli/rescue-snapshot.js +39 -0
  38. package/dist/cli/rescue-snapshot.js.map +1 -0
  39. package/dist/cli/rescue-snapshot.test.d.ts +2 -0
  40. package/dist/cli/rescue-snapshot.test.d.ts.map +1 -0
  41. package/dist/cli/rescue-snapshot.test.js +46 -0
  42. package/dist/cli/rescue-snapshot.test.js.map +1 -0
  43. package/dist/cli/sync-scope.test.js +3 -1
  44. package/dist/cli/sync-scope.test.js.map +1 -1
  45. package/dist/cli/sync.d.ts +6 -6
  46. package/dist/cli/sync.d.ts.map +1 -1
  47. package/dist/cli/sync.js +51 -41
  48. package/dist/cli/sync.js.map +1 -1
  49. package/dist/cli/sync.test.js +102 -2
  50. package/dist/cli/sync.test.js.map +1 -1
  51. package/dist/manifest-reconcile.d.ts +27 -0
  52. package/dist/manifest-reconcile.d.ts.map +1 -0
  53. package/dist/manifest-reconcile.js +120 -0
  54. package/dist/manifest-reconcile.js.map +1 -0
  55. package/dist/manifest-reconcile.test.d.ts +2 -0
  56. package/dist/manifest-reconcile.test.d.ts.map +1 -0
  57. package/dist/manifest-reconcile.test.js +97 -0
  58. package/dist/manifest-reconcile.test.js.map +1 -0
  59. package/dist/object-io.d.ts +19 -2
  60. package/dist/object-io.d.ts.map +1 -1
  61. package/dist/object-io.js +168 -32
  62. package/dist/object-io.js.map +1 -1
  63. package/dist/object-io.test.js +157 -1
  64. package/dist/object-io.test.js.map +1 -1
  65. package/dist/s3.d.ts +12 -1
  66. package/dist/s3.d.ts.map +1 -1
  67. package/dist/s3.js +51 -18
  68. package/dist/s3.js.map +1 -1
  69. package/dist/s3.test.js +12 -1
  70. package/dist/s3.test.js.map +1 -1
  71. package/dist/signals/get.test.js +21 -1
  72. package/dist/signals/get.test.js.map +1 -1
  73. package/dist/signals/list.test.js +21 -1
  74. package/dist/signals/list.test.js.map +1 -1
  75. package/dist/sources/get.test.js +21 -1
  76. package/dist/sources/get.test.js.map +1 -1
  77. package/dist/sources/list.test.js +21 -1
  78. package/dist/sources/list.test.js.map +1 -1
  79. package/package.json +3 -2
  80. package/src/backup-prune.test.ts +98 -0
  81. package/src/backup-prune.ts +182 -0
  82. package/src/bin/backup-prune-runner.ts +33 -0
  83. package/src/bin/sync-runner-company.ts +7 -0
  84. package/src/bin/sync-runner-planning.ts +16 -2
  85. package/src/bin/sync-runner-watch-loop.ts +18 -0
  86. package/src/bin/sync-runner.test.ts +82 -1
  87. package/src/bin/sync-runner.ts +45 -0
  88. package/src/cli/rescue-classify-ordering.test.ts +121 -0
  89. package/src/cli/rescue-core.ts +261 -86
  90. package/src/cli/rescue-snapshot.test.ts +57 -0
  91. package/src/cli/rescue-snapshot.ts +51 -0
  92. package/src/cli/sync-scope.test.ts +3 -1
  93. package/src/cli/sync.test.ts +121 -2
  94. package/src/cli/sync.ts +71 -53
  95. package/src/manifest-reconcile.test.ts +107 -0
  96. package/src/manifest-reconcile.ts +160 -0
  97. package/src/object-io.test.ts +175 -0
  98. package/src/object-io.ts +213 -32
  99. package/src/s3.test.ts +18 -0
  100. package/src/s3.ts +64 -30
  101. package/src/signals/get.test.ts +26 -2
  102. package/src/signals/list.test.ts +26 -2
  103. package/src/sources/get.test.ts +26 -2
  104. 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;
@@ -103,7 +112,18 @@ function redactGitDiagnostic(output, ghToken) {
103
112
  /** `command -v <bin>` — is a binary on PATH? */
104
113
  function hasCmd(bin, env) {
105
114
  const r = spawnSync(bin, ["--version"], { env, stdio: "ignore" });
106
- 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);
107
127
  }
108
128
  // fs predicates mirroring bash test operators.
109
129
  function isDir(p) {
@@ -139,6 +159,18 @@ function lstatOrNull(p) {
139
159
  return null;
140
160
  }
141
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
+ }
142
174
  function lexists(p) {
143
175
  return lstatOrNull(p) !== null;
144
176
  }
@@ -184,6 +216,7 @@ export function parseRescueArgs(argv) {
184
216
  doBackup: process.env.HQ_RESCUE_NO_BACKUP === "1" ? false : true,
185
217
  backupRoot: process.env.HQ_BACKUP_DIR || path.join(os.homedir(), ".hq", "backups"),
186
218
  backupRetentionDays: process.env.HQ_BACKUP_RETENTION_DAYS || "7",
219
+ backupRetentionCount: process.env.HQ_BACKUP_RETENTION_COUNT || "2",
187
220
  };
188
221
  const usage = () => {
189
222
  // The legacy script printed its comment header here; we emit a concise
@@ -381,6 +414,10 @@ function doRescue(cfg, env, out, err, setTmp) {
381
414
  throw new ExitError(3);
382
415
  }
383
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);
384
421
  // --- Read prior sync-point metadata (BEFORE the wipe) ---
385
422
  let prevSyncSha = "";
386
423
  let prevSyncSource = "";
@@ -419,7 +456,7 @@ function doRescue(cfg, env, out, err, setTmp) {
419
456
  else {
420
457
  out("==> Mode: preserve-list (default)\n");
421
458
  const extra = cfg.extraPreserve.length ? `, ${cfg.extraPreserve.join(" ")}` : "";
422
- 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`);
423
460
  }
424
461
  if (cfg.preserveSubpaths.length !== 0) {
425
462
  out("==> Preserved subpaths (backed up + restored across the overlay):\n");
@@ -439,7 +476,7 @@ function doRescue(cfg, env, out, err, setTmp) {
439
476
  out("==> History gate: OFF (--no-history-check; every diff rescued)\n");
440
477
  }
441
478
  if (cfg.doBackup) {
442
- 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`);
443
480
  }
444
481
  else {
445
482
  out("==> Safety backup: OFF (--no-backup / HQ_RESCUE_NO_BACKUP=1)\n");
@@ -450,7 +487,7 @@ function doRescue(cfg, env, out, err, setTmp) {
450
487
  if (!cfg.assumeYes && !cfg.dryRun) {
451
488
  let backupLine = " * NO pre-op backup (--no-backup),";
452
489
  if (cfg.doBackup)
453
- 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,`;
454
491
  let cloudLine = "";
455
492
  if (cfg.cloudUpdate)
456
493
  cloudLine =
@@ -594,6 +631,10 @@ function doRescue(cfg, env, out, err, setTmp) {
594
631
  "--exclude=.hq-sync-journal.json",
595
632
  "--exclude=.hq",
596
633
  "--exclude=.hq-conflicts",
634
+ "--exclude=/worktrees",
635
+ "--exclude=/.worktrees",
636
+ "--exclude=/.sst-sandbox-home",
637
+ "--exclude=/.tmp-codex-asar",
597
638
  "--include=/companies/",
598
639
  "--include=/companies/_template/***",
599
640
  "--exclude=/companies/*",
@@ -656,38 +697,6 @@ function doRescue(cfg, env, out, err, setTmp) {
656
697
  decisions: [],
657
698
  actions: [],
658
699
  };
659
- // --- Pre-operation safety snapshot (BEFORE any destructive op) ---
660
- let backupDir = "";
661
- if (cfg.doBackup && !cfg.dryRun && wipeToplevel.length !== 0) {
662
- backupDir = path.join(cfg.backupRoot, `pre-update-${RUN_TS}`);
663
- out("\n");
664
- out(`==> Safety snapshot -> ${backupDir}\n`);
665
- fs.mkdirSync(backupDir, { recursive: true });
666
- const rsyncAvailable = hasCmd("rsync", env);
667
- for (const rootRel of wipeToplevel) {
668
- const srcAbs = path.join(hqRoot, rootRel);
669
- if (!lexists(srcAbs))
670
- continue;
671
- const parentRel = path.dirname(rootRel);
672
- const destParent = path.join(backupDir, parentRel);
673
- fs.mkdirSync(destParent, { recursive: true });
674
- if (rsyncAvailable) {
675
- const r = run("rsync", [
676
- "-a",
677
- "--exclude=node_modules/",
678
- "--exclude=.git/",
679
- toCygwinRsyncPath(srcAbs),
680
- toCygwinRsyncPath(destParent + "/"),
681
- ], { env });
682
- if (r.status !== 0)
683
- cpA(srcAbs, destParent);
684
- }
685
- else {
686
- cpA(srcAbs, destParent);
687
- }
688
- }
689
- out(` snapshot complete (restore any file: cp "${backupDir}/<relpath>" "${hqRoot}/<relpath>")\n`);
690
- }
691
700
  // --- Walk + classify (PHASE 1: read-only) ---
692
701
  // Classification mutates nothing on disk: every outcome is recorded as a
693
702
  // typed RescueDecision. Destructive ops (delete, rescue-move,
@@ -800,246 +809,298 @@ function doRescue(cfg, env, out, err, setTmp) {
800
809
  throw new ExitError(overlayPreflight.status || 1);
801
810
  }
802
811
  ctx.actions = buildRescueActions(ctx);
803
- applyClassifiedActions(ctx, backupDir);
804
- // --- Back up preserve-subpaths to a mktemp shuttle ---
805
- const shuttle = path.join(tmpdir, "preserve");
806
- fs.mkdirSync(shuttle, { recursive: true });
807
- const preserveMap = [];
808
- let shuttleId = 0;
809
- for (const sp of cfg.preserveSubpaths) {
810
- const src = path.join(hqRoot, sp);
811
- if (lexists(src)) {
812
- shuttleId += 1;
813
- cpA(src, shuttle, String(shuttleId));
814
- preserveMap.push({ id: shuttleId, rel: sp });
815
- out(`==> Backed up ${sp} -> shuttle/${shuttleId}\n`);
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);
816
825
  }
817
- }
818
- out("\n");
819
- 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`);
820
- // --- Overlay source onto HQ root ---
821
- out("==> Overlaying source onto HQ root ...\n");
822
- // `unchangedList` is only populated during applyClassifiedActions above, so
823
- // re-derive the --exclude-from protect list HERE. Building overlayArgs before
824
- // classification left the list empty, so unchanged files were overlaid and
825
- // their mtimes reset to clone time (rescue-mtime-preserve regression). The
826
- // early overlayArgs remain correct for the pre-classification dry-run.
827
- const overlayRunArgs = fs.statSync(unchangedList).size > 0
828
- ? [
829
- "-a",
830
- `--exclude-from=${unchangedList}`,
831
- ...rsyncExcludes,
832
- toCygwinRsyncPath(srcDir + "/"),
833
- toCygwinRsyncPath(hqRoot + "/"),
834
- ]
835
- : overlayArgs;
836
- const overlay = run("rsync", overlayRunArgs, { env });
837
- if (overlay.status !== 0) {
838
- // bash runs under set -e: a failed rsync aborts with its status.
839
- err(overlay.stderr);
840
- throw new ExitError(overlay.status || 1);
841
- }
842
- // --- Restore preserved sub-paths ---
843
- if (preserveMap.length > 0) {
844
- out("==> Restoring preserved sub-paths ...\n");
845
- for (const { id, rel } of preserveMap) {
846
- const dest = path.join(hqRoot, rel);
847
- fs.mkdirSync(path.dirname(dest), { recursive: true });
848
- const shuttleItem = path.join(shuttle, String(id));
849
- if (isDir(shuttleItem)) {
850
- fs.rmSync(dest, { recursive: true, force: true });
851
- cpATo(shuttleItem, dest);
852
- }
853
- else {
854
- cpATo(shuttleItem, dest);
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);
855
840
  }
856
- out(` restored ${rel}\n`);
857
- }
858
- }
859
- // --- Restore executable bit on shipped shebang scripts (defense-in-depth) ---
860
- // rsync -a already propagated each file's upstream git tree mode; this guards
861
- // the case where upstream itself committed an executable script as 0644 -- a
862
- // hook the runtime exec's then dies with EACCES and is silently disabled. A
863
- // shebang is an unambiguous "meant to be run" marker. See restoreShebangExecBits.
864
- const execBitFixes = restoreShebangExecBits(srcDir, hqRoot, env);
865
- if (execBitFixes > 0) {
866
- out(`==> Restored executable bit on ${execBitFixes} shipped shebang script(s) lacking +x\n`);
867
- }
868
- // --- Stamp sync-point provenance into core/core.yaml ---
869
- // `last_sync_at` = the source commit's committer time, NOT wall-clock now:
870
- // the stamp must be a pure function of srcSha so every machine rescuing the
871
- // same release writes byte-identical core.yaml (see srcCommitIsoRaw). An
872
- // unparseable commit timestamp skips the stamp entirely — a nondeterministic
873
- // stamp is worse than no stamp (it re-creates cross-machine conflict mirrors).
874
- const srcCommitDate = new Date(srcCommitIsoRaw);
875
- if (cfg.narrowPaths.length === 0 && isFileFollow(coreYaml) && Number.isNaN(srcCommitDate.getTime())) {
876
- err(` WARN: could not parse source commit timestamp (${JSON.stringify(srcCommitIsoRaw)}); skipping core/core.yaml stamp to keep it deterministic\n`);
877
- }
878
- if (cfg.narrowPaths.length === 0 &&
879
- isFileFollow(coreYaml) &&
880
- !Number.isNaN(srcCommitDate.getTime())) {
881
- const nowUtc = utcStamp(srcCommitDate, "colon");
882
- if (yqAvailable) {
883
- const stampEnv = {
884
- ...env,
885
- SHA: srcSha,
886
- SOURCE: cfg.sourceRepo,
887
- THE_REF: cfg.ref,
888
- AT: nowUtc,
889
- };
890
- run("yq", [
891
- "-i",
892
- ".replaced_from_source.source = strenv(SOURCE) |\n" +
893
- " .replaced_from_source.ref = strenv(THE_REF) |\n" +
894
- " .replaced_from_source.last_sync_sha = strenv(SHA) |\n" +
895
- " .replaced_from_source.last_sync_at = strenv(AT) |\n" +
896
- " del(.replaced_from_staging)",
897
- coreYaml,
898
- ], { env: stampEnv });
899
- out(`==> Stamped core/core.yaml: replaced_from_source.last_sync_sha=${srcSha}\n`);
900
- }
901
- else if (hasCmd("python3", env) &&
902
- run("python3", ["-c", "import yaml"], { env }).status === 0) {
903
- const pyEnv = {
904
- ...env,
905
- SHA: srcSha,
906
- SOURCE: cfg.sourceRepo,
907
- THE_REF: cfg.ref,
908
- AT: nowUtc,
909
- CORE: coreYaml,
910
- };
911
- run("python3", [
912
- "-c",
913
- [
914
- "import os, yaml",
915
- 'path = os.environ["CORE"]',
916
- "try:",
917
- " with open(path) as f:",
918
- " d = yaml.safe_load(f) or {}",
919
- "except FileNotFoundError:",
920
- " d = {}",
921
- 'd["replaced_from_source"] = {',
922
- ' "source": os.environ["SOURCE"],',
923
- ' "ref": os.environ["THE_REF"],',
924
- ' "last_sync_sha": os.environ["SHA"],',
925
- ' "last_sync_at": os.environ["AT"],',
926
- "}",
927
- 'd.pop("replaced_from_staging", None)',
928
- 'with open(path, "w") as f:',
929
- " yaml.safe_dump(d, f, default_flow_style=False, sort_keys=False)",
930
- ].join("\n"),
931
- ], { env: pyEnv });
932
- out(`==> Stamped core/core.yaml: replaced_from_source.last_sync_sha=${srcSha}\n`);
841
+ updateSnapshotStatus(backupDir, "ready");
933
842
  }
934
- else {
935
- err(" WARN: neither yq nor python3+PyYAML available — skipping core/core.yaml stamp\n");
936
- }
937
- }
938
- // NOTE (6.2.4): the post-rescue "sync-journal baseline reconcile" that lived
939
- // here (6.2.1 itemize-parse, 6.2.3 hash-diff) is intentionally GONE. Masking
940
- // rescue-changed scaffold as already-synced starved the push leg — the vault
941
- // silently kept stale scaffold bytes while the journal claimed convergence.
942
- // Correct semantics: leave rescue-changed files visible as "local changed";
943
- // the next bidirectional sync pushes them up (vault converges to the rescue
944
- // output), and the pull-side byte-identical convergence probe (6.2.0)
945
- // absorbs cross-machine races without minting `.conflict-*` mirrors.
946
- // --- File count summary ---
947
- out("\n");
948
- out("==> File count summary:\n");
949
- for (const rootRel of wipeToplevel) {
950
- const abs = path.join(hqRoot, rootRel);
951
- if (lexists(abs)) {
952
- const n = countFiles(abs);
953
- out(` ${rootRel}: ${n} files\n`);
843
+ catch (error) {
844
+ updateSnapshotStatus(backupDir, "failed-pre-mutation");
845
+ throw error;
954
846
  }
847
+ pruneManagedBackups(cfg, hqRoot, out, true);
848
+ out(` snapshot ready (restore any file: cp "${backupDir}/<relpath>" "${hqRoot}/<relpath>")\n`);
955
849
  }
956
- out("\n");
957
- out("==> Classification:\n");
958
- out(` user-only (left in place): ${counts.userOnly} files\n`);
959
- out(` unchanged (preserved/re-laid): ${counts.unchanged} files\n`);
960
- out(` user-edits (rescued): ${counts.userEdit} files\n`);
961
- out(` user-edits (conflict quarantine): ${counts.userConflict} files\n`);
962
- out(` user-edits (overwrite-safe): ${counts.userOverwrite} files\n`);
963
- out(` cloud-symlink reconciled: ${counts.cloudSymlinkReconciled} files\n`);
964
- out(` drift reconciled (== upstream): ${counts.driftReconciled} files\n`);
965
- out(` reindex symlinks dropped: ${counts.symlinkDropped} entries\n`);
966
- if (counts.claudeDiffAppend > 0) {
967
- out(" of which .claude/CLAUDE.md diff-appended to personal/CLAUDE.md\n");
968
- }
969
- if (counts.userConflict > 0) {
970
- out(` conflict bucket: .hq-conflicts/rescue-${RUN_TS}/\n`);
971
- }
972
- if (baselineMode === "history_floor") {
973
- 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");
974
852
  }
975
- else {
976
- out(" baseline: upstream HEAD (no stamp; first run / floor unreachable)\n");
977
- }
978
- // --- Write recovery manifest into the snapshot + prune old snapshots ---
979
- if (cfg.doBackup && backupDir && isDir(backupDir)) {
980
- const lines = [];
981
- lines.push("# HQ pre-update safety snapshot", "");
982
- lines.push(`Created: ${RUN_TS}`);
983
- lines.push(`HQ root: ${hqRoot}`);
984
- lines.push(`Source: ${cfg.sourceRepo}@${cfg.ref} (${srcSha})`);
985
- if (baselineMode === "history_floor") {
986
- 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
+ }
987
869
  }
988
- else {
989
- lines.push("Baseline: head_compare (no last-sync stamp; first run or floor unreachable)");
990
- }
991
- lines.push("", "## What the rescue did");
992
- lines.push(` user-only (left in place): ${counts.userOnly}`);
993
- lines.push(` unchanged (preserved/re-laid): ${counts.unchanged}`);
994
- lines.push(` user-edit (rescued into personal/): ${counts.userEdit}`);
995
- lines.push(` user-edit (conflict quarantine): ${counts.userConflict}`);
996
- lines.push(` user-edit (overwrite-safe): ${counts.userOverwrite}`);
997
- lines.push(` cloud-symlink reconciled: ${counts.cloudSymlinkReconciled}`);
998
- lines.push(` drift reconciled (== upstream HEAD): ${counts.driftReconciled}`);
999
- lines.push(` reindex symlinks dropped: ${counts.symlinkDropped}`);
1000
- if (counts.userConflict > 0) {
1001
- 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
+ }
1002
910
  }
1003
- lines.push("", "## Moved / deleted files (tab-separated: action, path, detail)");
1004
- let rescueLogContent = "";
1005
- try {
1006
- 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
+ }
1007
989
  }
1008
- catch {
1009
- 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
+ }
1010
1007
  }
1011
- if (rescueLogContent.length > 0) {
1012
- // `cat` includes the trailing content verbatim.
1013
- 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");
1014
1020
  }
1015
- else {
1016
- lines.push(" (no files were moved or deleted)");
1017
- }
1018
- lines.push("", "## Restore");
1019
- lines.push("This directory holds the wipe set exactly as it was before the update.");
1020
- lines.push("Restore a single file:");
1021
- lines.push(` cp "${backupDir}/<relpath>" "${hqRoot}/<relpath>"`);
1022
- lines.push("Restore everything (overwrites current scaffold — use with care):");
1023
- lines.push(` rsync -a "${backupDir}/" "${hqRoot}/"`);
1024
- lines.push("", `Auto-pruned after ${cfg.backupRetentionDays} days (HQ_BACKUP_RETENTION_DAYS).`);
1025
- try {
1026
- 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`);
1027
1023
  }
1028
- catch {
1029
- /* 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);
1030
1087
  }
1031
1088
  out("\n");
1032
- out(`==> Pre-update snapshot + recovery manifest: ${backupDir}\n`);
1033
- 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 };
1034
1096
  }
1035
- out("\n");
1036
- out(`==> Done. Source: ${cfg.sourceRepo}@${cfg.ref} (${srcSha})\n`);
1037
- out(" User-edited files were rescued under personal/ (see scan output above).\n");
1038
- out(" User-only files (created by you, unknown to upstream) were left untouched.\n");
1039
- if (cfg.doBackup && backupDir) {
1040
- 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;
1041
1103
  }
1042
- return { status: 0 };
1043
1104
  }
1044
1105
  function recordDecision(ctx, decision) {
1045
1106
  ctx.decisions.push(decision);
@@ -1068,8 +1129,12 @@ function recordDecision(ctx, decision) {
1068
1129
  }
1069
1130
  function applyClassifiedActions(ctx, backupDir) {
1070
1131
  const completed = [];
1132
+ injectApplyPathChangeForTest(ctx);
1071
1133
  for (const action of ctx.actions) {
1072
1134
  try {
1135
+ for (const expected of action.expectedPaths) {
1136
+ assertRescuePathTypeUnchanged(expected.rel, expected.kind, rescuePathKind(path.join(ctx.hqRoot, expected.rel)));
1137
+ }
1073
1138
  action.run();
1074
1139
  completed.push(action);
1075
1140
  }
@@ -1088,6 +1153,21 @@ function applyClassifiedActions(ctx, backupDir) {
1088
1153
  }
1089
1154
  }
1090
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
+ }
1091
1171
  function rollbackCompletedActions(ctx, completed, backupDir) {
1092
1172
  if (!backupDir || !isDir(backupDir))
1093
1173
  return [];
@@ -1639,7 +1719,15 @@ function renderDryRunDecision(ctx, decision) {
1639
1719
  function buildRescueActions(ctx) {
1640
1720
  const actions = [];
1641
1721
  const add = (label, affectedRels, runAction) => {
1642
- 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
+ });
1643
1731
  };
1644
1732
  for (const decision of ctx.decisions) {
1645
1733
  const localPath = path.join(ctx.hqRoot, decision.rel);
@@ -1838,16 +1926,15 @@ function walkAndProcess(ctx, rootRel) {
1838
1926
  recordDecision(ctx, classifyOne(ctx, rootRel));
1839
1927
  return;
1840
1928
  }
1841
- // Top-level directory: recursive walk, pruning node_modules + nested .git.
1929
+ // Top-level directory: recursive walk, pruning canonical transient state.
1842
1930
  for (const rel of findFilesAndSymlinks(rootAbs, ctx.hqRoot)) {
1843
1931
  recordDecision(ctx, classifyOne(ctx, rel));
1844
1932
  }
1845
1933
  }
1846
1934
  /**
1847
- * Mirror of `find "$root" \( -type d \( -name node_modules -o -name .git \)
1848
- * -prune \) -o \( \( -type f -o -type l \) -print0 \)`: yield repo-relative
1849
- * paths of regular files and symlinks, pruning node_modules/.git dirs and not
1850
- * 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.
1851
1938
  */
1852
1939
  function findFilesAndSymlinks(rootAbs, hqRoot) {
1853
1940
  const found = [];
@@ -1875,11 +1962,9 @@ function findFilesAndSymlinks(rootAbs, hqRoot) {
1875
1962
  // - .claude/worktrees: Claude Code per-agent worktree sandboxes —
1876
1963
  // each holds a full nested HQ checkout, so not pruning them means
1877
1964
  // walking 100k+ throwaway files. The sync engine ignores them too.
1878
- if (ent.name === "node_modules" ||
1879
- ent.name === ".git" ||
1880
- ent.name === ".pnpm-store" ||
1881
- (ent.name === "worktrees" && dir.endsWith("/.claude")))
1882
- continue; // prune
1965
+ const rel = abs.slice(hqRoot.length + 1);
1966
+ if (isRescueTransientDirectory(rel))
1967
+ continue;
1883
1968
  walk(abs);
1884
1969
  continue;
1885
1970
  }
@@ -1934,41 +2019,98 @@ function restoreMtimesFromGit(srcDir, env, out) {
1934
2019
  }
1935
2020
  }
1936
2021
  }
1937
- // --- prune old pre-update-* snapshots past retention ---
1938
- function pruneOldBackups(cfg, out) {
1939
- if (!isDir(cfg.backupRoot))
1940
- return;
1941
- if (!/^[0-9]+$/.test(cfg.backupRetentionDays))
1942
- return;
1943
- const days = Number(cfg.backupRetentionDays);
1944
- if (!(days > 0))
1945
- return;
1946
- // `find -mtime +N`: strictly older than (N+1)*24h by mtime.
1947
- const cutoffMs = Date.now() - (days + 1) * 24 * 60 * 60 * 1000;
1948
- 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) {
1949
2032
  try {
1950
- entries = fs.readdirSync(cfg.backupRoot);
2033
+ return JSON.parse(fs.readFileSync(path.join(backupDir, "SNAPSHOT.json"), "utf8"));
1951
2034
  }
1952
2035
  catch {
1953
- return;
2036
+ return null;
1954
2037
  }
1955
- for (const name of entries) {
1956
- if (!name.startsWith("pre-update-"))
1957
- continue;
1958
- const abs = path.join(cfg.backupRoot, name);
1959
- const st = lstatOrNull(abs);
1960
- if (!st || !st.isDirectory())
1961
- continue;
1962
- if (st.mtimeMs < cutoffMs) {
1963
- out(` pruned old snapshot (> ${cfg.backupRetentionDays}d): ${name}\n`);
1964
- try {
1965
- fs.rmSync(abs, { recursive: true, force: true });
1966
- }
1967
- catch {
1968
- /* best-effort */
1969
- }
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;
1970
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`);
1971
2107
  }
2108
+ catch (error) {
2109
+ err(`error: could not measure snapshot size/free space (${error instanceof Error ? error.message : String(error)}).\n`);
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");
1972
2114
  }
1973
2115
  // --- recursive file count (`find <dir> -type f | wc -l`) ---
1974
2116
  function countFiles(dir) {
@@ -2048,6 +2190,42 @@ function cpATo(src, dest) {
2048
2190
  /* ignore */
2049
2191
  }
2050
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
+ }
2051
2229
  /** Synchronous single-line read from stdin (mirrors bash `read -r`). */
2052
2230
  function readLineSync() {
2053
2231
  const fd = 0;