@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.
- package/dist/backup-prune.d.ts +68 -0
- package/dist/backup-prune.d.ts.map +1 -0
- package/dist/backup-prune.js +196 -0
- package/dist/backup-prune.js.map +1 -0
- package/dist/backup-prune.test.d.ts +2 -0
- package/dist/backup-prune.test.d.ts.map +1 -0
- package/dist/backup-prune.test.js +89 -0
- package/dist/backup-prune.test.js.map +1 -0
- package/dist/bin/backup-prune-runner.d.ts +3 -0
- package/dist/bin/backup-prune-runner.d.ts.map +1 -0
- package/dist/bin/backup-prune-runner.js +50 -0
- package/dist/bin/backup-prune-runner.js.map +1 -0
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +28 -0
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +2 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/cli/reindex.d.ts.map +1 -1
- package/dist/cli/reindex.js +5 -3
- package/dist/cli/reindex.js.map +1 -1
- package/dist/cli/rescue-classify-ordering.test.js +105 -0
- package/dist/cli/rescue-classify-ordering.test.js.map +1 -1
- package/dist/cli/rescue-core.d.ts +7 -0
- package/dist/cli/rescue-core.d.ts.map +1 -1
- package/dist/cli/rescue-core.js +509 -282
- package/dist/cli/rescue-core.js.map +1 -1
- package/dist/cli/rescue-snapshot.d.ts +14 -0
- package/dist/cli/rescue-snapshot.d.ts.map +1 -0
- package/dist/cli/rescue-snapshot.js +39 -0
- package/dist/cli/rescue-snapshot.js.map +1 -0
- package/dist/cli/rescue-snapshot.test.d.ts +2 -0
- package/dist/cli/rescue-snapshot.test.d.ts.map +1 -0
- package/dist/cli/rescue-snapshot.test.js +46 -0
- package/dist/cli/rescue-snapshot.test.js.map +1 -0
- package/dist/cli/rescue.test.js +24 -0
- package/dist/cli/rescue.test.js.map +1 -1
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +12 -11
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +41 -6
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +51 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/local-path-codec.d.ts +19 -0
- package/dist/local-path-codec.d.ts.map +1 -0
- package/dist/local-path-codec.js +69 -0
- package/dist/local-path-codec.js.map +1 -0
- package/dist/object-io.d.ts +19 -2
- package/dist/object-io.d.ts.map +1 -1
- package/dist/object-io.js +168 -32
- package/dist/object-io.js.map +1 -1
- package/dist/object-io.test.js +157 -1
- package/dist/object-io.test.js.map +1 -1
- package/dist/s3.d.ts +8 -0
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +52 -22
- package/dist/s3.js.map +1 -1
- package/dist/scope-shrink.d.ts.map +1 -1
- package/dist/scope-shrink.js +4 -3
- package/dist/scope-shrink.js.map +1 -1
- package/dist/signals/get.test.js +21 -1
- package/dist/signals/get.test.js.map +1 -1
- package/dist/signals/list.test.js +21 -1
- package/dist/signals/list.test.js.map +1 -1
- package/dist/sources/get.test.js +21 -1
- package/dist/sources/get.test.js.map +1 -1
- package/dist/sources/list.test.js +21 -1
- package/dist/sources/list.test.js.map +1 -1
- package/package.json +3 -2
- package/scripts/presign-transport-e2e.mjs +57 -10
- package/scripts/vault-rebaseline.sh +49 -1
- package/scripts/vault-rescue.sh +42 -1
- package/src/backup-prune.test.ts +98 -0
- package/src/backup-prune.ts +182 -0
- package/src/bin/backup-prune-runner.ts +33 -0
- package/src/bin/sync-runner-watch-loop.ts +18 -0
- package/src/bin/sync-runner.ts +2 -0
- package/src/cli/reindex.ts +5 -3
- package/src/cli/rescue-classify-ordering.test.ts +121 -0
- package/src/cli/rescue-core.ts +311 -89
- package/src/cli/rescue-snapshot.test.ts +57 -0
- package/src/cli/rescue-snapshot.ts +51 -0
- package/src/cli/rescue.test.ts +34 -0
- package/src/cli/share.ts +12 -11
- package/src/cli/sync.test.ts +62 -0
- package/src/cli/sync.ts +51 -7
- package/src/local-path-codec.ts +95 -0
- package/src/object-io.test.ts +175 -0
- package/src/object-io.ts +213 -32
- package/src/s3.ts +61 -12
- package/src/scope-shrink.ts +4 -3
- package/src/signals/get.test.ts +26 -2
- package/src/signals/list.test.ts +26 -2
- package/src/sources/get.test.ts +26 -2
- package/src/sources/list.test.ts +26 -2
package/dist/cli/rescue-core.js
CHANGED
|
@@ -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
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
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
|
-
|
|
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
|
-
|
|
886
|
-
|
|
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
|
-
|
|
908
|
-
|
|
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
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
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
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
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
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
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
|
-
|
|
960
|
-
|
|
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
|
-
|
|
963
|
-
|
|
964
|
-
|
|
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
|
-
|
|
967
|
-
|
|
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
|
-
|
|
980
|
-
|
|
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(`==>
|
|
984
|
-
|
|
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
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
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({
|
|
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
|
|
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
|
-
*
|
|
1799
|
-
*
|
|
1800
|
-
*
|
|
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
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
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
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
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;
|