@indigoai-us/hq-cloud 6.15.15 → 6.15.17
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/bin/backup-prune-runner.js +0 -0
- package/dist/bin/journal-repair.js +0 -0
- package/dist/bin/rescue-runner.js +0 -0
- package/dist/bin/sync-mutation.js +0 -0
- package/dist/bin/sync-runner.js +0 -0
- package/dist/cli/rescue-core.d.ts +3 -0
- package/dist/cli/rescue-core.d.ts.map +1 -1
- package/dist/cli/rescue-core.js +207 -41
- package/dist/cli/rescue-core.js.map +1 -1
- package/dist/cli/rescue-keep-local-ignore.test.d.ts +2 -0
- package/dist/cli/rescue-keep-local-ignore.test.d.ts.map +1 -0
- package/dist/cli/rescue-keep-local-ignore.test.js +315 -0
- package/dist/cli/rescue-keep-local-ignore.test.js.map +1 -0
- package/package.json +3 -2
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
package/dist/bin/sync-runner.js
CHANGED
|
File without changes
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"rescue-core.d.ts","sourceRoot":"","sources":["../../src/cli/rescue-core.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"rescue-core.d.ts","sourceRoot":"","sources":["../../src/cli/rescue-core.ts"],"names":[],"mappings":"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"}
|
package/dist/cli/rescue-core.js
CHANGED
|
@@ -21,8 +21,10 @@
|
|
|
21
21
|
* upstream HEAD, leave in place + protect from the overlay so
|
|
22
22
|
* its mtime survives (Layer 1).
|
|
23
23
|
* USER-EDIT -> rescue into personal/ (or diff-append for .claude/CLAUDE.md),
|
|
24
|
-
* quarantine into .hq-conflicts/ (conflict class),
|
|
25
|
-
* overwrite (overwrite-safe class)
|
|
24
|
+
* quarantine into .hq-conflicts/ (conflict class), silently
|
|
25
|
+
* overwrite (overwrite-safe class), or keep the local file and
|
|
26
|
+
* protect it from the overlay (keep-local class: root-level
|
|
27
|
+
* ignore files — see LOCAL_IGNORE_FILES).
|
|
26
28
|
*/
|
|
27
29
|
import { spawnSync } from "child_process";
|
|
28
30
|
import * as fs from "fs";
|
|
@@ -196,7 +198,9 @@ function utcStamp(d, sep) {
|
|
|
196
198
|
const h = p(d.getUTCHours());
|
|
197
199
|
const m = p(d.getUTCMinutes());
|
|
198
200
|
const s = p(d.getUTCSeconds());
|
|
199
|
-
return sep === "colon"
|
|
201
|
+
return sep === "colon"
|
|
202
|
+
? `${date}T${h}:${m}:${s}Z`
|
|
203
|
+
: `${date}T${h}-${m}-${s}Z`;
|
|
200
204
|
}
|
|
201
205
|
/**
|
|
202
206
|
* Parse the rescue argv exactly as the bash `while case` loop did, including
|
|
@@ -494,10 +498,10 @@ function doRescue(cfg, env, out, err, setTmp) {
|
|
|
494
498
|
const yqAvailable = hasCmd("yq", env);
|
|
495
499
|
if (isFileFollow(coreYaml) && yqAvailable) {
|
|
496
500
|
const yq = (expr) => run("yq", ["-r", expr, coreYaml], { env }).stdout.trim();
|
|
497
|
-
prevSyncSha = yq(
|
|
498
|
-
prevSyncSource = yq(
|
|
499
|
-
prevSyncRef = yq(
|
|
500
|
-
prevSyncAt = yq(
|
|
501
|
+
prevSyncSha = yq('.replaced_from_source.last_sync_sha // .replaced_from_staging.last_sync_sha // ""');
|
|
502
|
+
prevSyncSource = yq('.replaced_from_source.source // .replaced_from_staging.source // ""');
|
|
503
|
+
prevSyncRef = yq('.replaced_from_source.ref // .replaced_from_staging.ref // ""');
|
|
504
|
+
prevSyncAt = yq('.replaced_from_source.last_sync_at // .replaced_from_staging.last_sync_at // ""');
|
|
501
505
|
}
|
|
502
506
|
// Caller override (--floor-sha) wins only when the on-disk stamp is empty.
|
|
503
507
|
if (!prevSyncSha && cfg.floorShaOverride) {
|
|
@@ -522,7 +526,9 @@ function doRescue(cfg, env, out, err, setTmp) {
|
|
|
522
526
|
}
|
|
523
527
|
else {
|
|
524
528
|
out("==> Mode: preserve-list (default)\n");
|
|
525
|
-
const extra = cfg.extraPreserve.length
|
|
529
|
+
const extra = cfg.extraPreserve.length
|
|
530
|
+
? `, ${cfg.extraPreserve.join(" ")}`
|
|
531
|
+
: "";
|
|
526
532
|
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, .local${extra}\n`);
|
|
527
533
|
}
|
|
528
534
|
if (cfg.preserveSubpaths.length !== 0) {
|
|
@@ -615,7 +621,10 @@ function doRescue(cfg, env, out, err, setTmp) {
|
|
|
615
621
|
err("error: clone failed\n");
|
|
616
622
|
throw new ExitError(5);
|
|
617
623
|
}
|
|
618
|
-
const checkoutResult = run("git", ["checkout", cfg.ref], {
|
|
624
|
+
const checkoutResult = run("git", ["checkout", cfg.ref], {
|
|
625
|
+
cwd: srcDir,
|
|
626
|
+
env,
|
|
627
|
+
});
|
|
619
628
|
if (checkoutResult.status !== 0) {
|
|
620
629
|
reportGitFailure(checkoutResult);
|
|
621
630
|
err(`error: could not check out ref '${cfg.ref}' from ${cfg.sourceRepo}\n`);
|
|
@@ -634,7 +643,10 @@ function doRescue(cfg, env, out, err, setTmp) {
|
|
|
634
643
|
err("error: clone failed\n");
|
|
635
644
|
throw new ExitError(5);
|
|
636
645
|
}
|
|
637
|
-
const checkoutResult = run("git", ["checkout", cfg.ref], {
|
|
646
|
+
const checkoutResult = run("git", ["checkout", cfg.ref], {
|
|
647
|
+
cwd: srcDir,
|
|
648
|
+
env,
|
|
649
|
+
});
|
|
638
650
|
if (checkoutResult.status !== 0) {
|
|
639
651
|
reportGitFailure(checkoutResult);
|
|
640
652
|
err(`error: could not check out ref '${cfg.ref}' from ${cfg.sourceRepo}\n`);
|
|
@@ -642,7 +654,10 @@ function doRescue(cfg, env, out, err, setTmp) {
|
|
|
642
654
|
}
|
|
643
655
|
}
|
|
644
656
|
}
|
|
645
|
-
const srcSha = run("git", ["rev-parse", "HEAD"], {
|
|
657
|
+
const srcSha = run("git", ["rev-parse", "HEAD"], {
|
|
658
|
+
cwd: srcDir,
|
|
659
|
+
env,
|
|
660
|
+
}).stdout.trim();
|
|
646
661
|
// Committer timestamp of the source SHA — the DETERMINISTIC stamp value for
|
|
647
662
|
// core/core.yaml's `last_sync_at`. Stamping wall-clock "now" made every
|
|
648
663
|
// rescue run on every machine produce a byte-different core.yaml for the
|
|
@@ -660,7 +675,8 @@ function doRescue(cfg, env, out, err, setTmp) {
|
|
|
660
675
|
let historyFloor = "";
|
|
661
676
|
let baselineMode = "head_compare";
|
|
662
677
|
if (cfg.historyCheck && prevSyncSha && prevSyncSource === cfg.sourceRepo) {
|
|
663
|
-
if (run("git", ["cat-file", "-e", prevSyncSha], { cwd: srcDir, env })
|
|
678
|
+
if (run("git", ["cat-file", "-e", prevSyncSha], { cwd: srcDir, env })
|
|
679
|
+
.status === 0) {
|
|
664
680
|
historyFloor = prevSyncSha;
|
|
665
681
|
baselineMode = "history_floor";
|
|
666
682
|
}
|
|
@@ -744,6 +760,7 @@ function doRescue(cfg, env, out, err, setTmp) {
|
|
|
744
760
|
userEdit: 0,
|
|
745
761
|
userConflict: 0,
|
|
746
762
|
userOverwrite: 0,
|
|
763
|
+
userKeptLocal: 0,
|
|
747
764
|
claudeDiffAppend: 0,
|
|
748
765
|
symlinkDropped: 0,
|
|
749
766
|
cloudSymlinkReconciled: 0,
|
|
@@ -801,6 +818,7 @@ function doRescue(cfg, env, out, err, setTmp) {
|
|
|
801
818
|
out(` user-edit (rescue / diff-append): ${counts.userEdit} files\n`);
|
|
802
819
|
out(` user-edit (conflict quarantine): ${counts.userConflict} files\n`);
|
|
803
820
|
out(` user-edit (overwrite-safe): ${counts.userOverwrite} files\n`);
|
|
821
|
+
out(` user-edit (kept local): ${counts.userKeptLocal} files\n`);
|
|
804
822
|
out(` cloud-symlink reconciled: ${counts.cloudSymlinkReconciled} files\n`);
|
|
805
823
|
out(` drift reconciled (== upstream): ${counts.driftReconciled} files\n`);
|
|
806
824
|
out(` reindex symlinks dropped: ${counts.symlinkDropped} entries\n`);
|
|
@@ -883,7 +901,10 @@ function doRescue(cfg, env, out, err, setTmp) {
|
|
|
883
901
|
let backupDir = "";
|
|
884
902
|
let mutationStarted = false;
|
|
885
903
|
const hasDestructivePaths = ctx.actions.some((action) => action.affectedRels.length > 0);
|
|
886
|
-
if (cfg.doBackup &&
|
|
904
|
+
if (cfg.doBackup &&
|
|
905
|
+
!cfg.dryRun &&
|
|
906
|
+
wipeToplevel.length !== 0 &&
|
|
907
|
+
hasDestructivePaths) {
|
|
887
908
|
const prePlan = pruneManagedBackups(cfg, hqRoot, out, false);
|
|
888
909
|
const unresolved = prePlan.protected.filter((x) => x.reason === "recovery-required-no-later-good");
|
|
889
910
|
if (unresolved.length >= 2) {
|
|
@@ -936,7 +957,7 @@ function doRescue(cfg, env, out, err, setTmp) {
|
|
|
936
957
|
}
|
|
937
958
|
}
|
|
938
959
|
out("\n");
|
|
939
|
-
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`);
|
|
960
|
+
out(`==> Walk complete (user-only: ${counts.userOnly}, unchanged: ${counts.unchanged}, user-edit-rescued: ${counts.userEdit}, conflict-quarantined: ${counts.userConflict}, overwrite-safe: ${counts.userOverwrite}, kept-local: ${counts.userKeptLocal}, cloud-symlink-reconciled: ${counts.cloudSymlinkReconciled}, symlinks-dropped: ${counts.symlinkDropped})\n`);
|
|
940
961
|
// --- Overlay source onto HQ root ---
|
|
941
962
|
out("==> Overlaying source onto HQ root ...\n");
|
|
942
963
|
// `unchangedList` is only populated during applyClassifiedActions above, so
|
|
@@ -984,7 +1005,14 @@ function doRescue(cfg, env, out, err, setTmp) {
|
|
|
984
1005
|
// (vs the last-sync floor) into settings.local.json so the effective merged
|
|
985
1006
|
// settings are preserved. No-op in narrow mode that excludes .claude.
|
|
986
1007
|
if (claudeSettingsInScope) {
|
|
987
|
-
reconcileClaudeSettings({
|
|
1008
|
+
reconcileClaudeSettings({
|
|
1009
|
+
hqRoot,
|
|
1010
|
+
srcDir,
|
|
1011
|
+
env,
|
|
1012
|
+
historyFloor,
|
|
1013
|
+
baselineMode,
|
|
1014
|
+
preserveSubpaths: cfg.preserveSubpaths,
|
|
1015
|
+
}, preRescueSettingsJson, out, err);
|
|
988
1016
|
}
|
|
989
1017
|
// --- Restore executable bit on shipped shebang scripts (defense-in-depth) ---
|
|
990
1018
|
// rsync -a already propagated each file's upstream git tree mode; this guards
|
|
@@ -1002,7 +1030,9 @@ function doRescue(cfg, env, out, err, setTmp) {
|
|
|
1002
1030
|
// unparseable commit timestamp skips the stamp entirely — a nondeterministic
|
|
1003
1031
|
// stamp is worse than no stamp (it re-creates cross-machine conflict mirrors).
|
|
1004
1032
|
const srcCommitDate = new Date(srcCommitIsoRaw);
|
|
1005
|
-
if (cfg.narrowPaths.length === 0 &&
|
|
1033
|
+
if (cfg.narrowPaths.length === 0 &&
|
|
1034
|
+
isFileFollow(coreYaml) &&
|
|
1035
|
+
Number.isNaN(srcCommitDate.getTime())) {
|
|
1006
1036
|
err(` WARN: could not parse source commit timestamp (${JSON.stringify(srcCommitIsoRaw)}); skipping core/core.yaml stamp to keep it deterministic\n`);
|
|
1007
1037
|
}
|
|
1008
1038
|
if (cfg.narrowPaths.length === 0 &&
|
|
@@ -1090,6 +1120,7 @@ function doRescue(cfg, env, out, err, setTmp) {
|
|
|
1090
1120
|
out(` user-edits (rescued): ${counts.userEdit} files\n`);
|
|
1091
1121
|
out(` user-edits (conflict quarantine): ${counts.userConflict} files\n`);
|
|
1092
1122
|
out(` user-edits (overwrite-safe): ${counts.userOverwrite} files\n`);
|
|
1123
|
+
out(` user-edit (kept local): ${counts.userKeptLocal} files\n`);
|
|
1093
1124
|
out(` cloud-symlink reconciled: ${counts.cloudSymlinkReconciled} files\n`);
|
|
1094
1125
|
out(` drift reconciled (== upstream): ${counts.driftReconciled} files\n`);
|
|
1095
1126
|
out(` reindex symlinks dropped: ${counts.symlinkDropped} entries\n`);
|
|
@@ -1124,6 +1155,7 @@ function doRescue(cfg, env, out, err, setTmp) {
|
|
|
1124
1155
|
lines.push(` user-edit (rescued into personal/): ${counts.userEdit}`);
|
|
1125
1156
|
lines.push(` user-edit (conflict quarantine): ${counts.userConflict}`);
|
|
1126
1157
|
lines.push(` user-edit (overwrite-safe): ${counts.userOverwrite}`);
|
|
1158
|
+
lines.push(` user-edit (kept local, ignore file): ${counts.userKeptLocal}`);
|
|
1127
1159
|
lines.push(` cloud-symlink reconciled: ${counts.cloudSymlinkReconciled}`);
|
|
1128
1160
|
lines.push(` drift reconciled (== upstream HEAD): ${counts.driftReconciled}`);
|
|
1129
1161
|
lines.push(` reindex symlinks dropped: ${counts.symlinkDropped}`);
|
|
@@ -1284,7 +1316,9 @@ function writeApplyFailureManifest(ctx, backupDir, failedAction, completed, rest
|
|
|
1284
1316
|
const completedLines = completed.length === 0
|
|
1285
1317
|
? [" (none)"]
|
|
1286
1318
|
: completed.map((action) => {
|
|
1287
|
-
const paths = action.affectedRels.length > 0
|
|
1319
|
+
const paths = action.affectedRels.length > 0
|
|
1320
|
+
? action.affectedRels.join(", ")
|
|
1321
|
+
: "(no filesystem path)";
|
|
1288
1322
|
return ` - ${action.label}: ${paths}`;
|
|
1289
1323
|
});
|
|
1290
1324
|
const restoredSet = new Set(restored);
|
|
@@ -1369,6 +1403,28 @@ function isConflictClass(rel) {
|
|
|
1369
1403
|
rel.startsWith(".obsidian/") ||
|
|
1370
1404
|
rel === "MIGRATION.md");
|
|
1371
1405
|
}
|
|
1406
|
+
/**
|
|
1407
|
+
* Root-level exclusion lists an operator tunes for their own machine
|
|
1408
|
+
* (`/workspace/`, large-media globs, local scratch dirs). They ship with a
|
|
1409
|
+
* release, so the generic classifier used to treat an edited one as scaffold
|
|
1410
|
+
* drift: archive it into `personal/` and lay the release copy down. On a real
|
|
1411
|
+
* HQ root that silently reverted the operator's rules, and the git mirror and
|
|
1412
|
+
* sync engine went straight back to walking tens of GB they had been told to
|
|
1413
|
+
* skip. Once edited, the local file wins outright — see KEEP-LOCAL in
|
|
1414
|
+
* classifyOne. Root-anchored on purpose: a nested `core/.gitignore` is
|
|
1415
|
+
* scaffold, not machine-local tuning, and stays on the normal rescue path.
|
|
1416
|
+
*/
|
|
1417
|
+
const LOCAL_IGNORE_FILES = new Set([
|
|
1418
|
+
".gitignore",
|
|
1419
|
+
".hqignore",
|
|
1420
|
+
".hqsyncignore",
|
|
1421
|
+
".hqinclude",
|
|
1422
|
+
".claudeignore",
|
|
1423
|
+
".ignore",
|
|
1424
|
+
]);
|
|
1425
|
+
function isLocalIgnoreFile(rel) {
|
|
1426
|
+
return LOCAL_IGNORE_FILES.has(rel);
|
|
1427
|
+
}
|
|
1372
1428
|
function isOverwriteSafe(rel) {
|
|
1373
1429
|
return (rel === "AGENTS.md" ||
|
|
1374
1430
|
rel === "USER-GUIDE.md" ||
|
|
@@ -1453,12 +1509,20 @@ function isCloudFlattenedSymlinkEquiv(ctx, rel, localPath) {
|
|
|
1453
1509
|
if (!localTarget)
|
|
1454
1510
|
return false;
|
|
1455
1511
|
const ref = ctx.baselineMode === "history_floor" ? ctx.historyFloor : "HEAD";
|
|
1456
|
-
const lsTree = run("git", ["ls-tree", ref, "--", rel], {
|
|
1512
|
+
const lsTree = run("git", ["ls-tree", ref, "--", rel], {
|
|
1513
|
+
cwd: ctx.srcDir,
|
|
1514
|
+
env: ctx.env,
|
|
1515
|
+
}).stdout;
|
|
1457
1516
|
const mode = lsTree.trim().split(/\s+/)[0] ?? "";
|
|
1458
1517
|
if (mode !== "120000")
|
|
1459
1518
|
return false;
|
|
1460
|
-
const showRef = ctx.baselineMode === "history_floor"
|
|
1461
|
-
|
|
1519
|
+
const showRef = ctx.baselineMode === "history_floor"
|
|
1520
|
+
? `${ctx.historyFloor}:${rel}`
|
|
1521
|
+
: `HEAD:${rel}`;
|
|
1522
|
+
const upstreamTarget = run("git", ["show", showRef], {
|
|
1523
|
+
cwd: ctx.srcDir,
|
|
1524
|
+
env: ctx.env,
|
|
1525
|
+
}).stdout;
|
|
1462
1526
|
if (!upstreamTarget)
|
|
1463
1527
|
return false;
|
|
1464
1528
|
return localTarget === upstreamTarget;
|
|
@@ -1841,12 +1905,16 @@ export function reconcileClaudeSettings(ctx, preRescueSettingsJson, out, err) {
|
|
|
1841
1905
|
}
|
|
1842
1906
|
// Additions drift: base = realBase when available, else theirs (conservative
|
|
1843
1907
|
// head_compare fallback — never loses a customization). See design note.
|
|
1844
|
-
const additionsBase = isPlainObject(realBase)
|
|
1908
|
+
const additionsBase = isPlainObject(realBase)
|
|
1909
|
+
? realBase
|
|
1910
|
+
: theirs;
|
|
1845
1911
|
const drift = settingsDrift(ours, additionsBase);
|
|
1846
1912
|
if (isPlainObject(drift) && Object.keys(drift).length > 0) {
|
|
1847
1913
|
const existingLocal = jsonParseOrNull((() => {
|
|
1848
1914
|
try {
|
|
1849
|
-
return isFileFollow(localPath)
|
|
1915
|
+
return isFileFollow(localPath)
|
|
1916
|
+
? fs.readFileSync(localPath, "utf-8")
|
|
1917
|
+
: null;
|
|
1850
1918
|
}
|
|
1851
1919
|
catch {
|
|
1852
1920
|
return null;
|
|
@@ -2005,7 +2073,9 @@ function rescueOne(ctx, rel) {
|
|
|
2005
2073
|
dest = `${dest}.drift-${Math.floor(Date.now() / 1000)}-${process.pid}`;
|
|
2006
2074
|
}
|
|
2007
2075
|
fs.renameSync(localPath, dest);
|
|
2008
|
-
const destRel = dest.startsWith(ctx.hqRoot + "/")
|
|
2076
|
+
const destRel = dest.startsWith(ctx.hqRoot + "/")
|
|
2077
|
+
? dest.slice(ctx.hqRoot.length + 1)
|
|
2078
|
+
: dest;
|
|
2009
2079
|
ctx.out(` rescued: ${rel} -> ${destRel}\n`);
|
|
2010
2080
|
ctx.appendLog(`rescued\t${rel}\t-> ${destRel}\n`);
|
|
2011
2081
|
}
|
|
@@ -2020,7 +2090,9 @@ function conflictOne(ctx, rel) {
|
|
|
2020
2090
|
dest = `${dest}.drift-${Math.floor(Date.now() / 1000)}-${process.pid}`;
|
|
2021
2091
|
}
|
|
2022
2092
|
fs.renameSync(localPath, dest);
|
|
2023
|
-
const destRel = dest.startsWith(ctx.hqRoot + "/")
|
|
2093
|
+
const destRel = dest.startsWith(ctx.hqRoot + "/")
|
|
2094
|
+
? dest.slice(ctx.hqRoot.length + 1)
|
|
2095
|
+
: dest;
|
|
2024
2096
|
ctx.out(` conflicted: ${rel} -> ${destRel}\n`);
|
|
2025
2097
|
ctx.appendLog(`conflicted\t${rel}\t-> ${destRel}\n`);
|
|
2026
2098
|
}
|
|
@@ -2036,7 +2108,8 @@ function classifyOne(ctx, rel) {
|
|
|
2036
2108
|
const srcPath = path.join(ctx.srcDir, rel);
|
|
2037
2109
|
// Test-only fault seam: prove the classify-before-delete ordering invariant
|
|
2038
2110
|
// by forcing a classifier throw at a chosen entry. Never set in production.
|
|
2039
|
-
if (ctx.env.HQ_RESCUE_FAULT_AT_REL &&
|
|
2111
|
+
if (ctx.env.HQ_RESCUE_FAULT_AT_REL &&
|
|
2112
|
+
rel === ctx.env.HQ_RESCUE_FAULT_AT_REL) {
|
|
2040
2113
|
throw new Error(`rescue classifier fault injected at ${rel} (HQ_RESCUE_FAULT_AT_REL)`);
|
|
2041
2114
|
}
|
|
2042
2115
|
if (isUnderPreserve(cfg, rel))
|
|
@@ -2053,6 +2126,14 @@ function classifyOne(ctx, rel) {
|
|
|
2053
2126
|
// Symlinks (mid-tree).
|
|
2054
2127
|
const lst = lstatOrNull(localPath);
|
|
2055
2128
|
if (lst && lst.isSymbolicLink()) {
|
|
2129
|
+
// A root ignore file pointed at a dotfiles repo or shared config is by
|
|
2130
|
+
// construction the operator's own — the release ships regular files here,
|
|
2131
|
+
// never symlinks. The generic arm below would `skip` it without recording
|
|
2132
|
+
// an exclusion, and `rsync -a` replaces a receiver symlink with the
|
|
2133
|
+
// source's regular file, so the local rules would still be lost.
|
|
2134
|
+
if (isLocalIgnoreFile(rel)) {
|
|
2135
|
+
return { kind: "user-edit-keep-local", rel };
|
|
2136
|
+
}
|
|
2056
2137
|
const target = masterSyncSymlinkTarget(localPath);
|
|
2057
2138
|
if (target !== null) {
|
|
2058
2139
|
return { kind: "drop-reindex-symlink", rel, target };
|
|
@@ -2088,6 +2169,12 @@ function classifyOne(ctx, rel) {
|
|
|
2088
2169
|
}
|
|
2089
2170
|
// Path is/was in upstream. Determine if user edited it.
|
|
2090
2171
|
let userEdited = 0;
|
|
2172
|
+
// Set only by the floor comparison below — the one arm that proves the local
|
|
2173
|
+
// bytes diverged from the baseline the operator last synced, rather than
|
|
2174
|
+
// merely differing from HEAD. KEEP-LOCAL requires this evidence: without it,
|
|
2175
|
+
// an untouched file from an older release (head_compare mode) or one upstream
|
|
2176
|
+
// deleted would be pinned locally forever instead of tracking the release.
|
|
2177
|
+
let editProvenAgainstFloor = false;
|
|
2091
2178
|
if (ctx.baselineMode === "history_floor" && inFloor === 1) {
|
|
2092
2179
|
const localSha = ctx.localShas?.get(rel) ??
|
|
2093
2180
|
run("git", ["hash-object", localPath], { env: ctx.env }).stdout.trim();
|
|
@@ -2096,8 +2183,10 @@ function classifyOne(ctx, rel) {
|
|
|
2096
2183
|
cwd: ctx.srcDir,
|
|
2097
2184
|
env: ctx.env,
|
|
2098
2185
|
}).stdout.trim();
|
|
2099
|
-
if (!localSha || !floorSha || localSha !== floorSha)
|
|
2186
|
+
if (!localSha || !floorSha || localSha !== floorSha) {
|
|
2100
2187
|
userEdited = 1;
|
|
2188
|
+
editProvenAgainstFloor = true;
|
|
2189
|
+
}
|
|
2101
2190
|
}
|
|
2102
2191
|
else if (inHead === 1) {
|
|
2103
2192
|
if (!bytesEqual(localPath, srcPath))
|
|
@@ -2116,6 +2205,11 @@ function classifyOne(ctx, rel) {
|
|
|
2116
2205
|
return { kind: "drift-reconciled", rel };
|
|
2117
2206
|
}
|
|
2118
2207
|
if (userEdited === 1) {
|
|
2208
|
+
// KEEP-LOCAL: an edited root ignore file is machine-local tuning. Leave it
|
|
2209
|
+
// untouched and keep the overlay off it; the release copy never wins.
|
|
2210
|
+
if (editProvenAgainstFloor && isLocalIgnoreFile(rel)) {
|
|
2211
|
+
return { kind: "user-edit-keep-local", rel };
|
|
2212
|
+
}
|
|
2119
2213
|
if (rel === ".claude/CLAUDE.md") {
|
|
2120
2214
|
return { kind: "user-edit-diff-append", rel };
|
|
2121
2215
|
}
|
|
@@ -2163,6 +2257,10 @@ function renderDryRunDecision(ctx, decision) {
|
|
|
2163
2257
|
ctx.out(` user-edit (overwrite-safe): ${decision.rel} -> upstream wins (no copy preserved)\n`);
|
|
2164
2258
|
ctx.counts.userOverwrite += 1;
|
|
2165
2259
|
break;
|
|
2260
|
+
case "user-edit-keep-local":
|
|
2261
|
+
ctx.out(` user-edit (keep local): ${decision.rel} -> local wins (machine-local ignore file; overlay skipped)\n`);
|
|
2262
|
+
ctx.counts.userKeptLocal += 1;
|
|
2263
|
+
break;
|
|
2166
2264
|
case "user-edit-conflict":
|
|
2167
2265
|
ctx.out(` user-edit (conflict): ${decision.rel} -> .hq-conflicts/rescue-${ctx.runTs}/${decision.rel}\n`);
|
|
2168
2266
|
ctx.counts.userConflict += 1;
|
|
@@ -2235,6 +2333,17 @@ function buildRescueActions(ctx) {
|
|
|
2235
2333
|
ctx.appendLog(`overwritten\t${decision.rel}\t(overwrite-safe; upstream wins, no copy preserved)\n`);
|
|
2236
2334
|
});
|
|
2237
2335
|
break;
|
|
2336
|
+
case "user-edit-keep-local":
|
|
2337
|
+
// No affected paths: nothing is moved or deleted. Listing the rel in
|
|
2338
|
+
// `unchangedList` makes it an `--exclude-from` entry for the overlay
|
|
2339
|
+
// rsync, so the release copy is never laid over the local one.
|
|
2340
|
+
add(`keep local: ${decision.rel}`, [], () => {
|
|
2341
|
+
fs.appendFileSync(ctx.unchangedList, `/${decision.rel}\n`);
|
|
2342
|
+
ctx.counts.userKeptLocal += 1;
|
|
2343
|
+
ctx.out(` kept local: ${decision.rel} (machine-local ignore file; your edits win, overlay skipped)\n`);
|
|
2344
|
+
ctx.appendLog(`kept-local\t${decision.rel}\t(machine-local ignore file; user edit wins, overlay excluded)\n`);
|
|
2345
|
+
});
|
|
2346
|
+
break;
|
|
2238
2347
|
case "user-edit-conflict":
|
|
2239
2348
|
add(`conflict quarantine: ${decision.rel}`, [decision.rel], () => conflictOne(ctx, decision.rel));
|
|
2240
2349
|
break;
|
|
@@ -2375,15 +2484,31 @@ function walkAndProcess(ctx, rootRel) {
|
|
|
2375
2484
|
return;
|
|
2376
2485
|
// companies/_template — wholesale-replace.
|
|
2377
2486
|
if (rootRel === "companies/_template") {
|
|
2378
|
-
recordDecision(ctx, {
|
|
2487
|
+
recordDecision(ctx, {
|
|
2488
|
+
kind: "wholesale-replace-template",
|
|
2489
|
+
rel: "companies/_template",
|
|
2490
|
+
});
|
|
2379
2491
|
return;
|
|
2380
2492
|
}
|
|
2381
2493
|
const lst = lstatOrNull(rootAbs);
|
|
2382
2494
|
// Top-level symlink.
|
|
2383
2495
|
if (lst && lst.isSymbolicLink()) {
|
|
2496
|
+
// A root ignore file pointed at a dotfiles repo or shared config is by
|
|
2497
|
+
// construction the operator's own — the release ships regular files here,
|
|
2498
|
+
// never symlinks. Falling through to the plain `return` below records no
|
|
2499
|
+
// exclusion, and the overlay's `rsync -a` then replaces the receiver
|
|
2500
|
+
// symlink with the source's regular file, losing the local rules.
|
|
2501
|
+
if (isLocalIgnoreFile(rootRel)) {
|
|
2502
|
+
recordDecision(ctx, { kind: "user-edit-keep-local", rel: rootRel });
|
|
2503
|
+
return;
|
|
2504
|
+
}
|
|
2384
2505
|
const target = masterSyncSymlinkTarget(rootAbs);
|
|
2385
2506
|
if (target !== null) {
|
|
2386
|
-
recordDecision(ctx, {
|
|
2507
|
+
recordDecision(ctx, {
|
|
2508
|
+
kind: "drop-reindex-symlink",
|
|
2509
|
+
rel: rootRel,
|
|
2510
|
+
target,
|
|
2511
|
+
});
|
|
2387
2512
|
}
|
|
2388
2513
|
return;
|
|
2389
2514
|
}
|
|
@@ -2445,17 +2570,28 @@ function findFilesAndSymlinks(rootAbs, hqRoot) {
|
|
|
2445
2570
|
// --- restore file mtimes from git commit history ---
|
|
2446
2571
|
function restoreMtimesFromGit(srcDir, env, out) {
|
|
2447
2572
|
// Deepen a shallow clone so per-file commit times are real.
|
|
2448
|
-
const isShallow = run("git", ["rev-parse", "--is-shallow-repository"], {
|
|
2449
|
-
|
|
2573
|
+
const isShallow = run("git", ["rev-parse", "--is-shallow-repository"], {
|
|
2574
|
+
cwd: srcDir,
|
|
2575
|
+
env,
|
|
2576
|
+
}).stdout.trim() === "true";
|
|
2450
2577
|
if (isShallow) {
|
|
2451
2578
|
out("==> Deepening clone for per-file mtime history (blob:none) ...\n");
|
|
2452
|
-
const r = run("git", ["fetch", "--unshallow", "--filter=blob:none"], {
|
|
2579
|
+
const r = run("git", ["fetch", "--unshallow", "--filter=blob:none"], {
|
|
2580
|
+
cwd: srcDir,
|
|
2581
|
+
env,
|
|
2582
|
+
});
|
|
2453
2583
|
if (r.status !== 0) {
|
|
2454
2584
|
out(" (unshallow failed; mtimes fall back to release tip-commit time)\n");
|
|
2455
2585
|
}
|
|
2456
2586
|
}
|
|
2457
2587
|
out("==> Restoring file mtimes from git commit history ...\n");
|
|
2458
|
-
const log = run("git", [
|
|
2588
|
+
const log = run("git", [
|
|
2589
|
+
"log",
|
|
2590
|
+
"--no-renames",
|
|
2591
|
+
"--pretty=format:C:%ct",
|
|
2592
|
+
"--name-only",
|
|
2593
|
+
"--diff-filter=ACMR",
|
|
2594
|
+
], { cwd: srcDir, env });
|
|
2459
2595
|
if (log.status !== 0)
|
|
2460
2596
|
return;
|
|
2461
2597
|
let ts = "";
|
|
@@ -2490,9 +2626,22 @@ function allocateSnapshotDir(backupRoot, runTs, rootHash, runId) {
|
|
|
2490
2626
|
}
|
|
2491
2627
|
function snapshotManifest(status, hqRoot, runId, cfg, sourceSha, includedRoots) {
|
|
2492
2628
|
const now = new Date().toISOString();
|
|
2493
|
-
return {
|
|
2494
|
-
|
|
2495
|
-
|
|
2629
|
+
return {
|
|
2630
|
+
schemaVersion: 1,
|
|
2631
|
+
status,
|
|
2632
|
+
canonicalHqRoot: hqRoot,
|
|
2633
|
+
rootHash: canonicalRootHash(hqRoot),
|
|
2634
|
+
runId,
|
|
2635
|
+
creatorPid: process.pid,
|
|
2636
|
+
creatorStartedAt: now,
|
|
2637
|
+
source: cfg.sourceRepo,
|
|
2638
|
+
ref: cfg.ref,
|
|
2639
|
+
sourceSha,
|
|
2640
|
+
createdAt: now,
|
|
2641
|
+
includedRoots,
|
|
2642
|
+
fileCount: 0,
|
|
2643
|
+
byteCount: 0,
|
|
2644
|
+
};
|
|
2496
2645
|
}
|
|
2497
2646
|
function readSnapshotManifest(backupDir) {
|
|
2498
2647
|
try {
|
|
@@ -2514,8 +2663,13 @@ function updateSnapshotStatus(backupDir, status, completedAt) {
|
|
|
2514
2663
|
if (!manifest)
|
|
2515
2664
|
return;
|
|
2516
2665
|
const stats = estimateFiltered(backupDir, "");
|
|
2517
|
-
writeSnapshotManifest(backupDir, {
|
|
2518
|
-
...
|
|
2666
|
+
writeSnapshotManifest(backupDir, {
|
|
2667
|
+
...manifest,
|
|
2668
|
+
status,
|
|
2669
|
+
fileCount: stats.files,
|
|
2670
|
+
byteCount: stats.bytes,
|
|
2671
|
+
...(completedAt ? { completedAt: new Date().toISOString() } : {}),
|
|
2672
|
+
});
|
|
2519
2673
|
}
|
|
2520
2674
|
function pruneManagedBackups(cfg, hqRoot, out, apply) {
|
|
2521
2675
|
const inventory = inventoryManagedSnapshots(cfg.backupRoot, hqRoot, (m) => {
|
|
@@ -2526,15 +2680,25 @@ function pruneManagedBackups(cfg, hqRoot, out, apply) {
|
|
|
2526
2680
|
catch {
|
|
2527
2681
|
return false;
|
|
2528
2682
|
}
|
|
2529
|
-
}, {
|
|
2530
|
-
|
|
2683
|
+
}, {
|
|
2684
|
+
keepGood: Number(cfg.backupRetentionCount),
|
|
2685
|
+
maxAgeDays: Number(cfg.backupRetentionDays),
|
|
2686
|
+
});
|
|
2687
|
+
const plan = {
|
|
2688
|
+
candidates: inventory.candidates,
|
|
2689
|
+
protected: inventory.protected,
|
|
2690
|
+
unmanaged: inventory.unmanaged,
|
|
2691
|
+
};
|
|
2531
2692
|
out(`==> Backup retention dry-run: ${plan.candidates.length} candidate(s), ${plan.protected.length} protected, ${plan.unmanaged.length} unmanaged.\n`);
|
|
2532
2693
|
for (const item of plan.candidates)
|
|
2533
2694
|
out(` candidate ${item.entry.name}: ${item.reason}\n`);
|
|
2534
2695
|
for (const item of plan.protected)
|
|
2535
2696
|
out(` protected ${item.entry.name}: ${item.reason}\n`);
|
|
2536
2697
|
if (apply && plan.candidates.length > 0) {
|
|
2537
|
-
const result = applyBackupPrune(cfg.backupRoot, hqRoot, plan, {
|
|
2698
|
+
const result = applyBackupPrune(cfg.backupRoot, hqRoot, plan, {
|
|
2699
|
+
keepGood: Number(cfg.backupRetentionCount),
|
|
2700
|
+
maxAgeDays: Number(cfg.backupRetentionDays),
|
|
2701
|
+
});
|
|
2538
2702
|
for (const name of result.deleted)
|
|
2539
2703
|
out(` deleted snapshot: ${name}\n`);
|
|
2540
2704
|
for (const failure of result.failures)
|
|
@@ -2614,7 +2778,9 @@ function countFiles(dir) {
|
|
|
2614
2778
|
* "$SHUTTLE/$id"`, an explicit destination name).
|
|
2615
2779
|
*/
|
|
2616
2780
|
function cpA(src, destDir, asName) {
|
|
2617
|
-
const dest = asName
|
|
2781
|
+
const dest = asName
|
|
2782
|
+
? path.join(destDir, asName)
|
|
2783
|
+
: path.join(destDir, path.basename(src));
|
|
2618
2784
|
cpATo(src, dest);
|
|
2619
2785
|
}
|
|
2620
2786
|
/** `cp -a <src> <dest>` with an explicit destination path. */
|