@indigoai-us/hq-cloud 5.48.3 → 5.48.4
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/cli/rescue-drift-reconcile.test.d.ts +2 -0
- package/dist/cli/rescue-drift-reconcile.test.d.ts.map +1 -0
- package/dist/cli/rescue-drift-reconcile.test.js +139 -0
- package/dist/cli/rescue-drift-reconcile.test.js.map +1 -0
- package/package.json +1 -1
- package/scripts/replace-rescue.sh +29 -0
- package/src/cli/rescue-drift-reconcile.test.ts +158 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rescue-drift-reconcile.test.d.ts","sourceRoot":"","sources":["../../src/cli/rescue-drift-reconcile.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration test for the rescue convergence guard in scripts/replace-rescue.sh.
|
|
3
|
+
*
|
|
4
|
+
* Regression for the false `.drift-*` litter: in `history_floor` mode the
|
|
5
|
+
* rescue overlay flags a scaffold file as USER-EDIT whenever its blob differs
|
|
6
|
+
* from the *old* baseline (floor). HQ's own Stop hooks rewrite scaffold files
|
|
7
|
+
* every session, so a file routinely drifts from the floor yet lands
|
|
8
|
+
* byte-for-byte identical to the *new* upstream HEAD the overlay is about to
|
|
9
|
+
* write — and the old code rescued it into personal/ with a redundant
|
|
10
|
+
* `.drift-<ts>-<pid>` copy. The guard reclassifies "drifted from floor but
|
|
11
|
+
* identical to upstream HEAD" as UNCHANGED (no rescue).
|
|
12
|
+
*
|
|
13
|
+
* The script clones its source from GitHub, so we shim `git clone` to clone a
|
|
14
|
+
* local fixture repo instead. Everything runs under --dry-run (no destructive
|
|
15
|
+
* ops, no backup) and we assert the classification the script reports.
|
|
16
|
+
*/
|
|
17
|
+
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
|
18
|
+
import { execFileSync, spawnSync } from "child_process";
|
|
19
|
+
import * as fs from "fs";
|
|
20
|
+
import * as os from "os";
|
|
21
|
+
import * as path from "path";
|
|
22
|
+
const RESCUE_SCRIPT = path.resolve(process.cwd(), "scripts/replace-rescue.sh");
|
|
23
|
+
function hasGit() {
|
|
24
|
+
try {
|
|
25
|
+
execFileSync("git", ["--version"], { stdio: "ignore" });
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const gitAvailable = hasGit();
|
|
33
|
+
describe.skipIf(!gitAvailable)("rescue drift convergence guard", () => {
|
|
34
|
+
let workDir;
|
|
35
|
+
let upstream;
|
|
36
|
+
let hqRoot;
|
|
37
|
+
let shimDir;
|
|
38
|
+
let floorSha;
|
|
39
|
+
let env;
|
|
40
|
+
const git = (cwd, ...args) => execFileSync("git", args, {
|
|
41
|
+
cwd,
|
|
42
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
43
|
+
env: {
|
|
44
|
+
...process.env,
|
|
45
|
+
GIT_AUTHOR_NAME: "t",
|
|
46
|
+
GIT_AUTHOR_EMAIL: "t@t",
|
|
47
|
+
GIT_COMMITTER_NAME: "t",
|
|
48
|
+
GIT_COMMITTER_EMAIL: "t@t",
|
|
49
|
+
},
|
|
50
|
+
})
|
|
51
|
+
.toString()
|
|
52
|
+
.trim();
|
|
53
|
+
beforeAll(() => {
|
|
54
|
+
workDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-rescue-drift-"));
|
|
55
|
+
// --- Build a local "upstream" repo: floor commit, then a HEAD commit
|
|
56
|
+
// where core/x.md changed (v1 -> v2) and the others stayed put. ---
|
|
57
|
+
upstream = path.join(workDir, "upstream");
|
|
58
|
+
fs.mkdirSync(path.join(upstream, "core"), { recursive: true });
|
|
59
|
+
git(workDir, "init", "-b", "main", "upstream");
|
|
60
|
+
fs.writeFileSync(path.join(upstream, "core/x.md"), "v1\n");
|
|
61
|
+
fs.writeFileSync(path.join(upstream, "core/edited.md"), "base\n");
|
|
62
|
+
fs.writeFileSync(path.join(upstream, "core/keep.md"), "same\n");
|
|
63
|
+
git(upstream, "add", "-A");
|
|
64
|
+
git(upstream, "commit", "-m", "floor");
|
|
65
|
+
floorSha = git(upstream, "rev-parse", "HEAD");
|
|
66
|
+
// HEAD: only x.md advances.
|
|
67
|
+
fs.writeFileSync(path.join(upstream, "core/x.md"), "v2\n");
|
|
68
|
+
git(upstream, "add", "-A");
|
|
69
|
+
git(upstream, "commit", "-m", "head");
|
|
70
|
+
// --- Build the local HQ root being rescued. ---
|
|
71
|
+
hqRoot = path.join(workDir, "hq");
|
|
72
|
+
fs.mkdirSync(path.join(hqRoot, "core"), { recursive: true });
|
|
73
|
+
// `companies/` + `personal/` make it "look like" an HQ root (script guard);
|
|
74
|
+
// both are preserved (never walked).
|
|
75
|
+
fs.mkdirSync(path.join(hqRoot, "personal"), { recursive: true });
|
|
76
|
+
fs.mkdirSync(path.join(hqRoot, "companies"), { recursive: true });
|
|
77
|
+
// Drifted from floor ("v1") but byte-identical to upstream HEAD ("v2").
|
|
78
|
+
// OLD behavior: rescued to personal/x.md (+ .drift on collision).
|
|
79
|
+
// NEW behavior: reconciled as UNCHANGED — no rescue.
|
|
80
|
+
fs.writeFileSync(path.join(hqRoot, "core/x.md"), "v2\n");
|
|
81
|
+
// Genuinely user-edited: differs from both floor and HEAD -> still rescued.
|
|
82
|
+
fs.writeFileSync(path.join(hqRoot, "core/edited.md"), "MINE\n");
|
|
83
|
+
// Untouched vs floor -> UNCHANGED.
|
|
84
|
+
fs.writeFileSync(path.join(hqRoot, "core/keep.md"), "same\n");
|
|
85
|
+
// --- git shim: rewrite `git clone <github-url> dest` to the local fixture. ---
|
|
86
|
+
// Resolve the real git BEFORE the shim is on PATH so we don't recurse.
|
|
87
|
+
const realGit = execFileSync("bash", ["-lc", "command -v git"]).toString().trim() || "/usr/bin/git";
|
|
88
|
+
shimDir = path.join(workDir, "shim");
|
|
89
|
+
fs.mkdirSync(shimDir, { recursive: true });
|
|
90
|
+
const shim = `#!/usr/bin/env bash
|
|
91
|
+
if [ "$1" = "clone" ]; then
|
|
92
|
+
args=()
|
|
93
|
+
for a in "$@"; do
|
|
94
|
+
case "$a" in
|
|
95
|
+
https://github.com/*) a=${JSON.stringify(upstream)} ;;
|
|
96
|
+
esac
|
|
97
|
+
args+=("$a")
|
|
98
|
+
done
|
|
99
|
+
exec ${JSON.stringify(realGit)} "\${args[@]}"
|
|
100
|
+
fi
|
|
101
|
+
exec ${JSON.stringify(realGit)} "$@"
|
|
102
|
+
`;
|
|
103
|
+
fs.writeFileSync(path.join(shimDir, "git"), shim, { mode: 0o755 });
|
|
104
|
+
env = { ...process.env, PATH: `${shimDir}:${process.env.PATH ?? ""}` };
|
|
105
|
+
});
|
|
106
|
+
afterAll(() => {
|
|
107
|
+
if (workDir)
|
|
108
|
+
fs.rmSync(workDir, { recursive: true, force: true });
|
|
109
|
+
});
|
|
110
|
+
function runRescue() {
|
|
111
|
+
return spawnSync("bash", [
|
|
112
|
+
RESCUE_SCRIPT,
|
|
113
|
+
"--hq-root", hqRoot,
|
|
114
|
+
"--source", "test/repo",
|
|
115
|
+
"--ref", "main",
|
|
116
|
+
"--floor-sha", floorSha,
|
|
117
|
+
"--dry-run",
|
|
118
|
+
"--yes",
|
|
119
|
+
"--no-backup",
|
|
120
|
+
], { env, encoding: "utf-8" });
|
|
121
|
+
}
|
|
122
|
+
it("reconciles a floor-drifted file that is identical to upstream HEAD (no rescue)", () => {
|
|
123
|
+
const r = runRescue();
|
|
124
|
+
const out = `${r.stdout}\n${r.stderr}`;
|
|
125
|
+
// Sanity: ran in history_floor mode against our floor.
|
|
126
|
+
expect(r.status, out).toBe(0);
|
|
127
|
+
expect(out).toContain(`Baseline: ${floorSha}`);
|
|
128
|
+
// The drifted-but-identical-to-HEAD file is reconciled, NOT rescued.
|
|
129
|
+
expect(out).toContain("drift reconciled (identical to upstream HEAD; no rescue): core/x.md");
|
|
130
|
+
expect(out).not.toMatch(/user-edit \(rescue\): core\/x\.md/);
|
|
131
|
+
// The genuinely-edited file is still rescued into personal/.
|
|
132
|
+
expect(out).toContain("user-edit (rescue): core/edited.md -> personal/edited.md");
|
|
133
|
+
// Untouched file classified UNCHANGED.
|
|
134
|
+
expect(out).toContain("unchanged (delete + replace): core/keep.md");
|
|
135
|
+
// Summary reflects exactly one reconcile.
|
|
136
|
+
expect(out).toMatch(/drift reconciled \(== upstream\):\s+1 files/);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
//# sourceMappingURL=rescue-drift-reconcile.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rescue-drift-reconcile.test.js","sourceRoot":"","sources":["../../src/cli/rescue-drift-reconcile.test.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -617,6 +617,7 @@ COUNT_USER_OVERWRITE=0
|
|
|
617
617
|
COUNT_CLAUDE_DIFF_APPEND=0
|
|
618
618
|
COUNT_SYMLINK_DROPPED=0
|
|
619
619
|
COUNT_CLOUD_SYMLINK_RECONCILED=0
|
|
620
|
+
COUNT_DRIFT_RECONCILED=0
|
|
620
621
|
|
|
621
622
|
# True if $rel is under any always-preserved subpath. Drift detection
|
|
622
623
|
# skips these — they're shuttled out, the wipe+overlay runs, then they're
|
|
@@ -1035,6 +1036,31 @@ process_one() {
|
|
|
1035
1036
|
return 0
|
|
1036
1037
|
fi
|
|
1037
1038
|
|
|
1039
|
+
# Convergence guard: in history_floor mode a file is flagged USER-EDIT when
|
|
1040
|
+
# its blob differs from the *old* baseline (the floor). But HQ's own Stop
|
|
1041
|
+
# hooks (master-sync symlinking personal/->core/, autocommit, registry/
|
|
1042
|
+
# _digest/core.yaml regeneration) rewrite scaffold files every session, so a
|
|
1043
|
+
# file routinely drifts from the floor yet lands byte-for-byte identical to
|
|
1044
|
+
# the *new* upstream HEAD this overlay is about to write. Rescuing it into
|
|
1045
|
+
# personal/ then just deposits a redundant `.drift-<ts>-<pid>` copy of bytes
|
|
1046
|
+
# the overlay would have written anyway (this is what produced the 32-file
|
|
1047
|
+
# `.drift-*` litter on a live tree). If local already equals upstream HEAD
|
|
1048
|
+
# there is nothing to preserve, so reclassify as UNCHANGED (delete; the
|
|
1049
|
+
# overlay re-writes the identical bytes). Parallels the cloud-symlink
|
|
1050
|
+
# reconcile above and the sync-side convergence guard. Gated on in_head so an
|
|
1051
|
+
# upstream-removed file (in_head=0) is still rescued; a no-op in head_compare
|
|
1052
|
+
# mode, where `cmp` already settled user_edited against upstream HEAD.
|
|
1053
|
+
if [ "$user_edited" = "1" ] && [ "$in_head" = "1" ] && cmp -s "$local_path" "$src_path"; then
|
|
1054
|
+
COUNT_DRIFT_RECONCILED=$((COUNT_DRIFT_RECONCILED + 1))
|
|
1055
|
+
if [ "$DRY_RUN" = "1" ]; then
|
|
1056
|
+
echo " drift reconciled (identical to upstream HEAD; no rescue): $rel"
|
|
1057
|
+
else
|
|
1058
|
+
rm -f "$local_path"
|
|
1059
|
+
printf 'drift-reconciled\t%s\t(identical to upstream HEAD; drifted from floor only — no personal/ copy)\n' "$rel" >> "$RESCUE_LOG" 2>/dev/null || true
|
|
1060
|
+
fi
|
|
1061
|
+
return 0
|
|
1062
|
+
fi
|
|
1063
|
+
|
|
1038
1064
|
if [ "$user_edited" = "1" ]; then
|
|
1039
1065
|
if [ "$DRY_RUN" = "1" ]; then
|
|
1040
1066
|
if [ "$rel" = ".claude/CLAUDE.md" ]; then
|
|
@@ -1190,6 +1216,7 @@ if [ "$DRY_RUN" = "1" ]; then
|
|
|
1190
1216
|
echo " user-edit (conflict quarantine): $COUNT_USER_CONFLICT files"
|
|
1191
1217
|
echo " user-edit (overwrite-safe): $COUNT_USER_OVERWRITE files"
|
|
1192
1218
|
echo " cloud-symlink reconciled: $COUNT_CLOUD_SYMLINK_RECONCILED files"
|
|
1219
|
+
echo " drift reconciled (== upstream): $COUNT_DRIFT_RECONCILED files"
|
|
1193
1220
|
echo " master-sync symlinks dropped: $COUNT_SYMLINK_DROPPED entries"
|
|
1194
1221
|
if [ "$COUNT_CLAUDE_DIFF_APPEND" -gt 0 ]; then
|
|
1195
1222
|
echo " of which .claude/CLAUDE.md would diff-append to personal/CLAUDE.md"
|
|
@@ -1333,6 +1360,7 @@ echo " user-edits (rescued): $COUNT_USER_EDIT files"
|
|
|
1333
1360
|
echo " user-edits (conflict quarantine): $COUNT_USER_CONFLICT files"
|
|
1334
1361
|
echo " user-edits (overwrite-safe): $COUNT_USER_OVERWRITE files"
|
|
1335
1362
|
echo " cloud-symlink reconciled: $COUNT_CLOUD_SYMLINK_RECONCILED files"
|
|
1363
|
+
echo " drift reconciled (== upstream): $COUNT_DRIFT_RECONCILED files"
|
|
1336
1364
|
echo " master-sync symlinks dropped: $COUNT_SYMLINK_DROPPED entries"
|
|
1337
1365
|
if [ "$COUNT_CLAUDE_DIFF_APPEND" -gt 0 ]; then
|
|
1338
1366
|
echo " of which .claude/CLAUDE.md diff-appended to personal/CLAUDE.md"
|
|
@@ -1367,6 +1395,7 @@ if [ "$DO_BACKUP" = "1" ] && [ -n "$BACKUP_DIR" ] && [ -d "$BACKUP_DIR" ]; then
|
|
|
1367
1395
|
echo " user-edit (conflict quarantine): $COUNT_USER_CONFLICT"
|
|
1368
1396
|
echo " user-edit (overwrite-safe): $COUNT_USER_OVERWRITE"
|
|
1369
1397
|
echo " cloud-symlink reconciled: $COUNT_CLOUD_SYMLINK_RECONCILED"
|
|
1398
|
+
echo " drift reconciled (== upstream HEAD): $COUNT_DRIFT_RECONCILED"
|
|
1370
1399
|
echo " master-sync symlinks dropped: $COUNT_SYMLINK_DROPPED"
|
|
1371
1400
|
if [ "$COUNT_USER_CONFLICT" -gt 0 ]; then
|
|
1372
1401
|
echo " conflict bucket: .hq-conflicts/rescue-$RUN_TS/"
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration test for the rescue convergence guard in scripts/replace-rescue.sh.
|
|
3
|
+
*
|
|
4
|
+
* Regression for the false `.drift-*` litter: in `history_floor` mode the
|
|
5
|
+
* rescue overlay flags a scaffold file as USER-EDIT whenever its blob differs
|
|
6
|
+
* from the *old* baseline (floor). HQ's own Stop hooks rewrite scaffold files
|
|
7
|
+
* every session, so a file routinely drifts from the floor yet lands
|
|
8
|
+
* byte-for-byte identical to the *new* upstream HEAD the overlay is about to
|
|
9
|
+
* write — and the old code rescued it into personal/ with a redundant
|
|
10
|
+
* `.drift-<ts>-<pid>` copy. The guard reclassifies "drifted from floor but
|
|
11
|
+
* identical to upstream HEAD" as UNCHANGED (no rescue).
|
|
12
|
+
*
|
|
13
|
+
* The script clones its source from GitHub, so we shim `git clone` to clone a
|
|
14
|
+
* local fixture repo instead. Everything runs under --dry-run (no destructive
|
|
15
|
+
* ops, no backup) and we assert the classification the script reports.
|
|
16
|
+
*/
|
|
17
|
+
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
|
18
|
+
import { execFileSync, spawnSync } from "child_process";
|
|
19
|
+
import * as fs from "fs";
|
|
20
|
+
import * as os from "os";
|
|
21
|
+
import * as path from "path";
|
|
22
|
+
|
|
23
|
+
const RESCUE_SCRIPT = path.resolve(process.cwd(), "scripts/replace-rescue.sh");
|
|
24
|
+
|
|
25
|
+
function hasGit(): boolean {
|
|
26
|
+
try {
|
|
27
|
+
execFileSync("git", ["--version"], { stdio: "ignore" });
|
|
28
|
+
return true;
|
|
29
|
+
} catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const gitAvailable = hasGit();
|
|
35
|
+
|
|
36
|
+
describe.skipIf(!gitAvailable)("rescue drift convergence guard", () => {
|
|
37
|
+
let workDir: string;
|
|
38
|
+
let upstream: string;
|
|
39
|
+
let hqRoot: string;
|
|
40
|
+
let shimDir: string;
|
|
41
|
+
let floorSha: string;
|
|
42
|
+
let env: NodeJS.ProcessEnv;
|
|
43
|
+
|
|
44
|
+
const git = (cwd: string, ...args: string[]) =>
|
|
45
|
+
execFileSync("git", args, {
|
|
46
|
+
cwd,
|
|
47
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
48
|
+
env: {
|
|
49
|
+
...process.env,
|
|
50
|
+
GIT_AUTHOR_NAME: "t",
|
|
51
|
+
GIT_AUTHOR_EMAIL: "t@t",
|
|
52
|
+
GIT_COMMITTER_NAME: "t",
|
|
53
|
+
GIT_COMMITTER_EMAIL: "t@t",
|
|
54
|
+
},
|
|
55
|
+
})
|
|
56
|
+
.toString()
|
|
57
|
+
.trim();
|
|
58
|
+
|
|
59
|
+
beforeAll(() => {
|
|
60
|
+
workDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-rescue-drift-"));
|
|
61
|
+
|
|
62
|
+
// --- Build a local "upstream" repo: floor commit, then a HEAD commit
|
|
63
|
+
// where core/x.md changed (v1 -> v2) and the others stayed put. ---
|
|
64
|
+
upstream = path.join(workDir, "upstream");
|
|
65
|
+
fs.mkdirSync(path.join(upstream, "core"), { recursive: true });
|
|
66
|
+
git(workDir, "init", "-b", "main", "upstream");
|
|
67
|
+
fs.writeFileSync(path.join(upstream, "core/x.md"), "v1\n");
|
|
68
|
+
fs.writeFileSync(path.join(upstream, "core/edited.md"), "base\n");
|
|
69
|
+
fs.writeFileSync(path.join(upstream, "core/keep.md"), "same\n");
|
|
70
|
+
git(upstream, "add", "-A");
|
|
71
|
+
git(upstream, "commit", "-m", "floor");
|
|
72
|
+
floorSha = git(upstream, "rev-parse", "HEAD");
|
|
73
|
+
// HEAD: only x.md advances.
|
|
74
|
+
fs.writeFileSync(path.join(upstream, "core/x.md"), "v2\n");
|
|
75
|
+
git(upstream, "add", "-A");
|
|
76
|
+
git(upstream, "commit", "-m", "head");
|
|
77
|
+
|
|
78
|
+
// --- Build the local HQ root being rescued. ---
|
|
79
|
+
hqRoot = path.join(workDir, "hq");
|
|
80
|
+
fs.mkdirSync(path.join(hqRoot, "core"), { recursive: true });
|
|
81
|
+
// `companies/` + `personal/` make it "look like" an HQ root (script guard);
|
|
82
|
+
// both are preserved (never walked).
|
|
83
|
+
fs.mkdirSync(path.join(hqRoot, "personal"), { recursive: true });
|
|
84
|
+
fs.mkdirSync(path.join(hqRoot, "companies"), { recursive: true });
|
|
85
|
+
// Drifted from floor ("v1") but byte-identical to upstream HEAD ("v2").
|
|
86
|
+
// OLD behavior: rescued to personal/x.md (+ .drift on collision).
|
|
87
|
+
// NEW behavior: reconciled as UNCHANGED — no rescue.
|
|
88
|
+
fs.writeFileSync(path.join(hqRoot, "core/x.md"), "v2\n");
|
|
89
|
+
// Genuinely user-edited: differs from both floor and HEAD -> still rescued.
|
|
90
|
+
fs.writeFileSync(path.join(hqRoot, "core/edited.md"), "MINE\n");
|
|
91
|
+
// Untouched vs floor -> UNCHANGED.
|
|
92
|
+
fs.writeFileSync(path.join(hqRoot, "core/keep.md"), "same\n");
|
|
93
|
+
|
|
94
|
+
// --- git shim: rewrite `git clone <github-url> dest` to the local fixture. ---
|
|
95
|
+
// Resolve the real git BEFORE the shim is on PATH so we don't recurse.
|
|
96
|
+
const realGit = execFileSync("bash", ["-lc", "command -v git"]).toString().trim() || "/usr/bin/git";
|
|
97
|
+
shimDir = path.join(workDir, "shim");
|
|
98
|
+
fs.mkdirSync(shimDir, { recursive: true });
|
|
99
|
+
const shim = `#!/usr/bin/env bash
|
|
100
|
+
if [ "$1" = "clone" ]; then
|
|
101
|
+
args=()
|
|
102
|
+
for a in "$@"; do
|
|
103
|
+
case "$a" in
|
|
104
|
+
https://github.com/*) a=${JSON.stringify(upstream)} ;;
|
|
105
|
+
esac
|
|
106
|
+
args+=("$a")
|
|
107
|
+
done
|
|
108
|
+
exec ${JSON.stringify(realGit)} "\${args[@]}"
|
|
109
|
+
fi
|
|
110
|
+
exec ${JSON.stringify(realGit)} "$@"
|
|
111
|
+
`;
|
|
112
|
+
fs.writeFileSync(path.join(shimDir, "git"), shim, { mode: 0o755 });
|
|
113
|
+
|
|
114
|
+
env = { ...process.env, PATH: `${shimDir}:${process.env.PATH ?? ""}` };
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
afterAll(() => {
|
|
118
|
+
if (workDir) fs.rmSync(workDir, { recursive: true, force: true });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
function runRescue() {
|
|
122
|
+
return spawnSync(
|
|
123
|
+
"bash",
|
|
124
|
+
[
|
|
125
|
+
RESCUE_SCRIPT,
|
|
126
|
+
"--hq-root", hqRoot,
|
|
127
|
+
"--source", "test/repo",
|
|
128
|
+
"--ref", "main",
|
|
129
|
+
"--floor-sha", floorSha,
|
|
130
|
+
"--dry-run",
|
|
131
|
+
"--yes",
|
|
132
|
+
"--no-backup",
|
|
133
|
+
],
|
|
134
|
+
{ env, encoding: "utf-8" },
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
it("reconciles a floor-drifted file that is identical to upstream HEAD (no rescue)", () => {
|
|
139
|
+
const r = runRescue();
|
|
140
|
+
const out = `${r.stdout}\n${r.stderr}`;
|
|
141
|
+
// Sanity: ran in history_floor mode against our floor.
|
|
142
|
+
expect(r.status, out).toBe(0);
|
|
143
|
+
expect(out).toContain(`Baseline: ${floorSha}`);
|
|
144
|
+
|
|
145
|
+
// The drifted-but-identical-to-HEAD file is reconciled, NOT rescued.
|
|
146
|
+
expect(out).toContain("drift reconciled (identical to upstream HEAD; no rescue): core/x.md");
|
|
147
|
+
expect(out).not.toMatch(/user-edit \(rescue\): core\/x\.md/);
|
|
148
|
+
|
|
149
|
+
// The genuinely-edited file is still rescued into personal/.
|
|
150
|
+
expect(out).toContain("user-edit (rescue): core/edited.md -> personal/edited.md");
|
|
151
|
+
|
|
152
|
+
// Untouched file classified UNCHANGED.
|
|
153
|
+
expect(out).toContain("unchanged (delete + replace): core/keep.md");
|
|
154
|
+
|
|
155
|
+
// Summary reflects exactly one reconcile.
|
|
156
|
+
expect(out).toMatch(/drift reconciled \(== upstream\):\s+1 files/);
|
|
157
|
+
});
|
|
158
|
+
});
|