@indigoai-us/hq-cloud 6.1.0 → 6.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/dist/bin/sync-runner.d.ts.map +1 -1
  2. package/dist/bin/sync-runner.js +18 -0
  3. package/dist/bin/sync-runner.js.map +1 -1
  4. package/dist/cli/index.d.ts +2 -2
  5. package/dist/cli/index.d.ts.map +1 -1
  6. package/dist/cli/index.js +2 -2
  7. package/dist/cli/index.js.map +1 -1
  8. package/dist/cli/reindex.d.ts +4 -11
  9. package/dist/cli/reindex.d.ts.map +1 -1
  10. package/dist/cli/reindex.js +336 -30
  11. package/dist/cli/reindex.js.map +1 -1
  12. package/dist/cli/reindex.test.d.ts +3 -3
  13. package/dist/cli/reindex.test.js +36 -11
  14. package/dist/cli/reindex.test.js.map +1 -1
  15. package/dist/cli/rescue-core.d.ts +36 -0
  16. package/dist/cli/rescue-core.d.ts.map +1 -0
  17. package/dist/cli/rescue-core.js +1536 -0
  18. package/dist/cli/rescue-core.js.map +1 -0
  19. package/dist/cli/rescue-drift-reconcile.test.js +33 -10
  20. package/dist/cli/rescue-drift-reconcile.test.js.map +1 -1
  21. package/dist/cli/rescue-mtime-preserve.test.js +36 -12
  22. package/dist/cli/rescue-mtime-preserve.test.js.map +1 -1
  23. package/dist/cli/rescue.d.ts +4 -10
  24. package/dist/cli/rescue.d.ts.map +1 -1
  25. package/dist/cli/rescue.js +14 -37
  26. package/dist/cli/rescue.js.map +1 -1
  27. package/dist/cli/rescue.reindex.test.js +9 -8
  28. package/dist/cli/rescue.reindex.test.js.map +1 -1
  29. package/dist/cli/rescue.test.js +1 -10
  30. package/dist/cli/rescue.test.js.map +1 -1
  31. package/dist/index.d.ts +2 -2
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +2 -2
  34. package/dist/index.js.map +1 -1
  35. package/dist/lib/conflict-index.d.ts +40 -0
  36. package/dist/lib/conflict-index.d.ts.map +1 -1
  37. package/dist/lib/conflict-index.js +121 -0
  38. package/dist/lib/conflict-index.js.map +1 -1
  39. package/dist/lib/conflict.test.js +145 -1
  40. package/dist/lib/conflict.test.js.map +1 -1
  41. package/package.json +1 -1
  42. package/src/bin/sync-runner.ts +18 -0
  43. package/src/cli/index.ts +2 -2
  44. package/src/cli/reindex.test.ts +45 -12
  45. package/src/cli/reindex.ts +345 -36
  46. package/src/cli/rescue-core.ts +1650 -0
  47. package/src/cli/rescue-drift-reconcile.test.ts +33 -12
  48. package/src/cli/rescue-mtime-preserve.test.ts +36 -15
  49. package/src/cli/rescue.reindex.test.ts +9 -8
  50. package/src/cli/rescue.test.ts +1 -11
  51. package/src/cli/rescue.ts +15 -40
  52. package/src/index.ts +2 -2
  53. package/src/lib/conflict-index.ts +146 -0
  54. package/src/lib/conflict.test.ts +171 -0
  55. package/scripts/reindex.sh +0 -318
  56. package/scripts/replace-rescue.sh +0 -1522
