@indigoai-us/hq-cloud 5.48.2 → 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.
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=rescue-runner.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rescue-runner.d.ts","sourceRoot":"","sources":["../../src/bin/rescue-runner.ts"],"names":[],"mappings":""}
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * hq-rescue — machine-targeted entrypoint for the drift-preserving HQ-core
4
+ * re-sync ("rescue") shipped in `@indigoai-us/hq-cloud`.
5
+ *
6
+ * This is the rescue analogue of `hq-sync-runner`: the HQ Sync menubar app
7
+ * (Tauri + Rust) spawns it the same way it spawns the sync runner —
8
+ * `npx -y --package=@indigoai-us/hq-cloud@<pin> hq-rescue <flags>`
9
+ * — so the app invokes hq-cloud directly at runtime (pinned by the Rust
10
+ * `HQ_CLOUD_VERSION` constant) rather than bundling a private copy of the
11
+ * script. `hq rescue` in `@indigoai-us/hq-cli` is the human-facing sibling.
12
+ *
13
+ * All flags are forwarded verbatim to scripts/replace-rescue.sh — see that
14
+ * script (and src/cli/rescue.ts) for the supported flags:
15
+ * --hq-root --source --ref --floor-sha --paths --preserve
16
+ * --preserve-subpath --no-history-check --no-backup --backup-dir
17
+ * --cloud-update --dry-run --yes
18
+ *
19
+ * stdout/stderr from the script are inherited so the caller (the menubar's
20
+ * RescueRunResult tail capture) sees the scan log + trailing summary.
21
+ */
22
+ import { rescue } from "../cli/rescue.js";
23
+ const { status } = rescue({ extraArgs: process.argv.slice(2) });
24
+ process.exit(status);
25
+ //# sourceMappingURL=rescue-runner.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rescue-runner.js","sourceRoot":"","sources":["../../src/bin/rescue-runner.ts"],"names":[],"mappings":";AACA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,OAAO,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAC;AAE1C,MAAM,EAAE,MAAM,EAAE,GAAG,MAAM,CAAC,EAAE,SAAS,EAAE,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;AAChE,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=rescue-drift-reconcile.test.d.ts.map
@@ -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
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cloud",
3
- "version": "5.48.2",
3
+ "version": "5.48.4",
4
4
  "description": "HQ by Indigo cloud sync engine — bidirectional S3 sync for mobile access",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
7
7
  "bin": {
8
- "hq-sync-runner": "dist/bin/sync-runner.js"
8
+ "hq-sync-runner": "dist/bin/sync-runner.js",
9
+ "hq-rescue": "dist/bin/rescue-runner.js"
9
10
  },
10
11
  "scripts": {
11
12
  "build": "tsc",
@@ -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,25 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * hq-rescue — machine-targeted entrypoint for the drift-preserving HQ-core
4
+ * re-sync ("rescue") shipped in `@indigoai-us/hq-cloud`.
5
+ *
6
+ * This is the rescue analogue of `hq-sync-runner`: the HQ Sync menubar app
7
+ * (Tauri + Rust) spawns it the same way it spawns the sync runner —
8
+ * `npx -y --package=@indigoai-us/hq-cloud@<pin> hq-rescue <flags>`
9
+ * — so the app invokes hq-cloud directly at runtime (pinned by the Rust
10
+ * `HQ_CLOUD_VERSION` constant) rather than bundling a private copy of the
11
+ * script. `hq rescue` in `@indigoai-us/hq-cli` is the human-facing sibling.
12
+ *
13
+ * All flags are forwarded verbatim to scripts/replace-rescue.sh — see that
14
+ * script (and src/cli/rescue.ts) for the supported flags:
15
+ * --hq-root --source --ref --floor-sha --paths --preserve
16
+ * --preserve-subpath --no-history-check --no-backup --backup-dir
17
+ * --cloud-update --dry-run --yes
18
+ *
19
+ * stdout/stderr from the script are inherited so the caller (the menubar's
20
+ * RescueRunResult tail capture) sees the scan log + trailing summary.
21
+ */
22
+ import { rescue } from "../cli/rescue.js";
23
+
24
+ const { status } = rescue({ extraArgs: process.argv.slice(2) });
25
+ process.exit(status);
@@ -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
+ });