@indigoai-us/hq-cloud 6.1.0 → 6.2.0
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 +1536 -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-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 +1650 -0
- package/src/cli/rescue-drift-reconcile.test.ts +33 -12
- 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);
|
|
@@ -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
|
+
}
|
package/src/lib/conflict.test.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
import {
|
|
17
17
|
appendConflictEntry,
|
|
18
18
|
getConflictIndexPath,
|
|
19
|
+
pruneConflictIndex,
|
|
19
20
|
readConflictIndex,
|
|
20
21
|
removeConflictEntry,
|
|
21
22
|
writeConflictIndex,
|
|
@@ -142,3 +143,173 @@ describe("conflict index", () => {
|
|
|
142
143
|
expect(idx.conflicts).toEqual([]);
|
|
143
144
|
});
|
|
144
145
|
});
|
|
146
|
+
|
|
147
|
+
describe("pruneConflictIndex", () => {
|
|
148
|
+
let tmpHq: string;
|
|
149
|
+
|
|
150
|
+
beforeEach(() => {
|
|
151
|
+
tmpHq = fs.mkdtempSync(path.join(os.tmpdir(), "hq-cprune-"));
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
afterEach(() => {
|
|
155
|
+
fs.rmSync(tmpHq, { recursive: true, force: true });
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
/** Write a file at a hq-relative path, creating parent dirs. */
|
|
159
|
+
function put(rel: string, content: string): void {
|
|
160
|
+
const abs = path.join(tmpHq, rel);
|
|
161
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
162
|
+
fs.writeFileSync(abs, content);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Write a symlink at a hq-relative path pointing at `target` (verbatim). */
|
|
166
|
+
function putLink(rel: string, target: string): void {
|
|
167
|
+
const abs = path.join(tmpHq, rel);
|
|
168
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
169
|
+
fs.symlinkSync(target, abs);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Build an entry whose original/conflict paths are derived from `id` so each
|
|
174
|
+
* row addresses distinct files on disk (the shared `entry()` helper above
|
|
175
|
+
* reuses one fixed path pair, which would collide across rows here).
|
|
176
|
+
*/
|
|
177
|
+
function rowFor(id: string): ConflictIndexEntry {
|
|
178
|
+
return {
|
|
179
|
+
id,
|
|
180
|
+
originalPath: `dir/${id}.md`,
|
|
181
|
+
conflictPath: `dir/${id}.md.conflict-2026-04-27T22-05-14Z-abc123.md`,
|
|
182
|
+
detectedAt: "2026-04-27T22:05:14Z",
|
|
183
|
+
side: "pull",
|
|
184
|
+
machineId: "abc123",
|
|
185
|
+
localHash: "local",
|
|
186
|
+
remoteHash: "remote",
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
it("returns a zeroed result and writes nothing for an empty/absent index", () => {
|
|
191
|
+
const res = pruneConflictIndex(tmpHq);
|
|
192
|
+
expect(res).toEqual({
|
|
193
|
+
prunedOrphans: 0,
|
|
194
|
+
prunedIdentical: 0,
|
|
195
|
+
removedMirrors: 0,
|
|
196
|
+
kept: 0,
|
|
197
|
+
});
|
|
198
|
+
expect(fs.existsSync(getConflictIndexPath(tmpHq))).toBe(false);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("drops a row whose .conflict-* mirror no longer exists (orphan)", () => {
|
|
202
|
+
const row = rowFor("orphan");
|
|
203
|
+
put(row.originalPath, "still here");
|
|
204
|
+
// No mirror file on disk.
|
|
205
|
+
writeConflictIndex(tmpHq, { version: 1, conflicts: [row] });
|
|
206
|
+
|
|
207
|
+
const res = pruneConflictIndex(tmpHq);
|
|
208
|
+
expect(res.prunedOrphans).toBe(1);
|
|
209
|
+
expect(res.kept).toBe(0);
|
|
210
|
+
expect(readConflictIndex(tmpHq).conflicts).toHaveLength(0);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("drops a byte-identical row and deletes its mirror file", () => {
|
|
214
|
+
const row = rowFor("identical");
|
|
215
|
+
put(row.originalPath, "same bytes");
|
|
216
|
+
put(row.conflictPath, "same bytes");
|
|
217
|
+
writeConflictIndex(tmpHq, { version: 1, conflicts: [row] });
|
|
218
|
+
|
|
219
|
+
const res = pruneConflictIndex(tmpHq);
|
|
220
|
+
expect(res.prunedIdentical).toBe(1);
|
|
221
|
+
expect(res.removedMirrors).toBe(1);
|
|
222
|
+
expect(readConflictIndex(tmpHq).conflicts).toHaveLength(0);
|
|
223
|
+
expect(fs.existsSync(path.join(tmpHq, row.conflictPath))).toBe(false);
|
|
224
|
+
// The user's original is never touched.
|
|
225
|
+
expect(fs.existsSync(path.join(tmpHq, row.originalPath))).toBe(true);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("keeps a genuinely divergent row and leaves both files in place", () => {
|
|
229
|
+
const row = rowFor("real");
|
|
230
|
+
put(row.originalPath, "local edit");
|
|
231
|
+
put(row.conflictPath, "remote edit");
|
|
232
|
+
writeConflictIndex(tmpHq, { version: 1, conflicts: [row] });
|
|
233
|
+
|
|
234
|
+
const res = pruneConflictIndex(tmpHq);
|
|
235
|
+
expect(res.kept).toBe(1);
|
|
236
|
+
expect(res.prunedIdentical).toBe(0);
|
|
237
|
+
expect(res.prunedOrphans).toBe(0);
|
|
238
|
+
expect(readConflictIndex(tmpHq).conflicts.map((c) => c.id)).toEqual(["real"]);
|
|
239
|
+
expect(fs.existsSync(path.join(tmpHq, row.conflictPath))).toBe(true);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("keeps a row whose original is missing (local-delete divergence)", () => {
|
|
243
|
+
const row = rowFor("deleted-local");
|
|
244
|
+
// No original; mirror present.
|
|
245
|
+
put(row.conflictPath, "remote bytes");
|
|
246
|
+
writeConflictIndex(tmpHq, { version: 1, conflicts: [row] });
|
|
247
|
+
|
|
248
|
+
const res = pruneConflictIndex(tmpHq);
|
|
249
|
+
expect(res.kept).toBe(1);
|
|
250
|
+
expect(readConflictIndex(tmpHq).conflicts).toHaveLength(1);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("treats two symlinks with the same target as identical, differing targets as a conflict", () => {
|
|
254
|
+
const same = rowFor("link-same");
|
|
255
|
+
putLink(same.originalPath, "../target/a");
|
|
256
|
+
putLink(same.conflictPath, "../target/a");
|
|
257
|
+
|
|
258
|
+
const diff = rowFor("link-diff");
|
|
259
|
+
putLink(diff.originalPath, "../target/a");
|
|
260
|
+
putLink(diff.conflictPath, "../target/b");
|
|
261
|
+
|
|
262
|
+
writeConflictIndex(tmpHq, { version: 1, conflicts: [same, diff] });
|
|
263
|
+
|
|
264
|
+
const res = pruneConflictIndex(tmpHq);
|
|
265
|
+
expect(res.prunedIdentical).toBe(1);
|
|
266
|
+
expect(res.kept).toBe(1);
|
|
267
|
+
expect(readConflictIndex(tmpHq).conflicts.map((c) => c.id)).toEqual([
|
|
268
|
+
"link-diff",
|
|
269
|
+
]);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("partitions a mixed batch and preserves only the real conflicts", () => {
|
|
273
|
+
const orphan = rowFor("orphan");
|
|
274
|
+
put(orphan.originalPath, "x"); // mirror missing
|
|
275
|
+
|
|
276
|
+
const identical = rowFor("identical");
|
|
277
|
+
put(identical.originalPath, "dup");
|
|
278
|
+
put(identical.conflictPath, "dup");
|
|
279
|
+
|
|
280
|
+
const real = rowFor("real");
|
|
281
|
+
put(real.originalPath, "L");
|
|
282
|
+
put(real.conflictPath, "R");
|
|
283
|
+
|
|
284
|
+
writeConflictIndex(tmpHq, {
|
|
285
|
+
version: 1,
|
|
286
|
+
conflicts: [orphan, identical, real],
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
const res = pruneConflictIndex(tmpHq);
|
|
290
|
+
expect(res).toEqual({
|
|
291
|
+
prunedOrphans: 1,
|
|
292
|
+
prunedIdentical: 1,
|
|
293
|
+
removedMirrors: 1,
|
|
294
|
+
kept: 1,
|
|
295
|
+
});
|
|
296
|
+
expect(readConflictIndex(tmpHq).conflicts.map((c) => c.id)).toEqual(["real"]);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("does not rewrite the index file when nothing is pruned", () => {
|
|
300
|
+
const real = rowFor("real");
|
|
301
|
+
put(real.originalPath, "L");
|
|
302
|
+
put(real.conflictPath, "R");
|
|
303
|
+
writeConflictIndex(tmpHq, { version: 1, conflicts: [real] });
|
|
304
|
+
|
|
305
|
+
const indexPath = getConflictIndexPath(tmpHq);
|
|
306
|
+
const before = fs.statSync(indexPath).mtimeMs;
|
|
307
|
+
// Advance the clock past filesystem mtime granularity, then prune.
|
|
308
|
+
const spin = Date.now() + 20;
|
|
309
|
+
while (Date.now() < spin) {
|
|
310
|
+
/* busy-wait ~20ms so a rewrite would move mtime */
|
|
311
|
+
}
|
|
312
|
+
pruneConflictIndex(tmpHq);
|
|
313
|
+
expect(fs.statSync(indexPath).mtimeMs).toBe(before);
|
|
314
|
+
});
|
|
315
|
+
});
|