@@ -1,1522 +0,0 @@
1
- #!/usr/bin/env bash
2
- # replace-rescue.sh
3
- #
4
- # Renamed from `replace-from-staging-rescue.sh` in v0.1.104 once the script
5
- # became channel-agnostic (`--source` + `--ref` drive both the @indigo
6
- # "Update to Staging" flow and the prod "Update to vX.Y.Z" flow). Old name
7
- # kept as a backup search path in `hq_core_staging::resolve_rescue_script`
8
- # so a stale resource dir mid-rebuild doesn't break dev. The persisted
9
- # `core/core.yaml` stamp key also got renamed (`replaced_from_staging` ->
10
- # `replaced_from_source`); reads honour both, writes use the new key and
11
- # `del` the old.
12
- #
13
- # Variant of replace-from-staging.sh (the original clobbering version,
14
- # kept upstream for non-HQ-Sync callers) that:
15
- #
16
- # 1. Does NOT require the destination to be a git repo. The `.git/` check is
17
- # dropped and git status reporting at the end is replaced with a plain
18
- # file-count summary. (git is still required to fetch the source repo.)
19
- #
20
- # 2. Rescues drifts instead of clobbering them. Before the wipe, every file
21
- # in the wipe set that DIFFERS from staging (or exists only locally) is
22
- # MOVED into `personal/` so it survives as a layered override.
23
- #
24
- # Drift detection — three-way classification (v0.1.104+):
25
- #
26
- # For every regular file under each wipe-set top-level entry, the walk
27
- # classifies it as one of:
28
- #
29
- # A. USER-ONLY — path not in upstream HEAD AND not in the last-sync
30
- # tree (per `replaced_from_source.last_sync_sha`). The
31
- # user created this file and upstream has no opinion
32
- # about it. ACTION: leave in place. The rsync overlay
33
- # is run without --delete so user-only files survive.
34
- #
35
- # B. UNCHANGED — local file is byte-identical to the version at the
36
- # last-sync SHA (or, when no stamp is available, to
37
- # the current upstream HEAD). User didn't touch it
38
- # since last sync. ACTION: delete locally; the overlay
39
- # writes the fresh upstream version on top.
40
- #
41
- # C. USER-EDIT — local file differs from the last-sync version (or
42
- # from HEAD in head-compare fallback mode). The user
43
- # authored a change after the last sync. ACTION:
44
- # rescue per the mapping below.
45
- #
46
- # Rescue mapping for USER-EDIT files:
47
- #
48
- # a. `.claude/CLAUDE.md` -> DIFF-APPEND additions to `personal/CLAUDE.md`
49
- # (whole-file move was too disruptive — most
50
- # users only add sections, so we extract the
51
- # lines present locally but not in baseline
52
- # and append them under a timestamped marker.)
53
- # b. `.claude/<rest>` -> `personal/<rest>` (mkdir -p personal/<top-of-rest>/)
54
- # c. `core/<rest>` -> `personal/<rest>` (mkdir -p personal/<top-of-rest>/)
55
- # d. `<rest>` (root file) -> `personal/<rest>` (mkdir -p personal/<top-of-rest>/)
56
- #
57
- # Conflict-class USER-EDITs (bypass the personal/ rescue and quarantine in
58
- # `.hq-conflicts/rescue-<RUN_TS>/<rel>` instead — see is_conflict_class):
59
- #
60
- # - `.agents/` — agent runtime state, not a portable overlay
61
- # - `.codex/` — codex per-machine cache/state
62
- # - `.obsidian/` — vault UI state (workspace.json, graph.json)
63
- # - `MIGRATION.md` (root) — always re-shipped by overlay; a divergent local
64
- # copy means an upgrade snag worth reviewing,
65
- # not a customization to preserve in personal/.
66
- #
67
- # Overwrite-safe USER-EDITs (silently overwritten by the overlay — no
68
- # rescue, no conflict, no copy preserved — see is_overwrite_safe):
69
- #
70
- # - `AGENTS.md` (root)
71
- # - `USER-GUIDE.md` (root, pre-v15 layout)
72
- # - `core/docs/hq/USER-GUIDE.md`
73
- # - `core/policies/_digest.md` (rebuilt by `build-policy-digest.sh`)
74
- #
75
- # These paths are either always re-shipped by upstream or auto-regenerated
76
- # by a script on the next Stop hook. A drifted local copy is just stale,
77
- # not a customization — saving it under personal/ or .hq-conflicts/ just
78
- # accumulates noise.
79
- #
80
- # Symlinks generated by `reindex.sh` (targets resolving into
81
- # `personal/`) are DELETED during the walk rather than preserved. They
82
- # rebuild deterministically on the next Stop-hook pass — keeping the old
83
- # link risks shadowing the upstream file the overlay writes at the same
84
- # path.
85
- #
86
- # --cloud-update flag (off by default): the rescue walk recognizes files
87
- # whose content is `hq-symlink:<target>` as the cloud-flattened
88
- # serialization of an upstream symlink. hq-sync stores symlinks in S3
89
- # as small text files with this magic prefix because S3 has no symlink
90
- # semantics; a vault snapshot pulled via `aws s3 sync` has these in
91
- # place of symlinks. When the local marker's target matches the upstream
92
- # symlink's target, the file is reclassified UNCHANGED (deleted; the
93
- # overlay re-lays the real symlink). Targets that disagree fall through
94
- # to normal user-edit routing. ONLY enable when --hq-root points at a
95
- # vault mirror, not a real local HQ tree.
96
- #
97
- # If the rescue destination already exists, the moved file is suffixed
98
- # with `.drift-<unix-ts>` so we never silently overwrite a prior override.
99
- #
100
- # Two operating modes (same as the parent script):
101
- #
102
- # - Default ("preserve-list"): wipes every top-level entry except a hardcoded
103
- # preserve set (`.git`, `companies/` except `companies/_template/`,
104
- # `personal/`, `workspace/`, `repos/`, `.github/`, `.leak-scan/`,
105
- # `.hq-sync-journal.json`, `.hq/`, `.hq-conflicts/`),
106
- # plus any paths passed via --preserve.
107
- #
108
- # `repos/` is always preserved: it holds user-owned git checkouts
109
- # (`repos/public/` + `repos/private/`) whose `.git/` directories would
110
- # shatter under a file-by-file rescue. hq-core never ships a `repos/`
111
- # tree, so the overlay can't restore it — the only safe handling is
112
- # to leave it alone entirely.
113
- #
114
- # - `--paths`: wipes ONLY the explicit comma-separated list of top-level
115
- # entries and overlays only those.
116
- #
117
- # `--preserve-subpath <rel>` (repeatable) carves out individual files INSIDE
118
- # the wipe set, copied to a mktemp shuttle pre-wipe and restored post-overlay.
119
- #
120
- # Baseline mode — what counts as "user edit":
121
- #
122
- # * `history_floor` (default, used when `replaced_from_source.last_sync_sha`
123
- # is stamped AND reachable in the clone): a file is a USER-EDIT iff its
124
- # `git hash-object` SHA differs from the blob SHA at that path at the
125
- # stamped commit. One `git rev-parse <floor>:<rel>` per file — cheap.
126
- # This is strictly more accurate than the v0.1.103 history-walk index:
127
- # the floor is the exact point the user diverged from, so no spurious
128
- # "matches some past staging blob" coincidences mask real edits.
129
- #
130
- # * `head_compare` (fallback when no stamp is set OR `--no-history-check`
131
- # is passed): a file is a USER-EDIT iff `cmp -s` against the upstream
132
- # HEAD copy disagrees. Loses the ability to distinguish
133
- # "upstream-removed since last sync" from "user-added new file" — both
134
- # look like "in local, not in HEAD". Safe default for first-ever runs.
135
- #
136
- # Cost: full-history clone with `--filter=blob:none` (~15 MB for
137
- # hq-core-staging vs. ~5 MB shallow). Per-file blob SHA comparisons are
138
- # microseconds each. The big v0.1.103 perf footgun (391k-file node_modules
139
- # walks) is dead now that `repos/` is preserved and node_modules + nested
140
- # .git are pruned from the walk.
141
- #
142
- # Sync-point provenance — `core/core.yaml`:
143
- #
144
- # On a successful default-mode (full-replace) run, the script records the
145
- # source commit it synced to under `core/core.yaml`'s
146
- # `replaced_from_source:` key (source / ref / last_sync_sha / last_sync_at).
147
- # On the NEXT run, this is read before the clone and — if the source repo
148
- # matches and the SHA is reachable in the new clone — used as the
149
- # **history floor**: the index walks `git log <last_sync_sha>` instead of
150
- # `git log --all`, scoping it to "blobs the source knew about at our last
151
- # sync point". A local file whose content matches one of those blobs is
152
- # provably lag (user had it via prior sync). A local file whose content
153
- # only matches blobs from AFTER the last sync — i.e. blobs the user
154
- # couldn't have copied from a sync — is treated as user-authored even if
155
- # it happens to coincide with a recent source commit.
156
- #
157
- # Backwards compat: pre-v0.1.104 runs stamped under the old key
158
- # `replaced_from_staging`. The read block tries the new key first and
159
- # falls back to the old one so a user's history-floor benefit survives
160
- # the rename. The write block stamps the new key AND deletes the old
161
- # one in the same yq pass so post-migration only one stamp exists.
162
- #
163
- # Usage:
164
- # replace-rescue.sh [--ref REF] [--source OWNER/REPO]
165
- # [--paths PATH1,PATH2,...]
166
- # [--preserve PATH]...
167
- # [--preserve-subpath REL]...
168
- # [--hq-root DIR]
169
- # [--no-history-check]
170
- # [--no-backup] [--backup-dir DIR]
171
- # [--cloud-update]
172
- # [--dry-run] [--yes]
173
- #
174
- # Defaults:
175
- # --ref main
176
- # --source indigoai-us/hq-core-staging
177
- # --hq-root <script>/../../.. (assumes script lives at personal/skills/<skill>/)
178
- #
179
- # Requires: git (for source clone + hash-object), rsync, cmp, awk, grep, sort.
180
-
181
- set -euo pipefail
182
-
183
- REF="main"
184
- SOURCE_REPO="indigoai-us/hq-core-staging"
185
- DRY_RUN=0
186
- ASSUME_YES=0
187
- # Cloud-update mode (--cloud-update). When ON, the rescue walk recognizes
188
- # files whose content is `hq-symlink:<target>` as the cloud-flattened
189
- # serialization of an upstream symlink — hq-sync stores symlinks in S3
190
- # as small text files with this magic prefix because S3 has no symlink
191
- # semantics. If upstream at this path is a symlink whose target matches
192
- # the local file's payload, the file is reclassified as UNCHANGED (just
193
- # deleted; the overlay re-lays the real symlink). Mismatches fall through
194
- # to normal user-edit routing. OFF by default for safety — only meaningful
195
- # when --hq-root points at a vault snapshot (e.g. an `aws s3 sync` mirror),
196
- # not a real local HQ tree where symlinks are real.
197
- CLOUD_UPDATE=0
198
- EXTRA_PRESERVE=()
199
- PRESERVE_SUBPATHS=()
200
- NARROW_PATHS_CSV=""
201
- HQ_ROOT_OVERRIDE=""
202
- HISTORY_CHECK=1
203
- # Caller-supplied history-floor SHA. When set (40-char hex), overrides
204
- # the value read from `core/core.yaml`'s `replaced_from_source.last_sync_sha`
205
- # stamp. Use case: a user whose `core/core.yaml` is unstamped (pre-rescue
206
- # install — e.g. existing v14.0.0 → v14.2.1 prod upgrade) should still
207
- # get `history_floor` mode rather than the `head_compare` fallback, which
208
- # misclassifies every upstream change since their installed version as a
209
- # USER-EDIT and shoves it into `personal/`. The hq-sync caller resolves
210
- # `v<installed-hqVersion>` against `--source` via the GitHub API and
211
- # passes it here so the rescue baselines against the user's actual
212
- # installed tree, not "any blob in main's history."
213
- FLOOR_SHA_OVERRIDE=""
214
-
215
- # Pre-operation safety snapshot (v0.4.1+). The rescue walk MOVES USER-EDIT
216
- # files into personal/ and DELETES UNCHANGED ones before the overlay. A
217
- # misclassification (head_compare fallback on an unstamped install, a binary
218
- # USER-EDIT, an interrupted run) would otherwise be unrecoverable — there was
219
- # previously NO backup taken by the destructive code itself, only a manual
220
- # step documented in MIGRATION.md that users skipped. We now snapshot the
221
- # wipe set to ~/.hq/backups/pre-update-<ts>/ + write a RECOVERY.md manifest
222
- # BEFORE any destructive op. Default ON; opt out with --no-backup or
223
- # HQ_RESCUE_NO_BACKUP=1. Override location with --backup-dir / HQ_BACKUP_DIR.
224
- # Old snapshots older than HQ_BACKUP_RETENTION_DAYS (default 7) are pruned
225
- # after a successful run — this is the auto-cleanup MIGRATION.md promised.
226
- DO_BACKUP=1
227
- [ "${HQ_RESCUE_NO_BACKUP:-0}" = "1" ] && DO_BACKUP=0
228
- BACKUP_ROOT="${HQ_BACKUP_DIR:-$HOME/.hq/backups}"
229
- BACKUP_RETENTION_DAYS="${HQ_BACKUP_RETENTION_DAYS:-7}"
230
- BACKUP_DIR="" # resolved at snapshot time (BACKUP_ROOT/pre-update-<RUN_TS>)
231
-
232
- # Paths that are ALWAYS preserved across the wipe+overlay, regardless of
233
- # mode or user flags. Each entry is shuttled to a mktemp area pre-wipe and
234
- # restored post-overlay (same mechanism as --preserve-subpath), AND skipped
235
- # by drift detection so the rescue scan never touches them.
236
- #
237
- # Why core/packages and packages: both hold user-curated packs (hq-pack-*)
238
- # resolved through the npm/hq-cli install path; staging may also ship under
239
- # `core/packages` with different content. Without this carve-out a
240
- # full-replace would clobber the local pack tree.
241
- # Why .claude/state: runtime session state — `.claude/state/active-session-*`
242
- # changes every session; shuttling preserves it across the overlay.
243
- # Why core/workers/registry.yaml: generated locally by
244
- # `core/scripts/generate-workers-registry.sh` from the union of core +
245
- # personal + installed-pack workers. Its content is a function of the
246
- # user's install state, not the upstream tree, so an overlay that
247
- # replaces it produces a registry that doesn't match what's on disk
248
- # (and `reindex` regenerates it on the next Stop hook anyway).
249
- # Shuttling preserves the up-to-date local version across the rescue.
250
- # Why .claude/settings.local.json: per-user local settings (denied paths,
251
- # env vars, allow-listed tool patterns) authored by the user via the CLI
252
- # or hand-edits. Never shipped from upstream — it has no upstream version
253
- # at all. Subjecting it to drift detection would just rescue it into
254
- # personal/ every run, which is busywork. Shuttle preserves the local
255
- # version across the overlay so the rescue is a no-op for this file.
256
- CARVE_OUT_PATHS=( "core/packages" "packages" ".claude/state" "core/workers/registry.yaml" ".claude/settings.local.json" )
257
-
258
- usage() {
259
- sed -n '2,55p' "$0"
260
- exit 1
261
- }
262
-
263
- while [ $# -gt 0 ]; do
264
- case "$1" in
265
- --ref) REF="$2"; shift 2 ;;
266
- --source) SOURCE_REPO="$2"; shift 2 ;;
267
- --preserve) EXTRA_PRESERVE+=("$2"); shift 2 ;;
268
- --preserve-subpath) PRESERVE_SUBPATHS+=("$2"); shift 2 ;;
269
- --paths) NARROW_PATHS_CSV="$2"; shift 2 ;;
270
- --hq-root) HQ_ROOT_OVERRIDE="$2"; shift 2 ;;
271
- --no-history-check) HISTORY_CHECK=0; shift ;;
272
- --floor-sha)
273
- FLOOR_SHA_OVERRIDE="$2"
274
- # Fail fast on obviously bad input (the rescue is destructive — better
275
- # to abort here than silently fall through to head_compare halfway in).
276
- if ! printf '%s' "$FLOOR_SHA_OVERRIDE" | grep -qE '^[0-9a-f]{40}$'; then
277
- echo "error: --floor-sha must be a 40-char lowercase hex SHA, got: $FLOOR_SHA_OVERRIDE" >&2
278
- exit 2
279
- fi
280
- shift 2 ;;
281
- --no-backup) DO_BACKUP=0; shift ;;
282
- --backup-dir) BACKUP_ROOT="$2"; shift 2 ;;
283
- --dry-run) DRY_RUN=1; shift ;;
284
- --cloud-update) CLOUD_UPDATE=1; shift ;;
285
- --yes|-y) ASSUME_YES=1; shift ;;
286
- -h|--help) usage ;;
287
- *) echo "unknown arg: $1" >&2; usage ;;
288
- esac
289
- done
290
-
291
- for p in "${EXTRA_PRESERVE[@]+"${EXTRA_PRESERVE[@]}"}"; do
292
- case "$p" in
293
- .git|companies|personal|workspace|repos|.github|.leak-scan|.hq-sync-journal.json|.hq|.hq-conflicts|.git/|companies/|personal/|workspace/|repos/|.github/|.leak-scan/|.hq/|.hq-conflicts/)
294
- echo "error: --preserve $p is redundant ('$p' is always preserved). Remove the flag." >&2
295
- exit 2
296
- ;;
297
- esac
298
- done
299
-
300
- for sp in "${PRESERVE_SUBPATHS[@]+"${PRESERVE_SUBPATHS[@]}"}"; do
301
- case "$sp" in
302
- /*|*..*)
303
- echo "error: --preserve-subpath $sp must be a relative path with no '..' segments." >&2
304
- exit 2
305
- ;;
306
- esac
307
- done
308
-
309
- # Append the always-preserved carve-outs to PRESERVE_SUBPATHS so the same
310
- # backup/restore code path handles them. Dedup against any user-supplied
311
- # entries (defensive — user may also have passed them explicitly).
312
- for cp in "${CARVE_OUT_PATHS[@]}"; do
313
- already=0
314
- for sp in "${PRESERVE_SUBPATHS[@]+"${PRESERVE_SUBPATHS[@]}"}"; do
315
- if [ "$sp" = "$cp" ]; then already=1; break; fi
316
- done
317
- [ "$already" = "1" ] || PRESERVE_SUBPATHS+=("$cp")
318
- done
319
-
320
- NARROW_PATHS=()
321
- if [ -n "$NARROW_PATHS_CSV" ]; then
322
- IFS=',' read -r -a _NARROW_RAW <<< "$NARROW_PATHS_CSV"
323
- for raw in "${_NARROW_RAW[@]}"; do
324
- name="${raw#"${raw%%[![:space:]]*}"}"
325
- name="${name%"${name##*[![:space:]]}"}"
326
- if [ -z "$name" ]; then continue; fi
327
- case "$name" in
328
- */*|..|.)
329
- echo "error: --paths entry '$name' must be a single top-level name (no slashes, no '.' or '..')." >&2
330
- exit 2
331
- ;;
332
- esac
333
- NARROW_PATHS+=("$name")
334
- done
335
- if [ "${#NARROW_PATHS[@]}" -eq 0 ]; then
336
- echo "error: --paths was passed but resolved to an empty list." >&2
337
- exit 2
338
- fi
339
- if [ "${#EXTRA_PRESERVE[@]}" -ne 0 ]; then
340
- echo "error: --paths and --preserve are mutually exclusive. Use --preserve-subpath for sub-paths inside the listed top-level entries." >&2
341
- exit 2
342
- fi
343
- fi
344
-
345
- # --- Resolve HQ root (no .git requirement) -----------------------------------
346
- SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
347
- if [ -n "$HQ_ROOT_OVERRIDE" ]; then
348
- HQ_ROOT="$(cd "$HQ_ROOT_OVERRIDE" && pwd)"
349
- else
350
- HQ_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
351
- fi
352
-
353
- # Sanity: look for `companies/` and `personal/` (not `.git/`). Drift rescue
354
- # needs personal/ to exist as the override target.
355
- if [ ! -d "$HQ_ROOT/companies" ] || [ ! -d "$HQ_ROOT/personal" ]; then
356
- echo "error: $HQ_ROOT does not look like an HQ root (missing companies/ or personal/). Aborting." >&2
357
- echo " pass --hq-root <dir> if the script is not at personal/skills/<skill>/." >&2
358
- exit 3
359
- fi
360
-
361
- # Per-run timestamp marker. Used in the CLAUDE.md diff-append header and
362
- # as the suffix on collision-renamed rescue destinations. The v0.1.103
363
- # `.hq-conflicts/rescue-<ts>/` fallback bucket is gone — every rescue
364
- # now lands under `personal/`, mkdir-ing the parent dir if missing.
365
- RUN_TS="$(date -u +%Y-%m-%dT%H-%M-%SZ)"
366
-
367
- # --- Read prior sync-point metadata (must happen BEFORE the wipe) -----------
368
- # If the user has run this script before in default mode, core/core.yaml
369
- # carries the source SHA we last synced to. We use that SHA later (after
370
- # the clone) as the history-floor: scope the index walk to commits reachable
371
- # from <last_sync_sha> instead of all branches. Only honored when the
372
- # previously-recorded source matches the current --source.
373
- #
374
- # Try the new key (`replaced_from_source`) first, fall back to the old
375
- # (`replaced_from_staging`) so a user whose last sync ran on v0.1.103-or-
376
- # earlier still gets the history-floor benefit on the next run. The yq
377
- # `//` alternative operator returns the left side when non-null, else the
378
- # right; the trailing `// ""` collapses null to empty string so the bash
379
- # checks below stay simple.
380
- PREV_SYNC_SHA=""
381
- PREV_SYNC_SOURCE=""
382
- PREV_SYNC_REF=""
383
- PREV_SYNC_AT=""
384
- if [ -f "$HQ_ROOT/core/core.yaml" ] && command -v yq >/dev/null 2>&1; then
385
- PREV_SYNC_SHA="$(yq -r '.replaced_from_source.last_sync_sha // .replaced_from_staging.last_sync_sha // ""' "$HQ_ROOT/core/core.yaml" 2>/dev/null || true)"
386
- PREV_SYNC_SOURCE="$(yq -r '.replaced_from_source.source // .replaced_from_staging.source // ""' "$HQ_ROOT/core/core.yaml" 2>/dev/null || true)"
387
- PREV_SYNC_REF="$(yq -r '.replaced_from_source.ref // .replaced_from_staging.ref // ""' "$HQ_ROOT/core/core.yaml" 2>/dev/null || true)"
388
- PREV_SYNC_AT="$(yq -r '.replaced_from_source.last_sync_at // .replaced_from_staging.last_sync_at // ""' "$HQ_ROOT/core/core.yaml" 2>/dev/null || true)"
389
- fi
390
-
391
- # Caller override (--floor-sha) wins when the on-disk stamp is empty.
392
- # Set PREV_SYNC_SOURCE to the current --source so the source-match check
393
- # below ("only honor the floor when the previously-recorded source
394
- # matches the current --source") trivially passes — the caller has
395
- # already verified the SHA resolves to a commit in `--source`.
396
- #
397
- # We intentionally do NOT overwrite a non-empty stamp: a real prior-sync
398
- # SHA is strictly more accurate than a caller's "best guess" floor
399
- # derived from the installed `hqVersion` tag (the user may have run a
400
- # rescue since the version stamp was written, advancing the floor).
401
- if [ -z "$PREV_SYNC_SHA" ] && [ -n "$FLOOR_SHA_OVERRIDE" ]; then
402
- PREV_SYNC_SHA="$FLOOR_SHA_OVERRIDE"
403
- PREV_SYNC_SOURCE="$SOURCE_REPO"
404
- PREV_SYNC_REF="(caller-supplied floor)"
405
- PREV_SYNC_AT="(unstamped install)"
406
- fi
407
-
408
- echo "==> HQ root: $HQ_ROOT"
409
- echo "==> Source: https://github.com/$SOURCE_REPO @ $REF"
410
- if [ -n "$PREV_SYNC_SHA" ]; then
411
- if [ "$PREV_SYNC_SOURCE" = "$SOURCE_REPO" ]; then
412
- echo "==> Prior sync: $PREV_SYNC_SHA from $PREV_SYNC_SOURCE@$PREV_SYNC_REF ($PREV_SYNC_AT) — will use as history floor"
413
- else
414
- echo "==> Prior sync: $PREV_SYNC_SHA from $PREV_SYNC_SOURCE (different source — ignoring as floor)"
415
- fi
416
- fi
417
- if [ "${#NARROW_PATHS[@]}" -ne 0 ]; then
418
- echo "==> Mode: narrow (--paths)"
419
- echo "==> Wipe set: ${NARROW_PATHS[*]}"
420
- else
421
- echo "==> Mode: preserve-list (default)"
422
- echo "==> Preserved: .git, companies (except companies/_template), personal, workspace, repos, .github, .leak-scan, .hq-sync-journal.json, .hq, .hq-conflicts${EXTRA_PRESERVE[*]+, ${EXTRA_PRESERVE[*]}}"
423
- fi
424
- if [ "${#PRESERVE_SUBPATHS[@]}" -ne 0 ]; then
425
- echo "==> Preserved subpaths (backed up + restored across the overlay):"
426
- for sp in "${PRESERVE_SUBPATHS[@]}"; do
427
- # Mark the always-on carve-outs so it's obvious they're not from --preserve-subpath.
428
- is_carve=0
429
- for cp in "${CARVE_OUT_PATHS[@]}"; do
430
- if [ "$cp" = "$sp" ]; then is_carve=1; break; fi
431
- done
432
- if [ "$is_carve" = "1" ]; then
433
- echo " - $sp (always-on carve-out)"
434
- else
435
- echo " - $sp"
436
- fi
437
- done
438
- fi
439
- echo "==> 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)"
440
- if [ "$CLOUD_UPDATE" = "1" ]; then
441
- echo "==> Cloud-update mode: ON (recognize \`hq-symlink:<target>\` flat files as upstream-symlink-equivalent — reconciled as UNCHANGED)"
442
- fi
443
- if [ "$HISTORY_CHECK" = "1" ]; then
444
- echo "==> History gate: ON (skip drift if local matches any past staging blob at that path)"
445
- else
446
- echo "==> History gate: OFF (--no-history-check; every diff rescued)"
447
- fi
448
- if [ "$DO_BACKUP" = "1" ]; then
449
- echo "==> Safety backup: ON -> $BACKUP_ROOT/pre-update-$RUN_TS (retention ${BACKUP_RETENTION_DAYS}d)"
450
- else
451
- echo "==> Safety backup: OFF (--no-backup / HQ_RESCUE_NO_BACKUP=1)"
452
- fi
453
- [ "$DRY_RUN" = "1" ] && echo "==> DRY RUN (no destructive operations will run)"
454
-
455
- if [ "$ASSUME_YES" != "1" ] && [ "$DRY_RUN" != "1" ]; then
456
- _backup_line=" * NO pre-op backup (--no-backup),"
457
- [ "$DO_BACKUP" = "1" ] && _backup_line=" * snapshot the wipe set to $BACKUP_ROOT/pre-update-$RUN_TS first,"
458
- _cloud_line=""
459
- if [ "$CLOUD_UPDATE" = "1" ]; then
460
- _cloud_line="\n * reconcile \`hq-symlink:<target>\` flat files against upstream symlinks (cloud-update mode),"
461
- fi
462
- printf "\nThis will:\n%s\n * rescue user-edited files (vs the last sync) into personal/,\n * route user-edited .agents/.codex/.obsidian/MIGRATION.md into .hq-conflicts/rescue-%s/ for review,\n * silently overwrite AGENTS.md / USER-GUIDE.md / core/policies/_digest.md (regenerable from upstream or reindex — no copy preserved),%b\n * drop reindex-generated symlinks (regenerated by reindex.sh on next Stop hook),\n * leave user-only files (not in upstream) untouched,\n * delete upstream files unchanged since last sync, then unpack %s@%s on top.\nType 'yes' to proceed: " "$_backup_line" "$RUN_TS" "$_cloud_line" "$SOURCE_REPO" "$REF"
463
- read -r confirm
464
- [ "$confirm" = "yes" ] || { echo "Aborted."; exit 4; }
465
- fi
466
-
467
- TMPDIR="$(mktemp -d -t hq-replace-rescue-XXXXXX)"
468
- trap 'rm -rf "$TMPDIR"' EXIT
469
-
470
- # Append-only record of every file the walk MOVES (rescue) or LEAVES in place
471
- # (unchanged). Folded into the snapshot's RECOVERY.md manifest after the run
472
- # so a user can see exactly what changed and restore any single file.
473
- RESCUE_LOG="$TMPDIR/rescue-actions.log"
474
- : > "$RESCUE_LOG"
475
-
476
- # Paths the walk classified UNCHANGED (left in place). Fed to the overlay as an
477
- # --exclude-from so rsync never touches them — preserving their on-disk mtime
478
- # exactly (Layer 1). rsync's -t re-stamps even a checksum-skipped file to the
479
- # source's time, so excluding is the only way to leave the local mtime truly
480
- # untouched.
481
- UNCHANGED_LIST="$TMPDIR/unchanged-paths.txt"
482
- : > "$UNCHANGED_LIST"
483
-
484
- # Build the clone URL. If GH_TOKEN is set in the environment, inject it as
485
- # the basic-auth user so `git clone` can access private staging repos
486
- # without an interactive credential prompt. This is the form the GitHub
487
- # docs recommend for token-based git over HTTPS:
488
- # https://x-access-token:<token>@github.com/<owner>/<repo>.git
489
- # The hq-sync Tauri caller resolves GH_TOKEN via `gh auth token` (same
490
- # path the existing drift-classifier uses) before spawning this script.
491
- if [ -n "${GH_TOKEN:-}" ]; then
492
- CLONE_URL="https://x-access-token:${GH_TOKEN}@github.com/$SOURCE_REPO.git"
493
- CLONE_URL_DISPLAY="https://x-access-token:***@github.com/$SOURCE_REPO.git"
494
- else
495
- CLONE_URL="https://github.com/$SOURCE_REPO.git"
496
- CLONE_URL_DISPLAY="$CLONE_URL"
497
- fi
498
-
499
- echo ""
500
- if [ "$HISTORY_CHECK" = "1" ]; then
501
- # Full commit/tree history (needed for the path → all-time-SHA index) but
502
- # lazy blob fetching. We never need blob contents for the index — only
503
- # blob SHAs from `git log --raw` — so blobs are never lazy-fetched in
504
- # practice. Checkout of HEAD does fetch HEAD-tree blobs, which we need
505
- # anyway for the cmp-based current-state comparison.
506
- echo "==> Cloning $CLONE_URL_DISPLAY @$REF (full history, blob:none filter) ..."
507
- git clone --filter=blob:none "$CLONE_URL" "$TMPDIR/src" >/dev/null 2>&1 || {
508
- echo "error: clone failed" >&2; exit 5
509
- }
510
- (cd "$TMPDIR/src" && git checkout "$REF" >/dev/null 2>&1) || {
511
- echo "error: could not check out ref '$REF' from $SOURCE_REPO" >&2
512
- exit 5
513
- }
514
- else
515
- echo "==> Cloning $CLONE_URL_DISPLAY @$REF (shallow) ..."
516
- git clone --depth 1 --branch "$REF" "$CLONE_URL" "$TMPDIR/src" >/dev/null 2>&1 || {
517
- echo " (shallow branch clone failed; trying full clone + checkout)"
518
- git clone "$CLONE_URL" "$TMPDIR/src" >/dev/null
519
- (cd "$TMPDIR/src" && git checkout "$REF" >/dev/null 2>&1) || {
520
- echo "error: could not check out ref '$REF' from $SOURCE_REPO" >&2
521
- exit 5
522
- }
523
- }
524
- fi
525
-
526
- SRC_SHA="$(cd "$TMPDIR/src" && git rev-parse HEAD)"
527
- echo "==> Source SHA: $SRC_SHA"
528
-
529
- # --- Restore file mtimes from git history (mtime-preservation fix) -----------
530
- #
531
- # A bare `git clone`/checkout stamps every working-tree file with clone-time
532
- # (git stores no per-file mtimes), so the `rsync -a` overlay below would reset
533
- # every rescued file's mtime to "whenever rescue ran" — collapsing all history
534
- # onto one instant and breaking "newer-than" comparisons, mtime-keyed caches,
535
- # and reproducible state across machines. This is the rescue-path analogue of
536
- # the 5.37.0 `hq-mtime` fix on the sync/S3 path, sourced from git commit times
537
- # because the rescue source is a clone, not the vault.
538
- #
539
- # Single history walk, newest-first: the first commit a path appears in is its
540
- # last-modifying commit, and that commit's committer-date becomes the file's
541
- # mtime. Applied via perl's utime() — portable across macOS/Linux, unlike
542
- # `touch -d @epoch` (GNU-only). Symlinks are skipped (utime follows the link
543
- # to its target). Deleted-then-readded paths resolve correctly: newest wins.
544
- restore_mtimes_from_git() {
545
- local src="$1"
546
- if ! command -v perl >/dev/null 2>&1; then
547
- echo " (perl unavailable; skipping mtime restore — overlay mtimes stay clone-time)"
548
- return 0
549
- fi
550
-
551
- # Correct per-file granularity needs full history. A shallow clone knows
552
- # only the tip commit, so every file would collapse to the release time;
553
- # deepen (blob:none keeps it to cheap commit/tree metadata) when shallow.
554
- if [ "$(git -C "$src" rev-parse --is-shallow-repository 2>/dev/null)" = "true" ]; then
555
- echo "==> Deepening clone for per-file mtime history (blob:none) ..."
556
- git -C "$src" fetch --unshallow --filter=blob:none >/dev/null 2>&1 \
557
- || echo " (unshallow failed; mtimes fall back to release tip-commit time)"
558
- fi
559
-
560
- echo "==> Restoring file mtimes from git commit history ..."
561
- git -C "$src" log --no-renames --pretty=format:'C:%ct' --name-only --diff-filter=ACMR 2>/dev/null \
562
- | awk '/^C:/ { ts = substr($0, 3); next } NF { if (!seen[$0]++) print ts "\t" $0 }' \
563
- | SRC="$src" perl -ne '
564
- chomp;
565
- my ($ts, $rel) = split(/\t/, $_, 2);
566
- next unless defined $rel and length $rel;
567
- my $f = "$ENV{SRC}/$rel";
568
- next if -l $f or not -f $f;
569
- utime $ts, $ts, $f;
570
- ' || true
571
- }
572
- restore_mtimes_from_git "$TMPDIR/src"
573
-
574
- # --- Resolve the history floor (last-sync SHA reachable in clone?) ----------
575
- #
576
- # The v0.1.104 algorithm drops the path → all-time-SHA index in favour of
577
- # per-file `git rev-parse <floor>:<rel>` comparisons. The floor is just the
578
- # stamped `replaced_from_source.last_sync_sha`, validated to be reachable
579
- # in this clone. When reachable, BASELINE_MODE=history_floor; otherwise
580
- # BASELINE_MODE=head_compare (cmp local vs upstream HEAD).
581
- #
582
- # Reachability check uses `git cat-file -e` because commits + trees are
583
- # always fully fetched even under --filter=blob:none. The floor SHA's
584
- # tree is what we'll consult per file; blobs at that tree may be lazy
585
- # but `git rev-parse <floor>:<rel>` only needs the tree, not the blob
586
- # contents.
587
- HISTORY_FLOOR=""
588
- BASELINE_MODE="head_compare"
589
- if [ "$HISTORY_CHECK" = "1" ] && [ -n "$PREV_SYNC_SHA" ] && [ "$PREV_SYNC_SOURCE" = "$SOURCE_REPO" ]; then
590
- if (cd "$TMPDIR/src" && git cat-file -e "$PREV_SYNC_SHA" 2>/dev/null); then
591
- HISTORY_FLOOR="$PREV_SYNC_SHA"
592
- BASELINE_MODE="history_floor"
593
- else
594
- echo " prior-sync SHA $PREV_SYNC_SHA not reachable in clone (rebased/dropped?); falling back to head_compare"
595
- fi
596
- fi
597
- if [ "$BASELINE_MODE" = "history_floor" ]; then
598
- echo "==> Baseline: $HISTORY_FLOOR (last-sync floor reachable)"
599
- else
600
- echo "==> Baseline: HEAD compare (no usable last-sync stamp; first-ever run or stamp mismatch)"
601
- fi
602
-
603
- # --- Build wipe/overlay arg sets per mode ------------------------------------
604
- PRUNE_ARGS=()
605
- RSYNC_EXCLUDES=()
606
-
607
- if [ "${#NARROW_PATHS[@]}" -ne 0 ]; then
608
- for n in "${NARROW_PATHS[@]}"; do
609
- RSYNC_EXCLUDES+=( --include="/$n" )
610
- RSYNC_EXCLUDES+=( --include="/$n/***" )
611
- done
612
- RSYNC_EXCLUDES+=( --exclude='/*' )
613
- else
614
- PRUNE_ARGS=( -not -name .git -not -name companies -not -name personal -not -name workspace -not -name repos -not -name .github -not -name .leak-scan -not -name .hq-sync-journal.json -not -name .hq -not -name .hq-conflicts )
615
- for p in "${EXTRA_PRESERVE[@]+"${EXTRA_PRESERVE[@]}"}"; do
616
- PRUNE_ARGS+=( -not -name "$p" )
617
- done
618
- RSYNC_EXCLUDES=(
619
- --exclude=.git
620
- --exclude=personal
621
- --exclude=workspace
622
- --exclude=repos
623
- --exclude=.github
624
- --exclude=.leak-scan
625
- --exclude=.hq-sync-journal.json
626
- --exclude=.hq
627
- --exclude=.hq-conflicts
628
- --include='/companies/'
629
- --include='/companies/_template/***'
630
- --exclude='/companies/*'
631
- )
632
- for p in "${EXTRA_PRESERVE[@]+"${EXTRA_PRESERVE[@]}"}"; do
633
- RSYNC_EXCLUDES+=( --exclude="$p" )
634
- done
635
- fi
636
-
637
- # --- Compute the wipe-set roots (top-level entries that will be deleted) -----
638
- WIPE_TOPLEVEL=()
639
- if [ "${#NARROW_PATHS[@]}" -ne 0 ]; then
640
- for n in "${NARROW_PATHS[@]}"; do
641
- [ -e "$HQ_ROOT/$n" ] && WIPE_TOPLEVEL+=("$n")
642
- done
643
- else
644
- while IFS= read -r line; do
645
- rel="${line#./}"
646
- WIPE_TOPLEVEL+=("$rel")
647
- done < <( cd "$HQ_ROOT" && find . -mindepth 1 -maxdepth 1 "${PRUNE_ARGS[@]}" -print )
648
- # companies/_template carve-out (wiped in default mode)
649
- [ -d "$HQ_ROOT/companies/_template" ] && WIPE_TOPLEVEL+=("companies/_template")
650
- fi
651
-
652
- # --- Drift detection + rescue (v0.1.104 algorithm) --------------------------
653
- #
654
- # For each file under each wipe-set top-level entry, three-way classify
655
- # as USER-ONLY / UNCHANGED / USER-EDIT and act:
656
- #
657
- # USER-ONLY -> leave in place (skip — neither rescued nor deleted).
658
- # The rsync overlay below runs WITHOUT --delete, so any
659
- # file we leave alone survives the operation cleanly.
660
- # UNCHANGED -> rm -f the local copy. Overlay re-creates from source.
661
- # USER-EDIT -> rescue (mv to personal/, or diff-append for CLAUDE.md),
662
- # then the rm is implicit (rescue_one mv'd it).
663
- #
664
- # Counters surfaced in the post-run summary.
665
- COUNT_USER_ONLY=0
666
- COUNT_UNCHANGED=0
667
- COUNT_USER_EDIT=0
668
- COUNT_USER_CONFLICT=0
669
- COUNT_USER_OVERWRITE=0
670
- COUNT_CLAUDE_DIFF_APPEND=0
671
- COUNT_SYMLINK_DROPPED=0
672
- COUNT_CLOUD_SYMLINK_RECONCILED=0
673
- COUNT_DRIFT_RECONCILED=0
674
-
675
- # True if $rel is under any always-preserved subpath. Drift detection
676
- # skips these — they're shuttled out, the wipe+overlay runs, then they're
677
- # restored unchanged. Detecting them as drifts would just move them to
678
- # personal/ and then leave a phantom-restored copy back at their original
679
- # location.
680
- is_under_preserve() {
681
- local rel="$1" sp
682
- for sp in "${PRESERVE_SUBPATHS[@]+"${PRESERVE_SUBPATHS[@]}"}"; do
683
- case "$rel" in
684
- "$sp"|"$sp"/*) return 0 ;;
685
- esac
686
- done
687
- return 1
688
- }
689
-
690
- # Map a USER-EDIT path to its personal/ rescue target. Always lands under
691
- # personal/ — no fallback bucket. Strips `.claude/` or `core/` prefix so
692
- # `.claude/policies/foo.md` -> `personal/policies/foo.md`, etc. Caller
693
- # `mkdir -p "$(dirname dest)"` to ensure the parent exists.
694
- map_rescue_target() {
695
- local rel="$1"
696
- if [ "$rel" = ".claude/CLAUDE.md" ]; then
697
- echo "personal/CLAUDE.md"
698
- return
699
- fi
700
- local rest=""
701
- case "$rel" in
702
- .claude/*) rest="${rel#.claude/}" ;;
703
- core/*) rest="${rel#core/}" ;;
704
- *) rest="$rel" ;;
705
- esac
706
- echo "personal/$rest"
707
- }
708
-
709
- # Diff-append the user's additions in .claude/CLAUDE.md to personal/CLAUDE.md.
710
- # "Additions" are lines present locally but not in baseline (the last-sync
711
- # version, or current HEAD when no floor). Removed/modified lines are NOT
712
- # preserved by this algorithm — users rarely delete from CLAUDE.md and the
713
- # complexity of a true three-way merge isn't worth it in v1. If the user
714
- # did delete or modify lines they'll need to manually reconcile against
715
- # the new upstream version.
716
- #
717
- # Header marker (`<!-- drift-append ... -->`) timestamps the run + records
718
- # the source SHA so a user reading personal/CLAUDE.md can audit when each
719
- # block landed.
720
- diff_append_claude_md() {
721
- local local_file="$HQ_ROOT/.claude/CLAUDE.md"
722
- local personal_file="$HQ_ROOT/personal/CLAUDE.md"
723
- local baseline_file
724
- baseline_file="$(mktemp -t claude-md-baseline.XXXXXX)"
725
-
726
- # Try the floor first, then HEAD. Either provides a "what the user
727
- # started from" snapshot.
728
- if [ "$BASELINE_MODE" = "history_floor" ]; then
729
- (cd "$TMPDIR/src" && git show "$HISTORY_FLOOR:.claude/CLAUDE.md" > "$baseline_file" 2>/dev/null) || :
730
- fi
731
- if [ ! -s "$baseline_file" ] && [ -f "$TMPDIR/src/.claude/CLAUDE.md" ]; then
732
- cp "$TMPDIR/src/.claude/CLAUDE.md" "$baseline_file"
733
- fi
734
-
735
- mkdir -p "$HQ_ROOT/personal"
736
-
737
- if [ ! -s "$baseline_file" ]; then
738
- # No baseline at all — record the whole local file as a drift block
739
- # under a marker noting the absence of a baseline.
740
- rm -f "$baseline_file"
741
- {
742
- [ -s "$personal_file" ] && printf '\n'
743
- printf '<!-- drift-append from .claude/CLAUDE.md @ %s (source %s; no baseline available) -->\n' \
744
- "$RUN_TS" "$SRC_SHA"
745
- cat "$local_file"
746
- } >> "$personal_file"
747
- echo " diff-appended (full local, no baseline): .claude/CLAUDE.md -> personal/CLAUDE.md"
748
- COUNT_CLAUDE_DIFF_APPEND=$((COUNT_CLAUDE_DIFF_APPEND + 1))
749
- return
750
- fi
751
-
752
- # Extract additions via `diff -u`. The sed strips the `+`/`+++` prefixes
753
- # and preserves blank-line additions. Lost: file-header (`+++ local`)
754
- # which we filter explicitly.
755
- local additions_file
756
- additions_file="$(mktemp -t claude-md-additions.XXXXXX)"
757
- diff -u "$baseline_file" "$local_file" 2>/dev/null \
758
- | sed -n '/^+++/!{ /^+/{ s/^+//; p } }' \
759
- > "$additions_file" || :
760
- rm -f "$baseline_file"
761
-
762
- if [ ! -s "$additions_file" ]; then
763
- rm -f "$additions_file"
764
- echo " no user edits to .claude/CLAUDE.md (skipped diff-append)"
765
- return
766
- fi
767
-
768
- {
769
- [ -s "$personal_file" ] && printf '\n'
770
- printf '<!-- drift-append from .claude/CLAUDE.md @ %s (source %s) -->\n' \
771
- "$RUN_TS" "$SRC_SHA"
772
- cat "$additions_file"
773
- } >> "$personal_file"
774
- rm -f "$additions_file"
775
- echo " diff-appended: .claude/CLAUDE.md additions -> personal/CLAUDE.md"
776
- COUNT_CLAUDE_DIFF_APPEND=$((COUNT_CLAUDE_DIFF_APPEND + 1))
777
- }
778
-
779
- # Rescue a single USER-EDIT file. Special-cases .claude/CLAUDE.md to
780
- # diff-append. All other paths land under personal/<rest>, mkdir-ing the
781
- # parent dir as needed. Collision suffix: .drift-<unix-ts>-<pid>.
782
- # Implicitly removes the original local path (CLAUDE.md is rm'd after
783
- # the diff-append; others are mv'd off).
784
- rescue_one() {
785
- local rel="$1"
786
- local local_path="$HQ_ROOT/$rel"
787
- COUNT_USER_EDIT=$((COUNT_USER_EDIT + 1))
788
-
789
- if [ "$rel" = ".claude/CLAUDE.md" ]; then
790
- diff_append_claude_md
791
- rm -f "$local_path"
792
- printf 'rescued\t%s\t-> personal/CLAUDE.md (diff-append)\n' "$rel" >> "$RESCUE_LOG" 2>/dev/null || true
793
- return
794
- fi
795
-
796
- local target
797
- target="$(map_rescue_target "$rel")"
798
- local dest="$HQ_ROOT/$target"
799
- mkdir -p "$(dirname "$dest")"
800
- if [ -e "$dest" ]; then
801
- dest="${dest}.drift-$(date +%s)-$$"
802
- fi
803
- mv "$local_path" "$dest"
804
- echo " rescued: $rel -> ${dest#"$HQ_ROOT/"}"
805
- printf 'rescued\t%s\t-> %s\n' "$rel" "${dest#"$HQ_ROOT/"}" >> "$RESCUE_LOG" 2>/dev/null || true
806
- }
807
-
808
- # Paths whose USER-EDIT outcome lands in .hq-conflicts/ rather than
809
- # personal/. These are runtime-managed dirs (IDE/agent state) and the
810
- # legacy root MIGRATION.md — they aren't user-overlay material the way
811
- # personal/ files are:
812
- #
813
- # .agents/ — agent runtime artifacts (regenerated; not a customization)
814
- # .codex/ — codex runtime/cache (per-machine; not portable as overlay)
815
- # .obsidian/ — vault UI state (workspace.json, graph.json — local-only)
816
- # MIGRATION.md — release migration doc; always re-shipped by overlay,
817
- # a divergent local copy means an upgrade snag worth
818
- # reviewing, not a customization to preserve under
819
- # personal/.
820
- #
821
- # Routing them to .hq-conflicts/rescue-<RUN_TS>/<rel> keeps them quarantined
822
- # under a timestamped bucket the user can inspect and discard, without
823
- # polluting the personal/ overlay tree (where reindex would then try
824
- # to re-mirror them into core/).
825
- is_conflict_class() {
826
- local rel="$1"
827
- case "$rel" in
828
- .agents|.agents/*) return 0 ;;
829
- .codex|.codex/*) return 0 ;;
830
- .obsidian|.obsidian/*) return 0 ;;
831
- MIGRATION.md) return 0 ;;
832
- esac
833
- return 1
834
- }
835
-
836
- # Move a USER-EDIT file into the .hq-conflicts/rescue-<RUN_TS>/ quarantine
837
- # bucket. Parallel to rescue_one() but never touches personal/.
838
- conflict_one() {
839
- local rel="$1"
840
- local local_path="$HQ_ROOT/$rel"
841
- COUNT_USER_CONFLICT=$((COUNT_USER_CONFLICT + 1))
842
-
843
- local target=".hq-conflicts/rescue-$RUN_TS/$rel"
844
- local dest="$HQ_ROOT/$target"
845
- mkdir -p "$(dirname "$dest")"
846
- if [ -e "$dest" ]; then
847
- dest="${dest}.drift-$(date +%s)-$$"
848
- fi
849
- mv "$local_path" "$dest"
850
- echo " conflicted: $rel -> ${dest#"$HQ_ROOT/"}"
851
- printf 'conflicted\t%s\t-> %s\n' "$rel" "${dest#"$HQ_ROOT/"}" >> "$RESCUE_LOG" 2>/dev/null || true
852
- }
853
-
854
- # Paths whose USER-EDIT is SILENTLY OVERWRITTEN by the overlay — no rescue
855
- # to personal/, no quarantine to .hq-conflicts/. The local copy is either
856
- # auto-regenerated by another script or always re-shipped by upstream, so a
857
- # divergent local version is just stale and the upstream version should
858
- # land cleanly. This is the third user-edit outcome (alongside rescue +
859
- # conflict) — semantically equivalent to UNCHANGED, but reached even when
860
- # the bytes differ.
861
- #
862
- # AGENTS.md — root alias; always re-shipped by upstream.
863
- # Locally a symlink to .claude/CLAUDE.md in
864
- # a healthy HQ; vault snapshots that flatten
865
- # to a file always read as drifted.
866
- # USER-GUIDE.md — pre-v15 root copy; always re-shipped.
867
- # core/docs/hq/USER-GUIDE.md — current release doc; always re-shipped.
868
- # core/policies/_digest.md — generated by `core/scripts/build-policy-digest.sh`
869
- # from the union of policy files; rebuilt on
870
- # the next Stop-hook reindex pass.
871
- # Same rationale as core/workers/registry.yaml,
872
- # minus the carve-out (digest is small + cheap
873
- # to regenerate, so just let the overlay win).
874
- is_overwrite_safe() {
875
- local rel="$1"
876
- case "$rel" in
877
- AGENTS.md) return 0 ;;
878
- USER-GUIDE.md) return 0 ;;
879
- core/docs/hq/USER-GUIDE.md) return 0 ;;
880
- core/policies/_digest.md) return 0 ;;
881
- esac
882
- return 1
883
- }
884
-
885
- # Cloud-flattened symlink detection (only meaningful with --cloud-update).
886
- # hq-sync serializes symlinks to S3 as small text files whose entire content
887
- # is `hq-symlink:<target>` (no newline; <target> is the same string the
888
- # symlink would resolve to). A vault snapshot pulled via `aws s3 sync` has
889
- # these in place of symlinks. When --cloud-update is on AND the upstream
890
- # blob at $rel is a symlink AND the local file is an `hq-symlink:<target>`
891
- # marker AND <target> matches the upstream symlink's target, the file is
892
- # semantically equivalent to upstream — not a user edit — so we route it
893
- # through the UNCHANGED branch (delete; the overlay re-lays the real
894
- # symlink). Returns 0 (reconciled / equivalent), 1 (not equivalent / not
895
- # a marker / upstream is not a symlink).
896
- #
897
- # Tradeoffs:
898
- # - Read of local file's first line; cheap for tiny markers.
899
- # - Two git calls per candidate (ls-tree mode check + symlink-target
900
- # fetch); only triggered for files already flagged user-edited under
901
- # --cloud-update, so the hot path is unaffected.
902
- # - Strict: target must match exactly. A drifted marker (`hq-symlink:`
903
- # pointing somewhere the upstream symlink no longer points) is still
904
- # treated as a user-edit and routed normally — never silently
905
- # overwritten.
906
- is_cloud_flattened_symlink_equiv() {
907
- local rel="$1"
908
- local local_path="$2"
909
- [ "$CLOUD_UPDATE" = "1" ] || return 1
910
- [ -f "$local_path" ] || return 1
911
-
912
- # File must START with the magic marker. `hq-symlink:` is 11 bytes; we
913
- # read exactly 11 to compare. Anything else (binary blob, longer doc,
914
- # different prefix) bails out cheaply.
915
- local head_bytes
916
- head_bytes="$(head -c 11 "$local_path" 2>/dev/null || true)"
917
- [ "$head_bytes" = "hq-symlink:" ] || return 1
918
-
919
- # Extract the target the marker points to. Strip the prefix; tolerate
920
- # an optional trailing newline (some serializers add one, some don't).
921
- local local_target
922
- local_target="$(sed -n '1p' "$local_path" 2>/dev/null | sed 's/^hq-symlink://' | tr -d '\n')"
923
- [ -n "$local_target" ] || return 1
924
-
925
- # Upstream blob at $rel must be a symlink (mode 120000). Look up the
926
- # tree entry mode at the floor commit (history_floor mode) or via the
927
- # checked-out source tree (head_compare fallback).
928
- local mode
929
- if [ "$BASELINE_MODE" = "history_floor" ]; then
930
- mode="$(cd "$TMPDIR/src" && git ls-tree "$HISTORY_FLOOR" -- "$rel" 2>/dev/null | awk '{print $1}')"
931
- else
932
- mode="$(cd "$TMPDIR/src" && git ls-tree HEAD -- "$rel" 2>/dev/null | awk '{print $1}')"
933
- fi
934
- [ "$mode" = "120000" ] || return 1
935
-
936
- # Read upstream symlink's target. For a symlink blob, `git show` returns
937
- # the target path as plain text (no newline).
938
- local upstream_target
939
- if [ "$BASELINE_MODE" = "history_floor" ]; then
940
- upstream_target="$(cd "$TMPDIR/src" && git show "$HISTORY_FLOOR:$rel" 2>/dev/null)"
941
- else
942
- upstream_target="$(cd "$TMPDIR/src" && git show "HEAD:$rel" 2>/dev/null)"
943
- fi
944
- [ -n "$upstream_target" ] || return 1
945
-
946
- # Equivalent iff the targets match byte-for-byte.
947
- [ "$local_target" = "$upstream_target" ]
948
- }
949
-
950
- # True when $local_path is a symlink generated by reindex.sh — those
951
- # point into personal/ (e.g. `core/<type>/<entry>` -> `../../personal/...`,
952
- # `.claude/skills/<ns>:<entry>` -> `../../../personal/skills/<entry>/...`,
953
- # `.claude/commands/<ns>/<entry>.md` -> `../../../personal/skills/<entry>/SKILL.md`).
954
- # They are deterministically rebuilt on the next reindex.sh run, so the
955
- # safe move during rescue is to delete rather than preserve — a leftover
956
- # symlink can shadow an overlaid-from-upstream real file at the same path.
957
- is_master_sync_symlink() {
958
- local local_path="$1"
959
- [ -L "$local_path" ] || return 1
960
- local tgt
961
- tgt="$(readlink "$local_path" 2>/dev/null || true)"
962
- case "$tgt" in
963
- */personal/*|personal/*) return 0 ;;
964
- esac
965
- return 1
966
- }
967
-
968
- # Classify + act on one file. The per-file workhorse of the new algorithm.
969
- process_one() {
970
- local rel="$1"
971
- local local_path="$HQ_ROOT/$rel"
972
- local src_path="$TMPDIR/src/$rel"
973
-
974
- # Always-preserved paths bypass drift detection entirely (shuttle owns
975
- # them across the overlay).
976
- if is_under_preserve "$rel"; then
977
- return 0
978
- fi
979
-
980
- # Conflict-resolution artifacts (HQ-Sync renames divergent local files
981
- # to `<name>.conflict-<ts>-<peer>.<ext>`). Never authored, never in
982
- # upstream — rm them so the wipe consumes them rather than dragging
983
- # them to personal/.
984
- case "${rel##*/}" in
985
- *.conflict-*)
986
- if [ "$DRY_RUN" = "1" ]; then
987
- echo " drop conflict artifact: $rel"
988
- else
989
- rm -f "$local_path"
990
- fi
991
- return 0
992
- ;;
993
- esac
994
-
995
- # Script-managed files: core/core.yaml (v14+) and core.yaml (pre-v14
996
- # legacy layout). The script reads the prior stamp at the start of the
997
- # run and rewrites a fresh stamp at the end (see the yq/python block
998
- # below). Subjecting these to drift detection just creates noise: the
999
- # local file always differs from the floor because we wrote a new
1000
- # stamp last run, but that's not a user edit — it's our own output.
1001
- # Skip rescue, just delete; the overlay restores the fresh upstream
1002
- # version, then the stamp step updates it with the new sync point.
1003
- case "$rel" in
1004
- core/core.yaml|core.yaml)
1005
- if [ "$DRY_RUN" = "1" ]; then
1006
- echo " skip script-managed (rewrites at stamp step): $rel"
1007
- else
1008
- rm -f "$local_path"
1009
- fi
1010
- return 0
1011
- ;;
1012
- esac
1013
-
1014
- # Symlinks (mid-tree). reindex.sh deterministically rebuilds the
1015
- # personal/-targeted ones on its next Stop-hook pass, so the safe move
1016
- # here is to delete them — a leftover symlink at a path the overlay
1017
- # also writes to creates an ambiguous "is this the symlink or the
1018
- # overlaid file?" state on disk. Non-reindex symlinks (user-created,
1019
- # absolute targets, or targets outside personal/) are left alone.
1020
- if [ -L "$local_path" ]; then
1021
- if is_master_sync_symlink "$local_path"; then
1022
- COUNT_SYMLINK_DROPPED=$((COUNT_SYMLINK_DROPPED + 1))
1023
- if [ "$DRY_RUN" = "1" ]; then
1024
- echo " drop reindex symlink: $rel -> $(readlink "$local_path" 2>/dev/null || true)"
1025
- else
1026
- rm -f "$local_path"
1027
- printf 'symlink-dropped\t%s\t(reindex regenerable)\n' "$rel" >> "$RESCUE_LOG" 2>/dev/null || true
1028
- fi
1029
- fi
1030
- return 0
1031
- fi
1032
-
1033
- # Is path in upstream HEAD?
1034
- local in_head=0
1035
- [ -e "$src_path" ] && in_head=1
1036
-
1037
- # Is path in last-sync floor (if we have one)?
1038
- local in_floor=0
1039
- if [ "$BASELINE_MODE" = "history_floor" ]; then
1040
- if (cd "$TMPDIR/src" && git cat-file -e "$HISTORY_FLOOR:$rel" 2>/dev/null); then
1041
- in_floor=1
1042
- fi
1043
- fi
1044
-
1045
- # USER-ONLY: path is unknown to upstream (HEAD AND floor both lack it).
1046
- # The user created this — leave it alone. The overlay (no --delete)
1047
- # doesn't touch files outside its source manifest, so the file survives.
1048
- if [ "$in_head" = "0" ] && [ "$in_floor" = "0" ]; then
1049
- COUNT_USER_ONLY=$((COUNT_USER_ONLY + 1))
1050
- if [ "$DRY_RUN" = "1" ]; then
1051
- echo " user-only (leave in place): $rel"
1052
- fi
1053
- return 0
1054
- fi
1055
-
1056
- # Path is/was in upstream. Determine if user edited it.
1057
- local user_edited=0
1058
- if [ "$BASELINE_MODE" = "history_floor" ] && [ "$in_floor" = "1" ]; then
1059
- # Compare local blob SHA against the blob at floor:rel.
1060
- local local_sha baseline_sha
1061
- local_sha="$(git hash-object "$local_path" 2>/dev/null || true)"
1062
- baseline_sha="$(cd "$TMPDIR/src" && git rev-parse "$HISTORY_FLOOR:$rel" 2>/dev/null || true)"
1063
- if [ -z "$local_sha" ] || [ -z "$baseline_sha" ] || [ "$local_sha" != "$baseline_sha" ]; then
1064
- user_edited=1
1065
- fi
1066
- elif [ "$in_head" = "1" ]; then
1067
- # head_compare fallback: cmp against current upstream copy.
1068
- cmp -s "$local_path" "$src_path" || user_edited=1
1069
- else
1070
- # in_floor=1 but in_head=0: upstream removed the file since last
1071
- # sync. Without a floor compare we can't tell if the user touched
1072
- # it; conservative default is to treat as USER-EDIT and rescue.
1073
- user_edited=1
1074
- fi
1075
-
1076
- # Cloud-update mode: before routing as a user-edit, check if the local
1077
- # bytes are actually the cloud-flattened serialization of the same
1078
- # upstream symlink. If so, this isn't an edit — it's a transport
1079
- # artifact. Reclassify as UNCHANGED so the overlay rewrites the real
1080
- # symlink without polluting personal/ or .hq-conflicts/.
1081
- if [ "$user_edited" = "1" ] && is_cloud_flattened_symlink_equiv "$rel" "$local_path"; then
1082
- COUNT_CLOUD_SYMLINK_RECONCILED=$((COUNT_CLOUD_SYMLINK_RECONCILED + 1))
1083
- if [ "$DRY_RUN" = "1" ]; then
1084
- echo " cloud-symlink reconciled (unchanged): $rel (hq-symlink: marker matches upstream target)"
1085
- else
1086
- rm -f "$local_path"
1087
- printf 'cloud-symlink-reconciled\t%s\t(hq-symlink: marker matches upstream target; overlay re-lays symlink)\n' "$rel" >> "$RESCUE_LOG" 2>/dev/null || true
1088
- fi
1089
- return 0
1090
- fi
1091
-
1092
- # Convergence guard: in history_floor mode a file is flagged USER-EDIT when
1093
- # its blob differs from the *old* baseline (the floor). But HQ's own Stop
1094
- # hooks (reindex symlinking personal/->core/, autocommit, registry/
1095
- # _digest/core.yaml regeneration) rewrite scaffold files every session, so a
1096
- # file routinely drifts from the floor yet lands byte-for-byte identical to
1097
- # the *new* upstream HEAD this overlay is about to write. Rescuing it into
1098
- # personal/ then just deposits a redundant `.drift-<ts>-<pid>` copy of bytes
1099
- # the overlay would have written anyway (this is what produced the 32-file
1100
- # `.drift-*` litter on a live tree). If local already equals upstream HEAD
1101
- # there is nothing to preserve, so reclassify as UNCHANGED (delete; the
1102
- # overlay re-writes the identical bytes). Parallels the cloud-symlink
1103
- # reconcile above and the sync-side convergence guard. Gated on in_head so an
1104
- # upstream-removed file (in_head=0) is still rescued; a no-op in head_compare
1105
- # mode, where `cmp` already settled user_edited against upstream HEAD.
1106
- if [ "$user_edited" = "1" ] && [ "$in_head" = "1" ] && cmp -s "$local_path" "$src_path"; then
1107
- COUNT_DRIFT_RECONCILED=$((COUNT_DRIFT_RECONCILED + 1))
1108
- if [ "$DRY_RUN" = "1" ]; then
1109
- echo " drift reconciled (identical to upstream HEAD; no rescue): $rel"
1110
- else
1111
- rm -f "$local_path"
1112
- printf 'drift-reconciled\t%s\t(identical to upstream HEAD; drifted from floor only — no personal/ copy)\n' "$rel" >> "$RESCUE_LOG" 2>/dev/null || true
1113
- fi
1114
- return 0
1115
- fi
1116
-
1117
- if [ "$user_edited" = "1" ]; then
1118
- if [ "$DRY_RUN" = "1" ]; then
1119
- if [ "$rel" = ".claude/CLAUDE.md" ]; then
1120
- echo " user-edit (diff-append): $rel -> personal/CLAUDE.md"
1121
- COUNT_USER_EDIT=$((COUNT_USER_EDIT + 1))
1122
- elif is_overwrite_safe "$rel"; then
1123
- echo " user-edit (overwrite-safe): $rel -> upstream wins (no copy preserved)"
1124
- COUNT_USER_OVERWRITE=$((COUNT_USER_OVERWRITE + 1))
1125
- elif is_conflict_class "$rel"; then
1126
- echo " user-edit (conflict): $rel -> .hq-conflicts/rescue-$RUN_TS/$rel"
1127
- COUNT_USER_CONFLICT=$((COUNT_USER_CONFLICT + 1))
1128
- else
1129
- echo " user-edit (rescue): $rel -> $(map_rescue_target "$rel")"
1130
- COUNT_USER_EDIT=$((COUNT_USER_EDIT + 1))
1131
- fi
1132
- else
1133
- if [ "$rel" = ".claude/CLAUDE.md" ]; then
1134
- rescue_one "$rel"
1135
- elif is_overwrite_safe "$rel"; then
1136
- rm -f "$local_path"
1137
- COUNT_USER_OVERWRITE=$((COUNT_USER_OVERWRITE + 1))
1138
- printf 'overwritten\t%s\t(overwrite-safe; upstream wins, no copy preserved)\n' "$rel" >> "$RESCUE_LOG" 2>/dev/null || true
1139
- elif is_conflict_class "$rel"; then
1140
- conflict_one "$rel"
1141
- else
1142
- rescue_one "$rel"
1143
- fi
1144
- fi
1145
- else
1146
- # user_edited=0: local matches the floor baseline (user didn't touch it).
1147
- # That does NOT mean it matches upstream HEAD — upstream may have advanced
1148
- # or removed it. Split on byte-equality to the HEAD version the overlay
1149
- # would write ($TMPDIR/src is checked out at HEAD):
1150
- # identical to HEAD -> overlay is a content no-op; leave it untouched and
1151
- # protect it from the overlay so its mtime is preserved (Layer 1).
1152
- # differs from HEAD -> keep the original delete semantics: rm now; the
1153
- # no-delete overlay re-lays it from source (with a git-commit mtime)
1154
- # when still upstream, or leaves it gone when upstream removed it.
1155
- COUNT_UNCHANGED=$((COUNT_UNCHANGED + 1))
1156
- if cmp -s "$local_path" "$TMPDIR/src/$rel" 2>/dev/null; then
1157
- if [ "$DRY_RUN" = "1" ]; then
1158
- echo " unchanged (preserved in place): $rel"
1159
- else
1160
- printf '/%s\n' "$rel" >> "$UNCHANGED_LIST"
1161
- printf 'unchanged\t%s\t(identical to upstream; left in place, mtime preserved)\n' "$rel" >> "$RESCUE_LOG" 2>/dev/null || true
1162
- fi
1163
- else
1164
- if [ "$DRY_RUN" = "1" ]; then
1165
- echo " unchanged (delete + replace): $rel"
1166
- else
1167
- rm -f "$local_path"
1168
- printf 'deleted\t%s\t(unchanged vs baseline; re-laid by overlay if still upstream)\n' "$rel" >> "$RESCUE_LOG" 2>/dev/null || true
1169
- fi
1170
- fi
1171
- fi
1172
- }
1173
-
1174
- # Walk a wipe-set root, processing each file. companies/_template is
1175
- # special-cased: always wholesale-replace (it's a template, never
1176
- # user-authored), so we just rm it here and let the overlay re-create.
1177
- walk_and_process() {
1178
- local root_rel="$1"
1179
- local root_abs="$HQ_ROOT/$root_rel"
1180
- [ -e "$root_abs" ] || [ -L "$root_abs" ] || return 0
1181
-
1182
- # companies/_template — wholesale-replace.
1183
- if [ "$root_rel" = "companies/_template" ]; then
1184
- if [ "$DRY_RUN" = "1" ]; then
1185
- echo " wholesale-replace: companies/_template (template carve-out)"
1186
- else
1187
- rm -rf "$HQ_ROOT/companies/_template"
1188
- fi
1189
- return 0
1190
- fi
1191
-
1192
- # Top-level symlinks (AGENTS.md, MIGRATION.md, etc.). Most are static
1193
- # convenience aliases the overlay overwrites cleanly (rsync -a preserves
1194
- # link semantics from source). Master-sync personal-overlay symlinks at
1195
- # the top level — rare but possible — are dropped here so the overlay
1196
- # can land a real file at the same path without ambiguity; reindex
1197
- # regenerates the link on its next Stop-hook pass.
1198
- if [ -L "$root_abs" ]; then
1199
- if is_master_sync_symlink "$root_abs"; then
1200
- COUNT_SYMLINK_DROPPED=$((COUNT_SYMLINK_DROPPED + 1))
1201
- if [ "$DRY_RUN" = "1" ]; then
1202
- echo " drop reindex symlink: $root_rel -> $(readlink "$root_abs" 2>/dev/null || true)"
1203
- else
1204
- rm -f "$root_abs"
1205
- printf 'symlink-dropped\t%s\t(reindex regenerable)\n' "$root_rel" >> "$RESCUE_LOG" 2>/dev/null || true
1206
- fi
1207
- fi
1208
- return 0
1209
- fi
1210
-
1211
- # Top-level regular file.
1212
- if [ -f "$root_abs" ]; then
1213
- process_one "$root_rel"
1214
- return
1215
- fi
1216
-
1217
- # Top-level directory: walk recursively, pruning node_modules + nested
1218
- # .git (v0.1.103 perf + correctness guard). Symlinks are surfaced via
1219
- # `-type l` so reindex-generated links (targets resolving into
1220
- # personal/) can be dropped — see process_one + is_master_sync_symlink.
1221
- # Under -P (the default), find does not descend INTO directory symlinks,
1222
- # which keeps the walk bounded even when those links exist.
1223
- while IFS= read -r -d '' f; do
1224
- local rel="${f#"$HQ_ROOT/"}"
1225
- process_one "$rel"
1226
- done < <(find "$root_abs" \( -type d \( -name node_modules -o -name .git \) -prune \) -o \( \( -type f -o -type l \) -print0 \))
1227
- }
1228
-
1229
- # Prune pre-update-* snapshots older than the retention window. Best-effort:
1230
- # a failure here never aborts the run (the snapshot we just took is what
1231
- # matters). This is the auto-cleanup MIGRATION.md promised but never shipped.
1232
- prune_old_backups() {
1233
- [ -d "$BACKUP_ROOT" ] || return 0
1234
- case "$BACKUP_RETENTION_DAYS" in ''|*[!0-9]*) return 0 ;; esac
1235
- [ "$BACKUP_RETENTION_DAYS" -gt 0 ] || return 0
1236
- find "$BACKUP_ROOT" -maxdepth 1 -type d -name 'pre-update-*' -mtime +"$BACKUP_RETENTION_DAYS" -print 2>/dev/null \
1237
- | while IFS= read -r old; do
1238
- echo " pruned old snapshot (> ${BACKUP_RETENTION_DAYS}d): ${old##*/}"
1239
- rm -rf "$old" 2>/dev/null || true
1240
- done
1241
- }
1242
-
1243
- # --- Pre-operation safety snapshot (BEFORE any destructive op) --------------
1244
- # Copy every wipe-set top-level entry to ~/.hq/backups/pre-update-<ts>/ so a
1245
- # misclassification or interrupted rescue is fully recoverable. Only the wipe
1246
- # set is snapshotted — repos/ (often ~GBs), personal/, companies/, workspace/
1247
- # are preserved by the overlay and never at risk, so copying them would be
1248
- # wasteful and slow. node_modules/ + nested .git/ are excluded for the same
1249
- # reason. Skipped in dry-run (nothing gets destroyed) and when --no-backup.
1250
- if [ "$DO_BACKUP" = "1" ] && [ "$DRY_RUN" != "1" ] && [ "${#WIPE_TOPLEVEL[@]}" -ne 0 ]; then
1251
- BACKUP_DIR="$BACKUP_ROOT/pre-update-$RUN_TS"
1252
- echo ""
1253
- echo "==> Safety snapshot -> $BACKUP_DIR"
1254
- mkdir -p "$BACKUP_DIR"
1255
- for root_rel in "${WIPE_TOPLEVEL[@]}"; do
1256
- src_abs="$HQ_ROOT/$root_rel"
1257
- [ -e "$src_abs" ] || continue
1258
- parent_rel="$(dirname "$root_rel")"
1259
- mkdir -p "$BACKUP_DIR/$parent_rel"
1260
- if command -v rsync >/dev/null 2>&1; then
1261
- rsync -a --exclude='node_modules/' --exclude='.git/' "$src_abs" "$BACKUP_DIR/$parent_rel/" 2>/dev/null \
1262
- || cp -a "$src_abs" "$BACKUP_DIR/$parent_rel/" 2>/dev/null || true
1263
- else
1264
- cp -a "$src_abs" "$BACKUP_DIR/$parent_rel/" 2>/dev/null || true
1265
- fi
1266
- done
1267
- echo " snapshot complete (restore any file: cp \"$BACKUP_DIR/<relpath>\" \"$HQ_ROOT/<relpath>\")"
1268
- fi
1269
-
1270
- echo ""
1271
- if [ "${#WIPE_TOPLEVEL[@]}" -eq 0 ]; then
1272
- echo "==> Wipe set is empty; nothing to process or overlay."
1273
- else
1274
- echo "==> Walking wipe set, classifying vs. $SOURCE_REPO@$REF ..."
1275
- for root_rel in "${WIPE_TOPLEVEL[@]}"; do
1276
- walk_and_process "$root_rel"
1277
- done
1278
- fi
1279
-
1280
- if [ "$DRY_RUN" = "1" ]; then
1281
- echo ""
1282
- echo "==> DRY RUN classification summary:"
1283
- echo " user-only (leave in place): $COUNT_USER_ONLY files"
1284
- echo " unchanged (preserved/re-laid): $COUNT_UNCHANGED files"
1285
- echo " user-edit (rescue / diff-append): $COUNT_USER_EDIT files"
1286
- echo " user-edit (conflict quarantine): $COUNT_USER_CONFLICT files"
1287
- echo " user-edit (overwrite-safe): $COUNT_USER_OVERWRITE files"
1288
- echo " cloud-symlink reconciled: $COUNT_CLOUD_SYMLINK_RECONCILED files"
1289
- echo " drift reconciled (== upstream): $COUNT_DRIFT_RECONCILED files"
1290
- echo " reindex symlinks dropped: $COUNT_SYMLINK_DROPPED entries"
1291
- if [ "$COUNT_CLAUDE_DIFF_APPEND" -gt 0 ]; then
1292
- echo " of which .claude/CLAUDE.md would diff-append to personal/CLAUDE.md"
1293
- fi
1294
- if [ "$COUNT_USER_CONFLICT" -gt 0 ]; then
1295
- echo " conflict bucket: .hq-conflicts/rescue-$RUN_TS/"
1296
- fi
1297
- if [ "${#PRESERVE_SUBPATHS[@]}" -ne 0 ]; then
1298
- echo ""
1299
- echo "==> DRY RUN: would back up + restore these sub-paths across the overlay:"
1300
- for sp in "${PRESERVE_SUBPATHS[@]}"; do
1301
- if [ -e "$HQ_ROOT/$sp" ]; then
1302
- echo " ~ ./$sp (present — will survive)"
1303
- else
1304
- echo " ~ ./$sp (not present locally — no-op)"
1305
- fi
1306
- done
1307
- fi
1308
- echo ""
1309
- echo "==> DRY RUN: would copy these top-level entries from source on overlay:"
1310
- if [ "${#NARROW_PATHS[@]}" -ne 0 ]; then
1311
- for n in "${NARROW_PATHS[@]}"; do
1312
- if [ -e "$TMPDIR/src/$n" ]; then
1313
- echo " + ./$n"
1314
- fi
1315
- done
1316
- else
1317
- ( cd "$TMPDIR/src" && find . -mindepth 1 -maxdepth 1 "${PRUNE_ARGS[@]}" | sed 's|^\./| + |' )
1318
- if [ -d "$TMPDIR/src/companies/_template" ]; then
1319
- echo " + ./companies/_template (carve-out from $SOURCE_REPO@$REF)"
1320
- fi
1321
- fi
1322
- echo ""
1323
- echo "==> DRY RUN complete. No filesystem changes made."
1324
- exit 0
1325
- fi
1326
-
1327
- # --- Back up preserve-subpaths to a mktemp shuttle ---------------------------
1328
- SHUTTLE="$TMPDIR/preserve"
1329
- mkdir -p "$SHUTTLE"
1330
- : > "$TMPDIR/preserve.map"
1331
- shuttle_id=0
1332
- for sp in "${PRESERVE_SUBPATHS[@]+"${PRESERVE_SUBPATHS[@]}"}"; do
1333
- src="$HQ_ROOT/$sp"
1334
- if [ -e "$src" ]; then
1335
- shuttle_id=$((shuttle_id + 1))
1336
- cp -a "$src" "$SHUTTLE/$shuttle_id"
1337
- printf '%s\t%s\n' "$shuttle_id" "$sp" >> "$TMPDIR/preserve.map"
1338
- echo "==> Backed up $sp -> shuttle/$shuttle_id"
1339
- fi
1340
- done
1341
-
1342
- echo ""
1343
- echo "==> Walk complete (user-only: $COUNT_USER_ONLY, unchanged: $COUNT_UNCHANGED, user-edit-rescued: $COUNT_USER_EDIT, conflict-quarantined: $COUNT_USER_CONFLICT, overwrite-safe: $COUNT_USER_OVERWRITE, cloud-symlink-reconciled: $COUNT_CLOUD_SYMLINK_RECONCILED, symlinks-dropped: $COUNT_SYMLINK_DROPPED)"
1344
- # Note: v0.1.103-and-earlier did a wholesale `rm -rf` of every wipe-set
1345
- # top-level entry here. The new walk_and_process does per-file deletion
1346
- # (only files that exist in upstream + are unchanged are deleted; user-only
1347
- # files survive). The wholesale wipe step is intentionally gone — keep
1348
- # the no-delete semantics of the rsync overlay below to preserve files
1349
- # the walk left alone.
1350
-
1351
- echo "==> Overlaying source onto HQ root ..."
1352
- # Protect UNCHANGED (identical-to-HEAD) files: feed their paths as an
1353
- # --exclude-from (FIRST in the filter chain, so it wins) so rsync never touches
1354
- # them — -a's -t would otherwise re-stamp their mtime to the source's time, and
1355
- # excluding is the only way to leave the local mtime truly untouched (Layer 1).
1356
- # Every other file under the wipe set was already rm'd by the walk, so the
1357
- # overlay writes into an absent slot and -a carries the git-commit mtimes
1358
- # restore_mtimes_from_git stamped onto the source.
1359
- OVERLAY_PROTECT=()
1360
- [ -s "$UNCHANGED_LIST" ] && OVERLAY_PROTECT=( --exclude-from="$UNCHANGED_LIST" )
1361
- rsync -a "${OVERLAY_PROTECT[@]+"${OVERLAY_PROTECT[@]}"}" "${RSYNC_EXCLUDES[@]}" "$TMPDIR/src/" "$HQ_ROOT/"
1362
-
1363
- if [ -s "$TMPDIR/preserve.map" ]; then
1364
- echo "==> Restoring preserved sub-paths ..."
1365
- while IFS=$'\t' read -r id relpath; do
1366
- dest="$HQ_ROOT/$relpath"
1367
- mkdir -p "$(dirname "$dest")"
1368
- if [ -d "$SHUTTLE/$id" ]; then
1369
- rm -rf "$dest"
1370
- cp -a "$SHUTTLE/$id" "$dest"
1371
- else
1372
- cp -a "$SHUTTLE/$id" "$dest"
1373
- fi
1374
- echo " restored $relpath"
1375
- done < "$TMPDIR/preserve.map"
1376
- fi
1377
-
1378
- # --- Stamp sync-point provenance into core/core.yaml ------------------------
1379
- # Default mode only — in narrow mode we only overlaid a subset of top-level
1380
- # entries, so claiming "the HQ root is at <sha>" would be misleading. yq
1381
- # is preferred (clean in-place edit, preserves comments mediocrely but well
1382
- # enough). Falls back to a python3+PyYAML one-liner if yq is missing.
1383
- if [ "${#NARROW_PATHS[@]}" -eq 0 ] && [ -f "$HQ_ROOT/core/core.yaml" ]; then
1384
- NOW_UTC="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
1385
- if command -v yq >/dev/null 2>&1; then
1386
- # Write the new key AND delete the old (pre-v0.1.104) one in the same
1387
- # pass so post-migration the file holds only the new stamp. `del(...)`
1388
- # is a no-op when the key is absent, so this is safe for fresh installs.
1389
- SHA="$SRC_SHA" SOURCE="$SOURCE_REPO" THE_REF="$REF" AT="$NOW_UTC" \
1390
- yq -i '
1391
- .replaced_from_source.source = strenv(SOURCE) |
1392
- .replaced_from_source.ref = strenv(THE_REF) |
1393
- .replaced_from_source.last_sync_sha = strenv(SHA) |
1394
- .replaced_from_source.last_sync_at = strenv(AT) |
1395
- del(.replaced_from_staging)
1396
- ' "$HQ_ROOT/core/core.yaml"
1397
- echo "==> Stamped core/core.yaml: replaced_from_source.last_sync_sha=$SRC_SHA"
1398
- elif command -v python3 >/dev/null 2>&1 && python3 -c 'import yaml' >/dev/null 2>&1; then
1399
- SHA="$SRC_SHA" SOURCE="$SOURCE_REPO" THE_REF="$REF" AT="$NOW_UTC" CORE="$HQ_ROOT/core/core.yaml" \
1400
- python3 -c '
1401
- import os, yaml
1402
- path = os.environ["CORE"]
1403
- try:
1404
- with open(path) as f:
1405
- d = yaml.safe_load(f) or {}
1406
- except FileNotFoundError:
1407
- d = {}
1408
- d["replaced_from_source"] = {
1409
- "source": os.environ["SOURCE"],
1410
- "ref": os.environ["THE_REF"],
1411
- "last_sync_sha": os.environ["SHA"],
1412
- "last_sync_at": os.environ["AT"],
1413
- }
1414
- # Drop the pre-v0.1.104 key on migration. .pop with a default is a no-op
1415
- # when the key is absent.
1416
- d.pop("replaced_from_staging", None)
1417
- with open(path, "w") as f:
1418
- yaml.safe_dump(d, f, default_flow_style=False, sort_keys=False)
1419
- '
1420
- echo "==> Stamped core/core.yaml: replaced_from_source.last_sync_sha=$SRC_SHA"
1421
- else
1422
- echo " WARN: neither yq nor python3+PyYAML available — skipping core/core.yaml stamp" >&2
1423
- fi
1424
- fi
1425
-
1426
- echo ""
1427
- echo "==> File count summary:"
1428
- for root_rel in "${WIPE_TOPLEVEL[@]}"; do
1429
- if [ -e "$HQ_ROOT/$root_rel" ]; then
1430
- n_files="$(find "$HQ_ROOT/$root_rel" -type f 2>/dev/null | wc -l | tr -d ' ')"
1431
- echo " $root_rel: $n_files files"
1432
- fi
1433
- done
1434
- echo ""
1435
- echo "==> Classification:"
1436
- echo " user-only (left in place): $COUNT_USER_ONLY files"
1437
- echo " unchanged (preserved/re-laid): $COUNT_UNCHANGED files"
1438
- echo " user-edits (rescued): $COUNT_USER_EDIT files"
1439
- echo " user-edits (conflict quarantine): $COUNT_USER_CONFLICT files"
1440
- echo " user-edits (overwrite-safe): $COUNT_USER_OVERWRITE files"
1441
- echo " cloud-symlink reconciled: $COUNT_CLOUD_SYMLINK_RECONCILED files"
1442
- echo " drift reconciled (== upstream): $COUNT_DRIFT_RECONCILED files"
1443
- echo " reindex symlinks dropped: $COUNT_SYMLINK_DROPPED entries"
1444
- if [ "$COUNT_CLAUDE_DIFF_APPEND" -gt 0 ]; then
1445
- echo " of which .claude/CLAUDE.md diff-appended to personal/CLAUDE.md"
1446
- fi
1447
- if [ "$COUNT_USER_CONFLICT" -gt 0 ]; then
1448
- echo " conflict bucket: .hq-conflicts/rescue-$RUN_TS/"
1449
- fi
1450
- if [ "$BASELINE_MODE" = "history_floor" ]; then
1451
- echo " baseline: last-sync floor $HISTORY_FLOOR"
1452
- else
1453
- echo " baseline: upstream HEAD (no stamp; first run / floor unreachable)"
1454
- fi
1455
-
1456
- # --- Write recovery manifest into the snapshot + prune old snapshots --------
1457
- if [ "$DO_BACKUP" = "1" ] && [ -n "$BACKUP_DIR" ] && [ -d "$BACKUP_DIR" ]; then
1458
- {
1459
- echo "# HQ pre-update safety snapshot"
1460
- echo ""
1461
- echo "Created: $RUN_TS"
1462
- echo "HQ root: $HQ_ROOT"
1463
- echo "Source: $SOURCE_REPO@$REF ($SRC_SHA)"
1464
- if [ "$BASELINE_MODE" = "history_floor" ]; then
1465
- echo "Baseline: history_floor ($HISTORY_FLOOR)"
1466
- else
1467
- echo "Baseline: head_compare (no last-sync stamp; first run or floor unreachable)"
1468
- fi
1469
- echo ""
1470
- echo "## What the rescue did"
1471
- echo " user-only (left in place): $COUNT_USER_ONLY"
1472
- echo " unchanged (preserved/re-laid): $COUNT_UNCHANGED"
1473
- echo " user-edit (rescued into personal/): $COUNT_USER_EDIT"
1474
- echo " user-edit (conflict quarantine): $COUNT_USER_CONFLICT"
1475
- echo " user-edit (overwrite-safe): $COUNT_USER_OVERWRITE"
1476
- echo " cloud-symlink reconciled: $COUNT_CLOUD_SYMLINK_RECONCILED"
1477
- echo " drift reconciled (== upstream HEAD): $COUNT_DRIFT_RECONCILED"
1478
- echo " reindex symlinks dropped: $COUNT_SYMLINK_DROPPED"
1479
- if [ "$COUNT_USER_CONFLICT" -gt 0 ]; then
1480
- echo " conflict bucket: .hq-conflicts/rescue-$RUN_TS/"
1481
- fi
1482
- echo ""
1483
- echo "## Moved / deleted files (tab-separated: action, path, detail)"
1484
- if [ -s "$RESCUE_LOG" ]; then
1485
- cat "$RESCUE_LOG"
1486
- else
1487
- echo " (no files were moved or deleted)"
1488
- fi
1489
- echo ""
1490
- echo "## Restore"
1491
- echo "This directory holds the wipe set exactly as it was before the update."
1492
- echo "Restore a single file:"
1493
- echo " cp \"$BACKUP_DIR/<relpath>\" \"$HQ_ROOT/<relpath>\""
1494
- echo "Restore everything (overwrites current scaffold — use with care):"
1495
- echo " rsync -a \"$BACKUP_DIR/\" \"$HQ_ROOT/\""
1496
- echo ""
1497
- echo "Auto-pruned after $BACKUP_RETENTION_DAYS days (HQ_BACKUP_RETENTION_DAYS)."
1498
- } > "$BACKUP_DIR/RECOVERY.md" 2>/dev/null || true
1499
- echo ""
1500
- echo "==> Pre-update snapshot + recovery manifest: $BACKUP_DIR"
1501
- prune_old_backups
1502
- fi
1503
-
1504
- echo ""
1505
- echo "==> Done. Source: $SOURCE_REPO@$REF ($SRC_SHA)"
1506
- echo " User-edited files were rescued under personal/ (see scan output above)."
1507
- echo " User-only files (created by you, unknown to upstream) were left untouched."
1508
- # Snapshot path footer: emit only when a backup was actually taken AND its
1509
- # resolved dir is non-empty. Wrapped in `if/then/fi` (not a `[...] && [...]
1510
- # && echo` chain) so the script's final exit status is independent of the
1511
- # branch taken — a `[ DO_BACKUP = 1 ]` test returning false in a `&&` chain
1512
- # became this script's exit code under `set -e`, which kills any parent
1513
- # wrapper script (e.g. `apps/hq-cloud/scripts/vault-rescue.sh`) that runs
1514
- # us under `set -euo pipefail`.
1515
- if [ "$DO_BACKUP" = "1" ] && [ -n "$BACKUP_DIR" ]; then
1516
- echo " A full pre-update snapshot is at $BACKUP_DIR (RECOVERY.md explains restore)."
1517
- fi
1518
-
1519
- # Always succeed if we reached the end. Without this, a future maintainer
1520
- # could add another conditional trailing line and silently regress the
1521
- # wrapper-script-kill bug. Explicit > implicit for the script's contract.
1522
- exit 0