@indigoai-us/hq-cloud 6.1.0 → 6.2.1
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/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +18 -0
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/cli/index.d.ts +2 -2
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +2 -2
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/reindex.d.ts +4 -11
- package/dist/cli/reindex.d.ts.map +1 -1
- package/dist/cli/reindex.js +336 -30
- package/dist/cli/reindex.js.map +1 -1
- package/dist/cli/reindex.test.d.ts +3 -3
- package/dist/cli/reindex.test.js +36 -11
- package/dist/cli/reindex.test.js.map +1 -1
- package/dist/cli/rescue-core.d.ts +36 -0
- package/dist/cli/rescue-core.d.ts.map +1 -0
- package/dist/cli/rescue-core.js +1589 -0
- package/dist/cli/rescue-core.js.map +1 -0
- package/dist/cli/rescue-drift-reconcile.test.js +33 -10
- package/dist/cli/rescue-drift-reconcile.test.js.map +1 -1
- package/dist/cli/rescue-journal-reconcile.test.d.ts +2 -0
- package/dist/cli/rescue-journal-reconcile.test.d.ts.map +1 -0
- package/dist/cli/rescue-journal-reconcile.test.js +135 -0
- package/dist/cli/rescue-journal-reconcile.test.js.map +1 -0
- package/dist/cli/rescue-mtime-preserve.test.js +36 -12
- package/dist/cli/rescue-mtime-preserve.test.js.map +1 -1
- package/dist/cli/rescue.d.ts +4 -10
- package/dist/cli/rescue.d.ts.map +1 -1
- package/dist/cli/rescue.js +14 -37
- package/dist/cli/rescue.js.map +1 -1
- package/dist/cli/rescue.reindex.test.js +9 -8
- package/dist/cli/rescue.reindex.test.js.map +1 -1
- package/dist/cli/rescue.test.js +1 -10
- package/dist/cli/rescue.test.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/lib/conflict-index.d.ts +40 -0
- package/dist/lib/conflict-index.d.ts.map +1 -1
- package/dist/lib/conflict-index.js +121 -0
- package/dist/lib/conflict-index.js.map +1 -1
- package/dist/lib/conflict.test.js +145 -1
- package/dist/lib/conflict.test.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner.ts +18 -0
- package/src/cli/index.ts +2 -2
- package/src/cli/reindex.test.ts +45 -12
- package/src/cli/reindex.ts +345 -36
- package/src/cli/rescue-core.ts +1719 -0
- package/src/cli/rescue-drift-reconcile.test.ts +33 -12
- package/src/cli/rescue-journal-reconcile.test.ts +156 -0
- package/src/cli/rescue-mtime-preserve.test.ts +36 -15
- package/src/cli/rescue.reindex.test.ts +9 -8
- package/src/cli/rescue.test.ts +1 -11
- package/src/cli/rescue.ts +15 -40
- package/src/index.ts +2 -2
- package/src/lib/conflict-index.ts +146 -0
- package/src/lib/conflict.test.ts +171 -0
- package/scripts/reindex.sh +0 -318
- package/scripts/replace-rescue.sh +0 -1522
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Integration test for the rescue convergence guard in
|
|
2
|
+
* Integration test for the rescue convergence guard in src/cli/rescue-core.ts.
|
|
3
3
|
*
|
|
4
4
|
* Regression for the false `.drift-*` litter: in `history_floor` mode the
|
|
5
5
|
* rescue overlay flags a scaffold file as USER-EDIT whenever its blob differs
|
|
@@ -10,17 +10,16 @@
|
|
|
10
10
|
* `.drift-<ts>-<pid>` copy. The guard reclassifies "drifted from floor but
|
|
11
11
|
* identical to upstream HEAD" as UNCHANGED (no rescue).
|
|
12
12
|
*
|
|
13
|
-
* The
|
|
13
|
+
* The rescue clones its source from GitHub, so we shim `git clone` to clone a
|
|
14
14
|
* local fixture repo instead. Everything runs under --dry-run (no destructive
|
|
15
|
-
* ops, no backup) and we assert the classification the
|
|
15
|
+
* ops, no backup) and we assert the classification the rescue reports.
|
|
16
16
|
*/
|
|
17
17
|
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
|
18
|
-
import { execFileSync
|
|
18
|
+
import { execFileSync } from "child_process";
|
|
19
19
|
import * as fs from "fs";
|
|
20
20
|
import * as os from "os";
|
|
21
21
|
import * as path from "path";
|
|
22
|
-
|
|
23
|
-
const RESCUE_SCRIPT = path.resolve(process.cwd(), "scripts/replace-rescue.sh");
|
|
22
|
+
import { runRescue } from "./rescue-core.js";
|
|
24
23
|
|
|
25
24
|
function hasGit(): boolean {
|
|
26
25
|
try {
|
|
@@ -33,6 +32,30 @@ function hasGit(): boolean {
|
|
|
33
32
|
|
|
34
33
|
const gitAvailable = hasGit();
|
|
35
34
|
|
|
35
|
+
/** Run the rescue in-process, capturing its stdout/stderr. */
|
|
36
|
+
function runRescueCapture(argv: string[], env: NodeJS.ProcessEnv) {
|
|
37
|
+
let stdout = "";
|
|
38
|
+
let stderr = "";
|
|
39
|
+
const origOut = process.stdout.write.bind(process.stdout);
|
|
40
|
+
const origErr = process.stderr.write.bind(process.stderr);
|
|
41
|
+
process.stdout.write = ((chunk: unknown) => {
|
|
42
|
+
stdout += String(chunk);
|
|
43
|
+
return true;
|
|
44
|
+
}) as typeof process.stdout.write;
|
|
45
|
+
process.stderr.write = ((chunk: unknown) => {
|
|
46
|
+
stderr += String(chunk);
|
|
47
|
+
return true;
|
|
48
|
+
}) as typeof process.stderr.write;
|
|
49
|
+
let status: number;
|
|
50
|
+
try {
|
|
51
|
+
status = runRescue(argv, { env }).status;
|
|
52
|
+
} finally {
|
|
53
|
+
process.stdout.write = origOut;
|
|
54
|
+
process.stderr.write = origErr;
|
|
55
|
+
}
|
|
56
|
+
return { status, stdout, stderr };
|
|
57
|
+
}
|
|
58
|
+
|
|
36
59
|
describe.skipIf(!gitAvailable)("rescue drift convergence guard", () => {
|
|
37
60
|
let workDir: string;
|
|
38
61
|
let upstream: string;
|
|
@@ -118,11 +141,9 @@ exec ${JSON.stringify(realGit)} "$@"
|
|
|
118
141
|
if (workDir) fs.rmSync(workDir, { recursive: true, force: true });
|
|
119
142
|
});
|
|
120
143
|
|
|
121
|
-
function
|
|
122
|
-
return
|
|
123
|
-
"bash",
|
|
144
|
+
function runRescueDry() {
|
|
145
|
+
return runRescueCapture(
|
|
124
146
|
[
|
|
125
|
-
RESCUE_SCRIPT,
|
|
126
147
|
"--hq-root", hqRoot,
|
|
127
148
|
"--source", "test/repo",
|
|
128
149
|
"--ref", "main",
|
|
@@ -131,12 +152,12 @@ exec ${JSON.stringify(realGit)} "$@"
|
|
|
131
152
|
"--yes",
|
|
132
153
|
"--no-backup",
|
|
133
154
|
],
|
|
134
|
-
|
|
155
|
+
env,
|
|
135
156
|
);
|
|
136
157
|
}
|
|
137
158
|
|
|
138
159
|
it("reconciles a floor-drifted file that is identical to upstream HEAD (no rescue)", () => {
|
|
139
|
-
const r =
|
|
160
|
+
const r = runRescueDry();
|
|
140
161
|
const out = `${r.stdout}\n${r.stderr}`;
|
|
141
162
|
// Sanity: ran in history_floor mode against our floor.
|
|
142
163
|
expect(r.status, out).toBe(0);
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression for the rescue → sync-journal stale-baseline bug.
|
|
3
|
+
*
|
|
4
|
+
* Root cause: the rescue overlay rewrites scaffold files from upstream but
|
|
5
|
+
* never re-stamps the personal-vault sync journal. The journal keeps the
|
|
6
|
+
* PRE-rescue hash, so the next sync reads localHash != journal.hash ("local
|
|
7
|
+
* changed") and — when the vault also moved — mints a false `.conflict-*`
|
|
8
|
+
* mirror. `runRescue` now re-stamps the journal baseline for the files the
|
|
9
|
+
* overlay actually re-laid (scoped to rsync `-i` itemize output, so a user's
|
|
10
|
+
* pending edit is never touched).
|
|
11
|
+
*
|
|
12
|
+
* This test seeds a journal whose entry for an overlaid file carries the OLD
|
|
13
|
+
* hash, runs a real (non-dry-run) rescue, and asserts the entry was re-stamped
|
|
14
|
+
* to the freshly laid-down content — i.e. `localChanged` would now be false.
|
|
15
|
+
*/
|
|
16
|
+
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
|
17
|
+
import { execFileSync } from "child_process";
|
|
18
|
+
import * as fs from "fs";
|
|
19
|
+
import * as os from "os";
|
|
20
|
+
import * as path from "path";
|
|
21
|
+
import { runRescue } from "./rescue-core.js";
|
|
22
|
+
import {
|
|
23
|
+
hashFile,
|
|
24
|
+
readJournal,
|
|
25
|
+
writeJournal,
|
|
26
|
+
PERSONAL_VAULT_JOURNAL_SLUG,
|
|
27
|
+
} from "../journal.js";
|
|
28
|
+
|
|
29
|
+
function hasGit(): boolean {
|
|
30
|
+
try {
|
|
31
|
+
execFileSync("git", ["--version"], { stdio: "ignore" });
|
|
32
|
+
return true;
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const gitAvailable = hasGit();
|
|
38
|
+
|
|
39
|
+
function runRescueCapture(argv: string[], env: NodeJS.ProcessEnv) {
|
|
40
|
+
let stdout = "";
|
|
41
|
+
const origOut = process.stdout.write.bind(process.stdout);
|
|
42
|
+
const origErr = process.stderr.write.bind(process.stderr);
|
|
43
|
+
process.stdout.write = ((c: unknown) => ((stdout += String(c)), true)) as typeof process.stdout.write;
|
|
44
|
+
process.stderr.write = (() => true) as typeof process.stderr.write;
|
|
45
|
+
let status: number;
|
|
46
|
+
try {
|
|
47
|
+
status = runRescue(argv, { env }).status;
|
|
48
|
+
} finally {
|
|
49
|
+
process.stdout.write = origOut;
|
|
50
|
+
process.stderr.write = origErr;
|
|
51
|
+
}
|
|
52
|
+
return { status, stdout };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe.skipIf(!gitAvailable)("rescue re-stamps sync-journal baseline for re-laid files", () => {
|
|
56
|
+
let workDir: string, upstream: string, hqRoot: string, stateDir: string, floorSha: string;
|
|
57
|
+
let env: NodeJS.ProcessEnv;
|
|
58
|
+
let savedStateDir: string | undefined;
|
|
59
|
+
|
|
60
|
+
const git = (cwd: string, ...args: string[]) =>
|
|
61
|
+
execFileSync("git", args, {
|
|
62
|
+
cwd,
|
|
63
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
64
|
+
env: { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" },
|
|
65
|
+
}).toString().trim();
|
|
66
|
+
|
|
67
|
+
beforeAll(() => {
|
|
68
|
+
workDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-rescue-journal-"));
|
|
69
|
+
|
|
70
|
+
// upstream: floor (doc.md=v1), then HEAD advances doc.md -> v2.
|
|
71
|
+
upstream = path.join(workDir, "upstream");
|
|
72
|
+
fs.mkdirSync(path.join(upstream, "core"), { recursive: true });
|
|
73
|
+
git(workDir, "init", "-b", "main", "upstream");
|
|
74
|
+
fs.writeFileSync(path.join(upstream, "core/doc.md"), "v1\n");
|
|
75
|
+
git(upstream, "add", "-A");
|
|
76
|
+
git(upstream, "commit", "-m", "floor");
|
|
77
|
+
floorSha = git(upstream, "rev-parse", "HEAD");
|
|
78
|
+
fs.writeFileSync(path.join(upstream, "core/doc.md"), "v2\n");
|
|
79
|
+
git(upstream, "add", "-A");
|
|
80
|
+
git(upstream, "commit", "-m", "head");
|
|
81
|
+
|
|
82
|
+
// local HQ root: doc.md == floor (v1) -> rescue overlays it to HEAD (v2).
|
|
83
|
+
hqRoot = path.join(workDir, "hq");
|
|
84
|
+
fs.mkdirSync(path.join(hqRoot, "core"), { recursive: true });
|
|
85
|
+
fs.mkdirSync(path.join(hqRoot, "personal"), { recursive: true });
|
|
86
|
+
fs.mkdirSync(path.join(hqRoot, "companies"), { recursive: true });
|
|
87
|
+
const docAbs = path.join(hqRoot, "core/doc.md");
|
|
88
|
+
fs.writeFileSync(docAbs, "v1\n");
|
|
89
|
+
|
|
90
|
+
// state dir + seeded personal-vault journal carrying the STALE (v1) hash.
|
|
91
|
+
stateDir = path.join(workDir, "state");
|
|
92
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
93
|
+
savedStateDir = process.env.HQ_STATE_DIR;
|
|
94
|
+
process.env.HQ_STATE_DIR = stateDir; // getStateDir() reads process.env
|
|
95
|
+
const staleHash = hashFile(docAbs); // hash of v1
|
|
96
|
+
writeJournal(PERSONAL_VAULT_JOURNAL_SLUG, {
|
|
97
|
+
version: "2",
|
|
98
|
+
lastSync: new Date(0).toISOString(),
|
|
99
|
+
files: {
|
|
100
|
+
"core/doc.md": {
|
|
101
|
+
hash: staleHash,
|
|
102
|
+
size: fs.statSync(docAbs).size,
|
|
103
|
+
syncedAt: new Date(0).toISOString(),
|
|
104
|
+
direction: "down",
|
|
105
|
+
remoteEtag: "seed-etag",
|
|
106
|
+
mtimeMs: fs.statSync(docAbs).mtimeMs,
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
pulls: [],
|
|
110
|
+
} as never);
|
|
111
|
+
|
|
112
|
+
// git shim: redirect `git clone <github-url>` to the local fixture.
|
|
113
|
+
const realGit = execFileSync("bash", ["-lc", "command -v git"]).toString().trim() || "/usr/bin/git";
|
|
114
|
+
const shimDir = path.join(workDir, "shim");
|
|
115
|
+
fs.mkdirSync(shimDir, { recursive: true });
|
|
116
|
+
fs.writeFileSync(
|
|
117
|
+
path.join(shimDir, "git"),
|
|
118
|
+
`#!/usr/bin/env bash
|
|
119
|
+
if [ "$1" = "clone" ]; then
|
|
120
|
+
args=(); for a in "$@"; do case "$a" in https://github.com/*) a=${JSON.stringify(upstream)} ;; esac; args+=("$a"); done
|
|
121
|
+
exec ${JSON.stringify(realGit)} "\${args[@]}"
|
|
122
|
+
fi
|
|
123
|
+
exec ${JSON.stringify(realGit)} "$@"
|
|
124
|
+
`,
|
|
125
|
+
{ mode: 0o755 },
|
|
126
|
+
);
|
|
127
|
+
env = { ...process.env, PATH: `${shimDir}:${process.env.PATH ?? ""}`, HQ_STATE_DIR: stateDir };
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
afterAll(() => {
|
|
131
|
+
if (savedStateDir === undefined) delete process.env.HQ_STATE_DIR;
|
|
132
|
+
else process.env.HQ_STATE_DIR = savedStateDir;
|
|
133
|
+
if (workDir) fs.rmSync(workDir, { recursive: true, force: true });
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("re-stamps the journal so the overlaid file is no longer seen as locally changed", () => {
|
|
137
|
+
const docAbs = path.join(hqRoot, "core/doc.md");
|
|
138
|
+
const staleHash = readJournal(PERSONAL_VAULT_JOURNAL_SLUG).files["core/doc.md"].hash;
|
|
139
|
+
|
|
140
|
+
const r = runRescueCapture(
|
|
141
|
+
["--hq-root", hqRoot, "--source", "test/repo", "--ref", "main", "--floor-sha", floorSha, "--yes", "--no-backup"],
|
|
142
|
+
env,
|
|
143
|
+
);
|
|
144
|
+
expect(r.status, r.stdout).toBe(0);
|
|
145
|
+
|
|
146
|
+
// overlay actually re-laid the file (v1 -> v2)
|
|
147
|
+
expect(fs.readFileSync(docAbs, "utf-8")).toBe("v2\n");
|
|
148
|
+
|
|
149
|
+
// journal entry was re-stamped to the NEW content's hash
|
|
150
|
+
const entry = readJournal(PERSONAL_VAULT_JOURNAL_SLUG).files["core/doc.md"];
|
|
151
|
+
expect(entry.hash).toBe(hashFile(docAbs)); // == hash(v2): localChanged would be FALSE
|
|
152
|
+
expect(entry.hash).not.toBe(staleHash); // proves it changed from the stale v1 baseline
|
|
153
|
+
expect(entry.remoteEtag).toBe("seed-etag"); // remote side untouched -> clean push/converge, not conflict
|
|
154
|
+
expect(r.stdout).toContain("Reconciled sync-journal baseline");
|
|
155
|
+
});
|
|
156
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Integration regression for the rescue mtime-preservation fix in
|
|
3
|
-
*
|
|
3
|
+
* src/cli/rescue-core.ts.
|
|
4
4
|
*
|
|
5
5
|
* The bug: rescue rebuilds the tree by `git clone` + `rsync -a`. A git
|
|
6
6
|
* checkout stamps every file with clone-time (git stores no per-file mtimes),
|
|
@@ -9,24 +9,23 @@
|
|
|
9
9
|
* this: hundreds of files sharing one mtime, mtime == ctime == birth).
|
|
10
10
|
*
|
|
11
11
|
* The fix has three parts, all asserted below:
|
|
12
|
-
* - Layer 1: UNCHANGED files are left in place; the
|
|
13
|
-
*
|
|
12
|
+
* - Layer 1: UNCHANGED files are left in place; the overlay excludes them, so
|
|
13
|
+
* their existing mtime survives.
|
|
14
14
|
* - Layer 2: files the overlay DOES write (upstream-changed, brand-new) get
|
|
15
15
|
* the git committer-date of their last-modifying commit, not wall-clock.
|
|
16
16
|
* - Layer 3: full history is used (history_floor mode clones full history;
|
|
17
17
|
* the shallow path unshallows) so per-file commit times are real.
|
|
18
18
|
*
|
|
19
|
-
* The
|
|
19
|
+
* The rescue clones from GitHub, so we shim `git clone` to a local fixture
|
|
20
20
|
* (same technique as rescue-drift-reconcile.test.ts) and run for real
|
|
21
21
|
* (non-dry-run, --no-backup) so the overlay actually lays files down.
|
|
22
22
|
*/
|
|
23
23
|
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
|
24
|
-
import { execFileSync
|
|
24
|
+
import { execFileSync } from "child_process";
|
|
25
25
|
import * as fs from "fs";
|
|
26
26
|
import * as os from "os";
|
|
27
27
|
import * as path from "path";
|
|
28
|
-
|
|
29
|
-
const RESCUE_SCRIPT = path.resolve(process.cwd(), "scripts/replace-rescue.sh");
|
|
28
|
+
import { runRescue } from "./rescue-core.js";
|
|
30
29
|
|
|
31
30
|
function has(bin: string, ...args: string[]): boolean {
|
|
32
31
|
try {
|
|
@@ -37,15 +36,39 @@ function has(bin: string, ...args: string[]): boolean {
|
|
|
37
36
|
}
|
|
38
37
|
}
|
|
39
38
|
|
|
40
|
-
// Needs git (fixture + clone)
|
|
41
|
-
|
|
42
|
-
|
|
39
|
+
// Needs git (fixture + clone) and rsync (overlay). The mtime restore is now
|
|
40
|
+
// native (fs.utimesSync), so perl is no longer required.
|
|
41
|
+
const toolsAvailable = has("git", "--version") && has("rsync", "--version");
|
|
43
42
|
|
|
44
43
|
// Fixed commit epochs so assertions are exact and machine-independent.
|
|
45
44
|
const FLOOR_EPOCH = 1577836800; // 2020-01-01T00:00:00Z
|
|
46
45
|
const HEAD_EPOCH = 1609459200; // 2021-01-01T00:00:00Z
|
|
47
46
|
const KEEP_PRESET_EPOCH = 1546300800; // 2019-01-01T00:00:00Z (local, pre-rescue)
|
|
48
47
|
|
|
48
|
+
/** Run the rescue in-process, capturing its stdout/stderr. */
|
|
49
|
+
function runRescueCapture(argv: string[], env: NodeJS.ProcessEnv) {
|
|
50
|
+
let stdout = "";
|
|
51
|
+
let stderr = "";
|
|
52
|
+
const origOut = process.stdout.write.bind(process.stdout);
|
|
53
|
+
const origErr = process.stderr.write.bind(process.stderr);
|
|
54
|
+
process.stdout.write = ((chunk: unknown) => {
|
|
55
|
+
stdout += String(chunk);
|
|
56
|
+
return true;
|
|
57
|
+
}) as typeof process.stdout.write;
|
|
58
|
+
process.stderr.write = ((chunk: unknown) => {
|
|
59
|
+
stderr += String(chunk);
|
|
60
|
+
return true;
|
|
61
|
+
}) as typeof process.stderr.write;
|
|
62
|
+
let status: number;
|
|
63
|
+
try {
|
|
64
|
+
status = runRescue(argv, { env }).status;
|
|
65
|
+
} finally {
|
|
66
|
+
process.stdout.write = origOut;
|
|
67
|
+
process.stderr.write = origErr;
|
|
68
|
+
}
|
|
69
|
+
return { status, stdout, stderr };
|
|
70
|
+
}
|
|
71
|
+
|
|
49
72
|
describe.skipIf(!toolsAvailable)("rescue preserves mtimes (no clone-time flattening)", () => {
|
|
50
73
|
let workDir: string;
|
|
51
74
|
let upstream: string;
|
|
@@ -129,10 +152,8 @@ exec ${JSON.stringify(realGit)} "$@"
|
|
|
129
152
|
env = { ...process.env, PATH: `${shimDir}:${process.env.PATH ?? ""}` };
|
|
130
153
|
|
|
131
154
|
// --- run the real rescue (non-dry-run) ----------------------------------
|
|
132
|
-
const r =
|
|
133
|
-
"bash",
|
|
155
|
+
const r = runRescueCapture(
|
|
134
156
|
[
|
|
135
|
-
RESCUE_SCRIPT,
|
|
136
157
|
"--hq-root", hqRoot,
|
|
137
158
|
"--source", "test/repo",
|
|
138
159
|
"--ref", "main",
|
|
@@ -140,9 +161,9 @@ exec ${JSON.stringify(realGit)} "$@"
|
|
|
140
161
|
"--yes",
|
|
141
162
|
"--no-backup",
|
|
142
163
|
],
|
|
143
|
-
|
|
164
|
+
env,
|
|
144
165
|
);
|
|
145
|
-
// Surface
|
|
166
|
+
// Surface output on failure for debuggability.
|
|
146
167
|
if (r.status !== 0) {
|
|
147
168
|
throw new Error(`rescue failed (${r.status}):\n${r.stdout}\n${r.stderr}`);
|
|
148
169
|
}
|
|
@@ -3,26 +3,27 @@
|
|
|
3
3
|
* rescue so the generated skill wrappers / personal mirrors / workers registry
|
|
4
4
|
* are refreshed once core/ has been re-laid-down.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
* ./reindex.js is mocked to a spy so we assert the call without
|
|
6
|
+
* The rescue algorithm (./rescue-core.js) is mocked so no real clone/overlay
|
|
7
|
+
* runs, and ./reindex.js is mocked to a spy so we assert the call without
|
|
8
|
+
* touching disk.
|
|
8
9
|
*/
|
|
9
10
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
10
11
|
|
|
11
|
-
vi.mock("
|
|
12
|
-
|
|
12
|
+
vi.mock("./rescue-core.js", () => ({
|
|
13
|
+
runRescue: vi.fn(() => ({ status: 0 })),
|
|
13
14
|
}));
|
|
14
15
|
vi.mock("./reindex.js", () => ({
|
|
15
16
|
reindex: vi.fn(() => ({ status: 0 })),
|
|
16
17
|
}));
|
|
17
18
|
|
|
18
|
-
import {
|
|
19
|
+
import { runRescue } from "./rescue-core.js";
|
|
19
20
|
import { reindex } from "./reindex.js";
|
|
20
21
|
import { rescue } from "./rescue.js";
|
|
21
22
|
|
|
22
23
|
describe("rescue → reindex", () => {
|
|
23
24
|
beforeEach(() => {
|
|
24
25
|
vi.clearAllMocks();
|
|
25
|
-
(
|
|
26
|
+
(runRescue as unknown as ReturnType<typeof vi.fn>).mockReturnValue({ status: 0 });
|
|
26
27
|
});
|
|
27
28
|
|
|
28
29
|
it("refreshes via reindex after a successful rescue", () => {
|
|
@@ -37,8 +38,8 @@ describe("rescue → reindex", () => {
|
|
|
37
38
|
expect(reindex).not.toHaveBeenCalled();
|
|
38
39
|
});
|
|
39
40
|
|
|
40
|
-
it("does NOT run reindex when the rescue
|
|
41
|
-
(
|
|
41
|
+
it("does NOT run reindex when the rescue fails", () => {
|
|
42
|
+
(runRescue as unknown as ReturnType<typeof vi.fn>).mockReturnValueOnce({ status: 1 });
|
|
42
43
|
const r = rescue({ hqRoot: "/tmp/hq", assumeYes: true });
|
|
43
44
|
expect(r.status).toBe(1);
|
|
44
45
|
expect(reindex).not.toHaveBeenCalled();
|
package/src/cli/rescue.test.ts
CHANGED
|
@@ -1,15 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { buildRescueArgs
|
|
3
|
-
|
|
4
|
-
describe("rescueScriptPath", () => {
|
|
5
|
-
it("resolves to the bundled script at the package root", () => {
|
|
6
|
-
const p = rescueScriptPath();
|
|
7
|
-
expect(p.endsWith("scripts/replace-rescue.sh")).toBe(true);
|
|
8
|
-
// From dist/cli/rescue.js the package root is two levels up; ensure we
|
|
9
|
-
// don't accidentally point inside dist/.
|
|
10
|
-
expect(p).not.toContain("/dist/scripts/");
|
|
11
|
-
});
|
|
12
|
-
});
|
|
2
|
+
import { buildRescueArgs } from "./rescue.js";
|
|
13
3
|
|
|
14
4
|
describe("buildRescueArgs", () => {
|
|
15
5
|
it("emits no args for an empty option set (script defaults apply)", () => {
|
package/src/cli/rescue.ts
CHANGED
|
@@ -2,34 +2,18 @@
|
|
|
2
2
|
* hq rescue — re-sync a local HQ tree to an upstream hq-core release (or a
|
|
3
3
|
* staging branch) WITHOUT destroying the user's local edits ("drift").
|
|
4
4
|
*
|
|
5
|
-
* The implementation
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* command — drive the exact same rescue logic.
|
|
5
|
+
* The implementation is native TypeScript in ./rescue-core.ts (the former
|
|
6
|
+
* scripts/replace-rescue.sh, ported and removed). This module owns the public
|
|
7
|
+
* option surface + flag mapping and the post-rescue reindex wiring; the heavy
|
|
8
|
+
* classify/overlay/stamp algorithm lives in rescue-core. BOTH consumers — the
|
|
9
|
+
* HQ Sync menubar app (which spawns `hq-rescue` from this package) and
|
|
10
|
+
* `@indigoai-us/hq-cli`'s `hq rescue` command — drive the exact same logic.
|
|
12
11
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* See scripts/replace-rescue.sh for the full classify/overlay/stamp algorithm.
|
|
12
|
+
* Channel-agnostic: `--source <repo>` + `--ref <tag|branch>` select prod vs
|
|
13
|
+
* staging, and `--floor-sha` pins the three-way history floor.
|
|
16
14
|
*/
|
|
17
|
-
import { spawnSync } from "child_process";
|
|
18
|
-
import { fileURLToPath } from "url";
|
|
19
|
-
import path from "path";
|
|
20
15
|
import { reindex } from "./reindex.js";
|
|
21
|
-
|
|
22
|
-
const __filename = fileURLToPath(import.meta.url);
|
|
23
|
-
const __dirname = path.dirname(__filename);
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Absolute path to the bundled replace-rescue.sh. From the compiled module at
|
|
27
|
-
* dist/cli/rescue.js, the package root is two levels up; the script lives at
|
|
28
|
-
* <package-root>/scripts/replace-rescue.sh.
|
|
29
|
-
*/
|
|
30
|
-
export function rescueScriptPath(): string {
|
|
31
|
-
return path.resolve(__dirname, "..", "..", "scripts", "replace-rescue.sh");
|
|
32
|
-
}
|
|
16
|
+
import { runRescue } from "./rescue-core.js";
|
|
33
17
|
|
|
34
18
|
export interface RescueOptions {
|
|
35
19
|
/** HQ root to operate on. Passed as `--hq-root`. Defaults to the script's
|
|
@@ -101,25 +85,16 @@ export function buildRescueArgs(opts: RescueOptions = {}): string[] {
|
|
|
101
85
|
}
|
|
102
86
|
|
|
103
87
|
/**
|
|
104
|
-
* Run the rescue
|
|
105
|
-
*
|
|
106
|
-
* stdout/stderr
|
|
107
|
-
*
|
|
88
|
+
* Run the rescue against an HQ root. Synchronous — the work is long-running
|
|
89
|
+
* (clone + scan + overlay) and callers want the exit status + live output.
|
|
90
|
+
* stdout/stderr are written directly so the user sees the scan log and the
|
|
91
|
+
* confirmation prompt (unless `assumeYes` is set).
|
|
108
92
|
*/
|
|
109
93
|
export function rescue(opts: RescueOptions = {}): RescueResult {
|
|
110
|
-
const script = rescueScriptPath();
|
|
111
94
|
const args = buildRescueArgs(opts);
|
|
112
|
-
const env = { ...process.env };
|
|
95
|
+
const env: NodeJS.ProcessEnv = { ...process.env };
|
|
113
96
|
if (opts.ghToken) env.GH_TOKEN = opts.ghToken;
|
|
114
|
-
const
|
|
115
|
-
stdio: "inherit",
|
|
116
|
-
env,
|
|
117
|
-
});
|
|
118
|
-
if (res.error) {
|
|
119
|
-
process.stderr.write(`rescue: failed to run ${script}: ${res.error.message}\n`);
|
|
120
|
-
return { status: 1 };
|
|
121
|
-
}
|
|
122
|
-
const status = res.status ?? 1;
|
|
97
|
+
const { status } = runRescue(args, { env });
|
|
123
98
|
// A successful, non-dry-run rescue re-lays-down core/, so refresh the
|
|
124
99
|
// generated skill wrappers, personal-overlay mirrors, and workers registry.
|
|
125
100
|
// Best-effort + idempotent — never overrides the rescue's own exit status.
|
package/src/index.ts
CHANGED
|
@@ -213,11 +213,11 @@ export { promote } from "./cli/index.js";
|
|
|
213
213
|
export type { PromoteOptions, PromoteResult } from "./cli/index.js";
|
|
214
214
|
|
|
215
215
|
// Skill/personal-overlay mirroring + workers-registry regen (`hq reindex`).
|
|
216
|
-
export { reindex
|
|
216
|
+
export { reindex } from "./cli/index.js";
|
|
217
217
|
export type { ReindexOptions, ReindexResult } from "./cli/index.js";
|
|
218
218
|
|
|
219
219
|
// Drift-preserving HQ-core re-sync — shared by the HQ Sync app and `hq rescue`.
|
|
220
|
-
export { rescue,
|
|
220
|
+
export { rescue, buildRescueArgs } from "./cli/index.js";
|
|
221
221
|
export type { RescueOptions, RescueResult } from "./cli/index.js";
|
|
222
222
|
|
|
223
223
|
export type {
|
|
@@ -125,3 +125,149 @@ export function removeConflictEntry(hqRoot: string, id: string): void {
|
|
|
125
125
|
if (filtered.length === index.conflicts.length) return;
|
|
126
126
|
writeConflictIndex(hqRoot, { version: index.version, conflicts: filtered });
|
|
127
127
|
}
|
|
128
|
+
|
|
129
|
+
/** Summary of what a {@link pruneConflictIndex} pass reclaimed. */
|
|
130
|
+
export interface PruneConflictIndexResult {
|
|
131
|
+
/** Rows dropped because their `.conflict-*` mirror no longer exists. */
|
|
132
|
+
prunedOrphans: number;
|
|
133
|
+
/** Rows dropped because the original file and its mirror are byte-identical. */
|
|
134
|
+
prunedIdentical: number;
|
|
135
|
+
/** Byte-identical mirror files deleted from disk during the pass. */
|
|
136
|
+
removedMirrors: number;
|
|
137
|
+
/** Rows kept (genuine divergence, or unprovable — fail-safe retained). */
|
|
138
|
+
kept: number;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Byte-identity check used by the prune pass. Symlink-aware: two symlinks are
|
|
143
|
+
* identical iff their link targets match (mirroring how the detector hashes a
|
|
144
|
+
* symlink record — `hashSymlinkTarget`, not the followed content). A symlink
|
|
145
|
+
* compared against a regular file is never identical. Regular files reject on a
|
|
146
|
+
* size mismatch first (cheap), then compare full bytes.
|
|
147
|
+
*
|
|
148
|
+
* Throws if either side can't be read — callers treat that as "unprovable" and
|
|
149
|
+
* keep the entry rather than risk dropping a real conflict.
|
|
150
|
+
*/
|
|
151
|
+
function entryFilesAreIdentical(
|
|
152
|
+
aPath: string,
|
|
153
|
+
aStat: fs.Stats,
|
|
154
|
+
bPath: string,
|
|
155
|
+
bStat: fs.Stats,
|
|
156
|
+
): boolean {
|
|
157
|
+
const aLink = aStat.isSymbolicLink();
|
|
158
|
+
const bLink = bStat.isSymbolicLink();
|
|
159
|
+
if (aLink || bLink) {
|
|
160
|
+
if (aLink !== bLink) return false;
|
|
161
|
+
return fs.readlinkSync(aPath) === fs.readlinkSync(bPath);
|
|
162
|
+
}
|
|
163
|
+
if (aStat.size !== bStat.size) return false;
|
|
164
|
+
return fs.readFileSync(aPath).equals(fs.readFileSync(bPath));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Garbage-collect the conflict index so it self-heals instead of growing
|
|
169
|
+
* without bound. Before this pass existed the ledger only ever shrank when a
|
|
170
|
+
* human resolved a conflict via `/resolve-conflicts`; every false positive and
|
|
171
|
+
* every orphaned row lingered forever, so the index over-reported the real
|
|
172
|
+
* pending-conflict count (the menubar's journal-derived count stayed correct,
|
|
173
|
+
* but `.hq-conflicts/index.json` did not).
|
|
174
|
+
*
|
|
175
|
+
* Two classes of entry are dropped — both provably not a pending conflict:
|
|
176
|
+
*
|
|
177
|
+
* 1. **Orphaned** — the `.conflict-*` mirror file is gone from disk. The
|
|
178
|
+
* mirror is the only artifact a human resolves against; once it's missing
|
|
179
|
+
* the row can never be acted on, so it's pure litter (the "missing-cloud"
|
|
180
|
+
* rows operators reported climbing into the dozens).
|
|
181
|
+
*
|
|
182
|
+
* 2. **Byte-identical false positives** — the original file and its conflict
|
|
183
|
+
* mirror both still exist and are byte-for-byte identical. The mirror
|
|
184
|
+
* holds the remote bytes captured at detection time, so identical bytes
|
|
185
|
+
* mean there was never a real divergence (this is exactly the manual
|
|
186
|
+
* safe-purge operators have been doing by hand). The stale mirror file is
|
|
187
|
+
* deleted and the row dropped.
|
|
188
|
+
*
|
|
189
|
+
* Conservative by construction — an entry is kept whenever it might be a real
|
|
190
|
+
* conflict: the original file is missing (a genuine local-delete-vs-remote
|
|
191
|
+
* divergence), the bytes differ, or either side can't be read. The index file
|
|
192
|
+
* is only rewritten when at least one row is actually dropped, so a clean
|
|
193
|
+
* ledger keeps its mtime untouched.
|
|
194
|
+
*/
|
|
195
|
+
export function pruneConflictIndex(hqRoot: string): PruneConflictIndexResult {
|
|
196
|
+
const result: PruneConflictIndexResult = {
|
|
197
|
+
prunedOrphans: 0,
|
|
198
|
+
prunedIdentical: 0,
|
|
199
|
+
removedMirrors: 0,
|
|
200
|
+
kept: 0,
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const index = readConflictIndex(hqRoot);
|
|
204
|
+
if (index.conflicts.length === 0) return result;
|
|
205
|
+
|
|
206
|
+
const kept: ConflictIndexEntry[] = [];
|
|
207
|
+
const mirrorsToRemove: string[] = [];
|
|
208
|
+
|
|
209
|
+
for (const entry of index.conflicts) {
|
|
210
|
+
const mirrorAbs = path.join(hqRoot, entry.conflictPath);
|
|
211
|
+
const originalAbs = path.join(hqRoot, entry.originalPath);
|
|
212
|
+
|
|
213
|
+
let mirrorStat: fs.Stats;
|
|
214
|
+
try {
|
|
215
|
+
mirrorStat = fs.lstatSync(mirrorAbs);
|
|
216
|
+
} catch {
|
|
217
|
+
// Mirror is gone — orphaned row, drop it. Nothing on disk to clean up.
|
|
218
|
+
result.prunedOrphans++;
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Both sides must exist to compare. A missing original is a real
|
|
223
|
+
// local-delete divergence — keep it for the human to resolve.
|
|
224
|
+
let originalStat: fs.Stats;
|
|
225
|
+
try {
|
|
226
|
+
originalStat = fs.lstatSync(originalAbs);
|
|
227
|
+
} catch {
|
|
228
|
+
kept.push(entry);
|
|
229
|
+
result.kept++;
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
let identical: boolean;
|
|
234
|
+
try {
|
|
235
|
+
identical = entryFilesAreIdentical(
|
|
236
|
+
originalAbs,
|
|
237
|
+
originalStat,
|
|
238
|
+
mirrorAbs,
|
|
239
|
+
mirrorStat,
|
|
240
|
+
);
|
|
241
|
+
} catch {
|
|
242
|
+
// Couldn't read one side — fail safe, keep the row.
|
|
243
|
+
kept.push(entry);
|
|
244
|
+
result.kept++;
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (identical) {
|
|
249
|
+
result.prunedIdentical++;
|
|
250
|
+
mirrorsToRemove.push(mirrorAbs);
|
|
251
|
+
} else {
|
|
252
|
+
kept.push(entry);
|
|
253
|
+
result.kept++;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// No row dropped → leave the file (and its mtime) untouched.
|
|
258
|
+
if (kept.length === index.conflicts.length) return result;
|
|
259
|
+
|
|
260
|
+
// Delete the byte-identical mirror litter. Best-effort: a leftover identical
|
|
261
|
+
// mirror is cosmetic, never corrupting, so a failed unlink doesn't abort.
|
|
262
|
+
for (const mirrorAbs of mirrorsToRemove) {
|
|
263
|
+
try {
|
|
264
|
+
fs.rmSync(mirrorAbs, { force: true });
|
|
265
|
+
result.removedMirrors++;
|
|
266
|
+
} catch {
|
|
267
|
+
/* best-effort cleanup */
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
writeConflictIndex(hqRoot, { version: index.version, conflicts: kept });
|
|
272
|
+
return result;
|
|
273
|
+
}
|