@indigoai-us/hq-cloud 6.15.0 → 6.15.2
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-mutation.d.ts +16 -0
- package/dist/bin/sync-mutation.d.ts.map +1 -0
- package/dist/bin/sync-mutation.js +60 -0
- package/dist/bin/sync-mutation.js.map +1 -0
- package/dist/bin/sync-mutation.test.d.ts +2 -0
- package/dist/bin/sync-mutation.test.d.ts.map +1 -0
- package/dist/bin/sync-mutation.test.js +165 -0
- package/dist/bin/sync-mutation.test.js.map +1 -0
- package/dist/bin/sync-runner-company.d.ts +8 -0
- package/dist/bin/sync-runner-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +16 -0
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner-company.test.d.ts +2 -0
- package/dist/bin/sync-runner-company.test.d.ts.map +1 -0
- package/dist/bin/sync-runner-company.test.js +36 -0
- package/dist/bin/sync-runner-company.test.js.map +1 -0
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +98 -8
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +17 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +109 -0
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/conflict-recovery.test.d.ts +2 -0
- package/dist/cli/conflict-recovery.test.d.ts.map +1 -0
- package/dist/cli/conflict-recovery.test.js +201 -0
- package/dist/cli/conflict-recovery.test.js.map +1 -0
- package/dist/cli/conflict.d.ts +60 -0
- package/dist/cli/conflict.d.ts.map +1 -1
- package/dist/cli/conflict.js +333 -0
- package/dist/cli/conflict.js.map +1 -1
- package/dist/cli/sync.d.ts +35 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +100 -0
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +85 -1
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/skill-telemetry.d.ts +6 -0
- package/dist/skill-telemetry.d.ts.map +1 -1
- package/dist/skill-telemetry.js +14 -2
- package/dist/skill-telemetry.js.map +1 -1
- package/dist/skill-telemetry.test.js +79 -0
- package/dist/skill-telemetry.test.js.map +1 -1
- package/dist/sync/candidate-uploader.d.ts +88 -0
- package/dist/sync/candidate-uploader.d.ts.map +1 -0
- package/dist/sync/candidate-uploader.js +212 -0
- package/dist/sync/candidate-uploader.js.map +1 -0
- package/dist/sync/candidate-uploader.test.d.ts +2 -0
- package/dist/sync/candidate-uploader.test.d.ts.map +1 -0
- package/dist/sync/candidate-uploader.test.js +132 -0
- package/dist/sync/candidate-uploader.test.js.map +1 -0
- package/dist/sync/delta-client.d.ts +73 -0
- package/dist/sync/delta-client.d.ts.map +1 -0
- package/dist/sync/delta-client.js +201 -0
- package/dist/sync/delta-client.js.map +1 -0
- package/dist/sync/delta-client.test.d.ts +2 -0
- package/dist/sync/delta-client.test.d.ts.map +1 -0
- package/dist/sync/delta-client.test.js +97 -0
- package/dist/sync/delta-client.test.js.map +1 -0
- package/dist/sync/durable-apply.d.ts +76 -0
- package/dist/sync/durable-apply.d.ts.map +1 -0
- package/dist/sync/durable-apply.js +530 -0
- package/dist/sync/durable-apply.js.map +1 -0
- package/dist/sync/durable-apply.test.d.ts +2 -0
- package/dist/sync/durable-apply.test.d.ts.map +1 -0
- package/dist/sync/durable-apply.test.js +180 -0
- package/dist/sync/durable-apply.test.js.map +1 -0
- package/dist/sync/event-sync.d.ts +33 -1
- package/dist/sync/event-sync.d.ts.map +1 -1
- package/dist/sync/event-sync.js +149 -1
- package/dist/sync/event-sync.js.map +1 -1
- package/dist/sync/event-sync.test.js +142 -1
- package/dist/sync/event-sync.test.js.map +1 -1
- package/dist/sync/index.d.ts +2 -0
- package/dist/sync/index.d.ts.map +1 -1
- package/dist/sync/index.js +1 -0
- package/dist/sync/index.js.map +1 -1
- package/dist/sync/multipart-uploader.d.ts +99 -0
- package/dist/sync/multipart-uploader.d.ts.map +1 -0
- package/dist/sync/multipart-uploader.js +447 -0
- package/dist/sync/multipart-uploader.js.map +1 -0
- package/dist/sync/multipart-uploader.test.d.ts +2 -0
- package/dist/sync/multipart-uploader.test.d.ts.map +1 -0
- package/dist/sync/multipart-uploader.test.js +119 -0
- package/dist/sync/multipart-uploader.test.js.map +1 -0
- package/dist/sync/mutation-client.d.ts +85 -0
- package/dist/sync/mutation-client.d.ts.map +1 -0
- package/dist/sync/mutation-client.js +245 -0
- package/dist/sync/mutation-client.js.map +1 -0
- package/dist/sync/mutation-client.test.d.ts +2 -0
- package/dist/sync/mutation-client.test.d.ts.map +1 -0
- package/dist/sync/mutation-client.test.js +51 -0
- package/dist/sync/mutation-client.test.js.map +1 -0
- package/dist/sync/push-receiver.d.ts +45 -0
- package/dist/sync/push-receiver.d.ts.map +1 -1
- package/dist/sync/push-receiver.js +101 -0
- package/dist/sync/push-receiver.js.map +1 -1
- package/dist/sync/push-receiver.test.js +54 -2
- package/dist/sync/push-receiver.test.js.map +1 -1
- package/dist/sync/scope-inventory-client.d.ts +69 -0
- package/dist/sync/scope-inventory-client.d.ts.map +1 -0
- package/dist/sync/scope-inventory-client.js +210 -0
- package/dist/sync/scope-inventory-client.js.map +1 -0
- package/dist/sync/scope-inventory-client.test.d.ts +2 -0
- package/dist/sync/scope-inventory-client.test.d.ts.map +1 -0
- package/dist/sync/scope-inventory-client.test.js +94 -0
- package/dist/sync/scope-inventory-client.test.js.map +1 -0
- package/dist/sync/snapshot-client.d.ts +98 -0
- package/dist/sync/snapshot-client.d.ts.map +1 -0
- package/dist/sync/snapshot-client.js +402 -0
- package/dist/sync/snapshot-client.js.map +1 -0
- package/dist/sync/snapshot-client.test.d.ts +2 -0
- package/dist/sync/snapshot-client.test.d.ts.map +1 -0
- package/dist/sync/snapshot-client.test.js +169 -0
- package/dist/sync/snapshot-client.test.js.map +1 -0
- package/dist/sync/uploader-finalization.d.ts +97 -0
- package/dist/sync/uploader-finalization.d.ts.map +1 -0
- package/dist/sync/uploader-finalization.js +273 -0
- package/dist/sync/uploader-finalization.js.map +1 -0
- package/dist/sync/uploader-finalization.test.d.ts +2 -0
- package/dist/sync/uploader-finalization.test.d.ts.map +1 -0
- package/dist/sync/uploader-finalization.test.js +92 -0
- package/dist/sync/uploader-finalization.test.js.map +1 -0
- package/dist/telemetry.d.ts +11 -1
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +21 -2
- package/dist/telemetry.js.map +1 -1
- package/dist/telemetry.test.js +80 -0
- package/dist/telemetry.test.js.map +1 -1
- package/package.json +6 -1
- package/.claude/policies/hq-cloud-esm-cannot-spy-fs-builtins.md +0 -30
- package/.claude/policies/hq-cloud-strip-types-no-parameter-properties.md +0 -22
- package/.github/workflows/ci.yml +0 -84
- package/.github/workflows/publish.yml +0 -56
- package/.github/workflows/unreleased-commits-nag.yml +0 -256
- package/eslint.config.js +0 -67
- package/pnpm-workspace.yaml +0 -2
- package/scripts/presign-transport-e2e.mjs +0 -250
- package/scripts/vault-rebaseline.sh +0 -323
- package/scripts/vault-rescue.sh +0 -332
- package/src/active-company.test.ts +0 -188
- package/src/active-company.ts +0 -168
- package/src/agent-codex-instructions.test.ts +0 -332
- package/src/agent-codex-instructions.ts +0 -309
- package/src/auth.ts +0 -146
- package/src/backup-prune.test.ts +0 -98
- package/src/backup-prune.ts +0 -182
- package/src/bin/backup-prune-runner.ts +0 -33
- package/src/bin/rescue-runner.ts +0 -25
- package/src/bin/sync-runner-company.ts +0 -695
- package/src/bin/sync-runner-events.test.ts +0 -143
- package/src/bin/sync-runner-events.ts +0 -55
- package/src/bin/sync-runner-planning.test.ts +0 -311
- package/src/bin/sync-runner-planning.ts +0 -258
- package/src/bin/sync-runner-rollup.test.ts +0 -37
- package/src/bin/sync-runner-rollup.ts +0 -97
- package/src/bin/sync-runner-telemetry.ts +0 -15
- package/src/bin/sync-runner-watch-loop.ts +0 -1235
- package/src/bin/sync-runner-watch-routes.test.ts +0 -71
- package/src/bin/sync-runner-watch-routes.ts +0 -184
- package/src/bin/sync-runner.test.ts +0 -8767
- package/src/bin/sync-runner.ts +0 -2190
- package/src/cli/accept.ts +0 -124
- package/src/cli/conflict.ts +0 -119
- package/src/cli/doctor.test.ts +0 -581
- package/src/cli/doctor.ts +0 -642
- package/src/cli/index.ts +0 -49
- package/src/cli/invite.test.ts +0 -250
- package/src/cli/invite.ts +0 -214
- package/src/cli/promote.ts +0 -157
- package/src/cli/reindex-knowledge.test.ts +0 -307
- package/src/cli/reindex-knowledge.ts +0 -450
- package/src/cli/reindex.test.ts +0 -957
- package/src/cli/reindex.ts +0 -979
- package/src/cli/rescue-classify-ordering.test.ts +0 -548
- package/src/cli/rescue-clone-diagnostics.test.ts +0 -120
- package/src/cli/rescue-core.ts +0 -3011
- package/src/cli/rescue-drift-reconcile.test.ts +0 -179
- package/src/cli/rescue-drop-dir-symlink.test.ts +0 -224
- package/src/cli/rescue-exec-bit-preserve.test.ts +0 -187
- package/src/cli/rescue-hq-root-guard.test.ts +0 -232
- package/src/cli/rescue-journal-reconcile.test.ts +0 -215
- package/src/cli/rescue-mtime-preserve.test.ts +0 -203
- package/src/cli/rescue-settings-reconcile.test.ts +0 -637
- package/src/cli/rescue-snapshot.test.ts +0 -57
- package/src/cli/rescue-snapshot.ts +0 -51
- package/src/cli/rescue.reindex.test.ts +0 -63
- package/src/cli/rescue.test.ts +0 -131
- package/src/cli/rescue.ts +0 -182
- package/src/cli/share.test.ts +0 -7843
- package/src/cli/share.ts +0 -3663
- package/src/cli/sync-scope.test.ts +0 -652
- package/src/cli/sync.test.ts +0 -5207
- package/src/cli/sync.ts +0 -3470
- package/src/cli/tombstones.ts +0 -106
- package/src/cli/watch-event-push-conflict.test.ts +0 -234
- package/src/client-info.test.ts +0 -214
- package/src/client-info.ts +0 -121
- package/src/cognito-auth.test.ts +0 -712
- package/src/cognito-auth.ts +0 -1422
- package/src/company-resolver.test.ts +0 -618
- package/src/company-resolver.ts +0 -521
- package/src/context.test.ts +0 -583
- package/src/context.ts +0 -378
- package/src/daemon-worker.ts +0 -26
- package/src/daemon.ts +0 -99
- package/src/entity-resolver.test.ts +0 -315
- package/src/entity-resolver.ts +0 -180
- package/src/ignore.test.ts +0 -466
- package/src/ignore.ts +0 -469
- package/src/index.ts +0 -439
- package/src/journal.test.ts +0 -968
- package/src/journal.ts +0 -765
- package/src/lib/cloud-authoritative.test.ts +0 -45
- package/src/lib/cloud-authoritative.ts +0 -59
- package/src/lib/conflict-file.ts +0 -86
- package/src/lib/conflict-index.ts +0 -289
- package/src/lib/conflict.test.ts +0 -348
- package/src/lib/describe-error.test.ts +0 -100
- package/src/lib/describe-error.ts +0 -58
- package/src/lib/exit-codes.ts +0 -24
- package/src/lib/machine-id.test.ts +0 -231
- package/src/lib/machine-id.ts +0 -175
- package/src/lib/net-errors.test.ts +0 -65
- package/src/lib/net-errors.ts +0 -86
- package/src/lib/readlink-safe.test.ts +0 -43
- package/src/lib/readlink-safe.ts +0 -29
- package/src/local-path-codec.test.ts +0 -138
- package/src/local-path-codec.ts +0 -161
- package/src/machine-auth.test.ts +0 -1323
- package/src/manifest-reconcile.test.ts +0 -1123
- package/src/manifest-reconcile.ts +0 -518
- package/src/object-io.test.ts +0 -1221
- package/src/object-io.ts +0 -1306
- package/src/operation-lock.test.ts +0 -484
- package/src/operation-lock.ts +0 -680
- package/src/outcome-telemetry.test.ts +0 -498
- package/src/outcome-telemetry.ts +0 -639
- package/src/personal-vault-exclusions.test.ts +0 -308
- package/src/personal-vault-exclusions.ts +0 -354
- package/src/personal-vault.test.ts +0 -756
- package/src/personal-vault.ts +0 -496
- package/src/prefix-coalesce.test.ts +0 -240
- package/src/prefix-coalesce.ts +0 -273
- package/src/public-surface.test.ts +0 -117
- package/src/qmd-reindex.test.ts +0 -877
- package/src/qmd-reindex.ts +0 -842
- package/src/read-only-state-dir.test.ts +0 -188
- package/src/remote-pull.test.ts +0 -1130
- package/src/remote-pull.ts +0 -618
- package/src/s3.symlink-materialize.test.ts +0 -492
- package/src/s3.test.ts +0 -1789
- package/src/s3.ts +0 -1532
- package/src/schemas/signal-types.test.ts +0 -82
- package/src/schemas/signal-types.ts +0 -38
- package/src/schemas/source-channels.test.ts +0 -82
- package/src/schemas/source-channels.ts +0 -53
- package/src/scope-shrink.test.ts +0 -633
- package/src/scope-shrink.ts +0 -481
- package/src/signals/get.test.ts +0 -310
- package/src/signals/get.ts +0 -75
- package/src/signals/internals.ts +0 -195
- package/src/signals/list.test.ts +0 -420
- package/src/signals/list.ts +0 -79
- package/src/signals/parse.ts +0 -8
- package/src/signals/types.ts +0 -91
- package/src/skill-telemetry.test.ts +0 -1825
- package/src/skill-telemetry.ts +0 -1439
- package/src/sources/get.test.ts +0 -293
- package/src/sources/get.ts +0 -66
- package/src/sources/internals.ts +0 -198
- package/src/sources/list.test.ts +0 -402
- package/src/sources/list.ts +0 -84
- package/src/sources/parse.ts +0 -43
- package/src/sources/types.ts +0 -84
- package/src/sync/event-sync.test.ts +0 -594
- package/src/sync/event-sync.ts +0 -545
- package/src/sync/feature-flags.test.ts +0 -378
- package/src/sync/feature-flags.ts +0 -62
- package/src/sync/index.ts +0 -76
- package/src/sync/lease-client.test.ts +0 -128
- package/src/sync/lease-client.ts +0 -207
- package/src/sync/logger.test.ts +0 -242
- package/src/sync/logger.ts +0 -79
- package/src/sync/metrics.test.ts +0 -462
- package/src/sync/metrics.ts +0 -213
- package/src/sync/pull-scope.ts +0 -265
- package/src/sync/push-event.test.ts +0 -266
- package/src/sync/push-event.ts +0 -224
- package/src/sync/push-receiver.test.ts +0 -566
- package/src/sync/push-receiver.ts +0 -1048
- package/src/sync/push-transport.ts +0 -231
- package/src/sync/realtime-rollout.test.ts +0 -86
- package/src/sync/realtime-rollout.ts +0 -262
- package/src/sync/state-store.test.ts +0 -194
- package/src/sync/state-store.ts +0 -727
- package/src/sync-core.ts +0 -58
- package/src/sync-progress.test.ts +0 -94
- package/src/sync-progress.ts +0 -140
- package/src/telemetry-events.test.ts +0 -88
- package/src/telemetry-events.ts +0 -205
- package/src/telemetry.test.ts +0 -1280
- package/src/telemetry.ts +0 -1109
- package/src/types.ts +0 -314
- package/src/vault-client.test.ts +0 -1380
- package/src/vault-client.ts +0 -1694
- package/src/version.ts +0 -24
- package/src/watch-roots.test.ts +0 -278
- package/src/watch-roots.ts +0 -162
- package/src/watcher-event-gate.test.ts +0 -212
- package/src/watcher.test.ts +0 -1079
- package/src/watcher.ts +0 -1741
- package/test/e2e/sync/cross-tenant-isolation.test.ts +0 -630
- package/test/e2e/sync/skill-telemetry-oversized-transcript.test.ts +0 -124
- package/test/e2e/sync/transient-company-leg.test.ts +0 -384
- package/test/e2e/sync/windows-unreadable-link-leg.test.ts +0 -191
- package/test/e2e/watcher-real-chokidar.test.ts +0 -165
- package/test/e2e/watcher-recursive-backend.test.ts +0 -181
- package/test/e2e/watcher-scoped-coverage.test.ts +0 -381
- package/test/invite-flow.integration.test.ts +0 -244
- package/test/joiner-manifest-reconcile.integration.test.ts +0 -322
- package/test/share-sync.integration.test.ts +0 -213
- package/tsconfig.json +0 -19
- package/vitest.config.ts +0 -22
package/src/cli/rescue-core.ts
DELETED
|
@@ -1,3011 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* hq rescue — drift-preserving re-sync of a local HQ tree to an upstream
|
|
3
|
-
* hq-core release (or staging branch). This is the native-TypeScript port of
|
|
4
|
-
* the former scripts/replace-rescue.sh (removed). It is an *orchestration*
|
|
5
|
-
* port: the control flow, three-way drift classification, routing, YAML
|
|
6
|
-
* stamping, mtime restoration, backup snapshot and summary are implemented in
|
|
7
|
-
* TypeScript, while the heavy primitives the bash itself delegated to —
|
|
8
|
-
* `git` (clone/checkout/hash-object/cat-file/ls-tree/show/log), `rsync` (the
|
|
9
|
-
* overlay), and the YAML editor (`yq`/`python3`) — are still invoked as
|
|
10
|
-
* subprocesses. Reimplementing git's blob hashing or rsync's include/exclude
|
|
11
|
-
* filter engine in pure JS would *increase* the risk of behavioural drift, so
|
|
12
|
-
* the port reuses the exact same tools with the exact same argument vectors.
|
|
13
|
-
*
|
|
14
|
-
* Behaviour (classification outcomes, on-disk results, log lines, exit codes)
|
|
15
|
-
* is preserved 1:1 with the legacy script. See the original script's header in
|
|
16
|
-
* git history for the full algorithm narrative.
|
|
17
|
-
*
|
|
18
|
-
* Three-way classification per file under each wipe-set top-level entry:
|
|
19
|
-
* USER-ONLY -> leave in place (overlay runs without --delete).
|
|
20
|
-
* UNCHANGED -> delete (overlay re-lays from source) OR, when identical to
|
|
21
|
-
* upstream HEAD, leave in place + protect from the overlay so
|
|
22
|
-
* its mtime survives (Layer 1).
|
|
23
|
-
* USER-EDIT -> rescue into personal/ (or diff-append for .claude/CLAUDE.md),
|
|
24
|
-
* quarantine into .hq-conflicts/ (conflict class), or silently
|
|
25
|
-
* overwrite (overwrite-safe class).
|
|
26
|
-
*/
|
|
27
|
-
import { spawnSync } from "child_process";
|
|
28
|
-
import * as fs from "fs";
|
|
29
|
-
import * as os from "os";
|
|
30
|
-
import * as path from "path";
|
|
31
|
-
import {
|
|
32
|
-
assertRescuePathTypeUnchanged,
|
|
33
|
-
isRescueTransientDirectory,
|
|
34
|
-
type RescuePathKind,
|
|
35
|
-
} from "./rescue-snapshot.js";
|
|
36
|
-
import {
|
|
37
|
-
applyBackupPrune,
|
|
38
|
-
canonicalRootHash,
|
|
39
|
-
inventoryManagedSnapshots,
|
|
40
|
-
type SnapshotManifest,
|
|
41
|
-
} from "../backup-prune.js";
|
|
42
|
-
|
|
43
|
-
export interface RunRescueResult {
|
|
44
|
-
status: number;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
// Paths always preserved across wipe+overlay (shuttled out, restored after).
|
|
48
|
-
const CARVE_OUT_PATHS = [
|
|
49
|
-
"core/packages",
|
|
50
|
-
"packages",
|
|
51
|
-
".claude/state",
|
|
52
|
-
"core/workers/registry.yaml",
|
|
53
|
-
".claude/settings.local.json",
|
|
54
|
-
];
|
|
55
|
-
|
|
56
|
-
// Preserve set that is redundant to pass via --preserve (always preserved).
|
|
57
|
-
const ALWAYS_PRESERVED_NAMES = [
|
|
58
|
-
".git",
|
|
59
|
-
"companies",
|
|
60
|
-
"personal",
|
|
61
|
-
"workspace",
|
|
62
|
-
"repos",
|
|
63
|
-
".github",
|
|
64
|
-
".leak-scan",
|
|
65
|
-
".hq-sync-journal.json",
|
|
66
|
-
".hq",
|
|
67
|
-
".hq-conflicts",
|
|
68
|
-
// Machine-local state. SSM/root invocations can leave credentials here
|
|
69
|
-
// unreadable to the HQ user; rescue must neither snapshot nor classify it.
|
|
70
|
-
".local",
|
|
71
|
-
// Package-manager caches: never release-shipped, never drift-tracked, and
|
|
72
|
-
// huge (the pnpm content-addressed store alone is tens of thousands of
|
|
73
|
-
// files). Walking them just to classify everything user-only dominates the
|
|
74
|
-
// rescue on any machine that has run `pnpm install`. The sync engine ignores
|
|
75
|
-
// these too (hq-cloud DEFAULT_IGNORES).
|
|
76
|
-
".pnpm-store",
|
|
77
|
-
"node_modules",
|
|
78
|
-
// Local worktrees and updater/build sandboxes are reproducible, often huge,
|
|
79
|
-
// and never owned by an hq-core release. Keep these root-only names outside
|
|
80
|
-
// both the wipe set and the safety snapshot.
|
|
81
|
-
"worktrees",
|
|
82
|
-
".worktrees",
|
|
83
|
-
".sst-sandbox-home",
|
|
84
|
-
".tmp-codex-asar",
|
|
85
|
-
];
|
|
86
|
-
|
|
87
|
-
interface Config {
|
|
88
|
-
ref: string;
|
|
89
|
-
sourceRepo: string;
|
|
90
|
-
dryRun: boolean;
|
|
91
|
-
assumeYes: boolean;
|
|
92
|
-
cloudUpdate: boolean;
|
|
93
|
-
extraPreserve: string[];
|
|
94
|
-
preserveSubpaths: string[];
|
|
95
|
-
narrowPaths: string[];
|
|
96
|
-
hqRootOverride: string;
|
|
97
|
-
historyCheck: boolean;
|
|
98
|
-
floorShaOverride: string;
|
|
99
|
-
doBackup: boolean;
|
|
100
|
-
backupRoot: string;
|
|
101
|
-
backupRetentionDays: string;
|
|
102
|
-
backupRetentionCount: string;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
class ExitError extends Error {
|
|
106
|
-
constructor(public code: number) {
|
|
107
|
-
super(`exit ${code}`);
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/** Run a subprocess synchronously, inheriting the parent environment by default. */
|
|
112
|
-
function run(
|
|
113
|
-
cmd: string,
|
|
114
|
-
args: string[],
|
|
115
|
-
opts: { cwd?: string; env?: NodeJS.ProcessEnv; input?: string } = {},
|
|
116
|
-
): { status: number; stdout: string; stderr: string } {
|
|
117
|
-
const res = spawnSync(cmd, args, {
|
|
118
|
-
cwd: opts.cwd,
|
|
119
|
-
env: opts.env ?? process.env,
|
|
120
|
-
input: opts.input,
|
|
121
|
-
encoding: "utf-8",
|
|
122
|
-
});
|
|
123
|
-
return {
|
|
124
|
-
status: res.status ?? (res.error ? 127 : 1),
|
|
125
|
-
stdout: res.stdout ?? "",
|
|
126
|
-
stderr: res.stderr ?? "",
|
|
127
|
-
};
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
* Cygwin's rsync is a POSIX program even when Node gave us a native Win32
|
|
132
|
-
* path. Keep UNC paths in Cygwin's documented `//server/share` form and map
|
|
133
|
-
* drive roots explicitly instead of relying on Cygwin's native-path fallback.
|
|
134
|
-
*/
|
|
135
|
-
export function toCygwinRsyncPath(localPath: string): string {
|
|
136
|
-
const slashPath = localPath.replaceAll("\\", "/");
|
|
137
|
-
if (/^\/\/[^/]+\/[^/]+(?:\/|$)/.test(slashPath)) return slashPath;
|
|
138
|
-
|
|
139
|
-
const drivePath = /^([a-z]):\/(.*)$/i.exec(slashPath);
|
|
140
|
-
if (drivePath) {
|
|
141
|
-
return `/cygdrive/${drivePath[1]!.toLowerCase()}/${drivePath[2]}`;
|
|
142
|
-
}
|
|
143
|
-
return localPath;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function redactGitDiagnostic(output: string, ghToken: string): string {
|
|
147
|
-
let redacted = output;
|
|
148
|
-
if (ghToken) redacted = redacted.replaceAll(ghToken, "***");
|
|
149
|
-
return redacted
|
|
150
|
-
.replace(/\b([a-z][a-z0-9+.-]*:\/\/)[^\s/@]+(?::[^\s/@]*)?@/gi, "$1***@")
|
|
151
|
-
.replace(/(authorization\s*:\s*)[^\r\n]*/gi, "$1***");
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/** `command -v <bin>` — is a binary on PATH? */
|
|
155
|
-
function hasCmd(bin: string, env: NodeJS.ProcessEnv): boolean {
|
|
156
|
-
const r = spawnSync(bin, ["--version"], { env, stdio: "ignore" });
|
|
157
|
-
return !r.error && r.status === 0;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/** The overlay requires rsync; do not allocate a recovery copy when it cannot run. */
|
|
161
|
-
function requireWorkingRsync(env: NodeJS.ProcessEnv, err: (s: string) => void): void {
|
|
162
|
-
const probe = run("rsync", ["--version"], { env });
|
|
163
|
-
const output = `${probe.stdout}\n${probe.stderr}`;
|
|
164
|
-
if (probe.status === 0 && /\brsync\b.*\bversion\b/i.test(output)) return;
|
|
165
|
-
err("error: rsync preflight failed before any safety snapshot was allocated.\n");
|
|
166
|
-
err(` resolved PATH: ${env.PATH ?? "(unset)"}\n`);
|
|
167
|
-
err(" Install or repair rsync, then retry the HQ update.\n");
|
|
168
|
-
throw new ExitError(probe.status || 127);
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
// fs predicates mirroring bash test operators.
|
|
172
|
-
function isDir(p: string): boolean {
|
|
173
|
-
try {
|
|
174
|
-
return fs.statSync(p).isDirectory();
|
|
175
|
-
} catch {
|
|
176
|
-
return false;
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
function isFileFollow(p: string): boolean {
|
|
180
|
-
try {
|
|
181
|
-
return fs.statSync(p).isFile();
|
|
182
|
-
} catch {
|
|
183
|
-
return false;
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
function existsFollow(p: string): boolean {
|
|
187
|
-
try {
|
|
188
|
-
fs.statSync(p);
|
|
189
|
-
return true;
|
|
190
|
-
} catch {
|
|
191
|
-
return false;
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
function lstatOrNull(p: string): fs.Stats | null {
|
|
195
|
-
try {
|
|
196
|
-
return fs.lstatSync(p);
|
|
197
|
-
} catch {
|
|
198
|
-
return null;
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
function rescuePathKind(p: string): RescuePathKind {
|
|
202
|
-
const st = lstatOrNull(p);
|
|
203
|
-
if (!st) return "missing";
|
|
204
|
-
if (st.isSymbolicLink()) return "symlink";
|
|
205
|
-
if (st.isFile()) return "file";
|
|
206
|
-
if (st.isDirectory()) return "directory";
|
|
207
|
-
return "other";
|
|
208
|
-
}
|
|
209
|
-
function lexists(p: string): boolean {
|
|
210
|
-
return lstatOrNull(p) !== null;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
/** `cmp -s a b` — byte-identical regular-file comparison. */
|
|
214
|
-
function bytesEqual(a: string, b: string): boolean {
|
|
215
|
-
try {
|
|
216
|
-
const sa = fs.statSync(a);
|
|
217
|
-
const sb = fs.statSync(b);
|
|
218
|
-
if (sa.size !== sb.size) return false;
|
|
219
|
-
return fs.readFileSync(a).equals(fs.readFileSync(b));
|
|
220
|
-
} catch {
|
|
221
|
-
return false;
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
function utcStamp(d: Date, sep: "colon" | "dash"): string {
|
|
226
|
-
const p = (n: number) => String(n).padStart(2, "0");
|
|
227
|
-
const date = `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
|
|
228
|
-
const h = p(d.getUTCHours());
|
|
229
|
-
const m = p(d.getUTCMinutes());
|
|
230
|
-
const s = p(d.getUTCSeconds());
|
|
231
|
-
return sep === "colon" ? `${date}T${h}:${m}:${s}Z` : `${date}T${h}-${m}-${s}Z`;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
/**
|
|
235
|
-
* Parse the rescue argv exactly as the bash `while case` loop did, including
|
|
236
|
-
* validation and the same exit codes (1 unknown/usage, 2 bad option). Throws
|
|
237
|
-
* ExitError on any validation failure.
|
|
238
|
-
*/
|
|
239
|
-
export function parseRescueArgs(argv: string[]): Config {
|
|
240
|
-
const cfg: Config = {
|
|
241
|
-
ref: "main",
|
|
242
|
-
sourceRepo: "indigoai-us/hq-core-staging",
|
|
243
|
-
dryRun: false,
|
|
244
|
-
assumeYes: false,
|
|
245
|
-
cloudUpdate: false,
|
|
246
|
-
extraPreserve: [],
|
|
247
|
-
preserveSubpaths: [],
|
|
248
|
-
narrowPaths: [],
|
|
249
|
-
hqRootOverride: "",
|
|
250
|
-
historyCheck: true,
|
|
251
|
-
floorShaOverride: "",
|
|
252
|
-
doBackup: process.env.HQ_RESCUE_NO_BACKUP === "1" ? false : true,
|
|
253
|
-
backupRoot: process.env.HQ_BACKUP_DIR || path.join(os.homedir(), ".hq", "backups"),
|
|
254
|
-
backupRetentionDays: process.env.HQ_BACKUP_RETENTION_DAYS || "7",
|
|
255
|
-
backupRetentionCount: process.env.HQ_BACKUP_RETENTION_COUNT || "2",
|
|
256
|
-
};
|
|
257
|
-
|
|
258
|
-
const usage = (): never => {
|
|
259
|
-
// The legacy script printed its comment header here; we emit a concise
|
|
260
|
-
// equivalent. Exit code (1) is preserved.
|
|
261
|
-
process.stderr.write(
|
|
262
|
-
"usage: hq rescue [--ref REF] [--source OWNER/REPO] [--paths P1,P2,...]\n" +
|
|
263
|
-
" [--preserve PATH]... [--preserve-subpath REL]...\n" +
|
|
264
|
-
" [--hq-root DIR] [--no-history-check] [--floor-sha SHA]\n" +
|
|
265
|
-
" [--no-backup] [--backup-dir DIR] [--cloud-update]\n" +
|
|
266
|
-
" [--dry-run] [--yes]\n",
|
|
267
|
-
);
|
|
268
|
-
throw new ExitError(1);
|
|
269
|
-
};
|
|
270
|
-
|
|
271
|
-
let narrowCsv = "";
|
|
272
|
-
let i = 0;
|
|
273
|
-
const need = (_flag: string): string => {
|
|
274
|
-
// bash `shift 2` with a missing operand would expand to empty under set -u
|
|
275
|
-
// off for positional; here we treat a missing operand as empty string,
|
|
276
|
-
// matching `"$2"` when absent.
|
|
277
|
-
return i + 1 < argv.length ? argv[++i] : "";
|
|
278
|
-
};
|
|
279
|
-
for (; i < argv.length; i++) {
|
|
280
|
-
const a = argv[i];
|
|
281
|
-
switch (a) {
|
|
282
|
-
case "--ref":
|
|
283
|
-
cfg.ref = need(a);
|
|
284
|
-
break;
|
|
285
|
-
case "--source":
|
|
286
|
-
cfg.sourceRepo = need(a);
|
|
287
|
-
break;
|
|
288
|
-
case "--preserve":
|
|
289
|
-
cfg.extraPreserve.push(need(a));
|
|
290
|
-
break;
|
|
291
|
-
case "--preserve-subpath":
|
|
292
|
-
cfg.preserveSubpaths.push(need(a));
|
|
293
|
-
break;
|
|
294
|
-
case "--paths":
|
|
295
|
-
narrowCsv = need(a);
|
|
296
|
-
break;
|
|
297
|
-
case "--hq-root":
|
|
298
|
-
cfg.hqRootOverride = need(a);
|
|
299
|
-
break;
|
|
300
|
-
case "--no-history-check":
|
|
301
|
-
cfg.historyCheck = false;
|
|
302
|
-
break;
|
|
303
|
-
case "--floor-sha": {
|
|
304
|
-
cfg.floorShaOverride = need(a);
|
|
305
|
-
if (!/^[0-9a-f]{40}$/.test(cfg.floorShaOverride)) {
|
|
306
|
-
process.stderr.write(
|
|
307
|
-
`error: --floor-sha must be a 40-char lowercase hex SHA, got: ${cfg.floorShaOverride}\n`,
|
|
308
|
-
);
|
|
309
|
-
throw new ExitError(2);
|
|
310
|
-
}
|
|
311
|
-
break;
|
|
312
|
-
}
|
|
313
|
-
case "--no-backup":
|
|
314
|
-
cfg.doBackup = false;
|
|
315
|
-
break;
|
|
316
|
-
case "--backup-dir":
|
|
317
|
-
cfg.backupRoot = need(a);
|
|
318
|
-
break;
|
|
319
|
-
case "--dry-run":
|
|
320
|
-
cfg.dryRun = true;
|
|
321
|
-
break;
|
|
322
|
-
case "--cloud-update":
|
|
323
|
-
cfg.cloudUpdate = true;
|
|
324
|
-
break;
|
|
325
|
-
case "--yes":
|
|
326
|
-
case "-y":
|
|
327
|
-
cfg.assumeYes = true;
|
|
328
|
-
break;
|
|
329
|
-
case "-h":
|
|
330
|
-
case "--help":
|
|
331
|
-
usage();
|
|
332
|
-
break;
|
|
333
|
-
default:
|
|
334
|
-
process.stderr.write(`unknown arg: ${a}\n`);
|
|
335
|
-
usage();
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
// --preserve redundancy check.
|
|
340
|
-
for (const p of cfg.extraPreserve) {
|
|
341
|
-
const stripped = p.replace(/\/$/, "");
|
|
342
|
-
if (ALWAYS_PRESERVED_NAMES.includes(stripped)) {
|
|
343
|
-
process.stderr.write(
|
|
344
|
-
`error: --preserve ${p} is redundant ('${p}' is always preserved). Remove the flag.\n`,
|
|
345
|
-
);
|
|
346
|
-
throw new ExitError(2);
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
// --preserve-subpath traversal check.
|
|
351
|
-
for (const sp of cfg.preserveSubpaths) {
|
|
352
|
-
if (sp.startsWith("/") || sp.includes("..")) {
|
|
353
|
-
process.stderr.write(
|
|
354
|
-
`error: --preserve-subpath ${sp} must be a relative path with no '..' segments.\n`,
|
|
355
|
-
);
|
|
356
|
-
throw new ExitError(2);
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
// Append always-on carve-outs to preserveSubpaths (dedup).
|
|
361
|
-
for (const cp of CARVE_OUT_PATHS) {
|
|
362
|
-
if (!cfg.preserveSubpaths.includes(cp)) cfg.preserveSubpaths.push(cp);
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
// Narrow paths parsing + validation.
|
|
366
|
-
if (narrowCsv) {
|
|
367
|
-
for (const raw of narrowCsv.split(",")) {
|
|
368
|
-
const name = raw.trim();
|
|
369
|
-
if (name === "") continue;
|
|
370
|
-
if (name.includes("/") || name === ".." || name === ".") {
|
|
371
|
-
process.stderr.write(
|
|
372
|
-
`error: --paths entry '${name}' must be a single top-level name (no slashes, no '.' or '..').\n`,
|
|
373
|
-
);
|
|
374
|
-
throw new ExitError(2);
|
|
375
|
-
}
|
|
376
|
-
cfg.narrowPaths.push(name);
|
|
377
|
-
}
|
|
378
|
-
if (cfg.narrowPaths.length === 0) {
|
|
379
|
-
process.stderr.write("error: --paths was passed but resolved to an empty list.\n");
|
|
380
|
-
throw new ExitError(2);
|
|
381
|
-
}
|
|
382
|
-
if (cfg.extraPreserve.length !== 0) {
|
|
383
|
-
process.stderr.write(
|
|
384
|
-
"error: --paths and --preserve are mutually exclusive. Use --preserve-subpath for sub-paths inside the listed top-level entries.\n",
|
|
385
|
-
);
|
|
386
|
-
throw new ExitError(2);
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
return cfg;
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
/**
|
|
394
|
-
* Execute a fully-parsed rescue. `argv` are the same flags the bash script
|
|
395
|
-
* accepted; `env` defaults to process.env (GH_TOKEN is read from it). Returns
|
|
396
|
-
* the process-equivalent exit status. Never throws for normal control-flow
|
|
397
|
-
* exits — those are mapped to the returned status.
|
|
398
|
-
*/
|
|
399
|
-
export function runRescue(
|
|
400
|
-
argv: string[],
|
|
401
|
-
opts: { env?: NodeJS.ProcessEnv } = {},
|
|
402
|
-
): RunRescueResult {
|
|
403
|
-
const env = opts.env ?? process.env;
|
|
404
|
-
const out = (s: string) => process.stdout.write(s);
|
|
405
|
-
const err = (s: string) => process.stderr.write(s);
|
|
406
|
-
|
|
407
|
-
let cfg: Config;
|
|
408
|
-
try {
|
|
409
|
-
cfg = parseRescueArgs(argv);
|
|
410
|
-
} catch (e) {
|
|
411
|
-
if (e instanceof ExitError) return { status: e.code };
|
|
412
|
-
throw e;
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
let tmpdir: string | null = null;
|
|
416
|
-
const cleanup = () => {
|
|
417
|
-
if (tmpdir) {
|
|
418
|
-
try {
|
|
419
|
-
fs.rmSync(tmpdir, { recursive: true, force: true });
|
|
420
|
-
} catch {
|
|
421
|
-
/* best-effort */
|
|
422
|
-
}
|
|
423
|
-
}
|
|
424
|
-
};
|
|
425
|
-
|
|
426
|
-
try {
|
|
427
|
-
return doRescue(cfg, env, out, err, (d) => {
|
|
428
|
-
tmpdir = d;
|
|
429
|
-
});
|
|
430
|
-
} catch (e) {
|
|
431
|
-
if (e instanceof ExitError) return { status: e.code };
|
|
432
|
-
throw e;
|
|
433
|
-
} finally {
|
|
434
|
-
cleanup();
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
/**
|
|
439
|
-
* True when `root` is usable as a rescue target: an absolute path that is not
|
|
440
|
-
* a bare drive letter. Pure + exported for tests; `platformPath` defaults to
|
|
441
|
-
* the host `path` module so Windows and POSIX runtimes each apply their own
|
|
442
|
-
* absoluteness rules (tests pass `path.win32` / `path.posix` explicitly).
|
|
443
|
-
*
|
|
444
|
-
* Rejecting bare `C:` matters even though `path.win32.isAbsolute("C:")` is
|
|
445
|
-
* already false — the explicit pattern documents the exact field failure and
|
|
446
|
-
* guards against a future helper swap that treats drive-relative paths as
|
|
447
|
-
* absolute.
|
|
448
|
-
*/
|
|
449
|
-
export function isUsableRescueRoot(root: string, platformPath: path.PlatformPath = path): boolean {
|
|
450
|
-
if (/^[A-Za-z]:$/.test(root)) return false;
|
|
451
|
-
return platformPath.isAbsolute(root);
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
/**
|
|
455
|
-
* `fs.realpathSync` that converts failure into the rescue's clean error path
|
|
456
|
-
* (message + ExitError) instead of an uncaught exception. An uncaught throw
|
|
457
|
-
* here is what turned one bad `--hq-root` into a daily crash-loop on Windows.
|
|
458
|
-
*/
|
|
459
|
-
function realpathOrExit(p: string, err: (s: string) => void): string {
|
|
460
|
-
try {
|
|
461
|
-
return fs.realpathSync(p);
|
|
462
|
-
} catch (e) {
|
|
463
|
-
err(`error: cannot resolve --hq-root ${p}: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
464
|
-
throw new ExitError(1);
|
|
465
|
-
}
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
function doRescue(
|
|
469
|
-
cfg: Config,
|
|
470
|
-
env: NodeJS.ProcessEnv,
|
|
471
|
-
out: (s: string) => void,
|
|
472
|
-
err: (s: string) => void,
|
|
473
|
-
setTmp: (d: string) => void,
|
|
474
|
-
): RunRescueResult {
|
|
475
|
-
// --- Resolve HQ root ---
|
|
476
|
-
let hqRoot: string;
|
|
477
|
-
if (cfg.hqRootOverride) {
|
|
478
|
-
// Windows field failure (2026-08-02): the menubar app's daily background
|
|
479
|
-
// core update crash-looped for days because `--hq-root` arrived as the
|
|
480
|
-
// bare drive letter `C:`. `isDir("C:")` is true (drive-relative, resolves
|
|
481
|
-
// against the per-drive cwd) but `fs.realpathSync("C:")` throws EISDIR,
|
|
482
|
-
// so every retry died with an uncaught exception — console-window
|
|
483
|
-
// flicker, git.exe 0xc0000142 dialogs, and no core updates ever landing.
|
|
484
|
-
// Fail closed with a diagnosable message instead: a rescue root must be
|
|
485
|
-
// an absolute directory path, and the error echoes the exact argv this
|
|
486
|
-
// process received so the daily log identifies the caller that mangled
|
|
487
|
-
// the path (the split happens upstream of this process).
|
|
488
|
-
if (!isUsableRescueRoot(cfg.hqRootOverride)) {
|
|
489
|
-
err(
|
|
490
|
-
`error: --hq-root must be an absolute path, got ${JSON.stringify(cfg.hqRootOverride)}.\n` +
|
|
491
|
-
` argv: ${JSON.stringify(process.argv.slice(2))}\n`,
|
|
492
|
-
);
|
|
493
|
-
throw new ExitError(1);
|
|
494
|
-
}
|
|
495
|
-
if (!isDir(cfg.hqRootOverride)) {
|
|
496
|
-
// bash `cd` failure under set -e would abort; mirror as a generic error.
|
|
497
|
-
err(`error: --hq-root ${cfg.hqRootOverride} is not a directory.\n`);
|
|
498
|
-
throw new ExitError(1);
|
|
499
|
-
}
|
|
500
|
-
hqRoot = realpathOrExit(cfg.hqRootOverride, err);
|
|
501
|
-
} else {
|
|
502
|
-
// Legacy default assumed the script lived at personal/skills/<skill>/.
|
|
503
|
-
// The package's programmatic callers always pass --hq-root; fall back to cwd.
|
|
504
|
-
hqRoot = realpathOrExit(process.cwd(), err);
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
// HQ-root sanity gate — guards against wiping a non-HQ directory. `companies/`
|
|
508
|
-
// is the stable anchor (present and preserved on every HQ install), confirmed
|
|
509
|
-
// by at least one core scaffold marker. We accept ANY of `.claude/`, `core/`,
|
|
510
|
-
// or `personal/` because the layout has drifted across releases: v14.0.0 ships
|
|
511
|
-
// NEITHER `personal/` (added v15) NOR `core/` (the v15 scaffold home) — it only
|
|
512
|
-
// has `.claude/`. Requiring `personal/` here left v14.0.0 users with no upgrade
|
|
513
|
-
// path (rescue aborted; DEV-1741). `.claude/` exists on every version v14→v15,
|
|
514
|
-
// so it admits the full upgrade range while still rejecting a bare directory.
|
|
515
|
-
const hasHqMarker =
|
|
516
|
-
isDir(path.join(hqRoot, ".claude")) ||
|
|
517
|
-
isDir(path.join(hqRoot, "core")) ||
|
|
518
|
-
isDir(path.join(hqRoot, "personal"));
|
|
519
|
-
if (!isDir(path.join(hqRoot, "companies")) || !hasHqMarker) {
|
|
520
|
-
err(
|
|
521
|
-
`error: ${hqRoot} does not look like an HQ root ` +
|
|
522
|
-
`(need companies/ plus one of .claude/, core/, or personal/). Aborting.\n`,
|
|
523
|
-
);
|
|
524
|
-
err(" pass --hq-root <dir> if the script is not at personal/skills/<skill>/.\n");
|
|
525
|
-
throw new ExitError(3);
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
const RUN_TS = utcStamp(new Date(), "dash");
|
|
529
|
-
// Both the release overlay and a restorable snapshot require rsync. A shim
|
|
530
|
-
// which merely starts but exits non-zero used to trigger an enormous cp -a
|
|
531
|
-
// fallback, then fail later at overlay. Stop before classification/allocation.
|
|
532
|
-
requireWorkingRsync(env, err);
|
|
533
|
-
|
|
534
|
-
// --- Capture the pre-rescue .claude/settings.json (BEFORE the wipe) ---
|
|
535
|
-
// This is `ours` for the post-overlay settings reconcile. It must be read now
|
|
536
|
-
// because the overlay-safe classification deletes/overwrites it before
|
|
537
|
-
// reconcileClaudeSettings runs. `null` when absent (fresh install / narrow
|
|
538
|
-
// mode that excludes .claude).
|
|
539
|
-
const claudeSettingsRel = ".claude/settings.json";
|
|
540
|
-
const claudeSettingsInScope =
|
|
541
|
-
cfg.narrowPaths.length === 0 || cfg.narrowPaths.includes(".claude");
|
|
542
|
-
let preRescueSettingsJson: string | null = null;
|
|
543
|
-
if (claudeSettingsInScope) {
|
|
544
|
-
const p = path.join(hqRoot, claudeSettingsRel);
|
|
545
|
-
if (isFileFollow(p)) {
|
|
546
|
-
try {
|
|
547
|
-
preRescueSettingsJson = fs.readFileSync(p, "utf-8");
|
|
548
|
-
} catch {
|
|
549
|
-
preRescueSettingsJson = null;
|
|
550
|
-
}
|
|
551
|
-
}
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
// --- Read prior sync-point metadata (BEFORE the wipe) ---
|
|
555
|
-
let prevSyncSha = "";
|
|
556
|
-
let prevSyncSource = "";
|
|
557
|
-
let prevSyncRef = "";
|
|
558
|
-
let prevSyncAt = "";
|
|
559
|
-
const coreYaml = path.join(hqRoot, "core", "core.yaml");
|
|
560
|
-
const yqAvailable = hasCmd("yq", env);
|
|
561
|
-
if (isFileFollow(coreYaml) && yqAvailable) {
|
|
562
|
-
const yq = (expr: string) =>
|
|
563
|
-
run("yq", ["-r", expr, coreYaml], { env }).stdout.trim();
|
|
564
|
-
prevSyncSha = yq(
|
|
565
|
-
".replaced_from_source.last_sync_sha // .replaced_from_staging.last_sync_sha // \"\"",
|
|
566
|
-
);
|
|
567
|
-
prevSyncSource = yq(
|
|
568
|
-
".replaced_from_source.source // .replaced_from_staging.source // \"\"",
|
|
569
|
-
);
|
|
570
|
-
prevSyncRef = yq(".replaced_from_source.ref // .replaced_from_staging.ref // \"\"");
|
|
571
|
-
prevSyncAt = yq(
|
|
572
|
-
".replaced_from_source.last_sync_at // .replaced_from_staging.last_sync_at // \"\"",
|
|
573
|
-
);
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
// Caller override (--floor-sha) wins only when the on-disk stamp is empty.
|
|
577
|
-
if (!prevSyncSha && cfg.floorShaOverride) {
|
|
578
|
-
prevSyncSha = cfg.floorShaOverride;
|
|
579
|
-
prevSyncSource = cfg.sourceRepo;
|
|
580
|
-
prevSyncRef = "(caller-supplied floor)";
|
|
581
|
-
prevSyncAt = "(unstamped install)";
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
out(`==> HQ root: ${hqRoot}\n`);
|
|
585
|
-
out(`==> Source: https://github.com/${cfg.sourceRepo} @ ${cfg.ref}\n`);
|
|
586
|
-
if (prevSyncSha) {
|
|
587
|
-
if (prevSyncSource === cfg.sourceRepo) {
|
|
588
|
-
out(
|
|
589
|
-
`==> Prior sync: ${prevSyncSha} from ${prevSyncSource}@${prevSyncRef} (${prevSyncAt}) — will use as history floor\n`,
|
|
590
|
-
);
|
|
591
|
-
} else {
|
|
592
|
-
out(
|
|
593
|
-
`==> Prior sync: ${prevSyncSha} from ${prevSyncSource} (different source — ignoring as floor)\n`,
|
|
594
|
-
);
|
|
595
|
-
}
|
|
596
|
-
}
|
|
597
|
-
if (cfg.narrowPaths.length !== 0) {
|
|
598
|
-
out("==> Mode: narrow (--paths)\n");
|
|
599
|
-
out(`==> Wipe set: ${cfg.narrowPaths.join(" ")}\n`);
|
|
600
|
-
} else {
|
|
601
|
-
out("==> Mode: preserve-list (default)\n");
|
|
602
|
-
const extra = cfg.extraPreserve.length ? `, ${cfg.extraPreserve.join(" ")}` : "";
|
|
603
|
-
out(
|
|
604
|
-
`==> Preserved: .git, companies (except companies/_template), personal, workspace, repos, worktrees, .worktrees, .sst-sandbox-home, .tmp-codex-asar, .github, .leak-scan, .hq-sync-journal.json, .hq, .hq-conflicts, .local${extra}\n`,
|
|
605
|
-
);
|
|
606
|
-
}
|
|
607
|
-
if (cfg.preserveSubpaths.length !== 0) {
|
|
608
|
-
out("==> Preserved subpaths (backed up + restored across the overlay):\n");
|
|
609
|
-
for (const sp of cfg.preserveSubpaths) {
|
|
610
|
-
const isCarve = CARVE_OUT_PATHS.includes(sp);
|
|
611
|
-
out(isCarve ? ` - ${sp} (always-on carve-out)\n` : ` - ${sp}\n`);
|
|
612
|
-
}
|
|
613
|
-
}
|
|
614
|
-
out(
|
|
615
|
-
`==> Drift policy: rescue user-edited files to personal/; route .agents|.codex|.obsidian|MIGRATION.md user-edits to .hq-conflicts/rescue-${RUN_TS}/; silently overwrite AGENTS.md|USER-GUIDE.md|core/policies/_digest.md (regenerable); leave user-only files untouched; drop reindex symlinks (regenerated by reindex.sh)\n`,
|
|
616
|
-
);
|
|
617
|
-
if (cfg.cloudUpdate) {
|
|
618
|
-
out(
|
|
619
|
-
"==> Cloud-update mode: ON (recognize `hq-symlink:<target>` flat files as upstream-symlink-equivalent — reconciled as UNCHANGED)\n",
|
|
620
|
-
);
|
|
621
|
-
}
|
|
622
|
-
if (cfg.historyCheck) {
|
|
623
|
-
out("==> History gate: ON (skip drift if local matches any past staging blob at that path)\n");
|
|
624
|
-
} else {
|
|
625
|
-
out("==> History gate: OFF (--no-history-check; every diff rescued)\n");
|
|
626
|
-
}
|
|
627
|
-
if (cfg.doBackup) {
|
|
628
|
-
out(
|
|
629
|
-
`==> Safety backup: ON -> ${cfg.backupRoot}/pre-update-${RUN_TS} (retention ${cfg.backupRetentionDays}d / ${cfg.backupRetentionCount} completed)\n`,
|
|
630
|
-
);
|
|
631
|
-
} else {
|
|
632
|
-
out("==> Safety backup: OFF (--no-backup / HQ_RESCUE_NO_BACKUP=1)\n");
|
|
633
|
-
}
|
|
634
|
-
if (cfg.dryRun) out("==> DRY RUN (no destructive operations will run)\n");
|
|
635
|
-
|
|
636
|
-
// --- Confirmation prompt ---
|
|
637
|
-
if (!cfg.assumeYes && !cfg.dryRun) {
|
|
638
|
-
let backupLine = " * NO pre-op backup (--no-backup),";
|
|
639
|
-
if (cfg.doBackup)
|
|
640
|
-
backupLine = ` * snapshot the release-managed wipe set (excluding worktrees/build caches) to ${cfg.backupRoot}/pre-update-${RUN_TS} first,`;
|
|
641
|
-
let cloudLine = "";
|
|
642
|
-
if (cfg.cloudUpdate)
|
|
643
|
-
cloudLine =
|
|
644
|
-
"\n * reconcile `hq-symlink:<target>` flat files against upstream symlinks (cloud-update mode),";
|
|
645
|
-
out(
|
|
646
|
-
`\nThis will:\n${backupLine}\n` +
|
|
647
|
-
" * rescue user-edited files (vs the last sync) into personal/,\n" +
|
|
648
|
-
` * route user-edited .agents/.codex/.obsidian/MIGRATION.md into .hq-conflicts/rescue-${RUN_TS}/ for review,\n` +
|
|
649
|
-
" * silently overwrite AGENTS.md / USER-GUIDE.md / core/policies/_digest.md (regenerable from upstream or reindex — no copy preserved)," +
|
|
650
|
-
cloudLine +
|
|
651
|
-
"\n * drop reindex-generated symlinks (regenerated by reindex.sh on next Stop hook),\n" +
|
|
652
|
-
" * leave user-only files (not in upstream) untouched,\n" +
|
|
653
|
-
` * delete upstream files unchanged since last sync, then unpack ${cfg.sourceRepo}@${cfg.ref} on top.\n` +
|
|
654
|
-
"Type 'yes' to proceed: ",
|
|
655
|
-
);
|
|
656
|
-
const confirm = readLineSync();
|
|
657
|
-
if (confirm !== "yes") {
|
|
658
|
-
out("Aborted.\n");
|
|
659
|
-
throw new ExitError(4);
|
|
660
|
-
}
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
const tmpdir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-replace-rescue-"));
|
|
664
|
-
setTmp(tmpdir);
|
|
665
|
-
const srcDir = path.join(tmpdir, "src");
|
|
666
|
-
const rescueLog = path.join(tmpdir, "rescue-actions.log");
|
|
667
|
-
fs.writeFileSync(rescueLog, "");
|
|
668
|
-
const unchangedList = path.join(tmpdir, "unchanged-paths.txt");
|
|
669
|
-
fs.writeFileSync(unchangedList, "");
|
|
670
|
-
|
|
671
|
-
const appendLog = (line: string) => {
|
|
672
|
-
try {
|
|
673
|
-
fs.appendFileSync(rescueLog, line);
|
|
674
|
-
} catch {
|
|
675
|
-
/* best-effort, matches `|| true` */
|
|
676
|
-
}
|
|
677
|
-
};
|
|
678
|
-
|
|
679
|
-
// --- Build clone URL (GH_TOKEN basic-auth injection) ---
|
|
680
|
-
const ghToken = env.GH_TOKEN || "";
|
|
681
|
-
let cloneUrl: string;
|
|
682
|
-
let cloneUrlDisplay: string;
|
|
683
|
-
if (ghToken) {
|
|
684
|
-
cloneUrl = `https://x-access-token:${ghToken}@github.com/${cfg.sourceRepo}.git`;
|
|
685
|
-
cloneUrlDisplay = `https://x-access-token:***@github.com/${cfg.sourceRepo}.git`;
|
|
686
|
-
} else {
|
|
687
|
-
cloneUrl = `https://github.com/${cfg.sourceRepo}.git`;
|
|
688
|
-
cloneUrlDisplay = cloneUrl;
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
const reportGitFailure = (result: ReturnType<typeof run>) => {
|
|
692
|
-
const diagnostic = result.stderr.trim() || result.stdout.trim();
|
|
693
|
-
if (diagnostic) err(`${redactGitDiagnostic(diagnostic, ghToken)}\n`);
|
|
694
|
-
};
|
|
695
|
-
|
|
696
|
-
out("\n");
|
|
697
|
-
if (cfg.historyCheck) {
|
|
698
|
-
out(`==> Cloning ${cloneUrlDisplay} @${cfg.ref} (full history, blob:none filter) ...\n`);
|
|
699
|
-
const cloneResult = run("git", ["clone", "--filter=blob:none", cloneUrl, srcDir], { env });
|
|
700
|
-
if (cloneResult.status !== 0) {
|
|
701
|
-
reportGitFailure(cloneResult);
|
|
702
|
-
err("error: clone failed\n");
|
|
703
|
-
throw new ExitError(5);
|
|
704
|
-
}
|
|
705
|
-
const checkoutResult = run("git", ["checkout", cfg.ref], { cwd: srcDir, env });
|
|
706
|
-
if (checkoutResult.status !== 0) {
|
|
707
|
-
reportGitFailure(checkoutResult);
|
|
708
|
-
err(`error: could not check out ref '${cfg.ref}' from ${cfg.sourceRepo}\n`);
|
|
709
|
-
throw new ExitError(5);
|
|
710
|
-
}
|
|
711
|
-
} else {
|
|
712
|
-
out(`==> Cloning ${cloneUrlDisplay} @${cfg.ref} (shallow) ...\n`);
|
|
713
|
-
const shallowCloneResult = run(
|
|
714
|
-
"git",
|
|
715
|
-
["clone", "--depth", "1", "--branch", cfg.ref, cloneUrl, srcDir],
|
|
716
|
-
{ env },
|
|
717
|
-
);
|
|
718
|
-
if (shallowCloneResult.status !== 0) {
|
|
719
|
-
reportGitFailure(shallowCloneResult);
|
|
720
|
-
out(" (shallow branch clone failed; trying full clone + checkout)\n");
|
|
721
|
-
const cloneResult = run("git", ["clone", cloneUrl, srcDir], { env });
|
|
722
|
-
if (cloneResult.status !== 0) {
|
|
723
|
-
reportGitFailure(cloneResult);
|
|
724
|
-
err("error: clone failed\n");
|
|
725
|
-
throw new ExitError(5);
|
|
726
|
-
}
|
|
727
|
-
const checkoutResult = run("git", ["checkout", cfg.ref], { cwd: srcDir, env });
|
|
728
|
-
if (checkoutResult.status !== 0) {
|
|
729
|
-
reportGitFailure(checkoutResult);
|
|
730
|
-
err(`error: could not check out ref '${cfg.ref}' from ${cfg.sourceRepo}\n`);
|
|
731
|
-
throw new ExitError(5);
|
|
732
|
-
}
|
|
733
|
-
}
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
const srcSha = run("git", ["rev-parse", "HEAD"], { cwd: srcDir, env }).stdout.trim();
|
|
737
|
-
// Committer timestamp of the source SHA — the DETERMINISTIC stamp value for
|
|
738
|
-
// core/core.yaml's `last_sync_at`. Stamping wall-clock "now" made every
|
|
739
|
-
// rescue run on every machine produce a byte-different core.yaml for the
|
|
740
|
-
// SAME release, which is exactly the genuine local≠remote divergence that
|
|
741
|
-
// minted `core.yaml.conflict-*` mirrors across machines. Same SHA in →
|
|
742
|
-
// identical bytes out, on any machine, any day.
|
|
743
|
-
const srcCommitIsoRaw = run("git", ["show", "-s", "--format=%cI", "HEAD"], {
|
|
744
|
-
cwd: srcDir,
|
|
745
|
-
env,
|
|
746
|
-
}).stdout.trim();
|
|
747
|
-
out(`==> Source SHA: ${srcSha}\n`);
|
|
748
|
-
|
|
749
|
-
// --- Restore file mtimes from git history ---
|
|
750
|
-
restoreMtimesFromGit(srcDir, env, out);
|
|
751
|
-
|
|
752
|
-
// --- Resolve history floor ---
|
|
753
|
-
let historyFloor = "";
|
|
754
|
-
let baselineMode: "history_floor" | "head_compare" = "head_compare";
|
|
755
|
-
if (cfg.historyCheck && prevSyncSha && prevSyncSource === cfg.sourceRepo) {
|
|
756
|
-
if (run("git", ["cat-file", "-e", prevSyncSha], { cwd: srcDir, env }).status === 0) {
|
|
757
|
-
historyFloor = prevSyncSha;
|
|
758
|
-
baselineMode = "history_floor";
|
|
759
|
-
} else {
|
|
760
|
-
out(
|
|
761
|
-
` prior-sync SHA ${prevSyncSha} not reachable in clone (rebased/dropped?); falling back to head_compare\n`,
|
|
762
|
-
);
|
|
763
|
-
}
|
|
764
|
-
}
|
|
765
|
-
if (baselineMode === "history_floor") {
|
|
766
|
-
out(`==> Baseline: ${historyFloor} (last-sync floor reachable)\n`);
|
|
767
|
-
} else {
|
|
768
|
-
out("==> Baseline: HEAD compare (no usable last-sync stamp; first-ever run or stamp mismatch)\n");
|
|
769
|
-
}
|
|
770
|
-
|
|
771
|
-
// --- Build wipe/overlay arg sets per mode ---
|
|
772
|
-
const pruneNames: string[] = [];
|
|
773
|
-
let rsyncExcludes: string[] = [];
|
|
774
|
-
if (cfg.narrowPaths.length !== 0) {
|
|
775
|
-
for (const n of cfg.narrowPaths) {
|
|
776
|
-
rsyncExcludes.push(`--include=/${n}`);
|
|
777
|
-
rsyncExcludes.push(`--include=/${n}/***`);
|
|
778
|
-
}
|
|
779
|
-
rsyncExcludes.push("--exclude=/*");
|
|
780
|
-
} else {
|
|
781
|
-
pruneNames.push(...ALWAYS_PRESERVED_NAMES);
|
|
782
|
-
for (const p of cfg.extraPreserve) pruneNames.push(p);
|
|
783
|
-
rsyncExcludes = [
|
|
784
|
-
"--exclude=.git",
|
|
785
|
-
"--exclude=personal",
|
|
786
|
-
"--exclude=workspace",
|
|
787
|
-
"--exclude=repos",
|
|
788
|
-
"--exclude=.github",
|
|
789
|
-
"--exclude=.leak-scan",
|
|
790
|
-
"--exclude=.hq-sync-journal.json",
|
|
791
|
-
"--exclude=.hq",
|
|
792
|
-
"--exclude=.hq-conflicts",
|
|
793
|
-
"--exclude=/.local",
|
|
794
|
-
"--exclude=/worktrees",
|
|
795
|
-
"--exclude=/.worktrees",
|
|
796
|
-
"--exclude=/.sst-sandbox-home",
|
|
797
|
-
"--exclude=/.tmp-codex-asar",
|
|
798
|
-
"--include=/companies/",
|
|
799
|
-
"--include=/companies/_template/***",
|
|
800
|
-
"--exclude=/companies/*",
|
|
801
|
-
];
|
|
802
|
-
for (const p of cfg.extraPreserve) rsyncExcludes.push(`--exclude=${p}`);
|
|
803
|
-
}
|
|
804
|
-
|
|
805
|
-
// --- Compute the wipe-set top-level roots ---
|
|
806
|
-
const wipeToplevel: string[] = [];
|
|
807
|
-
if (cfg.narrowPaths.length !== 0) {
|
|
808
|
-
for (const n of cfg.narrowPaths) {
|
|
809
|
-
if (lexists(path.join(hqRoot, n))) wipeToplevel.push(n);
|
|
810
|
-
}
|
|
811
|
-
} else {
|
|
812
|
-
const preserveSet = new Set(pruneNames);
|
|
813
|
-
let entries: string[] = [];
|
|
814
|
-
try {
|
|
815
|
-
entries = fs.readdirSync(hqRoot);
|
|
816
|
-
} catch {
|
|
817
|
-
entries = [];
|
|
818
|
-
}
|
|
819
|
-
entries.sort();
|
|
820
|
-
for (const name of entries) {
|
|
821
|
-
if (preserveSet.has(name)) continue;
|
|
822
|
-
wipeToplevel.push(name);
|
|
823
|
-
}
|
|
824
|
-
if (isDir(path.join(hqRoot, "companies", "_template"))) {
|
|
825
|
-
wipeToplevel.push("companies/_template");
|
|
826
|
-
}
|
|
827
|
-
}
|
|
828
|
-
|
|
829
|
-
// --- Classification state ---
|
|
830
|
-
const counts = {
|
|
831
|
-
userOnly: 0,
|
|
832
|
-
unchanged: 0,
|
|
833
|
-
userEdit: 0,
|
|
834
|
-
userConflict: 0,
|
|
835
|
-
userOverwrite: 0,
|
|
836
|
-
claudeDiffAppend: 0,
|
|
837
|
-
symlinkDropped: 0,
|
|
838
|
-
cloudSymlinkReconciled: 0,
|
|
839
|
-
driftReconciled: 0,
|
|
840
|
-
};
|
|
841
|
-
|
|
842
|
-
const ctx: WalkCtx = {
|
|
843
|
-
cfg,
|
|
844
|
-
env,
|
|
845
|
-
hqRoot,
|
|
846
|
-
srcDir,
|
|
847
|
-
historyFloor,
|
|
848
|
-
baselineMode,
|
|
849
|
-
runTs: RUN_TS,
|
|
850
|
-
srcSha,
|
|
851
|
-
counts,
|
|
852
|
-
unchangedList,
|
|
853
|
-
appendLog,
|
|
854
|
-
out,
|
|
855
|
-
decisions: [],
|
|
856
|
-
actions: [],
|
|
857
|
-
};
|
|
858
|
-
|
|
859
|
-
// --- Walk + classify (PHASE 1: read-only) ---
|
|
860
|
-
// Classification mutates nothing on disk: every outcome is recorded as a
|
|
861
|
-
// typed RescueDecision. Destructive ops (delete, rescue-move,
|
|
862
|
-
// conflict-quarantine, symlink-drop) are built from those decisions and
|
|
863
|
-
// executed later (PHASE 2). This is the
|
|
864
|
-
// ordering-safety invariant: if classification throws partway through the
|
|
865
|
-
// wipe set (e.g. an unreadable entry, a future unhandled file shape), it
|
|
866
|
-
// aborts HERE — before a single file has been touched or even queued as a
|
|
867
|
-
// filesystem closure — instead of leaving a half-applied wipe like the
|
|
868
|
-
// pre-port bash classifier did (DEV-1767:
|
|
869
|
-
// `Error: Path is a directory` on a nested dir-symlink, thrown after ~10 core
|
|
870
|
-
// files were already deleted). Dry-run renders each typed decision inline as
|
|
871
|
-
// the walk records it, preserving the legacy progress/fault timing while
|
|
872
|
-
// still sharing the same classify path as the live pass.
|
|
873
|
-
out("\n");
|
|
874
|
-
if (wipeToplevel.length === 0) {
|
|
875
|
-
out("==> Wipe set is empty; nothing to process or overlay.\n");
|
|
876
|
-
} else {
|
|
877
|
-
out(`==> Walking wipe set, classifying vs. ${cfg.sourceRepo}@${cfg.ref} ...\n`);
|
|
878
|
-
// Batch the per-file git facts (hashes + floor lookups) up front so the
|
|
879
|
-
// walk below is map lookups, not ~3 git spawns per file.
|
|
880
|
-
precomputeGitMaps(ctx, wipeToplevel);
|
|
881
|
-
for (const rootRel of wipeToplevel) {
|
|
882
|
-
walkAndProcess(ctx, rootRel);
|
|
883
|
-
}
|
|
884
|
-
}
|
|
885
|
-
|
|
886
|
-
// --- DRY RUN: report + exit ---
|
|
887
|
-
if (cfg.dryRun) {
|
|
888
|
-
out("\n");
|
|
889
|
-
out("==> DRY RUN classification summary:\n");
|
|
890
|
-
out(` user-only (leave in place): ${counts.userOnly} files\n`);
|
|
891
|
-
out(` unchanged (preserved/re-laid): ${counts.unchanged} files\n`);
|
|
892
|
-
out(` user-edit (rescue / diff-append): ${counts.userEdit} files\n`);
|
|
893
|
-
out(` user-edit (conflict quarantine): ${counts.userConflict} files\n`);
|
|
894
|
-
out(` user-edit (overwrite-safe): ${counts.userOverwrite} files\n`);
|
|
895
|
-
out(` cloud-symlink reconciled: ${counts.cloudSymlinkReconciled} files\n`);
|
|
896
|
-
out(` drift reconciled (== upstream): ${counts.driftReconciled} files\n`);
|
|
897
|
-
out(` reindex symlinks dropped: ${counts.symlinkDropped} entries\n`);
|
|
898
|
-
if (counts.claudeDiffAppend > 0) {
|
|
899
|
-
out(" of which .claude/CLAUDE.md would diff-append to personal/CLAUDE.md\n");
|
|
900
|
-
}
|
|
901
|
-
if (counts.userConflict > 0) {
|
|
902
|
-
out(` conflict bucket: .hq-conflicts/rescue-${RUN_TS}/\n`);
|
|
903
|
-
}
|
|
904
|
-
if (cfg.preserveSubpaths.length !== 0) {
|
|
905
|
-
out("\n");
|
|
906
|
-
out("==> DRY RUN: would back up + restore these sub-paths across the overlay:\n");
|
|
907
|
-
for (const sp of cfg.preserveSubpaths) {
|
|
908
|
-
if (lexists(path.join(hqRoot, sp))) {
|
|
909
|
-
out(` ~ ./${sp} (present — will survive)\n`);
|
|
910
|
-
} else {
|
|
911
|
-
out(` ~ ./${sp} (not present locally — no-op)\n`);
|
|
912
|
-
}
|
|
913
|
-
}
|
|
914
|
-
}
|
|
915
|
-
out("\n");
|
|
916
|
-
out("==> DRY RUN: would copy these top-level entries from source on overlay:\n");
|
|
917
|
-
if (cfg.narrowPaths.length !== 0) {
|
|
918
|
-
for (const n of cfg.narrowPaths) {
|
|
919
|
-
if (lexists(path.join(srcDir, n))) out(` + ./${n}\n`);
|
|
920
|
-
}
|
|
921
|
-
} else {
|
|
922
|
-
const preserveSet = new Set(pruneNames);
|
|
923
|
-
let srcEntries: string[] = [];
|
|
924
|
-
try {
|
|
925
|
-
srcEntries = fs.readdirSync(srcDir);
|
|
926
|
-
} catch {
|
|
927
|
-
srcEntries = [];
|
|
928
|
-
}
|
|
929
|
-
srcEntries.sort();
|
|
930
|
-
for (const name of srcEntries) {
|
|
931
|
-
if (preserveSet.has(name)) continue;
|
|
932
|
-
out(` + ${name}\n`);
|
|
933
|
-
}
|
|
934
|
-
if (isDir(path.join(srcDir, "companies", "_template"))) {
|
|
935
|
-
out(` + ./companies/_template (carve-out from ${cfg.sourceRepo}@${cfg.ref})\n`);
|
|
936
|
-
}
|
|
937
|
-
}
|
|
938
|
-
out("\n");
|
|
939
|
-
out("==> DRY RUN complete. No filesystem changes made.\n");
|
|
940
|
-
return { status: 0 };
|
|
941
|
-
}
|
|
942
|
-
|
|
943
|
-
// --- Apply classified actions (PHASE 2: destructive) ---
|
|
944
|
-
// The full wipe set classified without throwing, so it is now safe to mutate.
|
|
945
|
-
// Actions run in classification (walk) order — the same order the old
|
|
946
|
-
// interleaved walk mutated in — so per-file output and on-disk results are
|
|
947
|
-
// unchanged versus before the two-phase split.
|
|
948
|
-
const overlayProtect: string[] = [];
|
|
949
|
-
if (fs.statSync(unchangedList).size > 0) {
|
|
950
|
-
overlayProtect.push(`--exclude-from=${unchangedList}`);
|
|
951
|
-
}
|
|
952
|
-
const overlayArgs = [
|
|
953
|
-
"-a",
|
|
954
|
-
...overlayProtect,
|
|
955
|
-
...rsyncExcludes,
|
|
956
|
-
toCygwinRsyncPath(srcDir + "/"),
|
|
957
|
-
toCygwinRsyncPath(hqRoot + "/"),
|
|
958
|
-
];
|
|
959
|
-
// Exercise the exact overlay command before any classified delete/move. A
|
|
960
|
-
// Cygwin path conversion or filter failure therefore leaves the root intact.
|
|
961
|
-
const overlayPreflight = run("rsync", ["--dry-run", ...overlayArgs], { env });
|
|
962
|
-
if (overlayPreflight.status !== 0) {
|
|
963
|
-
err(overlayPreflight.stderr);
|
|
964
|
-
throw new ExitError(overlayPreflight.status || 1);
|
|
965
|
-
}
|
|
966
|
-
ctx.actions = buildRescueActions(ctx);
|
|
967
|
-
|
|
968
|
-
// Snapshot allocation is deliberately after the entire read-only walk and
|
|
969
|
-
// action-plan build. A no-op (or a classifier fault above) cannot consume
|
|
970
|
-
// backup space.
|
|
971
|
-
let backupDir = "";
|
|
972
|
-
let mutationStarted = false;
|
|
973
|
-
const hasDestructivePaths = ctx.actions.some((action) => action.affectedRels.length > 0);
|
|
974
|
-
if (cfg.doBackup && !cfg.dryRun && wipeToplevel.length !== 0 && hasDestructivePaths) {
|
|
975
|
-
const prePlan = pruneManagedBackups(cfg, hqRoot, out, false);
|
|
976
|
-
const unresolved = prePlan.protected.filter((x) => x.reason === "recovery-required-no-later-good");
|
|
977
|
-
if (unresolved.length >= 2) {
|
|
978
|
-
err("error: safety snapshot circuit breaker is open: two unresolved recovery-required snapshots exist.\n");
|
|
979
|
-
err(" Inspect/restore those snapshots and repair the release source before retrying (operator override required).\n");
|
|
980
|
-
throw new ExitError(1);
|
|
981
|
-
}
|
|
982
|
-
requireBackupCapacity(cfg, hqRoot, wipeToplevel, env, err);
|
|
983
|
-
fs.mkdirSync(cfg.backupRoot, { recursive: true });
|
|
984
|
-
const runId = `${process.pid}-${Date.now().toString(36)}`;
|
|
985
|
-
backupDir = allocateSnapshotDir(cfg.backupRoot, RUN_TS, canonicalRootHash(hqRoot), runId);
|
|
986
|
-
out("\n");
|
|
987
|
-
out(`==> Safety snapshot -> ${backupDir}\n`);
|
|
988
|
-
try {
|
|
989
|
-
fs.mkdirSync(backupDir, { recursive: false });
|
|
990
|
-
writeSnapshotManifest(backupDir, snapshotManifest("copying", hqRoot, runId, cfg, srcSha, wipeToplevel));
|
|
991
|
-
for (const rootRel of wipeToplevel) {
|
|
992
|
-
const srcAbs = path.join(hqRoot, rootRel);
|
|
993
|
-
if (!lexists(srcAbs) || isRescueTransientDirectory(rootRel)) continue;
|
|
994
|
-
cpAToFiltered(srcAbs, path.join(backupDir, rootRel), rootRel);
|
|
995
|
-
}
|
|
996
|
-
updateSnapshotStatus(backupDir, "ready");
|
|
997
|
-
} catch (error) {
|
|
998
|
-
updateSnapshotStatus(backupDir, "failed-pre-mutation");
|
|
999
|
-
throw error;
|
|
1000
|
-
}
|
|
1001
|
-
pruneManagedBackups(cfg, hqRoot, out, true);
|
|
1002
|
-
out(
|
|
1003
|
-
` snapshot ready (restore any file: cp "${backupDir}/<relpath>" "${hqRoot}/<relpath>")\n`,
|
|
1004
|
-
);
|
|
1005
|
-
} else if (cfg.doBackup && !cfg.dryRun && wipeToplevel.length !== 0) {
|
|
1006
|
-
out("\n==> Safety snapshot skipped (classification found no destructive paths).\n");
|
|
1007
|
-
}
|
|
1008
|
-
try {
|
|
1009
|
-
mutationStarted = true;
|
|
1010
|
-
applyClassifiedActions(ctx, backupDir);
|
|
1011
|
-
|
|
1012
|
-
// --- Back up preserve-subpaths to a mktemp shuttle ---
|
|
1013
|
-
const shuttle = path.join(tmpdir, "preserve");
|
|
1014
|
-
fs.mkdirSync(shuttle, { recursive: true });
|
|
1015
|
-
const preserveMap: { id: number; rel: string }[] = [];
|
|
1016
|
-
let shuttleId = 0;
|
|
1017
|
-
for (const sp of cfg.preserveSubpaths) {
|
|
1018
|
-
const src = path.join(hqRoot, sp);
|
|
1019
|
-
if (lexists(src)) {
|
|
1020
|
-
shuttleId += 1;
|
|
1021
|
-
cpA(src, shuttle, String(shuttleId));
|
|
1022
|
-
preserveMap.push({ id: shuttleId, rel: sp });
|
|
1023
|
-
out(`==> Backed up ${sp} -> shuttle/${shuttleId}\n`);
|
|
1024
|
-
}
|
|
1025
|
-
}
|
|
1026
|
-
|
|
1027
|
-
out("\n");
|
|
1028
|
-
out(
|
|
1029
|
-
`==> Walk complete (user-only: ${counts.userOnly}, unchanged: ${counts.unchanged}, user-edit-rescued: ${counts.userEdit}, conflict-quarantined: ${counts.userConflict}, overwrite-safe: ${counts.userOverwrite}, cloud-symlink-reconciled: ${counts.cloudSymlinkReconciled}, symlinks-dropped: ${counts.symlinkDropped})\n`,
|
|
1030
|
-
);
|
|
1031
|
-
|
|
1032
|
-
// --- Overlay source onto HQ root ---
|
|
1033
|
-
out("==> Overlaying source onto HQ root ...\n");
|
|
1034
|
-
// `unchangedList` is only populated during applyClassifiedActions above, so
|
|
1035
|
-
// re-derive the --exclude-from protect list HERE. Building overlayArgs before
|
|
1036
|
-
// classification left the list empty, so unchanged files were overlaid and
|
|
1037
|
-
// their mtimes reset to clone time (rescue-mtime-preserve regression). The
|
|
1038
|
-
// early overlayArgs remain correct for the pre-classification dry-run.
|
|
1039
|
-
const overlayRunArgs =
|
|
1040
|
-
fs.statSync(unchangedList).size > 0
|
|
1041
|
-
? [
|
|
1042
|
-
"-a",
|
|
1043
|
-
`--exclude-from=${unchangedList}`,
|
|
1044
|
-
...rsyncExcludes,
|
|
1045
|
-
toCygwinRsyncPath(srcDir + "/"),
|
|
1046
|
-
toCygwinRsyncPath(hqRoot + "/"),
|
|
1047
|
-
]
|
|
1048
|
-
: overlayArgs;
|
|
1049
|
-
const overlay = run("rsync", overlayRunArgs, { env });
|
|
1050
|
-
if (overlay.status !== 0) {
|
|
1051
|
-
// bash runs under set -e: a failed rsync aborts with its status.
|
|
1052
|
-
err(overlay.stderr);
|
|
1053
|
-
throw new ExitError(overlay.status || 1);
|
|
1054
|
-
}
|
|
1055
|
-
|
|
1056
|
-
// --- Restore preserved sub-paths ---
|
|
1057
|
-
if (preserveMap.length > 0) {
|
|
1058
|
-
out("==> Restoring preserved sub-paths ...\n");
|
|
1059
|
-
for (const { id, rel } of preserveMap) {
|
|
1060
|
-
const dest = path.join(hqRoot, rel);
|
|
1061
|
-
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
1062
|
-
const shuttleItem = path.join(shuttle, String(id));
|
|
1063
|
-
if (isDir(shuttleItem)) {
|
|
1064
|
-
fs.rmSync(dest, { recursive: true, force: true });
|
|
1065
|
-
cpATo(shuttleItem, dest);
|
|
1066
|
-
} else {
|
|
1067
|
-
cpATo(shuttleItem, dest);
|
|
1068
|
-
}
|
|
1069
|
-
out(` restored ${rel}\n`);
|
|
1070
|
-
}
|
|
1071
|
-
}
|
|
1072
|
-
|
|
1073
|
-
// --- Reconcile the generated .claude/settings.json ---
|
|
1074
|
-
// Runs AFTER the overlay (which re-laid the release template, wiring in any
|
|
1075
|
-
// newly-released hook registrations) and AFTER the preserve-subpath restore
|
|
1076
|
-
// (which brought back the user's .claude/settings.local.json). Re-composes the
|
|
1077
|
-
// machine env.PATH onto the release baseline and relocates genuine user drift
|
|
1078
|
-
// (vs the last-sync floor) into settings.local.json so the effective merged
|
|
1079
|
-
// settings are preserved. No-op in narrow mode that excludes .claude.
|
|
1080
|
-
if (claudeSettingsInScope) {
|
|
1081
|
-
reconcileClaudeSettings(
|
|
1082
|
-
{ hqRoot, srcDir, env, historyFloor, baselineMode, preserveSubpaths: cfg.preserveSubpaths },
|
|
1083
|
-
preRescueSettingsJson,
|
|
1084
|
-
out,
|
|
1085
|
-
err,
|
|
1086
|
-
);
|
|
1087
|
-
}
|
|
1088
|
-
|
|
1089
|
-
// --- Restore executable bit on shipped shebang scripts (defense-in-depth) ---
|
|
1090
|
-
// rsync -a already propagated each file's upstream git tree mode; this guards
|
|
1091
|
-
// the case where upstream itself committed an executable script as 0644 -- a
|
|
1092
|
-
// hook the runtime exec's then dies with EACCES and is silently disabled. A
|
|
1093
|
-
// shebang is an unambiguous "meant to be run" marker. See restoreShebangExecBits.
|
|
1094
|
-
const execBitFixes = restoreShebangExecBits(srcDir, hqRoot, env);
|
|
1095
|
-
if (execBitFixes > 0) {
|
|
1096
|
-
out(`==> Restored executable bit on ${execBitFixes} shipped shebang script(s) lacking +x\n`);
|
|
1097
|
-
}
|
|
1098
|
-
|
|
1099
|
-
// --- Stamp sync-point provenance into core/core.yaml ---
|
|
1100
|
-
// `last_sync_at` = the source commit's committer time, NOT wall-clock now:
|
|
1101
|
-
// the stamp must be a pure function of srcSha so every machine rescuing the
|
|
1102
|
-
// same release writes byte-identical core.yaml (see srcCommitIsoRaw). An
|
|
1103
|
-
// unparseable commit timestamp skips the stamp entirely — a nondeterministic
|
|
1104
|
-
// stamp is worse than no stamp (it re-creates cross-machine conflict mirrors).
|
|
1105
|
-
const srcCommitDate = new Date(srcCommitIsoRaw);
|
|
1106
|
-
if (cfg.narrowPaths.length === 0 && isFileFollow(coreYaml) && Number.isNaN(srcCommitDate.getTime())) {
|
|
1107
|
-
err(
|
|
1108
|
-
` WARN: could not parse source commit timestamp (${JSON.stringify(srcCommitIsoRaw)}); skipping core/core.yaml stamp to keep it deterministic\n`,
|
|
1109
|
-
);
|
|
1110
|
-
}
|
|
1111
|
-
if (
|
|
1112
|
-
cfg.narrowPaths.length === 0 &&
|
|
1113
|
-
isFileFollow(coreYaml) &&
|
|
1114
|
-
!Number.isNaN(srcCommitDate.getTime())
|
|
1115
|
-
) {
|
|
1116
|
-
const nowUtc = utcStamp(srcCommitDate, "colon");
|
|
1117
|
-
if (yqAvailable) {
|
|
1118
|
-
const stampEnv: NodeJS.ProcessEnv = {
|
|
1119
|
-
...env,
|
|
1120
|
-
SHA: srcSha,
|
|
1121
|
-
SOURCE: cfg.sourceRepo,
|
|
1122
|
-
THE_REF: cfg.ref,
|
|
1123
|
-
AT: nowUtc,
|
|
1124
|
-
};
|
|
1125
|
-
run(
|
|
1126
|
-
"yq",
|
|
1127
|
-
[
|
|
1128
|
-
"-i",
|
|
1129
|
-
".replaced_from_source.source = strenv(SOURCE) |\n" +
|
|
1130
|
-
" .replaced_from_source.ref = strenv(THE_REF) |\n" +
|
|
1131
|
-
" .replaced_from_source.last_sync_sha = strenv(SHA) |\n" +
|
|
1132
|
-
" .replaced_from_source.last_sync_at = strenv(AT) |\n" +
|
|
1133
|
-
" del(.replaced_from_staging)",
|
|
1134
|
-
coreYaml,
|
|
1135
|
-
],
|
|
1136
|
-
{ env: stampEnv },
|
|
1137
|
-
);
|
|
1138
|
-
out(`==> Stamped core/core.yaml: replaced_from_source.last_sync_sha=${srcSha}\n`);
|
|
1139
|
-
} else if (
|
|
1140
|
-
hasCmd("python3", env) &&
|
|
1141
|
-
run("python3", ["-c", "import yaml"], { env }).status === 0
|
|
1142
|
-
) {
|
|
1143
|
-
const pyEnv: NodeJS.ProcessEnv = {
|
|
1144
|
-
...env,
|
|
1145
|
-
SHA: srcSha,
|
|
1146
|
-
SOURCE: cfg.sourceRepo,
|
|
1147
|
-
THE_REF: cfg.ref,
|
|
1148
|
-
AT: nowUtc,
|
|
1149
|
-
CORE: coreYaml,
|
|
1150
|
-
};
|
|
1151
|
-
run(
|
|
1152
|
-
"python3",
|
|
1153
|
-
[
|
|
1154
|
-
"-c",
|
|
1155
|
-
[
|
|
1156
|
-
"import os, yaml",
|
|
1157
|
-
'path = os.environ["CORE"]',
|
|
1158
|
-
"try:",
|
|
1159
|
-
" with open(path) as f:",
|
|
1160
|
-
" d = yaml.safe_load(f) or {}",
|
|
1161
|
-
"except FileNotFoundError:",
|
|
1162
|
-
" d = {}",
|
|
1163
|
-
'd["replaced_from_source"] = {',
|
|
1164
|
-
' "source": os.environ["SOURCE"],',
|
|
1165
|
-
' "ref": os.environ["THE_REF"],',
|
|
1166
|
-
' "last_sync_sha": os.environ["SHA"],',
|
|
1167
|
-
' "last_sync_at": os.environ["AT"],',
|
|
1168
|
-
"}",
|
|
1169
|
-
'd.pop("replaced_from_staging", None)',
|
|
1170
|
-
'with open(path, "w") as f:',
|
|
1171
|
-
" yaml.safe_dump(d, f, default_flow_style=False, sort_keys=False)",
|
|
1172
|
-
].join("\n"),
|
|
1173
|
-
],
|
|
1174
|
-
{ env: pyEnv },
|
|
1175
|
-
);
|
|
1176
|
-
out(`==> Stamped core/core.yaml: replaced_from_source.last_sync_sha=${srcSha}\n`);
|
|
1177
|
-
} else {
|
|
1178
|
-
err(" WARN: neither yq nor python3+PyYAML available — skipping core/core.yaml stamp\n");
|
|
1179
|
-
}
|
|
1180
|
-
}
|
|
1181
|
-
|
|
1182
|
-
// NOTE (6.2.4): the post-rescue "sync-journal baseline reconcile" that lived
|
|
1183
|
-
// here (6.2.1 itemize-parse, 6.2.3 hash-diff) is intentionally GONE. Masking
|
|
1184
|
-
// rescue-changed scaffold as already-synced starved the push leg — the vault
|
|
1185
|
-
// silently kept stale scaffold bytes while the journal claimed convergence.
|
|
1186
|
-
// Correct semantics: leave rescue-changed files visible as "local changed";
|
|
1187
|
-
// the next bidirectional sync pushes them up (vault converges to the rescue
|
|
1188
|
-
// output), and the pull-side byte-identical convergence probe (6.2.0)
|
|
1189
|
-
// absorbs cross-machine races without minting `.conflict-*` mirrors.
|
|
1190
|
-
|
|
1191
|
-
// --- File count summary ---
|
|
1192
|
-
out("\n");
|
|
1193
|
-
out("==> File count summary:\n");
|
|
1194
|
-
for (const rootRel of wipeToplevel) {
|
|
1195
|
-
const abs = path.join(hqRoot, rootRel);
|
|
1196
|
-
if (lexists(abs)) {
|
|
1197
|
-
const n = countFiles(abs);
|
|
1198
|
-
out(` ${rootRel}: ${n} files\n`);
|
|
1199
|
-
}
|
|
1200
|
-
}
|
|
1201
|
-
out("\n");
|
|
1202
|
-
out("==> Classification:\n");
|
|
1203
|
-
out(` user-only (left in place): ${counts.userOnly} files\n`);
|
|
1204
|
-
out(` unchanged (preserved/re-laid): ${counts.unchanged} files\n`);
|
|
1205
|
-
out(` user-edits (rescued): ${counts.userEdit} files\n`);
|
|
1206
|
-
out(` user-edits (conflict quarantine): ${counts.userConflict} files\n`);
|
|
1207
|
-
out(` user-edits (overwrite-safe): ${counts.userOverwrite} files\n`);
|
|
1208
|
-
out(` cloud-symlink reconciled: ${counts.cloudSymlinkReconciled} files\n`);
|
|
1209
|
-
out(` drift reconciled (== upstream): ${counts.driftReconciled} files\n`);
|
|
1210
|
-
out(` reindex symlinks dropped: ${counts.symlinkDropped} entries\n`);
|
|
1211
|
-
if (counts.claudeDiffAppend > 0) {
|
|
1212
|
-
out(" of which .claude/CLAUDE.md diff-appended to personal/CLAUDE.md\n");
|
|
1213
|
-
}
|
|
1214
|
-
if (counts.userConflict > 0) {
|
|
1215
|
-
out(` conflict bucket: .hq-conflicts/rescue-${RUN_TS}/\n`);
|
|
1216
|
-
}
|
|
1217
|
-
if (baselineMode === "history_floor") {
|
|
1218
|
-
out(` baseline: last-sync floor ${historyFloor}\n`);
|
|
1219
|
-
} else {
|
|
1220
|
-
out(" baseline: upstream HEAD (no stamp; first run / floor unreachable)\n");
|
|
1221
|
-
}
|
|
1222
|
-
|
|
1223
|
-
// --- Write recovery manifest into the snapshot + prune old snapshots ---
|
|
1224
|
-
if (cfg.doBackup && backupDir && isDir(backupDir)) {
|
|
1225
|
-
const lines: string[] = [];
|
|
1226
|
-
lines.push("# HQ pre-update safety snapshot", "");
|
|
1227
|
-
lines.push(`Created: ${RUN_TS}`);
|
|
1228
|
-
lines.push(`HQ root: ${hqRoot}`);
|
|
1229
|
-
lines.push(`Source: ${cfg.sourceRepo}@${cfg.ref} (${srcSha})`);
|
|
1230
|
-
if (baselineMode === "history_floor") {
|
|
1231
|
-
lines.push(`Baseline: history_floor (${historyFloor})`);
|
|
1232
|
-
} else {
|
|
1233
|
-
lines.push("Baseline: head_compare (no last-sync stamp; first run or floor unreachable)");
|
|
1234
|
-
}
|
|
1235
|
-
lines.push("", "## What the rescue did");
|
|
1236
|
-
lines.push(` user-only (left in place): ${counts.userOnly}`);
|
|
1237
|
-
lines.push(` unchanged (preserved/re-laid): ${counts.unchanged}`);
|
|
1238
|
-
lines.push(` user-edit (rescued into personal/): ${counts.userEdit}`);
|
|
1239
|
-
lines.push(` user-edit (conflict quarantine): ${counts.userConflict}`);
|
|
1240
|
-
lines.push(` user-edit (overwrite-safe): ${counts.userOverwrite}`);
|
|
1241
|
-
lines.push(` cloud-symlink reconciled: ${counts.cloudSymlinkReconciled}`);
|
|
1242
|
-
lines.push(` drift reconciled (== upstream HEAD): ${counts.driftReconciled}`);
|
|
1243
|
-
lines.push(` reindex symlinks dropped: ${counts.symlinkDropped}`);
|
|
1244
|
-
if (counts.userConflict > 0) {
|
|
1245
|
-
lines.push(` conflict bucket: .hq-conflicts/rescue-${RUN_TS}/`);
|
|
1246
|
-
}
|
|
1247
|
-
lines.push("", "## Moved / deleted files (tab-separated: action, path, detail)");
|
|
1248
|
-
let rescueLogContent = "";
|
|
1249
|
-
try {
|
|
1250
|
-
rescueLogContent = fs.readFileSync(rescueLog, "utf-8");
|
|
1251
|
-
} catch {
|
|
1252
|
-
rescueLogContent = "";
|
|
1253
|
-
}
|
|
1254
|
-
if (rescueLogContent.length > 0) {
|
|
1255
|
-
// `cat` includes the trailing content verbatim.
|
|
1256
|
-
lines.push(rescueLogContent.replace(/\n$/, ""));
|
|
1257
|
-
} else {
|
|
1258
|
-
lines.push(" (no files were moved or deleted)");
|
|
1259
|
-
}
|
|
1260
|
-
lines.push("", "## Restore");
|
|
1261
|
-
lines.push(
|
|
1262
|
-
"This directory holds the release-managed wipe set as it was before the update; reproducible worktrees and build caches are intentionally excluded.",
|
|
1263
|
-
);
|
|
1264
|
-
lines.push("Restore a single file:");
|
|
1265
|
-
lines.push(` cp "${backupDir}/<relpath>" "${hqRoot}/<relpath>"`);
|
|
1266
|
-
lines.push("Restore everything (overwrites current scaffold — use with care):");
|
|
1267
|
-
lines.push(` rsync -a "${backupDir}/" "${hqRoot}/"`);
|
|
1268
|
-
lines.push(
|
|
1269
|
-
"",
|
|
1270
|
-
`Retention: ${cfg.backupRetentionDays} days and ${cfg.backupRetentionCount} completed snapshots (latest recovery floor retained).`,
|
|
1271
|
-
);
|
|
1272
|
-
try {
|
|
1273
|
-
fs.writeFileSync(path.join(backupDir, "RECOVERY.md"), lines.join("\n") + "\n");
|
|
1274
|
-
} catch {
|
|
1275
|
-
/* best-effort, matches `|| true` */
|
|
1276
|
-
}
|
|
1277
|
-
updateSnapshotStatus(backupDir, "completed-good", RUN_TS);
|
|
1278
|
-
out("\n");
|
|
1279
|
-
out(`==> Pre-update snapshot + recovery manifest: ${backupDir}\n`);
|
|
1280
|
-
pruneManagedBackups(cfg, hqRoot, out, true);
|
|
1281
|
-
}
|
|
1282
|
-
|
|
1283
|
-
out("\n");
|
|
1284
|
-
out(`==> Done. Source: ${cfg.sourceRepo}@${cfg.ref} (${srcSha})\n`);
|
|
1285
|
-
out(" User-edited files were rescued under personal/ (see scan output above).\n");
|
|
1286
|
-
out(" User-only files (created by you, unknown to upstream) were left untouched.\n");
|
|
1287
|
-
if (cfg.doBackup && backupDir) {
|
|
1288
|
-
out(
|
|
1289
|
-
` A filtered pre-update snapshot is at ${backupDir} (RECOVERY.md explains restore).\n`,
|
|
1290
|
-
);
|
|
1291
|
-
}
|
|
1292
|
-
|
|
1293
|
-
return { status: 0 };
|
|
1294
|
-
} catch (error) {
|
|
1295
|
-
if (backupDir && isDir(backupDir)) {
|
|
1296
|
-
updateSnapshotStatus(backupDir, mutationStarted ? "recovery-required" : "failed-pre-mutation");
|
|
1297
|
-
pruneManagedBackups(cfg, hqRoot, out, true);
|
|
1298
|
-
}
|
|
1299
|
-
throw error;
|
|
1300
|
-
}
|
|
1301
|
-
}
|
|
1302
|
-
|
|
1303
|
-
interface WalkCtx {
|
|
1304
|
-
cfg: Config;
|
|
1305
|
-
env: NodeJS.ProcessEnv;
|
|
1306
|
-
hqRoot: string;
|
|
1307
|
-
srcDir: string;
|
|
1308
|
-
historyFloor: string;
|
|
1309
|
-
baselineMode: "history_floor" | "head_compare";
|
|
1310
|
-
// Batched git results, precomputed once before the classify walk so
|
|
1311
|
-
// classifyOne does map lookups instead of spawning git ~3x per file. On a
|
|
1312
|
-
// large wipe set, process-startup overhead dominates and this is the
|
|
1313
|
-
// difference between minutes and seconds. A rel absent from a map falls back
|
|
1314
|
-
// to a per-file spawn (safety for paths that can't be batched line-delimited,
|
|
1315
|
-
// e.g. names containing a newline). See precomputeGitMaps.
|
|
1316
|
-
// localShas: rel -> local blob SHA (`git hash-object --stdin-paths`)
|
|
1317
|
-
// floorShas: rel -> floor blob SHA, or null when the path is not in the
|
|
1318
|
-
// floor (`git cat-file --batch-check`; replaces both the
|
|
1319
|
-
// per-file `cat-file -e` existence probe AND the `rev-parse`).
|
|
1320
|
-
localShas?: Map<string, string>;
|
|
1321
|
-
floorShas?: Map<string, string | null>;
|
|
1322
|
-
runTs: string;
|
|
1323
|
-
srcSha: string;
|
|
1324
|
-
// Read-only classifier output, collected in walk order. Dry-run rendering is
|
|
1325
|
-
// emitted inline as each decision is recorded; live filesystem action
|
|
1326
|
-
// construction is derived from this same list only after the full classify
|
|
1327
|
-
// walk completes.
|
|
1328
|
-
decisions: RescueDecision[];
|
|
1329
|
-
// Deferred destructive ops, built from RescueDecision only AFTER the entire
|
|
1330
|
-
// wipe set has been classified without error (PHASE 2 in doRescue). This is
|
|
1331
|
-
// what makes a classifier crash fail-safe — a throw mid-classify aborts before
|
|
1332
|
-
// a single file is mutated, so the wipe is never left half-applied
|
|
1333
|
-
// (DEV-1767). Dry-run never fills this (it returns after classification), so
|
|
1334
|
-
// live + dry-run share one classify path.
|
|
1335
|
-
actions: RescueAction[];
|
|
1336
|
-
counts: {
|
|
1337
|
-
userOnly: number;
|
|
1338
|
-
unchanged: number;
|
|
1339
|
-
userEdit: number;
|
|
1340
|
-
userConflict: number;
|
|
1341
|
-
userOverwrite: number;
|
|
1342
|
-
claudeDiffAppend: number;
|
|
1343
|
-
symlinkDropped: number;
|
|
1344
|
-
cloudSymlinkReconciled: number;
|
|
1345
|
-
driftReconciled: number;
|
|
1346
|
-
};
|
|
1347
|
-
unchangedList: string;
|
|
1348
|
-
appendLog: (line: string) => void;
|
|
1349
|
-
out: (s: string) => void;
|
|
1350
|
-
}
|
|
1351
|
-
|
|
1352
|
-
interface RescueAction {
|
|
1353
|
-
label: string;
|
|
1354
|
-
affectedRels: string[];
|
|
1355
|
-
expectedPaths: { rel: string; kind: RescuePathKind }[];
|
|
1356
|
-
run: () => void;
|
|
1357
|
-
}
|
|
1358
|
-
|
|
1359
|
-
export type RescueDecision =
|
|
1360
|
-
| { kind: "skip"; rel: string }
|
|
1361
|
-
| { kind: "drop-conflict-artifact"; rel: string }
|
|
1362
|
-
| { kind: "drop-script-managed"; rel: string }
|
|
1363
|
-
| { kind: "drop-reindex-symlink"; rel: string; target: string }
|
|
1364
|
-
| { kind: "user-only"; rel: string }
|
|
1365
|
-
| { kind: "cloud-symlink-reconciled"; rel: string }
|
|
1366
|
-
| { kind: "drift-reconciled"; rel: string }
|
|
1367
|
-
| { kind: "user-edit-diff-append"; rel: string }
|
|
1368
|
-
| { kind: "user-edit-overwrite-safe"; rel: string }
|
|
1369
|
-
| { kind: "user-edit-conflict"; rel: string }
|
|
1370
|
-
| { kind: "user-edit-rescue"; rel: string; target: string }
|
|
1371
|
-
| { kind: "unchanged-preserve"; rel: string }
|
|
1372
|
-
| { kind: "unchanged-delete"; rel: string }
|
|
1373
|
-
| { kind: "wholesale-replace-template"; rel: "companies/_template" };
|
|
1374
|
-
|
|
1375
|
-
function recordDecision(ctx: WalkCtx, decision: RescueDecision): void {
|
|
1376
|
-
ctx.decisions.push(decision);
|
|
1377
|
-
if (ctx.cfg.dryRun) renderDryRunDecision(ctx, decision);
|
|
1378
|
-
switch (decision.kind) {
|
|
1379
|
-
case "drop-reindex-symlink":
|
|
1380
|
-
ctx.counts.symlinkDropped += 1;
|
|
1381
|
-
return;
|
|
1382
|
-
case "user-only":
|
|
1383
|
-
ctx.counts.userOnly += 1;
|
|
1384
|
-
return;
|
|
1385
|
-
case "cloud-symlink-reconciled":
|
|
1386
|
-
ctx.counts.cloudSymlinkReconciled += 1;
|
|
1387
|
-
return;
|
|
1388
|
-
case "drift-reconciled":
|
|
1389
|
-
ctx.counts.driftReconciled += 1;
|
|
1390
|
-
return;
|
|
1391
|
-
case "unchanged-preserve":
|
|
1392
|
-
case "unchanged-delete":
|
|
1393
|
-
ctx.counts.unchanged += 1;
|
|
1394
|
-
return;
|
|
1395
|
-
default:
|
|
1396
|
-
return;
|
|
1397
|
-
}
|
|
1398
|
-
}
|
|
1399
|
-
|
|
1400
|
-
function applyClassifiedActions(ctx: WalkCtx, backupDir: string): void {
|
|
1401
|
-
const completed: RescueAction[] = [];
|
|
1402
|
-
injectApplyPathChangeForTest(ctx);
|
|
1403
|
-
for (const action of ctx.actions) {
|
|
1404
|
-
try {
|
|
1405
|
-
for (const expected of action.expectedPaths) {
|
|
1406
|
-
assertRescuePathTypeUnchanged(
|
|
1407
|
-
expected.rel,
|
|
1408
|
-
expected.kind,
|
|
1409
|
-
rescuePathKind(path.join(ctx.hqRoot, expected.rel)),
|
|
1410
|
-
);
|
|
1411
|
-
}
|
|
1412
|
-
action.run();
|
|
1413
|
-
completed.push(action);
|
|
1414
|
-
} catch (err) {
|
|
1415
|
-
const restored = rollbackCompletedActions(ctx, completed, backupDir);
|
|
1416
|
-
const manifest = writeApplyFailureManifest(
|
|
1417
|
-
ctx,
|
|
1418
|
-
backupDir,
|
|
1419
|
-
action,
|
|
1420
|
-
completed,
|
|
1421
|
-
restored,
|
|
1422
|
-
err,
|
|
1423
|
-
);
|
|
1424
|
-
ctx.out("\n");
|
|
1425
|
-
ctx.out(`error: rescue apply failed during '${action.label}'.\n`);
|
|
1426
|
-
if (restored.length > 0) {
|
|
1427
|
-
ctx.out(`==> Rolled back ${restored.length} prior action path(s) from the safety snapshot.\n`);
|
|
1428
|
-
}
|
|
1429
|
-
if (manifest) {
|
|
1430
|
-
ctx.out(`==> Recovery manifest: ${manifest}\n`);
|
|
1431
|
-
}
|
|
1432
|
-
throw err;
|
|
1433
|
-
}
|
|
1434
|
-
}
|
|
1435
|
-
}
|
|
1436
|
-
|
|
1437
|
-
/** Deterministic seam for the real reindex-vs-rescue race; never set in production. */
|
|
1438
|
-
function injectApplyPathChangeForTest(ctx: WalkCtx): void {
|
|
1439
|
-
const rel = ctx.env.HQ_RESCUE_FAULT_BEFORE_APPLY_REL;
|
|
1440
|
-
const kind = ctx.env.HQ_RESCUE_FAULT_BEFORE_APPLY_KIND;
|
|
1441
|
-
if (!rel || !kind) return;
|
|
1442
|
-
const abs = path.join(ctx.hqRoot, rel);
|
|
1443
|
-
fs.rmSync(abs, { recursive: true, force: true });
|
|
1444
|
-
if (kind === "directory") fs.mkdirSync(abs, { recursive: true });
|
|
1445
|
-
else if (kind === "file") {
|
|
1446
|
-
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
1447
|
-
fs.writeFileSync(abs, "fault-injected path replacement\n");
|
|
1448
|
-
}
|
|
1449
|
-
}
|
|
1450
|
-
|
|
1451
|
-
function rollbackCompletedActions(
|
|
1452
|
-
ctx: WalkCtx,
|
|
1453
|
-
completed: RescueAction[],
|
|
1454
|
-
backupDir: string,
|
|
1455
|
-
): string[] {
|
|
1456
|
-
if (!backupDir || !isDir(backupDir)) return [];
|
|
1457
|
-
|
|
1458
|
-
const rels: string[] = [];
|
|
1459
|
-
const seen = new Set<string>();
|
|
1460
|
-
for (let i = completed.length - 1; i >= 0; i--) {
|
|
1461
|
-
const action = completed[i];
|
|
1462
|
-
for (let j = action.affectedRels.length - 1; j >= 0; j--) {
|
|
1463
|
-
const rel = action.affectedRels[j];
|
|
1464
|
-
if (rel && !seen.has(rel)) {
|
|
1465
|
-
seen.add(rel);
|
|
1466
|
-
rels.push(rel);
|
|
1467
|
-
}
|
|
1468
|
-
}
|
|
1469
|
-
}
|
|
1470
|
-
|
|
1471
|
-
const restored: string[] = [];
|
|
1472
|
-
for (const rel of rels) {
|
|
1473
|
-
const snapshotPath = path.join(backupDir, rel);
|
|
1474
|
-
if (!lexists(snapshotPath)) continue;
|
|
1475
|
-
const dest = path.join(ctx.hqRoot, rel);
|
|
1476
|
-
try {
|
|
1477
|
-
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
1478
|
-
fs.rmSync(dest, { recursive: true, force: true });
|
|
1479
|
-
cpATo(snapshotPath, dest);
|
|
1480
|
-
restored.push(rel);
|
|
1481
|
-
} catch {
|
|
1482
|
-
// Recovery manifest below records any path that could not be restored.
|
|
1483
|
-
}
|
|
1484
|
-
}
|
|
1485
|
-
return restored;
|
|
1486
|
-
}
|
|
1487
|
-
|
|
1488
|
-
function writeApplyFailureManifest(
|
|
1489
|
-
ctx: WalkCtx,
|
|
1490
|
-
backupDir: string,
|
|
1491
|
-
failedAction: RescueAction,
|
|
1492
|
-
completed: RescueAction[],
|
|
1493
|
-
restored: string[],
|
|
1494
|
-
err: unknown,
|
|
1495
|
-
): string | null {
|
|
1496
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
1497
|
-
const completedLines =
|
|
1498
|
-
completed.length === 0
|
|
1499
|
-
? [" (none)"]
|
|
1500
|
-
: completed.map((action) => {
|
|
1501
|
-
const paths = action.affectedRels.length > 0 ? action.affectedRels.join(", ") : "(no filesystem path)";
|
|
1502
|
-
return ` - ${action.label}: ${paths}`;
|
|
1503
|
-
});
|
|
1504
|
-
const restoredSet = new Set(restored);
|
|
1505
|
-
const unrestored = completed
|
|
1506
|
-
.flatMap((action) => action.affectedRels)
|
|
1507
|
-
.filter((rel, idx, arr) => rel && arr.indexOf(rel) === idx && !restoredSet.has(rel));
|
|
1508
|
-
|
|
1509
|
-
const lines: string[] = [];
|
|
1510
|
-
lines.push("# HQ rescue apply recovery", "");
|
|
1511
|
-
lines.push(`Created: ${ctx.runTs}`);
|
|
1512
|
-
lines.push(`HQ root: ${ctx.hqRoot}`);
|
|
1513
|
-
lines.push(`Source: ${ctx.cfg.sourceRepo}@${ctx.cfg.ref} (${ctx.srcSha})`);
|
|
1514
|
-
lines.push(`Failed action: ${failedAction.label}`);
|
|
1515
|
-
lines.push(`Error: ${message}`);
|
|
1516
|
-
lines.push("");
|
|
1517
|
-
lines.push("## Completed Before Failure");
|
|
1518
|
-
for (const line of completedLines) {
|
|
1519
|
-
lines.push(line);
|
|
1520
|
-
}
|
|
1521
|
-
lines.push("");
|
|
1522
|
-
lines.push("## Failed Action Paths");
|
|
1523
|
-
if (failedAction.affectedRels.length > 0) {
|
|
1524
|
-
for (const rel of failedAction.affectedRels) lines.push(` - ${rel}`);
|
|
1525
|
-
} else {
|
|
1526
|
-
lines.push(" (no filesystem path)");
|
|
1527
|
-
}
|
|
1528
|
-
lines.push("");
|
|
1529
|
-
lines.push("## Rollback");
|
|
1530
|
-
if (restored.length > 0) {
|
|
1531
|
-
lines.push("Restored from the pre-op safety snapshot:");
|
|
1532
|
-
for (const rel of restored) lines.push(` - ${rel}`);
|
|
1533
|
-
} else {
|
|
1534
|
-
lines.push("No completed action paths were restored automatically.");
|
|
1535
|
-
}
|
|
1536
|
-
if (unrestored.length > 0) {
|
|
1537
|
-
lines.push("", "Completed action paths still requiring review:");
|
|
1538
|
-
for (const rel of unrestored) lines.push(` - ${rel}`);
|
|
1539
|
-
}
|
|
1540
|
-
if (backupDir && isDir(backupDir)) {
|
|
1541
|
-
lines.push("");
|
|
1542
|
-
lines.push("Manual restore examples:");
|
|
1543
|
-
lines.push(` cp "${backupDir}/<relpath>" "${ctx.hqRoot}/<relpath>"`);
|
|
1544
|
-
lines.push(` rsync -a "${backupDir}/" "${ctx.hqRoot}/"`);
|
|
1545
|
-
} else {
|
|
1546
|
-
lines.push("", "No pre-op safety snapshot was available for automatic restore.");
|
|
1547
|
-
}
|
|
1548
|
-
|
|
1549
|
-
const manifestDir = path.join(ctx.hqRoot, ".hq", "rescue-recovery");
|
|
1550
|
-
const manifestPath = path.join(manifestDir, `rescue-apply-failure-${ctx.runTs}.md`);
|
|
1551
|
-
try {
|
|
1552
|
-
fs.mkdirSync(manifestDir, { recursive: true });
|
|
1553
|
-
fs.writeFileSync(manifestPath, lines.join("\n") + "\n");
|
|
1554
|
-
return manifestPath;
|
|
1555
|
-
} catch {
|
|
1556
|
-
return null;
|
|
1557
|
-
}
|
|
1558
|
-
}
|
|
1559
|
-
|
|
1560
|
-
// --- Rescue-target mapping ---
|
|
1561
|
-
function mapRescueTarget(rel: string): string {
|
|
1562
|
-
if (rel === ".claude/CLAUDE.md") return "personal/CLAUDE.md";
|
|
1563
|
-
let rest: string;
|
|
1564
|
-
if (rel.startsWith(".claude/")) rest = rel.slice(".claude/".length);
|
|
1565
|
-
else if (rel.startsWith("core/")) rest = rel.slice("core/".length);
|
|
1566
|
-
else rest = rel;
|
|
1567
|
-
return `personal/${rest}`;
|
|
1568
|
-
}
|
|
1569
|
-
|
|
1570
|
-
function isConflictClass(rel: string): boolean {
|
|
1571
|
-
return (
|
|
1572
|
-
rel === ".agents" ||
|
|
1573
|
-
rel.startsWith(".agents/") ||
|
|
1574
|
-
rel === ".codex" ||
|
|
1575
|
-
rel.startsWith(".codex/") ||
|
|
1576
|
-
rel === ".obsidian" ||
|
|
1577
|
-
rel.startsWith(".obsidian/") ||
|
|
1578
|
-
rel === "MIGRATION.md"
|
|
1579
|
-
);
|
|
1580
|
-
}
|
|
1581
|
-
|
|
1582
|
-
function isOverwriteSafe(rel: string): boolean {
|
|
1583
|
-
return (
|
|
1584
|
-
rel === "AGENTS.md" ||
|
|
1585
|
-
rel === "USER-GUIDE.md" ||
|
|
1586
|
-
rel === "core/docs/hq/USER-GUIDE.md" ||
|
|
1587
|
-
rel === "core/policies/_digest.md" ||
|
|
1588
|
-
// `.claude/settings.json` is a machine-local *generated* file (setup.sh
|
|
1589
|
-
// step 3b composes env.PATH into the release template). It always differs
|
|
1590
|
-
// from every release blob because of that PATH, so the generic classifier
|
|
1591
|
-
// would rescue it into personal/ and lay a PATH-less template on top —
|
|
1592
|
-
// dropping the machine PATH and (depending on drift/history state) failing
|
|
1593
|
-
// to wire in newly-released hook registrations. Instead let the overlay
|
|
1594
|
-
// always re-lay the release template (no personal/ copy), then
|
|
1595
|
-
// reconcileClaudeSettings() re-composes env.PATH and relocates genuine user
|
|
1596
|
-
// drift into settings.local.json. See reconcileClaudeSettings.
|
|
1597
|
-
rel === ".claude/settings.json"
|
|
1598
|
-
);
|
|
1599
|
-
}
|
|
1600
|
-
|
|
1601
|
-
function masterSyncSymlinkTarget(localPath: string): string | null {
|
|
1602
|
-
const st = lstatOrNull(localPath);
|
|
1603
|
-
if (!st || !st.isSymbolicLink()) return null;
|
|
1604
|
-
let tgt = "";
|
|
1605
|
-
try {
|
|
1606
|
-
tgt = fs.readlinkSync(localPath);
|
|
1607
|
-
} catch {
|
|
1608
|
-
return null;
|
|
1609
|
-
}
|
|
1610
|
-
return tgt.includes("/personal/") || tgt.startsWith("personal/") ? tgt : null;
|
|
1611
|
-
}
|
|
1612
|
-
|
|
1613
|
-
/**
|
|
1614
|
-
* Drop a reindex symlink with lstat + unlink semantics: treat the path as a
|
|
1615
|
-
* link and remove the link itself, never following or recursing into its
|
|
1616
|
-
* target.
|
|
1617
|
-
*
|
|
1618
|
-
* `fs.rmSync(p, { force: true })` stat-follows on some platforms (notably
|
|
1619
|
-
* macOS): a symlink whose target is a directory is seen as a directory, and
|
|
1620
|
-
* with `recursive` unset it throws `EISDIR` ("Path is a directory"). During a
|
|
1621
|
-
* rescue apply that abort rolls back the whole update — the exact failure this
|
|
1622
|
-
* guards against (a directory-target overlay symlink such as
|
|
1623
|
-
* core/knowledge/<name> -> personal/knowledge/<name>). `unlink(2)` removes the
|
|
1624
|
-
* link regardless of what it points at, so file- and directory-target reindex
|
|
1625
|
-
* symlinks are dropped identically. reindex.sh re-lays the link afterward.
|
|
1626
|
-
*
|
|
1627
|
-
* A missing path is a no-op (force-equivalent); a non-symlink is left untouched
|
|
1628
|
-
* so we can never recurse into or delete a real directory (the classifier only
|
|
1629
|
-
* ever routes real symlinks here — this stays defensive).
|
|
1630
|
-
*/
|
|
1631
|
-
function dropReindexSymlink(localPath: string): void {
|
|
1632
|
-
const st = lstatOrNull(localPath);
|
|
1633
|
-
if (!st) return; // already gone — nothing to drop
|
|
1634
|
-
if (!st.isSymbolicLink()) return; // never follow/recurse into a real target
|
|
1635
|
-
fs.unlinkSync(localPath);
|
|
1636
|
-
}
|
|
1637
|
-
|
|
1638
|
-
function isUnderPreserve(cfg: Config, rel: string): boolean {
|
|
1639
|
-
for (const sp of cfg.preserveSubpaths) {
|
|
1640
|
-
if (rel === sp || rel.startsWith(sp + "/")) return true;
|
|
1641
|
-
}
|
|
1642
|
-
return false;
|
|
1643
|
-
}
|
|
1644
|
-
|
|
1645
|
-
/** Cloud-flattened `hq-symlink:<target>` equivalence check (--cloud-update). */
|
|
1646
|
-
function isCloudFlattenedSymlinkEquiv(ctx: WalkCtx, rel: string, localPath: string): boolean {
|
|
1647
|
-
if (!ctx.cfg.cloudUpdate) return false;
|
|
1648
|
-
if (!isFileFollow(localPath)) return false;
|
|
1649
|
-
let buf: Buffer;
|
|
1650
|
-
try {
|
|
1651
|
-
buf = fs.readFileSync(localPath);
|
|
1652
|
-
} catch {
|
|
1653
|
-
return false;
|
|
1654
|
-
}
|
|
1655
|
-
const head = buf.subarray(0, 11).toString("utf-8");
|
|
1656
|
-
if (head !== "hq-symlink:") return false;
|
|
1657
|
-
// First line, strip prefix, strip newlines.
|
|
1658
|
-
const firstLine = buf.toString("utf-8").split("\n")[0] ?? "";
|
|
1659
|
-
const localTarget = firstLine.replace(/^hq-symlink:/, "").replace(/\n/g, "");
|
|
1660
|
-
if (!localTarget) return false;
|
|
1661
|
-
|
|
1662
|
-
const ref = ctx.baselineMode === "history_floor" ? ctx.historyFloor : "HEAD";
|
|
1663
|
-
const lsTree = run("git", ["ls-tree", ref, "--", rel], { cwd: ctx.srcDir, env: ctx.env }).stdout;
|
|
1664
|
-
const mode = lsTree.trim().split(/\s+/)[0] ?? "";
|
|
1665
|
-
if (mode !== "120000") return false;
|
|
1666
|
-
|
|
1667
|
-
const showRef = ctx.baselineMode === "history_floor" ? `${ctx.historyFloor}:${rel}` : `HEAD:${rel}`;
|
|
1668
|
-
const upstreamTarget = run("git", ["show", showRef], { cwd: ctx.srcDir, env: ctx.env }).stdout;
|
|
1669
|
-
if (!upstreamTarget) return false;
|
|
1670
|
-
return localTarget === upstreamTarget;
|
|
1671
|
-
}
|
|
1672
|
-
|
|
1673
|
-
// --- diff-append .claude/CLAUDE.md user additions into personal/CLAUDE.md ---
|
|
1674
|
-
function diffAppendClaudeMd(ctx: WalkCtx): void {
|
|
1675
|
-
const localFile = path.join(ctx.hqRoot, ".claude/CLAUDE.md");
|
|
1676
|
-
const personalFile = path.join(ctx.hqRoot, "personal/CLAUDE.md");
|
|
1677
|
-
const baselineFile = path.join(
|
|
1678
|
-
fs.mkdtempSync(path.join(os.tmpdir(), "claude-md-baseline-")),
|
|
1679
|
-
"baseline",
|
|
1680
|
-
);
|
|
1681
|
-
|
|
1682
|
-
let wroteBaseline = false;
|
|
1683
|
-
if (ctx.baselineMode === "history_floor") {
|
|
1684
|
-
const show = run("git", ["show", `${ctx.historyFloor}:.claude/CLAUDE.md`], {
|
|
1685
|
-
cwd: ctx.srcDir,
|
|
1686
|
-
env: ctx.env,
|
|
1687
|
-
});
|
|
1688
|
-
if (show.status === 0) {
|
|
1689
|
-
fs.writeFileSync(baselineFile, show.stdout);
|
|
1690
|
-
wroteBaseline = true;
|
|
1691
|
-
}
|
|
1692
|
-
}
|
|
1693
|
-
const srcClaude = path.join(ctx.srcDir, ".claude/CLAUDE.md");
|
|
1694
|
-
const baselineNonEmpty = () => {
|
|
1695
|
-
try {
|
|
1696
|
-
return fs.statSync(baselineFile).size > 0;
|
|
1697
|
-
} catch {
|
|
1698
|
-
return false;
|
|
1699
|
-
}
|
|
1700
|
-
};
|
|
1701
|
-
if (!baselineNonEmpty() && isFileFollow(srcClaude)) {
|
|
1702
|
-
fs.copyFileSync(srcClaude, baselineFile);
|
|
1703
|
-
wroteBaseline = true;
|
|
1704
|
-
}
|
|
1705
|
-
|
|
1706
|
-
fs.mkdirSync(path.join(ctx.hqRoot, "personal"), { recursive: true });
|
|
1707
|
-
|
|
1708
|
-
const personalNonEmpty = () => {
|
|
1709
|
-
try {
|
|
1710
|
-
return fs.statSync(personalFile).size > 0;
|
|
1711
|
-
} catch {
|
|
1712
|
-
return false;
|
|
1713
|
-
}
|
|
1714
|
-
};
|
|
1715
|
-
|
|
1716
|
-
if (!baselineNonEmpty()) {
|
|
1717
|
-
// No baseline — record the whole local file under a marker.
|
|
1718
|
-
try {
|
|
1719
|
-
if (wroteBaseline) fs.rmSync(baselineFile, { force: true });
|
|
1720
|
-
} catch {
|
|
1721
|
-
/* ignore */
|
|
1722
|
-
}
|
|
1723
|
-
let block = "";
|
|
1724
|
-
if (personalNonEmpty()) block += "\n";
|
|
1725
|
-
block += `<!-- drift-append from .claude/CLAUDE.md @ ${ctx.runTs} (source ${ctx.srcSha}; no baseline available) -->\n`;
|
|
1726
|
-
block += readFileOrEmpty(localFile);
|
|
1727
|
-
fs.appendFileSync(personalFile, block);
|
|
1728
|
-
ctx.out(" diff-appended (full local, no baseline): .claude/CLAUDE.md -> personal/CLAUDE.md\n");
|
|
1729
|
-
ctx.counts.claudeDiffAppend += 1;
|
|
1730
|
-
return;
|
|
1731
|
-
}
|
|
1732
|
-
|
|
1733
|
-
// Extract additions via `diff -u` + the sed filter.
|
|
1734
|
-
const diff = run("diff", ["-u", baselineFile, localFile], { env: ctx.env });
|
|
1735
|
-
try {
|
|
1736
|
-
fs.rmSync(baselineFile, { force: true });
|
|
1737
|
-
} catch {
|
|
1738
|
-
/* ignore */
|
|
1739
|
-
}
|
|
1740
|
-
const additions = diff.stdout
|
|
1741
|
-
.split("\n")
|
|
1742
|
-
.filter((l) => !l.startsWith("+++"))
|
|
1743
|
-
.filter((l) => l.startsWith("+"))
|
|
1744
|
-
.map((l) => l.slice(1));
|
|
1745
|
-
// sed prints each matched line; reconstruct with trailing newline per line.
|
|
1746
|
-
const additionsText = additions.length ? additions.join("\n") + "\n" : "";
|
|
1747
|
-
|
|
1748
|
-
if (!additionsText) {
|
|
1749
|
-
ctx.out(" no user edits to .claude/CLAUDE.md (skipped diff-append)\n");
|
|
1750
|
-
return;
|
|
1751
|
-
}
|
|
1752
|
-
|
|
1753
|
-
let block = "";
|
|
1754
|
-
if (personalNonEmpty()) block += "\n";
|
|
1755
|
-
block += `<!-- drift-append from .claude/CLAUDE.md @ ${ctx.runTs} (source ${ctx.srcSha}) -->\n`;
|
|
1756
|
-
block += additionsText;
|
|
1757
|
-
fs.appendFileSync(personalFile, block);
|
|
1758
|
-
ctx.out(" diff-appended: .claude/CLAUDE.md additions -> personal/CLAUDE.md\n");
|
|
1759
|
-
ctx.counts.claudeDiffAppend += 1;
|
|
1760
|
-
}
|
|
1761
|
-
|
|
1762
|
-
// --- .claude/settings.json reconcile (recompose env.PATH + relocate drift) ---
|
|
1763
|
-
|
|
1764
|
-
type JsonValue =
|
|
1765
|
-
| string
|
|
1766
|
-
| number
|
|
1767
|
-
| boolean
|
|
1768
|
-
| null
|
|
1769
|
-
| JsonValue[]
|
|
1770
|
-
| { [k: string]: JsonValue };
|
|
1771
|
-
|
|
1772
|
-
function isPlainObject(v: unknown): v is { [k: string]: JsonValue } {
|
|
1773
|
-
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1774
|
-
}
|
|
1775
|
-
|
|
1776
|
-
/** Structural deep equality for JSON values (stable, order-sensitive for objects). */
|
|
1777
|
-
export function jsonDeepEqual(a: JsonValue, b: JsonValue): boolean {
|
|
1778
|
-
if (a === b) return true;
|
|
1779
|
-
if (Array.isArray(a) && Array.isArray(b)) {
|
|
1780
|
-
if (a.length !== b.length) return false;
|
|
1781
|
-
return a.every((x, i) => jsonDeepEqual(x, b[i]!));
|
|
1782
|
-
}
|
|
1783
|
-
if (isPlainObject(a) && isPlainObject(b)) {
|
|
1784
|
-
const ak = Object.keys(a);
|
|
1785
|
-
const bk = Object.keys(b);
|
|
1786
|
-
if (ak.length !== bk.length) return false;
|
|
1787
|
-
return ak.every((k) => k in b && jsonDeepEqual(a[k]!, b[k]!));
|
|
1788
|
-
}
|
|
1789
|
-
return false;
|
|
1790
|
-
}
|
|
1791
|
-
|
|
1792
|
-
function jsonParseOrNull(text: string | null | undefined): JsonValue | null {
|
|
1793
|
-
if (text == null) return null;
|
|
1794
|
-
try {
|
|
1795
|
-
return JSON.parse(text) as JsonValue;
|
|
1796
|
-
} catch {
|
|
1797
|
-
return null;
|
|
1798
|
-
}
|
|
1799
|
-
}
|
|
1800
|
-
|
|
1801
|
-
/**
|
|
1802
|
-
* Compute the drift of `ours` relative to `base` (the three-way "ours − base").
|
|
1803
|
-
* Returns a partial object holding only the values `ours` added or changed
|
|
1804
|
-
* versus `base`, suitable for merging into settings.local.json so Claude Code's
|
|
1805
|
-
* higher-precedence Local scope re-supplies them on top of the release baseline.
|
|
1806
|
-
*
|
|
1807
|
-
* - object: recurse per key; a key absent from `base` is drift wholesale.
|
|
1808
|
-
* - array: drift = the elements present in `ours` but not deep-contained in
|
|
1809
|
-
* `base`. This matches Claude Code's union merge for permissions/hooks, so a
|
|
1810
|
-
* relocated allow/deny entry unions back into the effective settings.
|
|
1811
|
-
* - scalar / type-mismatch: drift = ours's value when not deep-equal.
|
|
1812
|
-
*
|
|
1813
|
-
* `excludePath` names dotted key paths that are NEVER drift (env.PATH — a
|
|
1814
|
-
* machine-local value re-composed onto the release baseline, not relocated).
|
|
1815
|
-
* Returns `undefined` when there is no drift at this node.
|
|
1816
|
-
*/
|
|
1817
|
-
export function settingsDrift(
|
|
1818
|
-
ours: JsonValue,
|
|
1819
|
-
base: JsonValue | undefined,
|
|
1820
|
-
excludePath: string[] = ["env.PATH"],
|
|
1821
|
-
pathPrefix = "",
|
|
1822
|
-
): JsonValue | undefined {
|
|
1823
|
-
const here = pathPrefix;
|
|
1824
|
-
if (excludePath.includes(here)) return undefined;
|
|
1825
|
-
|
|
1826
|
-
if (base === undefined) {
|
|
1827
|
-
// Whole subtree is new in ours — but still honor exclusions nested under it.
|
|
1828
|
-
if (isPlainObject(ours)) {
|
|
1829
|
-
const out: { [k: string]: JsonValue } = {};
|
|
1830
|
-
let any = false;
|
|
1831
|
-
for (const k of Object.keys(ours)) {
|
|
1832
|
-
const child = settingsDrift(ours[k]!, undefined, excludePath, here ? `${here}.${k}` : k);
|
|
1833
|
-
if (child !== undefined) {
|
|
1834
|
-
out[k] = child;
|
|
1835
|
-
any = true;
|
|
1836
|
-
}
|
|
1837
|
-
}
|
|
1838
|
-
return any ? out : undefined;
|
|
1839
|
-
}
|
|
1840
|
-
return ours;
|
|
1841
|
-
}
|
|
1842
|
-
|
|
1843
|
-
if (isPlainObject(ours) && isPlainObject(base)) {
|
|
1844
|
-
const out: { [k: string]: JsonValue } = {};
|
|
1845
|
-
let any = false;
|
|
1846
|
-
for (const k of Object.keys(ours)) {
|
|
1847
|
-
const child = settingsDrift(
|
|
1848
|
-
ours[k]!,
|
|
1849
|
-
k in base ? base[k]! : undefined,
|
|
1850
|
-
excludePath,
|
|
1851
|
-
here ? `${here}.${k}` : k,
|
|
1852
|
-
);
|
|
1853
|
-
if (child !== undefined) {
|
|
1854
|
-
out[k] = child;
|
|
1855
|
-
any = true;
|
|
1856
|
-
}
|
|
1857
|
-
}
|
|
1858
|
-
return any ? out : undefined;
|
|
1859
|
-
}
|
|
1860
|
-
|
|
1861
|
-
if (Array.isArray(ours) && Array.isArray(base)) {
|
|
1862
|
-
const extra = ours.filter((el) => !base.some((b) => jsonDeepEqual(el, b)));
|
|
1863
|
-
return extra.length > 0 ? extra : undefined;
|
|
1864
|
-
}
|
|
1865
|
-
|
|
1866
|
-
return jsonDeepEqual(ours, base) ? undefined : ours;
|
|
1867
|
-
}
|
|
1868
|
-
|
|
1869
|
-
/**
|
|
1870
|
-
* Deep-merge `patch` into `target` in place and return it. Objects merge
|
|
1871
|
-
* per-key; arrays union (append patch elements not already deep-present, so a
|
|
1872
|
-
* re-run is idempotent and no duplicate permission/hook entries accumulate);
|
|
1873
|
-
* scalars are added ONLY when the key is absent from `target`. A scalar the
|
|
1874
|
-
* user already set in Local is the higher-precedence value and MUST NOT be
|
|
1875
|
-
* clobbered by the lower-precedence Project drift (e.g. Project `model: opus` +
|
|
1876
|
-
* Local `model: sonnet` must stay `sonnet` after rescue). Mirrors how Claude
|
|
1877
|
-
* Code's Local scope overrides/unions over Project.
|
|
1878
|
-
*/
|
|
1879
|
-
export function deepMergeSettings(
|
|
1880
|
-
target: { [k: string]: JsonValue },
|
|
1881
|
-
patch: { [k: string]: JsonValue },
|
|
1882
|
-
): { [k: string]: JsonValue } {
|
|
1883
|
-
for (const k of Object.keys(patch)) {
|
|
1884
|
-
const pv = patch[k]!;
|
|
1885
|
-
const tv = target[k];
|
|
1886
|
-
if (Array.isArray(pv) && Array.isArray(tv)) {
|
|
1887
|
-
for (const el of pv) {
|
|
1888
|
-
if (!tv.some((x) => jsonDeepEqual(x, el))) tv.push(el);
|
|
1889
|
-
}
|
|
1890
|
-
} else if (isPlainObject(pv) && isPlainObject(tv)) {
|
|
1891
|
-
deepMergeSettings(tv, pv);
|
|
1892
|
-
} else if (!(k in target)) {
|
|
1893
|
-
// Key absent from Local — safe to introduce the relocated drift value.
|
|
1894
|
-
target[k] = pv;
|
|
1895
|
-
}
|
|
1896
|
-
// else: Local already defines this scalar (or a differently-typed value);
|
|
1897
|
-
// it is the higher-precedence override and is left untouched.
|
|
1898
|
-
}
|
|
1899
|
-
return target;
|
|
1900
|
-
}
|
|
1901
|
-
|
|
1902
|
-
/**
|
|
1903
|
-
* Apply the user's array REMOVALS (elements present in `base` but absent from
|
|
1904
|
-
* `ours`) to the corresponding arrays in `theirs`, in place. A union-style Local
|
|
1905
|
-
* overlay cannot express a removal, so a permission/hook a user deliberately
|
|
1906
|
-
* deleted from the generated settings.json would otherwise be restored by the
|
|
1907
|
-
* release baseline and never removable. Filtering `theirs` — the Project file we
|
|
1908
|
-
* now own — is the only representation of that intent. Recurses through objects;
|
|
1909
|
-
* only acts when all three nodes are arrays (or objects to recurse). Requires a
|
|
1910
|
-
* REAL floor base: never call with base = theirs (HEAD), or release-only entries
|
|
1911
|
-
* would be mistaken for removals.
|
|
1912
|
-
*/
|
|
1913
|
-
export function applyArrayRemovals(
|
|
1914
|
-
theirs: { [k: string]: JsonValue },
|
|
1915
|
-
ours: { [k: string]: JsonValue },
|
|
1916
|
-
base: { [k: string]: JsonValue },
|
|
1917
|
-
): void {
|
|
1918
|
-
for (const k of Object.keys(base)) {
|
|
1919
|
-
const bv = base[k];
|
|
1920
|
-
const ov = ours[k];
|
|
1921
|
-
const tv = theirs[k];
|
|
1922
|
-
if (Array.isArray(bv) && Array.isArray(ov) && Array.isArray(tv)) {
|
|
1923
|
-
const removed = bv.filter((b) => !ov.some((o) => jsonDeepEqual(o, b)));
|
|
1924
|
-
if (removed.length > 0) {
|
|
1925
|
-
theirs[k] = tv.filter((t) => !removed.some((r) => jsonDeepEqual(r, t)));
|
|
1926
|
-
}
|
|
1927
|
-
} else if (isPlainObject(bv) && isPlainObject(ov) && isPlainObject(tv)) {
|
|
1928
|
-
applyArrayRemovals(tv, ov, bv);
|
|
1929
|
-
}
|
|
1930
|
-
}
|
|
1931
|
-
}
|
|
1932
|
-
|
|
1933
|
-
/**
|
|
1934
|
-
* Re-compose the machine env.PATH for the freshly-overlaid release template.
|
|
1935
|
-
* Runs the SAME composer setup.sh uses (core/scripts/compose-settings-path.sh)
|
|
1936
|
-
* with the machine's own PATH as the base — preferring the pre-rescue snapshot
|
|
1937
|
-
* so a working PATH is never degraded by a possibly-truncated rescue-process
|
|
1938
|
-
* PATH — so the value is always machine-specific and never a hardcoded release
|
|
1939
|
-
* PATH (the release template ships none). Prefers the release clone's composer,
|
|
1940
|
-
* falls back to the overlaid one; if neither runs, carries `basePath` verbatim.
|
|
1941
|
-
*/
|
|
1942
|
-
function composeSettingsPath(
|
|
1943
|
-
srcDir: string,
|
|
1944
|
-
hqRoot: string,
|
|
1945
|
-
basePath: string,
|
|
1946
|
-
env: NodeJS.ProcessEnv,
|
|
1947
|
-
): string {
|
|
1948
|
-
const candidates = [
|
|
1949
|
-
path.join(srcDir, "core", "scripts", "compose-settings-path.sh"),
|
|
1950
|
-
path.join(hqRoot, "core", "scripts", "compose-settings-path.sh"),
|
|
1951
|
-
];
|
|
1952
|
-
for (const script of candidates) {
|
|
1953
|
-
if (!isFileFollow(script)) continue;
|
|
1954
|
-
const r = run("bash", [script, basePath], { env });
|
|
1955
|
-
const composed = r.stdout.replace(/\n+$/, "");
|
|
1956
|
-
if (r.status === 0 && composed) return composed;
|
|
1957
|
-
}
|
|
1958
|
-
return basePath;
|
|
1959
|
-
}
|
|
1960
|
-
|
|
1961
|
-
interface ReconcileCtx {
|
|
1962
|
-
hqRoot: string;
|
|
1963
|
-
srcDir: string;
|
|
1964
|
-
env: NodeJS.ProcessEnv;
|
|
1965
|
-
historyFloor: string;
|
|
1966
|
-
baselineMode: "history_floor" | "head_compare";
|
|
1967
|
-
/** cfg.preserveSubpaths — used to honor an explicit `--preserve-subpath` of
|
|
1968
|
-
* settings.json (or the whole `.claude` tree). */
|
|
1969
|
-
preserveSubpaths: string[];
|
|
1970
|
-
}
|
|
1971
|
-
|
|
1972
|
-
/** True when `.claude/settings.json` is covered by an EXPLICIT preserve subpath
|
|
1973
|
-
* (the caller asked rescue to keep it, so the reconcile must not rewrite it).
|
|
1974
|
-
* The always-on carve-outs never cover settings.json, so any match here is a
|
|
1975
|
-
* deliberate caller request. */
|
|
1976
|
-
function settingsExplicitlyPreserved(preserveSubpaths: string[]): boolean {
|
|
1977
|
-
const rel = ".claude/settings.json";
|
|
1978
|
-
for (const sp of preserveSubpaths) {
|
|
1979
|
-
const s = sp.replace(/\/$/, "");
|
|
1980
|
-
if (s === rel || s === ".claude" || rel.startsWith(s + "/")) return true;
|
|
1981
|
-
}
|
|
1982
|
-
return false;
|
|
1983
|
-
}
|
|
1984
|
-
|
|
1985
|
-
/**
|
|
1986
|
-
* Post-overlay reconcile for the machine-local, generated `.claude/settings.json`.
|
|
1987
|
-
*
|
|
1988
|
-
* At entry the overlay has already re-laid the release template (theirs) over
|
|
1989
|
-
* settings.json, and the user's settings.local.json has been restored from the
|
|
1990
|
-
* preserve shuttle. This function:
|
|
1991
|
-
* 1. re-composes env.PATH onto theirs (machine-specific; deliverable 3), and
|
|
1992
|
-
* 2. extracts ours's drift versus the last-sync floor (base) — excluding
|
|
1993
|
-
* env.PATH — and merges it into settings.local.json (deliverable 2),
|
|
1994
|
-
* so the EFFECTIVE merged settings (Project ⊕ Local, per Claude Code's rules)
|
|
1995
|
-
* are preserved while the Project base returns to the shipped composition with
|
|
1996
|
-
* any newly-released hook registrations wired in (deliverable 1).
|
|
1997
|
-
*
|
|
1998
|
-
* Best-effort and non-fatal: unreadable/invalid JSON degrades to a
|
|
1999
|
-
* PATH-recompose-only pass (never worse than the old behavior), matching
|
|
2000
|
-
* rescue's posture elsewhere.
|
|
2001
|
-
*/
|
|
2002
|
-
export function reconcileClaudeSettings(
|
|
2003
|
-
ctx: ReconcileCtx,
|
|
2004
|
-
preRescueSettingsJson: string | null,
|
|
2005
|
-
out: (s: string) => void,
|
|
2006
|
-
err: (s: string) => void,
|
|
2007
|
-
): void {
|
|
2008
|
-
const { hqRoot, srcDir, env, historyFloor, baselineMode, preserveSubpaths } = ctx;
|
|
2009
|
-
const settingsPath = path.join(hqRoot, ".claude", "settings.json");
|
|
2010
|
-
const localPath = path.join(hqRoot, ".claude", "settings.local.json");
|
|
2011
|
-
|
|
2012
|
-
// The caller explicitly asked to keep settings.json (via `--preserve-subpath
|
|
2013
|
-
// .claude/settings.json` or `--preserve-subpath .claude`). The shuttle already
|
|
2014
|
-
// restored their file; honor that and do not rewrite it (nor land new
|
|
2015
|
-
// registrations — that is the point of an explicit preserve).
|
|
2016
|
-
if (settingsExplicitlyPreserved(preserveSubpaths)) {
|
|
2017
|
-
out("==> Skipped .claude/settings.json reconcile (explicitly preserved by --preserve-subpath)\n");
|
|
2018
|
-
return;
|
|
2019
|
-
}
|
|
2020
|
-
|
|
2021
|
-
const overlaidTemplateExists = isFileFollow(settingsPath);
|
|
2022
|
-
|
|
2023
|
-
if (!overlaidTemplateExists) {
|
|
2024
|
-
// The release no longer ships .claude/settings.json, but the overwrite-safe
|
|
2025
|
-
// classification already deleted the local file. Restore the pre-rescue copy
|
|
2026
|
-
// so the machine PATH and every user customization survive (critical under
|
|
2027
|
-
// --no-backup, where no snapshot exists to recover from).
|
|
2028
|
-
if (preRescueSettingsJson !== null) {
|
|
2029
|
-
try {
|
|
2030
|
-
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
2031
|
-
fs.writeFileSync(settingsPath, preRescueSettingsJson);
|
|
2032
|
-
out(
|
|
2033
|
-
"==> Release no longer ships .claude/settings.json; restored your existing settings.json (no reconcile).\n",
|
|
2034
|
-
);
|
|
2035
|
-
} catch {
|
|
2036
|
-
err(" WARN: could not restore pre-rescue .claude/settings.json after the release dropped the template\n");
|
|
2037
|
-
}
|
|
2038
|
-
}
|
|
2039
|
-
return;
|
|
2040
|
-
}
|
|
2041
|
-
|
|
2042
|
-
const theirsRaw = (() => {
|
|
2043
|
-
try {
|
|
2044
|
-
return fs.readFileSync(settingsPath, "utf-8");
|
|
2045
|
-
} catch {
|
|
2046
|
-
return null;
|
|
2047
|
-
}
|
|
2048
|
-
})();
|
|
2049
|
-
const theirs = jsonParseOrNull(theirsRaw);
|
|
2050
|
-
if (!isPlainObject(theirs)) {
|
|
2051
|
-
err(" WARN: overlaid .claude/settings.json is not valid JSON; skipping settings reconcile\n");
|
|
2052
|
-
return;
|
|
2053
|
-
}
|
|
2054
|
-
|
|
2055
|
-
const ours = jsonParseOrNull(preRescueSettingsJson);
|
|
2056
|
-
const oursIsValid = isPlainObject(ours);
|
|
2057
|
-
|
|
2058
|
-
// The pre-rescue settings.json existed but was not parseable, and the overlay
|
|
2059
|
-
// already replaced it. Preserve the raw bytes under personal/ (the same place
|
|
2060
|
-
// a user-edit rescue would have put them) so a temporarily-malformed file is
|
|
2061
|
-
// never destroyed by a --no-backup run. Then continue with a PATH-recompose
|
|
2062
|
-
// pass so the machine still works.
|
|
2063
|
-
if (preRescueSettingsJson !== null && !oursIsValid) {
|
|
2064
|
-
const backup = path.join(hqRoot, "personal", ".claude", "settings.json");
|
|
2065
|
-
try {
|
|
2066
|
-
fs.mkdirSync(path.dirname(backup), { recursive: true });
|
|
2067
|
-
fs.writeFileSync(backup, preRescueSettingsJson);
|
|
2068
|
-
err(
|
|
2069
|
-
" WARN: pre-rescue .claude/settings.json was not valid JSON; preserved a copy at personal/.claude/settings.json\n",
|
|
2070
|
-
);
|
|
2071
|
-
} catch {
|
|
2072
|
-
err(" WARN: pre-rescue .claude/settings.json was not valid JSON and could not be backed up\n");
|
|
2073
|
-
}
|
|
2074
|
-
}
|
|
2075
|
-
|
|
2076
|
-
const oursEnvPath =
|
|
2077
|
-
oursIsValid && isPlainObject(ours.env) && typeof ours.env.PATH === "string"
|
|
2078
|
-
? ours.env.PATH
|
|
2079
|
-
: undefined;
|
|
2080
|
-
|
|
2081
|
-
// 1) Re-compose env.PATH onto the release baseline. Base = the machine's own
|
|
2082
|
-
// prior PATH snapshot when present, else this process's PATH.
|
|
2083
|
-
const basePath = oursEnvPath ?? env.PATH ?? "";
|
|
2084
|
-
const composedPath = composeSettingsPath(srcDir, hqRoot, basePath, env);
|
|
2085
|
-
if (composedPath) {
|
|
2086
|
-
const envObj = isPlainObject(theirs.env) ? theirs.env : (theirs.env = {});
|
|
2087
|
-
envObj.PATH = composedPath;
|
|
2088
|
-
}
|
|
2089
|
-
|
|
2090
|
-
// 2) Relocate genuine user drift (ours vs floor baseline) into settings.local,
|
|
2091
|
-
// and apply user array removals to the Project baseline.
|
|
2092
|
-
let driftKeys = 0;
|
|
2093
|
-
let relocationFailed = false;
|
|
2094
|
-
if (oursIsValid) {
|
|
2095
|
-
// realBase = settings.json at the last-sync floor (only trustworthy in
|
|
2096
|
-
// history_floor mode). Removals require it; additions fall back to theirs.
|
|
2097
|
-
let realBase: JsonValue | undefined;
|
|
2098
|
-
if (baselineMode === "history_floor" && historyFloor) {
|
|
2099
|
-
const show = run("git", ["show", `${historyFloor}:.claude/settings.json`], {
|
|
2100
|
-
cwd: srcDir,
|
|
2101
|
-
env,
|
|
2102
|
-
});
|
|
2103
|
-
if (show.status === 0) realBase = jsonParseOrNull(show.stdout);
|
|
2104
|
-
}
|
|
2105
|
-
// Removals: only with a REAL floor base (never theirs — release-only entries
|
|
2106
|
-
// would look like removals).
|
|
2107
|
-
if (isPlainObject(realBase)) {
|
|
2108
|
-
applyArrayRemovals(theirs, ours, realBase);
|
|
2109
|
-
}
|
|
2110
|
-
// Additions drift: base = realBase when available, else theirs (conservative
|
|
2111
|
-
// head_compare fallback — never loses a customization). See design note.
|
|
2112
|
-
const additionsBase: JsonValue = isPlainObject(realBase) ? realBase : theirs;
|
|
2113
|
-
const drift = settingsDrift(ours, additionsBase);
|
|
2114
|
-
if (isPlainObject(drift) && Object.keys(drift).length > 0) {
|
|
2115
|
-
const existingLocal = jsonParseOrNull((() => {
|
|
2116
|
-
try {
|
|
2117
|
-
return isFileFollow(localPath) ? fs.readFileSync(localPath, "utf-8") : null;
|
|
2118
|
-
} catch {
|
|
2119
|
-
return null;
|
|
2120
|
-
}
|
|
2121
|
-
})());
|
|
2122
|
-
const localObj = isPlainObject(existingLocal) ? existingLocal : {};
|
|
2123
|
-
deepMergeSettings(localObj, drift);
|
|
2124
|
-
driftKeys = Object.keys(drift).length;
|
|
2125
|
-
try {
|
|
2126
|
-
fs.mkdirSync(path.dirname(localPath), { recursive: true });
|
|
2127
|
-
fs.writeFileSync(localPath, JSON.stringify(localObj, null, 2) + "\n");
|
|
2128
|
-
} catch {
|
|
2129
|
-
relocationFailed = true;
|
|
2130
|
-
err(" WARN: could not write .claude/settings.local.json during settings reconcile\n");
|
|
2131
|
-
}
|
|
2132
|
-
}
|
|
2133
|
-
}
|
|
2134
|
-
|
|
2135
|
-
// If drift relocation into settings.local.json failed, DO NOT overwrite
|
|
2136
|
-
// settings.json with the release baseline — that would drop the drift with no
|
|
2137
|
-
// on-disk copy under --no-backup. Restore the pre-rescue file so nothing is
|
|
2138
|
-
// lost; the update simply isn't applied to settings this run (the user can
|
|
2139
|
-
// retry once settings.local.json is writable).
|
|
2140
|
-
if (relocationFailed) {
|
|
2141
|
-
if (preRescueSettingsJson !== null) {
|
|
2142
|
-
try {
|
|
2143
|
-
fs.writeFileSync(settingsPath, preRescueSettingsJson);
|
|
2144
|
-
} catch {
|
|
2145
|
-
err(" WARN: could not restore pre-rescue .claude/settings.json after a failed drift relocation\n");
|
|
2146
|
-
}
|
|
2147
|
-
}
|
|
2148
|
-
err(
|
|
2149
|
-
" WARN: kept your original .claude/settings.json (settings update not applied — settings.local.json was unwritable)\n",
|
|
2150
|
-
);
|
|
2151
|
-
return;
|
|
2152
|
-
}
|
|
2153
|
-
|
|
2154
|
-
// Write the re-composed release baseline back to settings.json.
|
|
2155
|
-
try {
|
|
2156
|
-
fs.writeFileSync(settingsPath, JSON.stringify(theirs, null, 2) + "\n");
|
|
2157
|
-
} catch {
|
|
2158
|
-
err(" WARN: could not write re-composed .claude/settings.json during reconcile\n");
|
|
2159
|
-
return;
|
|
2160
|
-
}
|
|
2161
|
-
|
|
2162
|
-
if (driftKeys > 0) {
|
|
2163
|
-
out(
|
|
2164
|
-
`==> Reconciled .claude/settings.json to release baseline (env.PATH re-composed; ` +
|
|
2165
|
-
`relocated ${driftKeys} drift key(s) -> .claude/settings.local.json)\n`,
|
|
2166
|
-
);
|
|
2167
|
-
} else {
|
|
2168
|
-
out(
|
|
2169
|
-
"==> Reconciled .claude/settings.json to release baseline (env.PATH re-composed; no local drift to relocate)\n",
|
|
2170
|
-
);
|
|
2171
|
-
}
|
|
2172
|
-
}
|
|
2173
|
-
|
|
2174
|
-
/**
|
|
2175
|
-
* Defense-in-depth for executable scaffold scripts. `rescue` rebuilds the tree
|
|
2176
|
-
* with `git clone` + `rsync -a`, faithfully propagating each file's git tree
|
|
2177
|
-
* mode (100755 vs 100644). So a script accidentally committed upstream WITHOUT
|
|
2178
|
-
* its executable bit (e.g. a `.claude/hooks/*.sh` checked in as 100644) is
|
|
2179
|
-
* delivered non-executable to every machine, and a hook the runtime exec's
|
|
2180
|
-
* directly then fails with EACCES ("Permission denied") and is silently
|
|
2181
|
-
* disabled.
|
|
2182
|
-
*
|
|
2183
|
-
* A `#!` shebang is an unambiguous "this file is meant to be run" marker, so
|
|
2184
|
-
* after the overlay we ensure every shipped shebang script is executable,
|
|
2185
|
-
* regardless of the (possibly wrong) upstream mode bit. Execute is added only
|
|
2186
|
-
* where read is already granted (0644 -> 0755, 0640 -> 0750), mirroring the
|
|
2187
|
-
* umask convention; files that already carry any exec bit, non-files, and
|
|
2188
|
-
* symlinks are left untouched. Paths are enumerated from the SOURCE repo's git
|
|
2189
|
-
* index, so this only ever touches release-shipped scaffold -- never user
|
|
2190
|
-
* content the rescue leaves in place.
|
|
2191
|
-
*
|
|
2192
|
-
* Best-effort and non-fatal: a failed `git ls-files` or chmod (read-only FS,
|
|
2193
|
-
* EPERM) is swallowed, matching the rest of rescue's posture. Returns the count
|
|
2194
|
-
* of files whose mode was changed, for the run summary.
|
|
2195
|
-
*/
|
|
2196
|
-
function restoreShebangExecBits(
|
|
2197
|
-
srcDir: string,
|
|
2198
|
-
hqRoot: string,
|
|
2199
|
-
env: NodeJS.ProcessEnv,
|
|
2200
|
-
): number {
|
|
2201
|
-
let listing: string;
|
|
2202
|
-
try {
|
|
2203
|
-
const r = spawnSync("git", ["-C", srcDir, "ls-files", "-z"], {
|
|
2204
|
-
encoding: "utf-8",
|
|
2205
|
-
env,
|
|
2206
|
-
maxBuffer: 128 * 1024 * 1024,
|
|
2207
|
-
});
|
|
2208
|
-
if (r.status !== 0 || typeof r.stdout !== "string") return 0;
|
|
2209
|
-
listing = r.stdout;
|
|
2210
|
-
} catch {
|
|
2211
|
-
return 0;
|
|
2212
|
-
}
|
|
2213
|
-
let fixed = 0;
|
|
2214
|
-
for (const rel of listing.split("\0")) {
|
|
2215
|
-
if (!rel) continue;
|
|
2216
|
-
const dest = path.join(hqRoot, rel);
|
|
2217
|
-
let st: fs.Stats;
|
|
2218
|
-
try {
|
|
2219
|
-
st = fs.lstatSync(dest);
|
|
2220
|
-
} catch {
|
|
2221
|
-
continue; // not delivered here (preserve-excluded, ignored, narrowed out)
|
|
2222
|
-
}
|
|
2223
|
-
if (!st.isFile()) continue; // skip dirs and symlinks (lstat: a link is not a file)
|
|
2224
|
-
if ((st.mode & 0o111) !== 0) continue; // already executable
|
|
2225
|
-
// Sniff the first two bytes for a shebang.
|
|
2226
|
-
let head = "";
|
|
2227
|
-
try {
|
|
2228
|
-
const fd = fs.openSync(dest, "r");
|
|
2229
|
-
try {
|
|
2230
|
-
const buf = Buffer.alloc(2);
|
|
2231
|
-
const n = fs.readSync(fd, buf, 0, 2, 0);
|
|
2232
|
-
head = buf.subarray(0, n).toString("latin1");
|
|
2233
|
-
} finally {
|
|
2234
|
-
fs.closeSync(fd);
|
|
2235
|
-
}
|
|
2236
|
-
} catch {
|
|
2237
|
-
continue;
|
|
2238
|
-
}
|
|
2239
|
-
if (head !== "#!") continue;
|
|
2240
|
-
const execBits = (st.mode & 0o444) >> 2; // copy r-bits into the x-bit slots
|
|
2241
|
-
try {
|
|
2242
|
-
fs.chmodSync(dest, st.mode | execBits);
|
|
2243
|
-
fixed += 1;
|
|
2244
|
-
} catch {
|
|
2245
|
-
// read-only FS / EPERM -- non-fatal.
|
|
2246
|
-
}
|
|
2247
|
-
}
|
|
2248
|
-
return fixed;
|
|
2249
|
-
}
|
|
2250
|
-
|
|
2251
|
-
function readFileOrEmpty(p: string): string {
|
|
2252
|
-
try {
|
|
2253
|
-
return fs.readFileSync(p, "utf-8");
|
|
2254
|
-
} catch {
|
|
2255
|
-
return "";
|
|
2256
|
-
}
|
|
2257
|
-
}
|
|
2258
|
-
|
|
2259
|
-
// --- rescue a single USER-EDIT file into personal/ ---
|
|
2260
|
-
function rescueOne(ctx: WalkCtx, rel: string): void {
|
|
2261
|
-
const localPath = path.join(ctx.hqRoot, rel);
|
|
2262
|
-
ctx.counts.userEdit += 1;
|
|
2263
|
-
|
|
2264
|
-
if (rel === ".claude/CLAUDE.md") {
|
|
2265
|
-
diffAppendClaudeMd(ctx);
|
|
2266
|
-
fs.rmSync(localPath, { force: true });
|
|
2267
|
-
ctx.appendLog(`rescued\t${rel}\t-> personal/CLAUDE.md (diff-append)\n`);
|
|
2268
|
-
return;
|
|
2269
|
-
}
|
|
2270
|
-
|
|
2271
|
-
const target = mapRescueTarget(rel);
|
|
2272
|
-
let dest = path.join(ctx.hqRoot, target);
|
|
2273
|
-
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
2274
|
-
if (lexists(dest)) {
|
|
2275
|
-
dest = `${dest}.drift-${Math.floor(Date.now() / 1000)}-${process.pid}`;
|
|
2276
|
-
}
|
|
2277
|
-
fs.renameSync(localPath, dest);
|
|
2278
|
-
const destRel = dest.startsWith(ctx.hqRoot + "/") ? dest.slice(ctx.hqRoot.length + 1) : dest;
|
|
2279
|
-
ctx.out(` rescued: ${rel} -> ${destRel}\n`);
|
|
2280
|
-
ctx.appendLog(`rescued\t${rel}\t-> ${destRel}\n`);
|
|
2281
|
-
}
|
|
2282
|
-
|
|
2283
|
-
// --- quarantine a single USER-EDIT file into .hq-conflicts/ ---
|
|
2284
|
-
function conflictOne(ctx: WalkCtx, rel: string): void {
|
|
2285
|
-
const localPath = path.join(ctx.hqRoot, rel);
|
|
2286
|
-
ctx.counts.userConflict += 1;
|
|
2287
|
-
|
|
2288
|
-
const target = `.hq-conflicts/rescue-${ctx.runTs}/${rel}`;
|
|
2289
|
-
let dest = path.join(ctx.hqRoot, target);
|
|
2290
|
-
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
2291
|
-
if (lexists(dest)) {
|
|
2292
|
-
dest = `${dest}.drift-${Math.floor(Date.now() / 1000)}-${process.pid}`;
|
|
2293
|
-
}
|
|
2294
|
-
fs.renameSync(localPath, dest);
|
|
2295
|
-
const destRel = dest.startsWith(ctx.hqRoot + "/") ? dest.slice(ctx.hqRoot.length + 1) : dest;
|
|
2296
|
-
ctx.out(` conflicted: ${rel} -> ${destRel}\n`);
|
|
2297
|
-
ctx.appendLog(`conflicted\t${rel}\t-> ${destRel}\n`);
|
|
2298
|
-
}
|
|
2299
|
-
|
|
2300
|
-
// --- classify one file (the per-file workhorse) ---
|
|
2301
|
-
//
|
|
2302
|
-
// Read-only by contract: this returns a RescueDecision only. It does not render
|
|
2303
|
-
// dry-run text, update action queues, or mutate the filesystem. Dry-run text is
|
|
2304
|
-
// rendered by recordDecision, and PHASE 2 action building happens only once the
|
|
2305
|
-
// whole wipe set has classified without throwing. Keep it that way.
|
|
2306
|
-
function classifyOne(ctx: WalkCtx, rel: string): RescueDecision {
|
|
2307
|
-
const { cfg } = ctx;
|
|
2308
|
-
const localPath = path.join(ctx.hqRoot, rel);
|
|
2309
|
-
const srcPath = path.join(ctx.srcDir, rel);
|
|
2310
|
-
|
|
2311
|
-
// Test-only fault seam: prove the classify-before-delete ordering invariant
|
|
2312
|
-
// by forcing a classifier throw at a chosen entry. Never set in production.
|
|
2313
|
-
if (ctx.env.HQ_RESCUE_FAULT_AT_REL && rel === ctx.env.HQ_RESCUE_FAULT_AT_REL) {
|
|
2314
|
-
throw new Error(`rescue classifier fault injected at ${rel} (HQ_RESCUE_FAULT_AT_REL)`);
|
|
2315
|
-
}
|
|
2316
|
-
|
|
2317
|
-
if (isUnderPreserve(cfg, rel)) return { kind: "skip", rel };
|
|
2318
|
-
|
|
2319
|
-
// Conflict-resolution artifacts (`<name>.conflict-<ts>-<peer>.<ext>`).
|
|
2320
|
-
const base = rel.includes("/") ? rel.slice(rel.lastIndexOf("/") + 1) : rel;
|
|
2321
|
-
if (/\.conflict-/.test(base)) {
|
|
2322
|
-
return { kind: "drop-conflict-artifact", rel };
|
|
2323
|
-
}
|
|
2324
|
-
|
|
2325
|
-
// Script-managed files: core/core.yaml + legacy core.yaml.
|
|
2326
|
-
if (rel === "core/core.yaml" || rel === "core.yaml") {
|
|
2327
|
-
return { kind: "drop-script-managed", rel };
|
|
2328
|
-
}
|
|
2329
|
-
|
|
2330
|
-
// Symlinks (mid-tree).
|
|
2331
|
-
const lst = lstatOrNull(localPath);
|
|
2332
|
-
if (lst && lst.isSymbolicLink()) {
|
|
2333
|
-
const target = masterSyncSymlinkTarget(localPath);
|
|
2334
|
-
if (target !== null) {
|
|
2335
|
-
return { kind: "drop-reindex-symlink", rel, target };
|
|
2336
|
-
}
|
|
2337
|
-
return { kind: "skip", rel };
|
|
2338
|
-
}
|
|
2339
|
-
|
|
2340
|
-
// Is path in upstream HEAD?
|
|
2341
|
-
const inHead = existsFollow(srcPath) ? 1 : 0;
|
|
2342
|
-
|
|
2343
|
-
// Is path in last-sync floor? Read the batched precompute (floorShas); fall
|
|
2344
|
-
// back to a per-file probe for any rel not represented there. A non-null
|
|
2345
|
-
// entry both proves presence (inFloor=1) and carries the baseline blob SHA,
|
|
2346
|
-
// reused below so the `rev-parse` never has to run.
|
|
2347
|
-
let inFloor = 0;
|
|
2348
|
-
let baselineSha: string | undefined;
|
|
2349
|
-
if (ctx.baselineMode === "history_floor") {
|
|
2350
|
-
const pre = ctx.floorShas?.get(rel);
|
|
2351
|
-
if (pre !== undefined) {
|
|
2352
|
-
if (pre) {
|
|
2353
|
-
inFloor = 1;
|
|
2354
|
-
baselineSha = pre;
|
|
2355
|
-
}
|
|
2356
|
-
} else if (
|
|
2357
|
-
run("git", ["cat-file", "-e", `${ctx.historyFloor}:${rel}`], {
|
|
2358
|
-
cwd: ctx.srcDir,
|
|
2359
|
-
env: ctx.env,
|
|
2360
|
-
}).status === 0
|
|
2361
|
-
) {
|
|
2362
|
-
inFloor = 1;
|
|
2363
|
-
}
|
|
2364
|
-
}
|
|
2365
|
-
|
|
2366
|
-
// USER-ONLY: unknown to upstream (HEAD AND floor both lack it).
|
|
2367
|
-
if (inHead === 0 && inFloor === 0) {
|
|
2368
|
-
return { kind: "user-only", rel };
|
|
2369
|
-
}
|
|
2370
|
-
|
|
2371
|
-
// Path is/was in upstream. Determine if user edited it.
|
|
2372
|
-
let userEdited = 0;
|
|
2373
|
-
if (ctx.baselineMode === "history_floor" && inFloor === 1) {
|
|
2374
|
-
const localSha =
|
|
2375
|
-
ctx.localShas?.get(rel) ??
|
|
2376
|
-
run("git", ["hash-object", localPath], { env: ctx.env }).stdout.trim();
|
|
2377
|
-
const floorSha =
|
|
2378
|
-
baselineSha ??
|
|
2379
|
-
run("git", ["rev-parse", `${ctx.historyFloor}:${rel}`], {
|
|
2380
|
-
cwd: ctx.srcDir,
|
|
2381
|
-
env: ctx.env,
|
|
2382
|
-
}).stdout.trim();
|
|
2383
|
-
if (!localSha || !floorSha || localSha !== floorSha) userEdited = 1;
|
|
2384
|
-
} else if (inHead === 1) {
|
|
2385
|
-
if (!bytesEqual(localPath, srcPath)) userEdited = 1;
|
|
2386
|
-
} else {
|
|
2387
|
-
// in_floor=1, in_head=0: upstream removed since last sync — rescue.
|
|
2388
|
-
userEdited = 1;
|
|
2389
|
-
}
|
|
2390
|
-
|
|
2391
|
-
// Cloud-update reclassification.
|
|
2392
|
-
if (userEdited === 1 && isCloudFlattenedSymlinkEquiv(ctx, rel, localPath)) {
|
|
2393
|
-
return { kind: "cloud-symlink-reconciled", rel };
|
|
2394
|
-
}
|
|
2395
|
-
|
|
2396
|
-
// Convergence guard: drifted from floor but identical to upstream HEAD.
|
|
2397
|
-
if (userEdited === 1 && inHead === 1 && bytesEqual(localPath, srcPath)) {
|
|
2398
|
-
return { kind: "drift-reconciled", rel };
|
|
2399
|
-
}
|
|
2400
|
-
|
|
2401
|
-
if (userEdited === 1) {
|
|
2402
|
-
if (rel === ".claude/CLAUDE.md") {
|
|
2403
|
-
return { kind: "user-edit-diff-append", rel };
|
|
2404
|
-
}
|
|
2405
|
-
if (isOverwriteSafe(rel)) {
|
|
2406
|
-
return { kind: "user-edit-overwrite-safe", rel };
|
|
2407
|
-
}
|
|
2408
|
-
if (isConflictClass(rel)) {
|
|
2409
|
-
return { kind: "user-edit-conflict", rel };
|
|
2410
|
-
}
|
|
2411
|
-
return { kind: "user-edit-rescue", rel, target: mapRescueTarget(rel) };
|
|
2412
|
-
}
|
|
2413
|
-
|
|
2414
|
-
// user_edited=0: matches floor baseline. Split on byte-equality to HEAD.
|
|
2415
|
-
if (bytesEqual(localPath, path.join(ctx.srcDir, rel))) {
|
|
2416
|
-
return { kind: "unchanged-preserve", rel };
|
|
2417
|
-
}
|
|
2418
|
-
return { kind: "unchanged-delete", rel };
|
|
2419
|
-
}
|
|
2420
|
-
|
|
2421
|
-
function renderDryRunDecision(ctx: WalkCtx, decision: RescueDecision): void {
|
|
2422
|
-
switch (decision.kind) {
|
|
2423
|
-
case "skip":
|
|
2424
|
-
break;
|
|
2425
|
-
case "drop-conflict-artifact":
|
|
2426
|
-
ctx.out(` drop conflict artifact: ${decision.rel}\n`);
|
|
2427
|
-
break;
|
|
2428
|
-
case "drop-script-managed":
|
|
2429
|
-
ctx.out(` skip script-managed (rewrites at stamp step): ${decision.rel}\n`);
|
|
2430
|
-
break;
|
|
2431
|
-
case "drop-reindex-symlink":
|
|
2432
|
-
ctx.out(` drop reindex symlink: ${decision.rel} -> ${decision.target}\n`);
|
|
2433
|
-
break;
|
|
2434
|
-
case "user-only":
|
|
2435
|
-
ctx.out(` user-only (leave in place): ${decision.rel}\n`);
|
|
2436
|
-
break;
|
|
2437
|
-
case "cloud-symlink-reconciled":
|
|
2438
|
-
ctx.out(
|
|
2439
|
-
` cloud-symlink reconciled (unchanged): ${decision.rel} (hq-symlink: marker matches upstream target)\n`,
|
|
2440
|
-
);
|
|
2441
|
-
break;
|
|
2442
|
-
case "drift-reconciled":
|
|
2443
|
-
ctx.out(` drift reconciled (identical to upstream HEAD; no rescue): ${decision.rel}\n`);
|
|
2444
|
-
break;
|
|
2445
|
-
case "user-edit-diff-append":
|
|
2446
|
-
ctx.out(` user-edit (diff-append): ${decision.rel} -> personal/CLAUDE.md\n`);
|
|
2447
|
-
ctx.counts.userEdit += 1;
|
|
2448
|
-
break;
|
|
2449
|
-
case "user-edit-overwrite-safe":
|
|
2450
|
-
ctx.out(` user-edit (overwrite-safe): ${decision.rel} -> upstream wins (no copy preserved)\n`);
|
|
2451
|
-
ctx.counts.userOverwrite += 1;
|
|
2452
|
-
break;
|
|
2453
|
-
case "user-edit-conflict":
|
|
2454
|
-
ctx.out(
|
|
2455
|
-
` user-edit (conflict): ${decision.rel} -> .hq-conflicts/rescue-${ctx.runTs}/${decision.rel}\n`,
|
|
2456
|
-
);
|
|
2457
|
-
ctx.counts.userConflict += 1;
|
|
2458
|
-
break;
|
|
2459
|
-
case "user-edit-rescue":
|
|
2460
|
-
ctx.out(` user-edit (rescue): ${decision.rel} -> ${decision.target}\n`);
|
|
2461
|
-
ctx.counts.userEdit += 1;
|
|
2462
|
-
break;
|
|
2463
|
-
case "unchanged-preserve":
|
|
2464
|
-
ctx.out(` unchanged (preserved in place): ${decision.rel}\n`);
|
|
2465
|
-
break;
|
|
2466
|
-
case "unchanged-delete":
|
|
2467
|
-
ctx.out(` unchanged (delete + replace): ${decision.rel}\n`);
|
|
2468
|
-
break;
|
|
2469
|
-
case "wholesale-replace-template":
|
|
2470
|
-
ctx.out(" wholesale-replace: companies/_template (template carve-out)\n");
|
|
2471
|
-
break;
|
|
2472
|
-
}
|
|
2473
|
-
}
|
|
2474
|
-
|
|
2475
|
-
function buildRescueActions(ctx: WalkCtx): RescueAction[] {
|
|
2476
|
-
const actions: RescueAction[] = [];
|
|
2477
|
-
const add = (label: string, affectedRels: string[], runAction: () => void) => {
|
|
2478
|
-
actions.push({
|
|
2479
|
-
label,
|
|
2480
|
-
affectedRels,
|
|
2481
|
-
expectedPaths: affectedRels.map((rel) => ({
|
|
2482
|
-
rel,
|
|
2483
|
-
kind: rescuePathKind(path.join(ctx.hqRoot, rel)),
|
|
2484
|
-
})),
|
|
2485
|
-
run: runAction,
|
|
2486
|
-
});
|
|
2487
|
-
};
|
|
2488
|
-
|
|
2489
|
-
for (const decision of ctx.decisions) {
|
|
2490
|
-
const localPath = path.join(ctx.hqRoot, decision.rel);
|
|
2491
|
-
switch (decision.kind) {
|
|
2492
|
-
case "skip":
|
|
2493
|
-
case "user-only":
|
|
2494
|
-
break;
|
|
2495
|
-
case "drop-conflict-artifact":
|
|
2496
|
-
add(`drop conflict artifact: ${decision.rel}`, [decision.rel], () =>
|
|
2497
|
-
fs.rmSync(localPath, { force: true }),
|
|
2498
|
-
);
|
|
2499
|
-
break;
|
|
2500
|
-
case "drop-script-managed":
|
|
2501
|
-
add(`drop script-managed: ${decision.rel}`, [decision.rel], () =>
|
|
2502
|
-
fs.rmSync(localPath, { force: true }),
|
|
2503
|
-
);
|
|
2504
|
-
break;
|
|
2505
|
-
case "drop-reindex-symlink":
|
|
2506
|
-
add(`drop reindex symlink: ${decision.rel}`, [decision.rel], () => {
|
|
2507
|
-
dropReindexSymlink(localPath);
|
|
2508
|
-
ctx.appendLog(`symlink-dropped\t${decision.rel}\t(reindex regenerable)\n`);
|
|
2509
|
-
});
|
|
2510
|
-
break;
|
|
2511
|
-
case "cloud-symlink-reconciled":
|
|
2512
|
-
add(`cloud-symlink reconciled: ${decision.rel}`, [decision.rel], () => {
|
|
2513
|
-
fs.rmSync(localPath, { force: true });
|
|
2514
|
-
ctx.appendLog(
|
|
2515
|
-
`cloud-symlink-reconciled\t${decision.rel}\t(hq-symlink: marker matches upstream target; overlay re-lays symlink)\n`,
|
|
2516
|
-
);
|
|
2517
|
-
});
|
|
2518
|
-
break;
|
|
2519
|
-
case "drift-reconciled":
|
|
2520
|
-
add(`drift reconciled: ${decision.rel}`, [decision.rel], () => {
|
|
2521
|
-
fs.rmSync(localPath, { force: true });
|
|
2522
|
-
ctx.appendLog(
|
|
2523
|
-
`drift-reconciled\t${decision.rel}\t(identical to upstream HEAD; drifted from floor only — no personal/ copy)\n`,
|
|
2524
|
-
);
|
|
2525
|
-
});
|
|
2526
|
-
break;
|
|
2527
|
-
case "user-edit-diff-append":
|
|
2528
|
-
add(`rescue diff-append: ${decision.rel}`, [decision.rel], () =>
|
|
2529
|
-
rescueOne(ctx, decision.rel),
|
|
2530
|
-
);
|
|
2531
|
-
break;
|
|
2532
|
-
case "user-edit-overwrite-safe":
|
|
2533
|
-
add(`overwrite-safe: ${decision.rel}`, [decision.rel], () => {
|
|
2534
|
-
fs.rmSync(localPath, { force: true });
|
|
2535
|
-
ctx.counts.userOverwrite += 1;
|
|
2536
|
-
ctx.appendLog(
|
|
2537
|
-
`overwritten\t${decision.rel}\t(overwrite-safe; upstream wins, no copy preserved)\n`,
|
|
2538
|
-
);
|
|
2539
|
-
});
|
|
2540
|
-
break;
|
|
2541
|
-
case "user-edit-conflict":
|
|
2542
|
-
add(`conflict quarantine: ${decision.rel}`, [decision.rel], () =>
|
|
2543
|
-
conflictOne(ctx, decision.rel),
|
|
2544
|
-
);
|
|
2545
|
-
break;
|
|
2546
|
-
case "user-edit-rescue":
|
|
2547
|
-
add(`rescue: ${decision.rel}`, [decision.rel], () => rescueOne(ctx, decision.rel));
|
|
2548
|
-
break;
|
|
2549
|
-
case "unchanged-preserve":
|
|
2550
|
-
add(`preserve unchanged: ${decision.rel}`, [], () => {
|
|
2551
|
-
fs.appendFileSync(ctx.unchangedList, `/${decision.rel}\n`);
|
|
2552
|
-
ctx.appendLog(
|
|
2553
|
-
`unchanged\t${decision.rel}\t(identical to upstream; left in place, mtime preserved)\n`,
|
|
2554
|
-
);
|
|
2555
|
-
});
|
|
2556
|
-
break;
|
|
2557
|
-
case "unchanged-delete":
|
|
2558
|
-
add(`delete unchanged: ${decision.rel}`, [decision.rel], () => {
|
|
2559
|
-
fs.rmSync(localPath, { force: true });
|
|
2560
|
-
ctx.appendLog(
|
|
2561
|
-
`deleted\t${decision.rel}\t(unchanged vs baseline; re-laid by overlay if still upstream)\n`,
|
|
2562
|
-
);
|
|
2563
|
-
});
|
|
2564
|
-
break;
|
|
2565
|
-
case "wholesale-replace-template":
|
|
2566
|
-
add("wholesale-replace: companies/_template", [decision.rel], () =>
|
|
2567
|
-
fs.rmSync(path.join(ctx.hqRoot, "companies", "_template"), {
|
|
2568
|
-
recursive: true,
|
|
2569
|
-
force: true,
|
|
2570
|
-
}),
|
|
2571
|
-
);
|
|
2572
|
-
break;
|
|
2573
|
-
}
|
|
2574
|
-
}
|
|
2575
|
-
|
|
2576
|
-
return actions;
|
|
2577
|
-
}
|
|
2578
|
-
|
|
2579
|
-
// --- walk a wipe-set root ---
|
|
2580
|
-
// Precompute the per-file git facts classifyOne needs, in two batched passes
|
|
2581
|
-
// instead of ~3 `git` spawns per file. Process-startup overhead (~15-20ms per
|
|
2582
|
-
// spawn) dominates the per-file path, so on a large wipe set this turns a
|
|
2583
|
-
// minutes-long classify into a seconds-long one. Behaviour is identical: the
|
|
2584
|
-
// SHAs produced here are exactly what the per-file commands returned, and any
|
|
2585
|
-
// rel not represented in a map falls back to its original per-file spawn.
|
|
2586
|
-
//
|
|
2587
|
-
// Mirrors walkAndProcess's enumeration so the batched set matches what actually
|
|
2588
|
-
// reaches classifyOne: it skips `companies/_template` (wholesale-replaced, never
|
|
2589
|
-
// classified per-file) and top-level symlinks (dropped, never classified).
|
|
2590
|
-
function precomputeGitMaps(ctx: WalkCtx, wipeToplevel: string[]): void {
|
|
2591
|
-
const allRels: string[] = []; // every rel that reaches classifyOne (files + symlinks)
|
|
2592
|
-
const fileRels: string[] = []; // subset that are regular files (hashable)
|
|
2593
|
-
const fileAbs: string[] = []; // parallel to fileRels
|
|
2594
|
-
|
|
2595
|
-
for (const rootRel of wipeToplevel) {
|
|
2596
|
-
if (rootRel === "companies/_template") continue;
|
|
2597
|
-
const rootAbs = path.join(ctx.hqRoot, rootRel);
|
|
2598
|
-
const lst = lstatOrNull(rootAbs);
|
|
2599
|
-
if (!lst || lst.isSymbolicLink()) continue;
|
|
2600
|
-
const rels = lst.isFile()
|
|
2601
|
-
? [rootRel]
|
|
2602
|
-
: lst.isDirectory()
|
|
2603
|
-
? findFilesAndSymlinks(rootAbs, ctx.hqRoot)
|
|
2604
|
-
: [];
|
|
2605
|
-
for (const rel of rels) {
|
|
2606
|
-
// A path containing a newline can't survive line-delimited batch stdin;
|
|
2607
|
-
// leave it out of the maps and let classifyOne spawn per-file for it.
|
|
2608
|
-
if (rel.includes("\n")) continue;
|
|
2609
|
-
allRels.push(rel);
|
|
2610
|
-
const st = lstatOrNull(path.join(ctx.hqRoot, rel));
|
|
2611
|
-
if (st && st.isFile()) {
|
|
2612
|
-
fileRels.push(rel);
|
|
2613
|
-
fileAbs.push(path.join(ctx.hqRoot, rel));
|
|
2614
|
-
}
|
|
2615
|
-
}
|
|
2616
|
-
}
|
|
2617
|
-
|
|
2618
|
-
// In head_compare mode classifyOne never consults these maps (it compares
|
|
2619
|
-
// bytes against the upstream working tree directly), so skip both git passes.
|
|
2620
|
-
if (ctx.baselineMode !== "history_floor") {
|
|
2621
|
-
ctx.floorShas = new Map();
|
|
2622
|
-
ctx.localShas = new Map();
|
|
2623
|
-
return;
|
|
2624
|
-
}
|
|
2625
|
-
|
|
2626
|
-
// Pass 1: floor existence + blob SHA for every rel, in one process.
|
|
2627
|
-
// `cat-file --batch-check` prints "<sha> <type> <size>" for a present object
|
|
2628
|
-
// or "<input> missing" otherwise, one line per input in order. This single
|
|
2629
|
-
// pass replaces BOTH the per-file `cat-file -e` (existence) and `rev-parse`
|
|
2630
|
-
// (sha). It reads only the git object DB — never local file contents — so it
|
|
2631
|
-
// stays cheap even on a 100k-file wipe set.
|
|
2632
|
-
const floorShas = new Map<string, string | null>();
|
|
2633
|
-
if (allRels.length > 0) {
|
|
2634
|
-
const res = run("git", ["cat-file", "--batch-check"], {
|
|
2635
|
-
cwd: ctx.srcDir,
|
|
2636
|
-
env: ctx.env,
|
|
2637
|
-
input: allRels.map((rel) => `${ctx.historyFloor}:${rel}`).join("\n") + "\n",
|
|
2638
|
-
});
|
|
2639
|
-
if (res.status === 0) {
|
|
2640
|
-
const lines = res.stdout.split("\n");
|
|
2641
|
-
for (let i = 0; i < allRels.length; i++) {
|
|
2642
|
-
const line = lines[i] ?? "";
|
|
2643
|
-
if (line === "" || line.endsWith(" missing")) {
|
|
2644
|
-
floorShas.set(allRels[i], null);
|
|
2645
|
-
} else {
|
|
2646
|
-
const sha = line.split(" ")[0];
|
|
2647
|
-
floorShas.set(allRels[i], sha || null);
|
|
2648
|
-
}
|
|
2649
|
-
}
|
|
2650
|
-
}
|
|
2651
|
-
}
|
|
2652
|
-
ctx.floorShas = floorShas;
|
|
2653
|
-
|
|
2654
|
-
// Pass 2: local blob SHA, but ONLY for regular files that are in the floor.
|
|
2655
|
-
// classifyOne reads localShas exclusively in the `inFloor === 1` branch, so a
|
|
2656
|
-
// file absent from the floor (e.g. the transient `.claude/worktrees/` trees,
|
|
2657
|
-
// which classify as user-only) never needs a hash — and hashing it would mean
|
|
2658
|
-
// reading its bytes for nothing. Restricting the hash set to floor members is
|
|
2659
|
-
// what keeps a worktree-heavy tree fast: hundreds of reads instead of 100k.
|
|
2660
|
-
const localShas = new Map<string, string>();
|
|
2661
|
-
const hashAbs: string[] = [];
|
|
2662
|
-
const hashRels: string[] = [];
|
|
2663
|
-
for (let i = 0; i < fileRels.length; i++) {
|
|
2664
|
-
if (floorShas.get(fileRels[i])) {
|
|
2665
|
-
hashRels.push(fileRels[i]);
|
|
2666
|
-
hashAbs.push(fileAbs[i]);
|
|
2667
|
-
}
|
|
2668
|
-
}
|
|
2669
|
-
if (hashAbs.length > 0) {
|
|
2670
|
-
const res = run("git", ["hash-object", "--stdin-paths"], {
|
|
2671
|
-
env: ctx.env,
|
|
2672
|
-
input: hashAbs.join("\n") + "\n",
|
|
2673
|
-
});
|
|
2674
|
-
if (res.status === 0) {
|
|
2675
|
-
const lines = res.stdout.split("\n");
|
|
2676
|
-
for (let i = 0; i < hashRels.length; i++) {
|
|
2677
|
-
const sha = (lines[i] ?? "").trim();
|
|
2678
|
-
if (sha) localShas.set(hashRels[i], sha);
|
|
2679
|
-
}
|
|
2680
|
-
}
|
|
2681
|
-
}
|
|
2682
|
-
ctx.localShas = localShas;
|
|
2683
|
-
}
|
|
2684
|
-
|
|
2685
|
-
function walkAndProcess(ctx: WalkCtx, rootRel: string): void {
|
|
2686
|
-
const rootAbs = path.join(ctx.hqRoot, rootRel);
|
|
2687
|
-
if (!lexists(rootAbs)) return;
|
|
2688
|
-
|
|
2689
|
-
// companies/_template — wholesale-replace.
|
|
2690
|
-
if (rootRel === "companies/_template") {
|
|
2691
|
-
recordDecision(ctx, { kind: "wholesale-replace-template", rel: "companies/_template" });
|
|
2692
|
-
return;
|
|
2693
|
-
}
|
|
2694
|
-
|
|
2695
|
-
const lst = lstatOrNull(rootAbs);
|
|
2696
|
-
|
|
2697
|
-
// Top-level symlink.
|
|
2698
|
-
if (lst && lst.isSymbolicLink()) {
|
|
2699
|
-
const target = masterSyncSymlinkTarget(rootAbs);
|
|
2700
|
-
if (target !== null) {
|
|
2701
|
-
recordDecision(ctx, { kind: "drop-reindex-symlink", rel: rootRel, target });
|
|
2702
|
-
}
|
|
2703
|
-
return;
|
|
2704
|
-
}
|
|
2705
|
-
|
|
2706
|
-
// Top-level regular file.
|
|
2707
|
-
if (lst && lst.isFile()) {
|
|
2708
|
-
recordDecision(ctx, classifyOne(ctx, rootRel));
|
|
2709
|
-
return;
|
|
2710
|
-
}
|
|
2711
|
-
|
|
2712
|
-
// Top-level directory: recursive walk, pruning canonical transient state.
|
|
2713
|
-
for (const rel of findFilesAndSymlinks(rootAbs, ctx.hqRoot)) {
|
|
2714
|
-
recordDecision(ctx, classifyOne(ctx, rel));
|
|
2715
|
-
}
|
|
2716
|
-
}
|
|
2717
|
-
|
|
2718
|
-
/**
|
|
2719
|
-
* Yield repo-relative regular files and symlinks while pruning canonical
|
|
2720
|
-
* transient directories and never descending into directory symlinks. Order is
|
|
2721
|
-
* deterministic so classification and apply plans remain reproducible.
|
|
2722
|
-
*/
|
|
2723
|
-
function findFilesAndSymlinks(rootAbs: string, hqRoot: string): string[] {
|
|
2724
|
-
const found: string[] = [];
|
|
2725
|
-
const walk = (dir: string) => {
|
|
2726
|
-
let entries: fs.Dirent[];
|
|
2727
|
-
try {
|
|
2728
|
-
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
2729
|
-
} catch {
|
|
2730
|
-
return;
|
|
2731
|
-
}
|
|
2732
|
-
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
2733
|
-
for (const ent of entries) {
|
|
2734
|
-
const abs = path.join(dir, ent.name);
|
|
2735
|
-
if (ent.isSymbolicLink()) {
|
|
2736
|
-
// Surfaced as an entry (type l); never descended into.
|
|
2737
|
-
found.push(abs.slice(hqRoot.length + 1));
|
|
2738
|
-
continue;
|
|
2739
|
-
}
|
|
2740
|
-
if (ent.isDirectory()) {
|
|
2741
|
-
// Prune dirs that are never release content and never worth walking:
|
|
2742
|
-
// - node_modules / .git: standard.
|
|
2743
|
-
// - .pnpm-store: pnpm cache (defense-in-depth; also preserved at the
|
|
2744
|
-
// top level), huge content-addressed tree.
|
|
2745
|
-
// - .claude/worktrees: Claude Code per-agent worktree sandboxes —
|
|
2746
|
-
// each holds a full nested HQ checkout, so not pruning them means
|
|
2747
|
-
// walking 100k+ throwaway files. The sync engine ignores them too.
|
|
2748
|
-
const rel = abs.slice(hqRoot.length + 1);
|
|
2749
|
-
if (isRescueTransientDirectory(rel)) continue;
|
|
2750
|
-
walk(abs);
|
|
2751
|
-
continue;
|
|
2752
|
-
}
|
|
2753
|
-
if (ent.isFile()) {
|
|
2754
|
-
found.push(abs.slice(hqRoot.length + 1));
|
|
2755
|
-
}
|
|
2756
|
-
}
|
|
2757
|
-
};
|
|
2758
|
-
walk(rootAbs);
|
|
2759
|
-
return found;
|
|
2760
|
-
}
|
|
2761
|
-
|
|
2762
|
-
// --- restore file mtimes from git commit history ---
|
|
2763
|
-
function restoreMtimesFromGit(
|
|
2764
|
-
srcDir: string,
|
|
2765
|
-
env: NodeJS.ProcessEnv,
|
|
2766
|
-
out: (s: string) => void,
|
|
2767
|
-
): void {
|
|
2768
|
-
// Deepen a shallow clone so per-file commit times are real.
|
|
2769
|
-
const isShallow =
|
|
2770
|
-
run("git", ["rev-parse", "--is-shallow-repository"], { cwd: srcDir, env }).stdout.trim() ===
|
|
2771
|
-
"true";
|
|
2772
|
-
if (isShallow) {
|
|
2773
|
-
out("==> Deepening clone for per-file mtime history (blob:none) ...\n");
|
|
2774
|
-
const r = run("git", ["fetch", "--unshallow", "--filter=blob:none"], { cwd: srcDir, env });
|
|
2775
|
-
if (r.status !== 0) {
|
|
2776
|
-
out(" (unshallow failed; mtimes fall back to release tip-commit time)\n");
|
|
2777
|
-
}
|
|
2778
|
-
}
|
|
2779
|
-
|
|
2780
|
-
out("==> Restoring file mtimes from git commit history ...\n");
|
|
2781
|
-
const log = run(
|
|
2782
|
-
"git",
|
|
2783
|
-
["log", "--no-renames", "--pretty=format:C:%ct", "--name-only", "--diff-filter=ACMR"],
|
|
2784
|
-
{ cwd: srcDir, env },
|
|
2785
|
-
);
|
|
2786
|
-
if (log.status !== 0) return;
|
|
2787
|
-
|
|
2788
|
-
let ts = "";
|
|
2789
|
-
const seen = new Set<string>();
|
|
2790
|
-
for (const line of log.stdout.split("\n")) {
|
|
2791
|
-
if (line.startsWith("C:")) {
|
|
2792
|
-
ts = line.slice(2);
|
|
2793
|
-
continue;
|
|
2794
|
-
}
|
|
2795
|
-
if (line.length === 0) continue;
|
|
2796
|
-
if (seen.has(line)) continue;
|
|
2797
|
-
seen.add(line);
|
|
2798
|
-
const f = path.join(srcDir, line);
|
|
2799
|
-
const st = lstatOrNull(f);
|
|
2800
|
-
if (!st || st.isSymbolicLink() || !st.isFile()) continue;
|
|
2801
|
-
const epoch = Number(ts);
|
|
2802
|
-
if (!Number.isFinite(epoch)) continue;
|
|
2803
|
-
try {
|
|
2804
|
-
fs.utimesSync(f, epoch, epoch);
|
|
2805
|
-
} catch {
|
|
2806
|
-
/* best-effort, matches perl `|| true` */
|
|
2807
|
-
}
|
|
2808
|
-
}
|
|
2809
|
-
}
|
|
2810
|
-
|
|
2811
|
-
function allocateSnapshotDir(backupRoot: string, runTs: string, rootHash: string, runId: string): string {
|
|
2812
|
-
return path.join(backupRoot, `pre-update-${runTs}-${rootHash}-${runId}`);
|
|
2813
|
-
}
|
|
2814
|
-
|
|
2815
|
-
function snapshotManifest(
|
|
2816
|
-
status: SnapshotManifest["status"], hqRoot: string, runId: string, cfg: Config,
|
|
2817
|
-
sourceSha: string, includedRoots: string[],
|
|
2818
|
-
): SnapshotManifest {
|
|
2819
|
-
const now = new Date().toISOString();
|
|
2820
|
-
return { schemaVersion: 1, status, canonicalHqRoot: hqRoot, rootHash: canonicalRootHash(hqRoot), runId,
|
|
2821
|
-
creatorPid: process.pid, creatorStartedAt: now, source: cfg.sourceRepo, ref: cfg.ref, sourceSha,
|
|
2822
|
-
createdAt: now, includedRoots, fileCount: 0, byteCount: 0 };
|
|
2823
|
-
}
|
|
2824
|
-
|
|
2825
|
-
function readSnapshotManifest(backupDir: string): SnapshotManifest | null {
|
|
2826
|
-
try { return JSON.parse(fs.readFileSync(path.join(backupDir, "SNAPSHOT.json"), "utf8")) as SnapshotManifest; }
|
|
2827
|
-
catch { return null; }
|
|
2828
|
-
}
|
|
2829
|
-
|
|
2830
|
-
/** Publish only complete JSON; a crash can leave a temp file but never a torn manifest. */
|
|
2831
|
-
function writeSnapshotManifest(backupDir: string, manifest: SnapshotManifest): void {
|
|
2832
|
-
const target = path.join(backupDir, "SNAPSHOT.json");
|
|
2833
|
-
const temp = `${target}.${process.pid}.tmp`;
|
|
2834
|
-
fs.writeFileSync(temp, JSON.stringify(manifest, null, 2) + "\n");
|
|
2835
|
-
fs.renameSync(temp, target);
|
|
2836
|
-
}
|
|
2837
|
-
|
|
2838
|
-
function updateSnapshotStatus(backupDir: string, status: SnapshotManifest["status"], completedAt?: string): void {
|
|
2839
|
-
const manifest = readSnapshotManifest(backupDir);
|
|
2840
|
-
if (!manifest) return;
|
|
2841
|
-
const stats = estimateFiltered(backupDir, "");
|
|
2842
|
-
writeSnapshotManifest(backupDir, { ...manifest, status, fileCount: stats.files, byteCount: stats.bytes,
|
|
2843
|
-
...(completedAt ? { completedAt: new Date().toISOString() } : {}) });
|
|
2844
|
-
}
|
|
2845
|
-
|
|
2846
|
-
function pruneManagedBackups(cfg: Config, hqRoot: string, out: (s: string) => void, apply: boolean) {
|
|
2847
|
-
const inventory = inventoryManagedSnapshots(cfg.backupRoot, hqRoot, (m) => {
|
|
2848
|
-
try { process.kill(m.creatorPid, 0); return true; } catch { return false; }
|
|
2849
|
-
}, { keepGood: Number(cfg.backupRetentionCount), maxAgeDays: Number(cfg.backupRetentionDays) });
|
|
2850
|
-
const plan = { candidates: inventory.candidates, protected: inventory.protected, unmanaged: inventory.unmanaged };
|
|
2851
|
-
out(`==> Backup retention dry-run: ${plan.candidates.length} candidate(s), ${plan.protected.length} protected, ${plan.unmanaged.length} unmanaged.\n`);
|
|
2852
|
-
for (const item of plan.candidates) out(` candidate ${item.entry.name}: ${item.reason}\n`);
|
|
2853
|
-
for (const item of plan.protected) out(` protected ${item.entry.name}: ${item.reason}\n`);
|
|
2854
|
-
if (apply && plan.candidates.length > 0) {
|
|
2855
|
-
const result = applyBackupPrune(cfg.backupRoot, hqRoot, plan, { keepGood: Number(cfg.backupRetentionCount), maxAgeDays: Number(cfg.backupRetentionDays) });
|
|
2856
|
-
for (const name of result.deleted) out(` deleted snapshot: ${name}\n`);
|
|
2857
|
-
for (const failure of result.failures) out(` retention delete failed (retained): ${failure}\n`);
|
|
2858
|
-
}
|
|
2859
|
-
return plan;
|
|
2860
|
-
}
|
|
2861
|
-
|
|
2862
|
-
function estimateFiltered(abs: string, rel: string): { files: number; bytes: number } {
|
|
2863
|
-
const st = lstatOrNull(abs);
|
|
2864
|
-
if (!st || (st.isDirectory() && rel && isRescueTransientDirectory(rel))) return { files: 0, bytes: 0 };
|
|
2865
|
-
if (st.isSymbolicLink()) return { files: 1, bytes: Buffer.byteLength(fs.readlinkSync(abs)) };
|
|
2866
|
-
if (st.isFile()) return { files: 1, bytes: st.size };
|
|
2867
|
-
if (!st.isDirectory()) return { files: 0, bytes: 0 };
|
|
2868
|
-
let files = 0, bytes = 0;
|
|
2869
|
-
for (const name of fs.readdirSync(abs)) { const x = estimateFiltered(path.join(abs, name), path.posix.join(rel, name)); files += x.files; bytes += x.bytes; }
|
|
2870
|
-
return { files, bytes };
|
|
2871
|
-
}
|
|
2872
|
-
|
|
2873
|
-
function requireBackupCapacity(cfg: Config, hqRoot: string, roots: string[], _env: NodeJS.ProcessEnv, err: (s: string) => void): void {
|
|
2874
|
-
try {
|
|
2875
|
-
const estimate = roots.reduce((total, root) => total + estimateFiltered(path.join(hqRoot, root), root).bytes, 0);
|
|
2876
|
-
const stat = fs.statfsSync(isDir(cfg.backupRoot) ? cfg.backupRoot : path.dirname(cfg.backupRoot));
|
|
2877
|
-
const free = Number(stat.bavail) * Number(stat.bsize);
|
|
2878
|
-
const capacity = Number(stat.blocks) * Number(stat.bsize);
|
|
2879
|
-
const required = estimate + Math.max(5 * 1024 ** 3, Math.ceil(capacity * 0.1));
|
|
2880
|
-
if (free >= required) return;
|
|
2881
|
-
err(`error: insufficient free space for safety snapshot (need ${required} bytes, have ${free}).\n`);
|
|
2882
|
-
} catch (error) {
|
|
2883
|
-
err(`error: could not measure snapshot size/free space (${error instanceof Error ? error.message : String(error)}).\n`);
|
|
2884
|
-
}
|
|
2885
|
-
if (cfg.cloudUpdate) throw new ExitError(1);
|
|
2886
|
-
err(" Continuing only because this is an interactive rescue; use --cloud-update to fail closed.\n");
|
|
2887
|
-
}
|
|
2888
|
-
|
|
2889
|
-
// --- recursive file count (`find <dir> -type f | wc -l`) ---
|
|
2890
|
-
function countFiles(dir: string): number {
|
|
2891
|
-
let n = 0;
|
|
2892
|
-
const st = lstatOrNull(dir);
|
|
2893
|
-
if (!st) return 0;
|
|
2894
|
-
if (st.isFile()) return 1;
|
|
2895
|
-
if (!st.isDirectory() || st.isSymbolicLink()) return 0;
|
|
2896
|
-
const walk = (d: string) => {
|
|
2897
|
-
let entries: fs.Dirent[];
|
|
2898
|
-
try {
|
|
2899
|
-
entries = fs.readdirSync(d, { withFileTypes: true });
|
|
2900
|
-
} catch {
|
|
2901
|
-
return;
|
|
2902
|
-
}
|
|
2903
|
-
for (const ent of entries) {
|
|
2904
|
-
const abs = path.join(d, ent.name);
|
|
2905
|
-
if (ent.isDirectory()) walk(abs);
|
|
2906
|
-
else if (ent.isFile()) n += 1;
|
|
2907
|
-
}
|
|
2908
|
-
};
|
|
2909
|
-
walk(dir);
|
|
2910
|
-
return n;
|
|
2911
|
-
}
|
|
2912
|
-
|
|
2913
|
-
/**
|
|
2914
|
-
* `cp -a <src> <destDir>/[<asName>]` — recursive copy preserving symlinks,
|
|
2915
|
-
* mtimes and modes. Mirrors the bash `cp -a "$src" "$destDir/"` (copies the
|
|
2916
|
-
* source basename into destDir) and the shuttle form (`cp -a "$src"
|
|
2917
|
-
* "$SHUTTLE/$id"`, an explicit destination name).
|
|
2918
|
-
*/
|
|
2919
|
-
function cpA(src: string, destDir: string, asName?: string): void {
|
|
2920
|
-
const dest = asName ? path.join(destDir, asName) : path.join(destDir, path.basename(src));
|
|
2921
|
-
cpATo(src, dest);
|
|
2922
|
-
}
|
|
2923
|
-
|
|
2924
|
-
/** `cp -a <src> <dest>` with an explicit destination path. */
|
|
2925
|
-
function cpATo(src: string, dest: string): void {
|
|
2926
|
-
const st = lstatOrNull(src);
|
|
2927
|
-
if (!st) return;
|
|
2928
|
-
if (st.isSymbolicLink()) {
|
|
2929
|
-
const link = fs.readlinkSync(src);
|
|
2930
|
-
try {
|
|
2931
|
-
fs.rmSync(dest, { force: true });
|
|
2932
|
-
} catch {
|
|
2933
|
-
/* ignore */
|
|
2934
|
-
}
|
|
2935
|
-
fs.symlinkSync(link, dest);
|
|
2936
|
-
return;
|
|
2937
|
-
}
|
|
2938
|
-
if (st.isDirectory()) {
|
|
2939
|
-
fs.mkdirSync(dest, { recursive: true });
|
|
2940
|
-
for (const name of fs.readdirSync(src)) {
|
|
2941
|
-
cpATo(path.join(src, name), path.join(dest, name));
|
|
2942
|
-
}
|
|
2943
|
-
try {
|
|
2944
|
-
fs.utimesSync(dest, st.atime, st.mtime);
|
|
2945
|
-
} catch {
|
|
2946
|
-
/* ignore */
|
|
2947
|
-
}
|
|
2948
|
-
return;
|
|
2949
|
-
}
|
|
2950
|
-
// Regular file.
|
|
2951
|
-
fs.copyFileSync(src, dest);
|
|
2952
|
-
try {
|
|
2953
|
-
fs.chmodSync(dest, st.mode);
|
|
2954
|
-
fs.utimesSync(dest, st.atime, st.mtime);
|
|
2955
|
-
} catch {
|
|
2956
|
-
/* ignore */
|
|
2957
|
-
}
|
|
2958
|
-
}
|
|
2959
|
-
|
|
2960
|
-
/** Snapshot copy that preserves metadata while pruning canonical transient directories. */
|
|
2961
|
-
function cpAToFiltered(src: string, dest: string, rel: string): void {
|
|
2962
|
-
const st = lstatOrNull(src);
|
|
2963
|
-
if (!st) return;
|
|
2964
|
-
if (st.isDirectory() && isRescueTransientDirectory(rel)) return;
|
|
2965
|
-
if (st.isSymbolicLink()) {
|
|
2966
|
-
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
2967
|
-
fs.symlinkSync(fs.readlinkSync(src), dest);
|
|
2968
|
-
return;
|
|
2969
|
-
}
|
|
2970
|
-
if (st.isDirectory()) {
|
|
2971
|
-
fs.mkdirSync(dest, { recursive: true });
|
|
2972
|
-
for (const name of fs.readdirSync(src)) {
|
|
2973
|
-
cpAToFiltered(path.join(src, name), path.join(dest, name), path.posix.join(rel, name));
|
|
2974
|
-
}
|
|
2975
|
-
try {
|
|
2976
|
-
fs.chmodSync(dest, st.mode);
|
|
2977
|
-
fs.utimesSync(dest, st.atime, st.mtime);
|
|
2978
|
-
} catch {
|
|
2979
|
-
/* metadata preservation is best-effort */
|
|
2980
|
-
}
|
|
2981
|
-
return;
|
|
2982
|
-
}
|
|
2983
|
-
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
2984
|
-
fs.copyFileSync(src, dest);
|
|
2985
|
-
try {
|
|
2986
|
-
fs.chmodSync(dest, st.mode);
|
|
2987
|
-
fs.utimesSync(dest, st.atime, st.mtime);
|
|
2988
|
-
} catch {
|
|
2989
|
-
/* metadata preservation is best-effort */
|
|
2990
|
-
}
|
|
2991
|
-
}
|
|
2992
|
-
|
|
2993
|
-
/** Synchronous single-line read from stdin (mirrors bash `read -r`). */
|
|
2994
|
-
function readLineSync(): string {
|
|
2995
|
-
const fd = 0;
|
|
2996
|
-
const buf = Buffer.alloc(1);
|
|
2997
|
-
let line = "";
|
|
2998
|
-
for (;;) {
|
|
2999
|
-
let bytes = 0;
|
|
3000
|
-
try {
|
|
3001
|
-
bytes = fs.readSync(fd, buf, 0, 1, null);
|
|
3002
|
-
} catch {
|
|
3003
|
-
break; // EOF / not readable
|
|
3004
|
-
}
|
|
3005
|
-
if (bytes === 0) break;
|
|
3006
|
-
const ch = buf.toString("utf-8");
|
|
3007
|
-
if (ch === "\n") break;
|
|
3008
|
-
line += ch;
|
|
3009
|
-
}
|
|
3010
|
-
return line.replace(/\r$/, "");
|
|
3011
|
-
}
|