-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsync-kernel.sh
More file actions
executable file
·383 lines (350 loc) · 15.8 KB
/
Copy pathsync-kernel.sh
File metadata and controls
executable file
·383 lines (350 loc) · 15.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
#!/usr/bin/env bash
#
# sync-kernel.sh — re-pull kernel-tier + stack-tier files into an existing
# consuming app (slice 4). Run it FROM THE KERNEL CHECKOUT against a consuming
# app; the script is itself excluded from generation/sync (design D3a), so the
# running copy is always the kernel's current version.
#
# Mirrors new-app.sh's manifest semantics exactly (design D3): kernel.paths
# minus excludeFromGenerate, preset paths flattened, preset-wins overlap, specs
# verbatim, appTemplates re-applied — but NO identity substitution, NO git init,
# NO release-manifest reset, and instrumentStubs are NEVER re-applied (the
# instrument is app-owned content after generation).
#
# Sync is a deliberate version bump (D1): it compares the app's .kernel-version
# to the kernel's .release-please-manifest.json "." value and is a no-op if the
# app is already at/ahead of the kernel. Clobber protection (D2) refuses to
# overwrite a locally-modified synced file unless --accept-kernel is passed; the
# baseline lives in the app's committed .kernel-sync-hashes.json.
#
# Usage:
# scripts/sync-kernel.sh --kernel-repo <path> [--app-repo <path>] \
# [--dry-run] [--accept-kernel] [--adopt-existing]
#
# See openspec/specs/kernel-sync/spec.md.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
usage() {
cat <<'EOF'
Usage: sync-kernel.sh --kernel-repo <path> [options]
Required:
--kernel-repo <path> the kernel checkout to sync FROM (contains
kernel-manifest.json + .release-please-manifest.json)
Options:
--app-repo <path> the consuming app to sync INTO (default: .)
--dry-run report the upgrade path + copy plan, write nothing
--accept-kernel overwrite locally-modified synced files with the
kernel's version (local edits are lost; git is the
safety net)
--adopt-existing bootstrap: when the app has no .kernel-sync-hashes.json,
adopt its current file contents as the baseline and exit
-h, --help show this help
EOF
}
# ─── Parse arguments (4.1) ──────────────────────────────────────────────────
KERNEL_REPO=""
APP_REPO="."
DRY_RUN=0
ACCEPT_KERNEL=0
ADOPT_EXISTING=0
while [ "$#" -gt 0 ]; do
case "$1" in
--kernel-repo | --app-repo)
if [ "$#" -lt 2 ]; then
echo "sync-kernel.sh: $1 requires a value" >&2
exit 2
fi
case "$1" in
--kernel-repo) KERNEL_REPO="$2" ;;
--app-repo) APP_REPO="$2" ;;
esac
shift 2
;;
--dry-run)
DRY_RUN=1
shift
;;
--accept-kernel)
ACCEPT_KERNEL=1
shift
;;
--adopt-existing)
ADOPT_EXISTING=1
shift
;;
-h | --help)
usage
exit 0
;;
*)
echo "sync-kernel.sh: unknown argument '$1'" >&2
usage >&2
exit 2
;;
esac
done
# ─── Early prereq preamble (4.1) ────────────────────────────────────────────
# node is required by semver_cmp (and the JSON writer); jq by every manifest
# read. sync may plausibly run before the user has done `setup.sh --check`, so
# fail fast with the same install hints that table uses.
if ! command -v node >/dev/null 2>&1; then
echo "sync-kernel.sh: 'node' not found — install via nvm: nvm install \$(cat .nvmrc)" >&2
exit 1
fi
if ! command -v jq >/dev/null 2>&1; then
echo "sync-kernel.sh: 'jq' not found — install via 'brew install jq'" >&2
exit 1
fi
# ─── Validate inputs (4.1) ──────────────────────────────────────────────────
if [ -z "$KERNEL_REPO" ]; then
echo "sync-kernel.sh: --kernel-repo is required" >&2
exit 2
fi
if [ ! -d "$KERNEL_REPO" ]; then
echo "sync-kernel.sh: --kernel-repo '$KERNEL_REPO' is not a directory" >&2
exit 1
fi
KERNEL_REPO_ABS="$(cd "$KERNEL_REPO" && pwd)"
if [ ! -f "$KERNEL_REPO_ABS/kernel-manifest.json" ]; then
echo "sync-kernel.sh: --kernel-repo '$KERNEL_REPO_ABS' has no kernel-manifest.json (not a kernel checkout)" >&2
exit 1
fi
if [ ! -f "$KERNEL_REPO_ABS/.release-please-manifest.json" ]; then
echo "sync-kernel.sh: --kernel-repo '$KERNEL_REPO_ABS' has no .release-please-manifest.json (not a kernel checkout)" >&2
exit 1
fi
if [ ! -d "$APP_REPO" ]; then
echo "sync-kernel.sh: --app-repo '$APP_REPO' does not exist or is not a directory" >&2
exit 1
fi
APP_REPO_ABS="$(cd "$APP_REPO" && pwd)"
# ─── Shared manifest primitives (design D6) ─────────────────────────────────
# MANIFEST is the kernel checkout's manifest (absolute); expand_entry globs
# against the cwd, so we cd into the kernel repo before any expansion.
MANIFEST="$KERNEL_REPO_ABS/kernel-manifest.json"
# shellcheck source=scripts/lib/manifest.sh
. "$SCRIPT_DIR/lib/manifest.sh"
cd "$KERNEL_REPO_ABS"
# ─── Determine the preset (D3c) ─────────────────────────────────────────────
preset_keys=()
while IFS= read -r line; do [ -n "$line" ] && preset_keys+=("$line"); done < <(manifest_preset_keys)
if [ "${#preset_keys[@]}" -eq 0 ]; then
echo "sync-kernel.sh: manifest has no stack preset; nothing to sync" >&2
exit 1
fi
if [ "${#preset_keys[@]}" -gt 1 ]; then
echo "sync-kernel.sh: manifest has multiple presets (${preset_keys[*]}); sync cannot disambiguate. Add --preset <name> (not implemented this slice — see design D3c). Until then, the kernel supports a single preset only." >&2
exit 1
fi
PRESET_KEY="${preset_keys[0]}"
KERNEL_VERSION="$(manifest_kernel_version "$KERNEL_REPO_ABS")"
HASHES_FILE="$APP_REPO_ABS/.kernel-sync-hashes.json"
VERSION_FILE="$APP_REPO_ABS/.kernel-version"
# ─── Build the copy plan (4.3) ──────────────────────────────────────────────
# manifest_copy_plan (scripts/lib/manifest.sh) is the single definition of "what
# travels", shared with new-app.sh so generation and sync cannot drift (D3/D6).
# Deduplicate on target (preset-wins overlap already removed the kernel copy;
# this guards any other accidental double-listing). Keep first occurrence.
PLAN="$(manifest_copy_plan "$PRESET_KEY" | awk -F'\t' '!seen[$1]++')"
# ─── Adopt the current app contents as the baseline (4.2, D2a) ──────────────
# write_hash_file (scripts/lib/manifest.sh) assembles .kernel-sync-hashes.json
# from "<target>\t<sha256hex>" lines on stdin — no temp file, shared with
# new-app.sh.
if [ ! -f "$HASHES_FILE" ]; then
if [ "$ADOPT_EXISTING" -ne 1 ]; then
echo "sync-kernel.sh: '$HASHES_FILE' not found — this app has no sync baseline." >&2
echo " Run with --adopt-existing to adopt the app's current file contents as the baseline." >&2
exit 1
fi
# Adopt: hash every present plan target, write the baseline + version stamp.
adopt_pairs="$(
while IFS=$'\t' read -r target _src; do
[ -n "$target" ] || continue
[ -f "$APP_REPO_ABS/$target" ] && printf '%s\t%s\n' "$target" "$(sha256_file "$APP_REPO_ABS/$target")"
done <<<"$PLAN"
)"
present_count="$(printf '%s' "$adopt_pairs" | awk 'NF{c++} END{print c + 0}')"
if [ "$DRY_RUN" -eq 1 ]; then
echo "sync-kernel.sh: [dry-run] would adopt $present_count present file(s) as the baseline at $KERNEL_VERSION; no file written"
exit 0
fi
printf '%s\n' "$adopt_pairs" | write_hash_file "$HASHES_FILE" "$KERNEL_VERSION"
printf '%s\n' "$KERNEL_VERSION" >"$VERSION_FILE"
echo "sync-kernel.sh: adopted baseline at $KERNEL_VERSION ($present_count file(s)); .kernel-sync-hashes.json + .kernel-version written. Re-run sync to apply kernel updates."
exit 0
fi
# A baseline already exists, so --adopt-existing is a no-op here — say so rather
# than silently ignoring it, so the user knows their flag had no effect.
if [ "$ADOPT_EXISTING" -eq 1 ]; then
echo "sync-kernel.sh: --adopt-existing ignored — .kernel-sync-hashes.json already exists (baseline is already established)" >&2
fi
# ─── Version comparison (4.2, D1) ───────────────────────────────────────────
if [ -f "$VERSION_FILE" ]; then
APP_VERSION="$(tr -d '[:space:]' <"$VERSION_FILE")"
[ -n "$APP_VERSION" ] || APP_VERSION="0.0.0"
else
APP_VERSION="0.0.0"
fi
cmp=0
semver_cmp "$APP_VERSION" "$KERNEL_VERSION" || cmp=$?
if [ "$cmp" -ne 2 ]; then
# app >= kernel (equal or ahead) → no-op.
echo "sync-kernel.sh: up to date at $APP_VERSION (kernel is $KERNEL_VERSION)"
exit 0
fi
# ─── Categorize each plan entry (4.4) ───────────────────────────────────────
# Preload the recorded baseline ONCE (a single jq call) into parallel arrays and
# look it up in-shell, rather than spawning jq per plan entry. bash 3.2 has no
# associative arrays, so this is a linear scan — O(n²) string compares but zero
# process spawns, which beats ~n jq invocations at any realistic manifest size.
rec_keys=()
rec_vals=()
while IFS=$'\t' read -r _rk _rv; do
[ -n "$_rk" ] || continue
rec_keys+=("$_rk")
rec_vals+=("$_rv")
done < <(jq -r '.hashes // {} | to_entries[] | "\(.key)\t\(.value)"' "$HASHES_FILE" 2>/dev/null)
# Print the recorded hash for a target, or nothing if absent. Intentionally
# returns 0 even on a miss: callers use it as `rec="$(recorded_hash "$t")"` under
# `set -e`, and a non-zero return would abort the script via the command
# substitution for every NEW file (whose target is not in the baseline). Callers
# distinguish found/empty via `[ -n "$rec" ]`, not the exit code.
recorded_hash() {
local k="$1" i
for i in "${!rec_keys[@]}"; do
if [ "${rec_keys[$i]}" = "$k" ]; then
printf '%s' "${rec_vals[$i]}"
return 0
fi
done
}
new_paths=()
clean_paths=()
conflict_paths=()
# parallel arrays: plan_target[i] -> plan_src[i] for the write phase.
plan_target=()
plan_src=()
while IFS=$'\t' read -r target src; do
[ -n "$target" ] || continue
plan_target+=("$target")
plan_src+=("$src")
app_file="$APP_REPO_ABS/$target"
if [ ! -f "$app_file" ]; then
new_paths+=("$target")
continue
fi
rec="$(recorded_hash "$target")"
rec="${rec#sha256:}"
cur="$(sha256_file "$app_file")"
if [ -n "$rec" ] && [ "$rec" = "$cur" ]; then
clean_paths+=("$target")
else
# exists but no recorded baseline, or hash differs → cannot verify it is
# unmodified → treat as a conflict (safe default).
conflict_paths+=("$target")
fi
done <<<"$PLAN"
# ─── Pre-flight: every plan source must exist before any write ──────────────
# manifest_copy_plan only emits expanded paths that exist plus appTemplates
# sources that check-manifest validates, so this should never fire — but verify
# up front anyway, so a malformed kernel checkout aborts the sync cleanly here
# rather than failing a `cp -p` mid-write and leaving the app partially synced
# with a stale hash file (the hash file is written only after all copies).
missing_src=()
for i in "${!plan_src[@]}"; do
[ -f "${plan_src[$i]}" ] || missing_src+=("${plan_src[$i]}")
done
if [ "${#missing_src[@]}" -gt 0 ]; then
echo "sync-kernel.sh: kernel source file(s) missing — aborting before any write:" >&2
for m in "${missing_src[@]+"${missing_src[@]}"}"; do echo " - $m" >&2; done
exit 1
fi
# ─── Kernel-deleted paths (4.8, D4) ─────────────────────────────────────────
# Paths recorded in the app's baseline but no longer in the kernel's plan.
deleted_paths=()
# `+`-expansion guards the empty-array case under set -u on bash < 4.4 (PLAN is
# non-empty in practice, but stay consistent with the conflict_paths guard).
plan_targets_sorted="$(printf '%s\n' "${plan_target[@]+"${plan_target[@]}"}" | sort -u)"
while IFS= read -r old; do
[ -n "$old" ] || continue
if ! printf '%s\n' "$plan_targets_sorted" | grep -Fxq -- "$old"; then
deleted_paths+=("$old")
fi
done < <(jq -r '.hashes | keys[]' "$HASHES_FILE" 2>/dev/null)
report_deleted() {
if [ "${#deleted_paths[@]}" -gt 0 ]; then
echo " no longer tracked by kernel (left in place):"
local p
for p in "${deleted_paths[@]}"; do echo " - $p"; done
fi
}
# App-owned files (D3d) are excluded from the copy plan entirely; report them
# informationally so the user knows a kernel-side change to one of them won't
# arrive via sync and needs a manual merge if wanted.
report_app_owned() {
local owned p
owned="$(manifest_app_owned_files)"
if [ -n "$owned" ]; then
echo " app-owned, not re-synced (customized at generation — merge upstream changes by hand if needed):"
while IFS= read -r p; do [ -n "$p" ] && echo " - $p"; done <<<"$owned"
fi
}
# ─── Dry-run report / conflict refusal (4.5) ────────────────────────────────
if [ "$DRY_RUN" -eq 1 ]; then
echo "sync-kernel.sh: [dry-run] syncing $APP_VERSION → $KERNEL_VERSION"
echo " new: ${#new_paths[@]}"
echo " clean: ${#clean_paths[@]} (would be overwritten with the kernel's version)"
echo " conflict: ${#conflict_paths[@]} (locally modified)"
if [ "${#conflict_paths[@]}" -gt 0 ]; then
echo " conflicting paths:"
for p in "${conflict_paths[@]}"; do echo " - $p"; done
echo " → re-run with --accept-kernel to overwrite these with the kernel's version."
fi
report_deleted
report_app_owned
echo "sync-kernel.sh: [dry-run] no file written"
exit 0
fi
if [ "${#conflict_paths[@]}" -gt 0 ] && [ "$ACCEPT_KERNEL" -ne 1 ]; then
echo "sync-kernel.sh: refusing to sync — ${#conflict_paths[@]} locally-modified file(s) would be clobbered:" >&2
for p in "${conflict_paths[@]}"; do echo " - $p" >&2; done
echo " Re-run with --accept-kernel to overwrite them with the kernel's version (local edits lost; git is the safety net)," >&2
echo " or merge the kernel's changes by hand. No file was written." >&2
exit 1
fi
# ─── Write phase (4.6) ──────────────────────────────────────────────────────
# Every plan entry (new + clean + conflict-under-accept) is re-copied, including
# "clean" files already byte-identical to the kernel's. This is a deliberate
# simplicity trade-off: a sync is an infrequent, explicit version bump, so the
# `cp -p` mtime churn on clean files (which could trigger an mtime-based rebuild
# afterward) is acceptable and not worth a per-file content comparison to avoid.
echo "sync-kernel.sh: syncing $APP_VERSION → $KERNEL_VERSION"
is_in() {
local needle="$1"
shift
local x
for x in "$@"; do [ "$x" = "$needle" ] && return 0; done
return 1
}
copied=0
for i in "${!plan_target[@]}"; do
target="${plan_target[$i]}"
src="${plan_src[$i]}"
if is_in "$target" "${conflict_paths[@]+"${conflict_paths[@]}"}"; then
# only reachable when --accept-kernel (else we exited above)
echo " ! overwriting locally-modified $target with the kernel's version" >&2
fi
mkdir -p "$(dirname "$APP_REPO_ABS/$target")"
cp -p "$src" "$APP_REPO_ABS/$target"
copied=$((copied + 1))
done
# ─── Post-write: rewrite state files (4.7) ──────────────────────────────────
{
for target in "${plan_target[@]+"${plan_target[@]}"}"; do
printf '%s\t%s\n' "$target" "$(sha256_file "$APP_REPO_ABS/$target")"
done
} | write_hash_file "$HASHES_FILE" "$KERNEL_VERSION"
printf '%s\n' "$KERNEL_VERSION" >"$VERSION_FILE"
echo "sync-kernel.sh: synced $copied file(s); .kernel-version now $KERNEL_VERSION"
report_deleted
report_app_owned