@indigoai-us/hq-cloud 6.1.0 → 6.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +18 -0
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/cli/index.d.ts +2 -2
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +2 -2
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/reindex.d.ts +4 -11
- package/dist/cli/reindex.d.ts.map +1 -1
- package/dist/cli/reindex.js +336 -30
- package/dist/cli/reindex.js.map +1 -1
- package/dist/cli/reindex.test.d.ts +3 -3
- package/dist/cli/reindex.test.js +36 -11
- package/dist/cli/reindex.test.js.map +1 -1
- package/dist/cli/rescue-core.d.ts +36 -0
- package/dist/cli/rescue-core.d.ts.map +1 -0
- package/dist/cli/rescue-core.js +1589 -0
- package/dist/cli/rescue-core.js.map +1 -0
- package/dist/cli/rescue-drift-reconcile.test.js +33 -10
- package/dist/cli/rescue-drift-reconcile.test.js.map +1 -1
- package/dist/cli/rescue-journal-reconcile.test.d.ts +2 -0
- package/dist/cli/rescue-journal-reconcile.test.d.ts.map +1 -0
- package/dist/cli/rescue-journal-reconcile.test.js +135 -0
- package/dist/cli/rescue-journal-reconcile.test.js.map +1 -0
- package/dist/cli/rescue-mtime-preserve.test.js +36 -12
- package/dist/cli/rescue-mtime-preserve.test.js.map +1 -1
- package/dist/cli/rescue.d.ts +4 -10
- package/dist/cli/rescue.d.ts.map +1 -1
- package/dist/cli/rescue.js +14 -37
- package/dist/cli/rescue.js.map +1 -1
- package/dist/cli/rescue.reindex.test.js +9 -8
- package/dist/cli/rescue.reindex.test.js.map +1 -1
- package/dist/cli/rescue.test.js +1 -10
- package/dist/cli/rescue.test.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/lib/conflict-index.d.ts +40 -0
- package/dist/lib/conflict-index.d.ts.map +1 -1
- package/dist/lib/conflict-index.js +121 -0
- package/dist/lib/conflict-index.js.map +1 -1
- package/dist/lib/conflict.test.js +145 -1
- package/dist/lib/conflict.test.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner.ts +18 -0
- package/src/cli/index.ts +2 -2
- package/src/cli/reindex.test.ts +45 -12
- package/src/cli/reindex.ts +345 -36
- package/src/cli/rescue-core.ts +1719 -0
- package/src/cli/rescue-drift-reconcile.test.ts +33 -12
- package/src/cli/rescue-journal-reconcile.test.ts +156 -0
- package/src/cli/rescue-mtime-preserve.test.ts +36 -15
- package/src/cli/rescue.reindex.test.ts +9 -8
- package/src/cli/rescue.test.ts +1 -11
- package/src/cli/rescue.ts +15 -40
- package/src/index.ts +2 -2
- package/src/lib/conflict-index.ts +146 -0
- package/src/lib/conflict.test.ts +171 -0
- package/scripts/reindex.sh +0 -318
- package/scripts/replace-rescue.sh +0 -1522
|
@@ -1,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
|