diff options
Diffstat (limited to 'claude-templates/.ai/scripts/sync-templates')
| -rwxr-xr-x | claude-templates/.ai/scripts/sync-templates | 131 |
1 files changed, 131 insertions, 0 deletions
diff --git a/claude-templates/.ai/scripts/sync-templates b/claude-templates/.ai/scripts/sync-templates new file mode 100755 index 0000000..b9769f3 --- /dev/null +++ b/claude-templates/.ai/scripts/sync-templates @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# sync-templates — copy rulesets' canonical .ai/ templates into this project. +# +# Extracted verbatim from startup.org Phase A step 3 (2026-07-31). This is the +# mechanism that distributes every workflow, protocol and script change to every +# project, and until the extraction it was untested inline bash running in every +# session. The extraction exists so the guard changes that follow can be tested +# before they reach a file whose failure mode is "no project starts". +# +# Behavior is deliberately identical to the inline block it replaces, including +# its rough edges. Anything that looks like a defect here is characterized by a +# test rather than fixed in passing — a change of behavior belongs in its own +# commit, not smuggled into an extraction. +# +# Usage: sync-templates [project-root] (default: $PWD) +# Output: one line naming the outcome, matching the previous inline wording +# Exit: 0 always, as the inline block did — the outcome is on stdout +# +# Two guards, and they work differently. One withholds files; the other skips +# the whole run: +# +# Rulesets dirty under the synced paths → withhold exactly those files. +# rsync -a --delete copies the working tree by disk presence, so an in-flight +# edit in rulesets would otherwise land downstream as drift the project never +# authored. Each dirty path becomes an --exclude, which rsync honors on both +# sides: the file is neither overwritten nor deleted downstream, and every +# clean file still propagates. This was a global skip until 2026-07-31, which +# meant one uncommitted file in rulesets froze every template for every +# project until it was committed. +# +# Project branch behind its upstream → skip the whole sync. Syncing onto a +# stale committed .ai/ baseline measures the diff against old content, so it +# comes out huge and conflicts once the branch reconciles to an upstream that +# already carries the newer templates. This one stays all-or-nothing because +# the staleness is in the destination, not in any particular source file. +# +# The rulesets location is injectable (SYNC_RULESETS_DIR) so the guards can be +# exercised against a fixture instead of the real checkout. + +rs="${SYNC_RULESETS_DIR:-$HOME/code/rulesets}" +proj="${1:-$PWD}" + +cd "$proj" || { + echo "sync-templates: cannot enter '$proj'" >&2 + exit 0 +} + +# The dirty set under the synced paths, one repo-relative path per line. +# core.quotePath=false keeps a non-ASCII filename literal instead of \xNN-escaped, +# so the path we build an --exclude from is the path on disk. +synced_dirty=$(cd "$rs" && git -c core.quotePath=false status --porcelain -- \ + claude-templates/.ai/protocols.org \ + claude-templates/.ai/workflows/ \ + claude-templates/.ai/scripts/ 2>/dev/null) + +# Turn that set into per-rsync --exclude flags rather than a global skip. An +# excluded path is neither overwritten nor deleted on the receiving side, so an +# in-flight edit stays in-flight while every file it doesn't touch propagates +# normally. The all-or-nothing skip this replaces is what caused the 2026-07-30 +# outage: one uncommitted workflow file withheld every template from every +# project for a full day. +protocols_dirty=0 +wf_excludes=() +sc_excludes=() +withheld=() + +while IFS= read -r line; do + [ -z "$line" ] && continue + path="${line:3}" + # A rename reports "old -> new". Withhold both sides: the new name is + # half-landed, and sweeping the old copy downstream would delete a file the + # project still runs while the rename sits uncommitted. + if [[ "$path" == *" -> "* ]]; then + paths=("${path%% -> *}" "${path##* -> }") + else + paths=("$path") + fi + for p in "${paths[@]}"; do + p="${p%\"}"; p="${p#\"}" + case "$p" in + claude-templates/.ai/protocols.org) + # A single-file rsync has nothing to exclude within, so this one + # transfer is skipped outright while the other two still run. + protocols_dirty=1 + withheld+=("$p") + ;; + claude-templates/.ai/workflows/*) + # Leading / anchors the pattern to the transfer root, so a dirty + # workflows/foo.org can't also suppress scripts/tests/foo.org. + wf_excludes+=("--exclude=/${p#claude-templates/.ai/workflows/}") + withheld+=("$p") + ;; + claude-templates/.ai/scripts/*) + sc_excludes+=("--exclude=/${p#claude-templates/.ai/scripts/}") + withheld+=("$p") + ;; + esac + done +done <<< "$synced_dirty" + +# behind==0 (up-to-date or ahead-only) means HEAD contains all of upstream, so +# the baseline is current. No upstream (new/unpushed branch) → rev-list fails → +# proj_behind stays 0 → the sync runs. +proj_behind=0 +if [ -d .git ]; then + counts=$(git rev-list --left-right --count '@{u}...HEAD' 2>/dev/null) \ + && [ "$(printf '%s' "$counts" | cut -f1)" -gt 0 ] 2>/dev/null \ + && proj_behind=1 +fi + +if [ "$proj_behind" -eq 1 ]; then + echo "project branch is behind upstream — skipping .ai/ sync this session (templates never land on a stale baseline; the sync runs once the branch is current)" +else + [ "$protocols_dirty" -eq 0 ] && rsync -a "$rs/claude-templates/.ai/protocols.org" .ai/protocols.org + rsync -a --delete "${wf_excludes[@]}" "$rs/claude-templates/.ai/workflows/" .ai/workflows/ + # Running rulesets' own pytest leaves these in the canonical scripts/tests/, + # and rsync -a copies by disk presence regardless of .gitignore, so without + # the excludes every project's tree collects machine-specific cache files. + rsync -a --delete --exclude='__pycache__' --exclude='.pytest_cache' --exclude='*.pyc' \ + "${sc_excludes[@]}" "$rs/claude-templates/.ai/scripts/" .ai/scripts/ + # Known false-success path, inherited and characterized rather than fixed + # here: this line prints unconditionally, so a run where all three rsyncs + # failed (an absent canonical source, say) still reports a successful sync. + # A last-synced manifest must not be written from this branch as it stands — + # it would stamp success onto a sync that did nothing. + echo ".ai/ synced from templates" + if [ ${#withheld[@]} -gt 0 ]; then + echo " withheld — uncommitted in rulesets, lands once committed:" + printf ' %s\n' "${withheld[@]}" + fi +fi |
