#!/usr/bin/env bash # lib/state.sh — Sync state management on orphan branch # # State persists on an orphan git branch (default: josh-sync-state), # committed and pushed to origin. Survives CI runner teardown. # # Storage layout: # origin/josh-sync-state/ # /.json (e.g., billing/main.json) # # JSON per state file: # { # "last_forward": { "mono_sha": "...", "subrepo_sha": "...", "timestamp": "...", "status": "..." }, # "last_reverse": { "subrepo_sha": "...", "mono_sha": "...", "timestamp": "...", "status": "..." } # } # # Forward and reverse state are independent — updated with jq merge. # # Requires: lib/core.sh sourced first # Expects: JOSH_SYNC_TARGET_NAME, BOT_NAME, BOT_EMAIL (set by load_target) STATE_BRANCH="${JOSH_SYNC_STATE_BRANCH:-josh-sync-state}" # ─── State Key ────────────────────────────────────────────────────── # Namespace with target: "billing" + "main" → "billing/main" # Slashes in branch names converted to hyphens. state_key() { local branch_key="${1//\//-}" echo "${JOSH_SYNC_TARGET_NAME}/${branch_key}" } # ─── Read State ───────────────────────────────────────────────────── read_state() { local key key=$(state_key "$1") git fetch origin "$STATE_BRANCH" 2>/dev/null || true git show "origin/${STATE_BRANCH}:${key}.json" 2>/dev/null || echo '{}' } # ─── Write State ──────────────────────────────────────────────────── # Uses git worktree to avoid touching the working tree. write_state() { local key key=$(state_key "$1") local state_json="$2" local tmp_dir tmp_dir=$(mktemp -d) # Try to check out existing state branch, or create orphan if git rev-parse "origin/${STATE_BRANCH}" >/dev/null 2>&1; then git worktree add "$tmp_dir" "origin/${STATE_BRANCH}" 2>/dev/null else git worktree add --detach "$tmp_dir" 2>/dev/null (cd "$tmp_dir" && git checkout --orphan "$STATE_BRANCH" && git rm -rf . 2>/dev/null || true) fi # Create target subdirectory and write state mkdir -p "$(dirname "${tmp_dir}/${key}.json")" echo "$state_json" | jq '.' > "${tmp_dir}/${key}.json" ( cd "$tmp_dir" || exit git add -A if ! git diff --cached --quiet 2>/dev/null; then git -c user.name="$BOT_NAME" -c user.email="$BOT_EMAIL" \ commit -m "state: update ${key}" git push origin "HEAD:${STATE_BRANCH}" || log "WARN" "Failed to push state" fi ) git worktree remove "$tmp_dir" 2>/dev/null || rm -rf "$tmp_dir" } # ─── Target-Prefix Migration (used by `rename`) ──────────────────── # Generic helpers over the whole "/" directory on the state # branch — agnostic to which command wrote the individual files inside it # (this file's per-branch state, or onboard.sh's onboard.json/adopt.json). # List every path under / on the freshly-fetched state branch. # One relative path per line (e.g. "billing/main.json"); empty if none — # including when the state branch doesn't exist yet at all. state_list_target_files() { local target_name="$1" git fetch origin "$STATE_BRANCH" 2>/dev/null || true git rev-parse -q --verify "origin/${STATE_BRANCH}" >/dev/null 2>&1 || return 0 git ls-tree -r --name-only "origin/${STATE_BRANCH}" -- "${target_name}/" 2>/dev/null } # Move every file under / to / (basename preserved), and # — when new_filter is non-empty — force `.last_forward.josh_filter` to # new_filter in each moved JSON file that has that key (whatever value it # currently holds; we don't need to know the prior value, which keeps this # safe to re-run mid-rename after the config has already been edited). # Files without that key (e.g. onboard.json) pass through untouched. Both # the move and the content rewrite land in ONE commit + push. # # No-op (returns 0, no commit) if / has no files. # Dies before mutating anything if any destination path already exists. # # Usage: state_migrate_target_prefix [new_filter] state_migrate_target_prefix() { local old_name="$1" new_name="$2" new_filter="${3:-}" local files files=$(state_list_target_files "$old_name") [ -n "$files" ] || return 0 local tmp_dir tmp_dir=$(mktemp -d) git worktree add "$tmp_dir" "origin/${STATE_BRANCH}" 2>/dev/null \ || { rm -rf "$tmp_dir"; die "Failed to check out ${STATE_BRANCH}"; } # The migration itself runs in a subshell so a mid-flight `exit` (from a # collision `die`, or any unexpected failure) can't skip the worktree # cleanup below. Its exit status is captured explicitly and re-raised via # an unconditional `exit` — relying on the caller's `set -e` to propagate a # failure out of a subshell is NOT reliable (e.g. bats' `run` disables # errexit while capturing output), so this must not depend on it. local migrate_status=0 ( cd "$tmp_dir" || exit 1 local f base dest # Pre-flight collision check across all files before mutating anything. while IFS= read -r f; do [ -n "$f" ] || continue base="${f#"${old_name}"/}" dest="${new_name}/${base}" if [ "$f" != "$dest" ] && [ -e "$dest" ]; then die "Destination '${dest}' already exists on ${STATE_BRANCH} — refusing to overwrite" fi done <<< "$files" while IFS= read -r f; do [ -n "$f" ] || continue base="${f#"${old_name}"/}" dest="${new_name}/${base}" if [ "$f" != "$dest" ]; then mkdir -p "$(dirname "$dest")" git mv "$f" "$dest" fi if [ -n "$new_filter" ]; then jq --arg new "$new_filter" \ 'if has("last_forward") and (.last_forward | has("josh_filter")) then .last_forward.josh_filter = $new else . end' \ "$dest" > "${dest}.tmp" && mv "${dest}.tmp" "$dest" git add "$dest" fi done <<< "$files" if ! git diff --cached --quiet 2>/dev/null; then git -c user.name="$BOT_NAME" -c user.email="$BOT_EMAIL" \ commit -m "state: rename ${old_name} -> ${new_name}" git push origin "HEAD:${STATE_BRANCH}" \ || die "Failed to push state migration for ${old_name} -> ${new_name}" fi ) || migrate_status=$? git worktree remove "$tmp_dir" 2>/dev/null || rm -rf "$tmp_dir" [ "$migrate_status" -eq 0 ] || exit "$E_GENERAL" }