@indigoai-us/hq-cloud 6.14.7 → 6.14.9
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/backup-prune.d.ts +68 -0
- package/dist/backup-prune.d.ts.map +1 -0
- package/dist/backup-prune.js +196 -0
- package/dist/backup-prune.js.map +1 -0
- package/dist/backup-prune.test.d.ts +2 -0
- package/dist/backup-prune.test.d.ts.map +1 -0
- package/dist/backup-prune.test.js +89 -0
- package/dist/backup-prune.test.js.map +1 -0
- package/dist/bin/backup-prune-runner.d.ts +3 -0
- package/dist/bin/backup-prune-runner.d.ts.map +1 -0
- package/dist/bin/backup-prune-runner.js +50 -0
- package/dist/bin/backup-prune-runner.js.map +1 -0
- package/dist/bin/sync-runner-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +8 -0
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner-planning.d.ts +1 -1
- package/dist/bin/sync-runner-planning.d.ts.map +1 -1
- package/dist/bin/sync-runner-planning.js +15 -1
- package/dist/bin/sync-runner-planning.js.map +1 -1
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +28 -0
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +14 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +26 -0
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +68 -1
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/rescue-classify-ordering.test.js +105 -0
- package/dist/cli/rescue-classify-ordering.test.js.map +1 -1
- package/dist/cli/rescue-core.d.ts +1 -0
- package/dist/cli/rescue-core.d.ts.map +1 -1
- package/dist/cli/rescue-core.js +478 -300
- package/dist/cli/rescue-core.js.map +1 -1
- package/dist/cli/rescue-snapshot.d.ts +14 -0
- package/dist/cli/rescue-snapshot.d.ts.map +1 -0
- package/dist/cli/rescue-snapshot.js +39 -0
- package/dist/cli/rescue-snapshot.js.map +1 -0
- package/dist/cli/rescue-snapshot.test.d.ts +2 -0
- package/dist/cli/rescue-snapshot.test.d.ts.map +1 -0
- package/dist/cli/rescue-snapshot.test.js +46 -0
- package/dist/cli/rescue-snapshot.test.js.map +1 -0
- package/dist/cli/sync-scope.test.js +3 -1
- package/dist/cli/sync-scope.test.js.map +1 -1
- package/dist/cli/sync.d.ts +6 -6
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +51 -41
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +102 -2
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/manifest-reconcile.d.ts +27 -0
- package/dist/manifest-reconcile.d.ts.map +1 -0
- package/dist/manifest-reconcile.js +120 -0
- package/dist/manifest-reconcile.js.map +1 -0
- package/dist/manifest-reconcile.test.d.ts +2 -0
- package/dist/manifest-reconcile.test.d.ts.map +1 -0
- package/dist/manifest-reconcile.test.js +97 -0
- package/dist/manifest-reconcile.test.js.map +1 -0
- package/dist/object-io.d.ts +19 -2
- package/dist/object-io.d.ts.map +1 -1
- package/dist/object-io.js +168 -32
- package/dist/object-io.js.map +1 -1
- package/dist/object-io.test.js +157 -1
- package/dist/object-io.test.js.map +1 -1
- package/dist/s3.d.ts +12 -1
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +51 -18
- package/dist/s3.js.map +1 -1
- package/dist/s3.test.js +12 -1
- package/dist/s3.test.js.map +1 -1
- package/dist/signals/get.test.js +21 -1
- package/dist/signals/get.test.js.map +1 -1
- package/dist/signals/list.test.js +21 -1
- package/dist/signals/list.test.js.map +1 -1
- package/dist/sources/get.test.js +21 -1
- package/dist/sources/get.test.js.map +1 -1
- package/dist/sources/list.test.js +21 -1
- package/dist/sources/list.test.js.map +1 -1
- package/package.json +3 -2
- package/src/backup-prune.test.ts +98 -0
- package/src/backup-prune.ts +182 -0
- package/src/bin/backup-prune-runner.ts +33 -0
- package/src/bin/sync-runner-company.ts +7 -0
- package/src/bin/sync-runner-planning.ts +16 -2
- package/src/bin/sync-runner-watch-loop.ts +18 -0
- package/src/bin/sync-runner.test.ts +82 -1
- package/src/bin/sync-runner.ts +45 -0
- package/src/cli/rescue-classify-ordering.test.ts +121 -0
- package/src/cli/rescue-core.ts +261 -86
- package/src/cli/rescue-snapshot.test.ts +57 -0
- package/src/cli/rescue-snapshot.ts +51 -0
- package/src/cli/sync-scope.test.ts +3 -1
- package/src/cli/sync.test.ts +121 -2
- package/src/cli/sync.ts +71 -53
- package/src/manifest-reconcile.test.ts +107 -0
- package/src/manifest-reconcile.ts +160 -0
- package/src/object-io.test.ts +175 -0
- package/src/object-io.ts +213 -32
- package/src/s3.test.ts +18 -0
- package/src/s3.ts +64 -30
- package/src/signals/get.test.ts +26 -2
- package/src/signals/list.test.ts +26 -2
- package/src/sources/get.test.ts +26 -2
- package/src/sources/list.test.ts +26 -2
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as path from "path";
|
|
2
|
+
import { inventoryManagedSnapshots } from "../backup-prune.js";
|
|
2
3
|
import { VaultClient } from "../index.js";
|
|
3
4
|
import { pickCanonicalPersonEntity } from "../vault-client.js";
|
|
4
5
|
import {
|
|
@@ -98,6 +99,15 @@ export async function runWatchLoop(
|
|
|
98
99
|
const eventPush = argv.includes("--event-push");
|
|
99
100
|
const companiesMode = argv.includes("--companies");
|
|
100
101
|
const hqRoot = parsed.hqRoot;
|
|
102
|
+
// Delayed and daily: this must never be attached to the 15s poll cadence.
|
|
103
|
+
// Stage 0 remains dry-run only; the standalone hq-backup-prune command is
|
|
104
|
+
// the explicitly-gated apply surface.
|
|
105
|
+
let nextBackupPruneAt = Date.now() + 60_000;
|
|
106
|
+
const runBackupPrune = deps.runBackupPrune ?? (async (root: string) => {
|
|
107
|
+
const backupRoot = path.join(process.env.HOME ?? "", ".hq", "backups");
|
|
108
|
+
const plan = inventoryManagedSnapshots(backupRoot, root, (m) => { try { process.kill(m.creatorPid, 0); return true; } catch { return false; } });
|
|
109
|
+
process.stderr.write(`hq-sync-runner: backup retention dry-run — ${plan.candidates.length} candidate(s), ${plan.protected.length} protected\n`);
|
|
110
|
+
});
|
|
101
111
|
|
|
102
112
|
const passArgv = argv.filter((a, i) => {
|
|
103
113
|
if (a === "--watch") return false;
|
|
@@ -510,6 +520,14 @@ export async function runWatchLoop(
|
|
|
510
520
|
} else if (result !== 0) {
|
|
511
521
|
return result;
|
|
512
522
|
}
|
|
523
|
+
if (Date.now() >= nextBackupPruneAt) {
|
|
524
|
+
// Advance before invoking: a failed/unavailable backstop cannot become
|
|
525
|
+
// an every-tick retry loop. It is diagnostic only and never restarts
|
|
526
|
+
// the sync daemon.
|
|
527
|
+
nextBackupPruneAt = Date.now() + 24 * 60 * 60 * 1000;
|
|
528
|
+
try { await runBackupPrune(hqRoot); }
|
|
529
|
+
catch (error) { process.stderr.write(`hq-sync-runner: backup retention dry-run failed — ${error instanceof Error ? error.message : String(error)}\n`); }
|
|
530
|
+
}
|
|
513
531
|
await Promise.race([sleep(pollMs), stoppedSignal]);
|
|
514
532
|
}
|
|
515
533
|
return 0;
|
|
@@ -47,7 +47,7 @@ import type {
|
|
|
47
47
|
PendingInviteByEmail,
|
|
48
48
|
TelemetryEventsBatch,
|
|
49
49
|
} from "../vault-client.js";
|
|
50
|
-
import { VaultAuthError } from "../vault-client.js";
|
|
50
|
+
import { VaultAuthError, VaultNotFoundError } from "../vault-client.js";
|
|
51
51
|
import type { PushEvent } from "../sync/push-event.js";
|
|
52
52
|
|
|
53
53
|
// ---------------------------------------------------------------------------
|
|
@@ -219,6 +219,7 @@ function makeDeps(overrides: Partial<RunnerDeps> = {}): TestDeps {
|
|
|
219
219
|
embedded: false,
|
|
220
220
|
pendingDirty: false,
|
|
221
221
|
})),
|
|
222
|
+
reconcileManifest: vi.fn().mockResolvedValue(undefined),
|
|
222
223
|
...overrides,
|
|
223
224
|
stdout,
|
|
224
225
|
stderr,
|
|
@@ -926,6 +927,86 @@ describe("fanout-plan", () => {
|
|
|
926
927
|
expect(plan.companies).toEqual([{ uid: "cmp_ghost", slug: "cmp_ghost" }]);
|
|
927
928
|
});
|
|
928
929
|
|
|
930
|
+
it("skips a ghost membership whose entity is gone so it cannot force exit 2", async () => {
|
|
931
|
+
const sync = vi.fn().mockResolvedValue(defaultSyncResult());
|
|
932
|
+
const deps = makeDeps({
|
|
933
|
+
createVaultClient: () =>
|
|
934
|
+
makeVaultStub({
|
|
935
|
+
memberships: [
|
|
936
|
+
{ companyUid: "cmp_live" },
|
|
937
|
+
{ companyUid: "cmp_ghost" },
|
|
938
|
+
],
|
|
939
|
+
entityGet: (uid: string) => {
|
|
940
|
+
if (uid === "cmp_ghost") {
|
|
941
|
+
return Promise.reject(new VaultNotFoundError("company deleted"));
|
|
942
|
+
}
|
|
943
|
+
return Promise.resolve({
|
|
944
|
+
uid,
|
|
945
|
+
slug: "live",
|
|
946
|
+
type: "company",
|
|
947
|
+
status: "active",
|
|
948
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
949
|
+
});
|
|
950
|
+
},
|
|
951
|
+
}),
|
|
952
|
+
sync,
|
|
953
|
+
});
|
|
954
|
+
|
|
955
|
+
const code = await runRunner(["--companies"], deps);
|
|
956
|
+
|
|
957
|
+
expect(code).toBe(0);
|
|
958
|
+
expect(sync).toHaveBeenCalledTimes(1);
|
|
959
|
+
expect(sync).toHaveBeenCalledWith(
|
|
960
|
+
expect.objectContaining({ company: "cmp_live" }),
|
|
961
|
+
);
|
|
962
|
+
const plan = deps.stdout
|
|
963
|
+
.events()
|
|
964
|
+
.find((e) => e.type === "fanout-plan") as Extract<
|
|
965
|
+
RunnerEvent,
|
|
966
|
+
{ type: "fanout-plan" }
|
|
967
|
+
>;
|
|
968
|
+
expect(plan.companies).toEqual([{ uid: "cmp_live", slug: "live" }]);
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
it("reconciles after the personal leg completes", async () => {
|
|
972
|
+
const order: string[] = [];
|
|
973
|
+
const deps = makeDeps({
|
|
974
|
+
createVaultClient: () =>
|
|
975
|
+
makeVaultStub({
|
|
976
|
+
memberships: [{ companyUid: "cmp_acme" }],
|
|
977
|
+
entityGet: (uid: string) =>
|
|
978
|
+
Promise.resolve({
|
|
979
|
+
uid,
|
|
980
|
+
slug: "acme",
|
|
981
|
+
type: "company",
|
|
982
|
+
status: "active",
|
|
983
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
984
|
+
}),
|
|
985
|
+
listPersons: () =>
|
|
986
|
+
Promise.resolve([
|
|
987
|
+
{
|
|
988
|
+
uid: "prs_me",
|
|
989
|
+
slug: "me",
|
|
990
|
+
type: "person",
|
|
991
|
+
status: "active",
|
|
992
|
+
bucketName: "hq-vault-prs-me",
|
|
993
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
994
|
+
} as EntityInfo,
|
|
995
|
+
]),
|
|
996
|
+
}),
|
|
997
|
+
sync: vi.fn().mockImplementation(async (options: SyncOptions) => {
|
|
998
|
+
order.push(options.personalMode === true ? "personal" : "company");
|
|
999
|
+
return defaultSyncResult();
|
|
1000
|
+
}),
|
|
1001
|
+
reconcileManifest: vi.fn().mockImplementation(async () => {
|
|
1002
|
+
order.push("reconcile");
|
|
1003
|
+
}),
|
|
1004
|
+
});
|
|
1005
|
+
|
|
1006
|
+
expect(await runRunner(["--companies"], deps)).toBe(0);
|
|
1007
|
+
expect(order).toEqual(["company", "personal", "reconcile"]);
|
|
1008
|
+
});
|
|
1009
|
+
|
|
929
1010
|
it("includes entity.name on plan entries when available", async () => {
|
|
930
1011
|
const deps = makeDeps({
|
|
931
1012
|
createVaultClient: () =>
|
package/src/bin/sync-runner.ts
CHANGED
|
@@ -139,6 +139,10 @@ import {
|
|
|
139
139
|
emitFanoutPlan,
|
|
140
140
|
resolveMembershipsForRun,
|
|
141
141
|
} from "./sync-runner-planning.js";
|
|
142
|
+
import {
|
|
143
|
+
reconcileCompanyManifest,
|
|
144
|
+
type ManifestReconcileOptions,
|
|
145
|
+
} from "../manifest-reconcile.js";
|
|
142
146
|
import { executeCompanyFanout } from "./sync-runner-company.js";
|
|
143
147
|
import { rollupAllComplete } from "./sync-runner-rollup.js";
|
|
144
148
|
import { emitTelemetry } from "./sync-runner-telemetry.js";
|
|
@@ -351,6 +355,10 @@ export type RunnerEvent =
|
|
|
351
355
|
| { type: "new-files"; company: string; files: Array<{ path: string; bytes: number; addedBy: string | null }> }
|
|
352
356
|
| { type: "scope-excluded"; company: string; count: number; samplePaths: string[] }
|
|
353
357
|
| { type: "ignore-excluded"; company: string; count: number; totalExcluded: number; samplePaths: string[] }
|
|
358
|
+
| ({ type: "skip-invalid-scoped-key"; company: string } & Omit<
|
|
359
|
+
Extract<SyncProgressEvent, { type: "skip-invalid-scoped-key" }>,
|
|
360
|
+
"type"
|
|
361
|
+
>)
|
|
354
362
|
| ({
|
|
355
363
|
type: "complete";
|
|
356
364
|
company: string;
|
|
@@ -603,6 +611,14 @@ export interface RunnerDeps {
|
|
|
603
611
|
hqRoot: string,
|
|
604
612
|
options: QmdReindexOptions,
|
|
605
613
|
) => ReturnType<typeof reindexAfterSync>;
|
|
614
|
+
/**
|
|
615
|
+
* Merge successfully pulled cloud companies into the local manifest after
|
|
616
|
+
* the complete fanout. Injectable so runner tests do not touch a developer's
|
|
617
|
+
* HQ root.
|
|
618
|
+
*/
|
|
619
|
+
reconcileManifest?: (
|
|
620
|
+
options: ManifestReconcileOptions,
|
|
621
|
+
) => Promise<void>;
|
|
606
622
|
/** Internal: set when runRunner is invoked under the per-root operation lock. */
|
|
607
623
|
operationLockAlreadyHeld?: boolean;
|
|
608
624
|
/** Share function (push phase). Defaults to `cli/share.share`. */
|
|
@@ -1311,6 +1327,33 @@ export async function runRunner(
|
|
|
1311
1327
|
const { errors, allConflicts, meaningfulOutcomeCount } = fanout;
|
|
1312
1328
|
const rollup = rollupAllComplete(plan, fanout.stateByCompany);
|
|
1313
1329
|
|
|
1330
|
+
// The personal-vault target runs last and can pull an older copy of the
|
|
1331
|
+
// manifest. Reconcile only after the entire pull fanout has settled so the
|
|
1332
|
+
// successfully materialized cloud directories restore their own entries.
|
|
1333
|
+
if (parsed.direction !== "push") {
|
|
1334
|
+
try {
|
|
1335
|
+
const completedCompanySlugs = new Set(
|
|
1336
|
+
Array.from(fanout.stateByCompany.entries())
|
|
1337
|
+
.filter(([, state]) => state.status === "complete")
|
|
1338
|
+
.map(([slug]) => slug),
|
|
1339
|
+
);
|
|
1340
|
+
await (deps.reconcileManifest ?? reconcileCompanyManifest)({
|
|
1341
|
+
hqRoot: parsed.hqRoot,
|
|
1342
|
+
targets: plan,
|
|
1343
|
+
completedCompanySlugs,
|
|
1344
|
+
getEntity: client.entity.get,
|
|
1345
|
+
});
|
|
1346
|
+
} catch (err) {
|
|
1347
|
+
reportDiagnostic({
|
|
1348
|
+
component: "manifest-reconcile",
|
|
1349
|
+
event: "runner.manifest_reconcile.failed",
|
|
1350
|
+
message: "post-fanout manifest reconciliation failed",
|
|
1351
|
+
err,
|
|
1352
|
+
context: { hqRoot: parsed.hqRoot },
|
|
1353
|
+
});
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1314
1357
|
if (rollup.needsReindex) {
|
|
1315
1358
|
try {
|
|
1316
1359
|
const reindexOptions = {
|
|
@@ -1649,6 +1692,8 @@ export interface RunnerLoopDeps {
|
|
|
1649
1692
|
* `deps` and forwards just the argv to `runRunner`.
|
|
1650
1693
|
*/
|
|
1651
1694
|
runPass?: (passArgv: string[]) => Promise<number>;
|
|
1695
|
+
/** Stage-0 backup retention backstop: a dry-run only, never a sync-tick action. */
|
|
1696
|
+
runBackupPrune?: (hqRoot: string) => Promise<void>;
|
|
1652
1697
|
/**
|
|
1653
1698
|
* Clock seam for the event-push watcher's debounce window. Defaults to
|
|
1654
1699
|
* {@link systemClock}; tests inject a `FakeClock` to advance the window
|
|
@@ -369,4 +369,125 @@ exec ${JSON.stringify(realGit)} "$@"
|
|
|
369
369
|
expect(lexists(link)).toBe(true);
|
|
370
370
|
expect(fs.existsSync(path.join(hqRoot, "core/a.md"))).toBe(true);
|
|
371
371
|
});
|
|
372
|
+
|
|
373
|
+
it("fails with a typed state-change error when reindex replaces a classified symlink before apply", () => {
|
|
374
|
+
const hqRoot = makeHqRoot();
|
|
375
|
+
const target = path.join(hqRoot, "personal/skills/foreman/tests");
|
|
376
|
+
fs.mkdirSync(target, { recursive: true });
|
|
377
|
+
const link = path.join(hqRoot, ".claude/skills/personal:foreman/tests");
|
|
378
|
+
fs.mkdirSync(path.dirname(link), { recursive: true });
|
|
379
|
+
fs.symlinkSync("../../../personal/skills/foreman/tests", link);
|
|
380
|
+
|
|
381
|
+
const args = [
|
|
382
|
+
"--hq-root", hqRoot,
|
|
383
|
+
"--source", "test/repo",
|
|
384
|
+
"--ref", "main",
|
|
385
|
+
"--floor-sha", floorSha,
|
|
386
|
+
"--yes",
|
|
387
|
+
"--no-backup",
|
|
388
|
+
];
|
|
389
|
+
const r = runRescueCapture(args, {
|
|
390
|
+
...baseEnv,
|
|
391
|
+
HQ_RESCUE_FAULT_BEFORE_APPLY_REL: ".claude/skills/personal:foreman/tests",
|
|
392
|
+
HQ_RESCUE_FAULT_BEFORE_APPLY_KIND: "directory",
|
|
393
|
+
});
|
|
394
|
+
const out = `${r.stdout}\n${r.stderr}\n${String(r.threw ?? "")}`;
|
|
395
|
+
|
|
396
|
+
expect(r.threw, out).toBeInstanceOf(Error);
|
|
397
|
+
expect(out).toContain("changed after classification");
|
|
398
|
+
expect(out).toContain("expected symlink, found directory");
|
|
399
|
+
expect(out).not.toContain("EISDIR");
|
|
400
|
+
expect(fs.readFileSync(path.join(hqRoot, "core/a.md"), "utf-8")).toBe("v1\n");
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it("keeps the full default wipe set out of worktrees and omits transient build state from the snapshot", () => {
|
|
404
|
+
const hqRoot = makeHqRoot();
|
|
405
|
+
const backupRoot = path.join(workDir, `backups-${caseSeq}`);
|
|
406
|
+
const writeTransient = (rel: string) => {
|
|
407
|
+
const dest = path.join(hqRoot, rel, "large.bin");
|
|
408
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
409
|
+
fs.writeFileSync(dest, "reproducible\n");
|
|
410
|
+
};
|
|
411
|
+
for (const rel of [
|
|
412
|
+
"worktrees/repo",
|
|
413
|
+
".worktrees/repo",
|
|
414
|
+
".sst-sandbox-home/runtime",
|
|
415
|
+
".tmp-codex-asar/cache",
|
|
416
|
+
".claude/worktrees/agent/repo",
|
|
417
|
+
".claude/skills/demo/node_modules/pkg",
|
|
418
|
+
".claude/skills/demo/.pnpm-store/v3",
|
|
419
|
+
".claude/skills/demo/.sst/build",
|
|
420
|
+
".claude/skills/demo/.next/cache",
|
|
421
|
+
".claude/skills/demo/target/debug",
|
|
422
|
+
]) {
|
|
423
|
+
writeTransient(rel);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const r = runRescueCapture(
|
|
427
|
+
[
|
|
428
|
+
"--hq-root", hqRoot,
|
|
429
|
+
"--source", "test/repo",
|
|
430
|
+
"--ref", "main",
|
|
431
|
+
"--floor-sha", floorSha,
|
|
432
|
+
"--backup-dir", backupRoot,
|
|
433
|
+
"--yes",
|
|
434
|
+
],
|
|
435
|
+
baseEnv,
|
|
436
|
+
);
|
|
437
|
+
const out = `${r.stdout}\n${r.stderr}\n${String(r.threw ?? "")}`;
|
|
438
|
+
|
|
439
|
+
expect(r.threw, out).toBeUndefined();
|
|
440
|
+
expect(r.status, out).toBe(0);
|
|
441
|
+
for (const rel of ["worktrees", ".worktrees", ".sst-sandbox-home", ".tmp-codex-asar"]) {
|
|
442
|
+
expect(fs.existsSync(path.join(hqRoot, rel, "repo", "large.bin")) ||
|
|
443
|
+
fs.existsSync(path.join(hqRoot, rel, "runtime", "large.bin")) ||
|
|
444
|
+
fs.existsSync(path.join(hqRoot, rel, "cache", "large.bin")), rel).toBe(true);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const snapshots = fs.readdirSync(backupRoot).filter((name) => name.startsWith("pre-update-"));
|
|
448
|
+
expect(snapshots).toHaveLength(1);
|
|
449
|
+
const snapshot = path.join(backupRoot, snapshots[0]);
|
|
450
|
+
expect(fs.existsSync(path.join(snapshot, "core/a.md"))).toBe(true);
|
|
451
|
+
expect(fs.existsSync(path.join(snapshot, "RECOVERY.md"))).toBe(true);
|
|
452
|
+
for (const rel of [
|
|
453
|
+
"worktrees",
|
|
454
|
+
".worktrees",
|
|
455
|
+
".sst-sandbox-home",
|
|
456
|
+
".tmp-codex-asar",
|
|
457
|
+
".claude/worktrees",
|
|
458
|
+
".claude/skills/demo/node_modules",
|
|
459
|
+
".claude/skills/demo/.pnpm-store",
|
|
460
|
+
".claude/skills/demo/.sst",
|
|
461
|
+
".claude/skills/demo/.next",
|
|
462
|
+
".claude/skills/demo/target",
|
|
463
|
+
]) {
|
|
464
|
+
expect(lexists(path.join(snapshot, rel)), `snapshot unexpectedly contains ${rel}`).toBe(false);
|
|
465
|
+
}
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it("dry-run never allocates or prunes snapshots", () => {
|
|
469
|
+
const hqRoot = makeHqRoot();
|
|
470
|
+
const backupRoot = path.join(workDir, `dry-backups-${caseSeq}`);
|
|
471
|
+
const existing = path.join(backupRoot, "pre-update-existing");
|
|
472
|
+
fs.mkdirSync(existing, { recursive: true });
|
|
473
|
+
fs.writeFileSync(path.join(existing, "keep.txt"), "do not prune\n");
|
|
474
|
+
|
|
475
|
+
const r = runRescueCapture(
|
|
476
|
+
[
|
|
477
|
+
"--hq-root", hqRoot,
|
|
478
|
+
"--source", "test/repo",
|
|
479
|
+
"--ref", "main",
|
|
480
|
+
"--floor-sha", floorSha,
|
|
481
|
+
"--backup-dir", backupRoot,
|
|
482
|
+
"--dry-run",
|
|
483
|
+
],
|
|
484
|
+
baseEnv,
|
|
485
|
+
);
|
|
486
|
+
const out = `${r.stdout}\n${r.stderr}\n${String(r.threw ?? "")}`;
|
|
487
|
+
|
|
488
|
+
expect(r.threw, out).toBeUndefined();
|
|
489
|
+
expect(r.status, out).toBe(0);
|
|
490
|
+
expect(fs.readdirSync(backupRoot)).toEqual(["pre-update-existing"]);
|
|
491
|
+
expect(fs.readFileSync(path.join(existing, "keep.txt"), "utf-8")).toBe("do not prune\n");
|
|
492
|
+
});
|
|
372
493
|
});
|