Files
josh-sync/lib/state.sh
T

162 lines
6.5 KiB
Bash
Raw Permalink Normal View History

2026-05-28 04:15:02 +01:00
#!/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/
# <target>/<branch>.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 "<target_name>/" 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 <target_name>/ 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 <old_name>/ to <new_name>/ (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 <old_name>/ has no files.
# Dies before mutating anything if any destination path already exists.
#
# Usage: state_migrate_target_prefix <old_name> <new_name> [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"
}