#!/usr/bin/env bash # post-checkout-version-sync.sh — Optional post-checkout hook for Red Bear OS. # # Purpose: # When an operator switches to a release branch whose name is an anchored # semver (e.g. `0.3.1`, `0.3.2`), automatically synchronise every Cat 1 # (in-house) and Cat 2 (upstream fork) crate version label to match that # branch. This is the label-only half of a release bump — it does NOT # touch fork source content, regenerate lockfiles, or commit anything. # # Why label-only: # Labels are cheap and mechanical (sed rewrites performed by # sync-versions.sh). Source upgrades (rebasing a fork onto a newer upstream # tag) and lockfile regen are expensive and operator-judgement steps, so # they remain explicit (`bump-release.sh --with-sources --with-external` # and `sync-versions.sh --regen`). See # local/docs/RELEASE-BUMP-WORKFLOW.md for the full release model. # # Git post-checkout contract (git invokes this hook with three arguments): # $1 = previous HEAD sha # $2 = new HEAD sha # $3 = 1 for a branch checkout, 0 for a file checkout # # Install (operator opt-in — NEVER auto-installed): # cp local/scripts/post-checkout-version-sync.sh .git/hooks/post-checkout # chmod +x .git/hooks/post-checkout # # Or via the installer (preferred): # ./local/scripts/install-git-hooks.sh # post-checkout only # ./local/scripts/install-git-hooks.sh --all # + pre-push + commit-msg # # Bypass for a single checkout: # REDBEAR_NO_AUTO_SYNC=1 git checkout 0.3.2 # # Hard guarantees (a failing hook MUST NEVER block a checkout): # - Always exits 0. Any internal failure degrades to a stderr note + exit 0. # - No network access. No git mutations (no commit / branch / push / stash). # - No lockfile regeneration (NEVER calls `sync-versions.sh --regen`). # - Does not run on non-semver branches (master, submodule/*, recovered/*, # detached HEAD). # - Does not run inside an in-progress rebase / cherry-pick / merge. # - Does not run when the working tree is dirty (warns + skips). # - Total runtime budget: < ~5 seconds on a warm tree. # # See: local/docs/HOOKS.md, local/docs/RELEASE-BUMP-WORKFLOW.md, # local/AGENTS.md § "Version conventions — two categories". # NOTE: deliberately `set -uo pipefail` and NOT `-e`. A post-checkout hook # must never abort mid-way and leave the operator staring at a non-zero exit # that blocks the checkout. Every fallible step is either guarded or wrapped # so that failure degrades to an exit 0 with a stderr note. set -uo pipefail # --------------------------------------------------------------------------- # Locate the repo root. We prefer `git rev-parse --show-toplevel` over a # dirname-relative derivation because git places the hook under `.git/hooks/` # which may itself be a gitdir pointer (worktrees, linked checkouts). # --------------------------------------------------------------------------- ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)" if [[ -z "$ROOT" ]]; then # Not inside a git work tree — nothing to do. exit 0 fi # Git passes exactly three arguments to post-checkout. If invoked manually # without them, treat $3 as unknown (skip the branch-flag guard). BRANCH_FLAG="${3:-}" # --------------------------------------------------------------------------- # Guard (a): branch checkouts only. $3 == 1 means a branch checkout; # $3 == 0 means a file checkout (e.g. `git checkout -- path`). Silently # skip file checkouts — there is no branch context to sync against. # --------------------------------------------------------------------------- if [[ "$BRANCH_FLAG" != "1" ]]; then exit 0 fi # --------------------------------------------------------------------------- # Guard (b): the new branch name MUST be an anchored semver # `^[0-9]+\.[0-9]+\.[0-9]+$`. This silently excludes master, main, # submodule/* branches, recovered/* branches, and detached HEAD. # --------------------------------------------------------------------------- BRANCH="$(git branch --show-current 2>/dev/null || true)" if [[ -z "$BRANCH" ]]; then # Detached HEAD — no branch name to anchor a version against. exit 0 fi if ! printf '%s' "$BRANCH" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then # Non-semver branch (master, submodule/*, recovered/*, feature/*, …). # Silently skip — version labels are only meaningful on release branches. exit 0 fi # --------------------------------------------------------------------------- # Guard (c): never run inside an in-progress rebase / cherry-pick / merge. # Touching version labels mid-sequence would corrupt the operator's # in-flight history rewrite. Silently skip in all such states. # --------------------------------------------------------------------------- GIT_DIR="$(git rev-parse --git-dir 2>/dev/null || true)" if [[ -n "$GIT_DIR" ]]; then # Resolve relative to the worktree root for linked worktrees. [[ "$GIT_DIR" = /* ]] || GIT_DIR="$ROOT/$GIT_DIR" for marker in rebase-merge rebase-apply CHERRY_PICK_HEAD MERGE_HEAD; do if [[ -e "$GIT_DIR/$marker" ]]; then # An interactive operation is in progress — stay out of the way. exit 0 fi done fi # --------------------------------------------------------------------------- # Guard (d): honour the explicit opt-out env var. # --------------------------------------------------------------------------- if [[ -n "${REDBEAR_NO_AUTO_SYNC:-}" ]]; then exit 0 fi # --------------------------------------------------------------------------- # Guard (e): working tree MUST be clean. A bump rewrites Cargo.toml files in # place; running it over uncommitted operator edits would silently swallow # those edits into the bump. Warn + skip instead. # --------------------------------------------------------------------------- if ! git -C "$ROOT" diff --quiet --ignore-submodules HEAD 2>/dev/null \ || [[ -n "$(git -C "$ROOT" status --porcelain 2>/dev/null)" ]]; then echo "post-checkout-version-sync: skipping version auto-sync: uncommitted changes present" >&2 echo " (commit or stash, then run: ./local/scripts/sync-versions.sh --no-regen)" >&2 exit 0 fi # --------------------------------------------------------------------------- # All guards passed. Compare the root cookbook Cargo.toml `[package] version` # against the branch version. They are the canonical indicator of whether a # label sync is needed: sync-versions.sh Phase 0 keeps them in lock-step, # so if the root is already at the branch version, every Cat 1/Cat 2 label # is already correct too. # --------------------------------------------------------------------------- ROOT_CARGO="$ROOT/Cargo.toml" if [[ ! -f "$ROOT_CARGO" ]]; then # Not a Red Bear OS checkout (no root Cargo.toml). Stay silent. exit 0 fi CURRENT_VERSION="" if grep -qE '^\[package\]' "$ROOT_CARGO" 2>/dev/null; then # Must match sync-versions.sh Phase 0's extraction so both scripts agree. CURRENT_VERSION="$(awk ' /^\[package\]/ { in_pkg=1; next } /^\[/ { in_pkg=0 } in_pkg && /^version[[:space:]]*=/ { print; exit } ' "$ROOT_CARGO" 2>/dev/null | sed 's/.*"\(.*\)".*/\1/' || true)" fi if [[ "$CURRENT_VERSION" == "$BRANCH" ]]; then # Labels already synced for this branch — nothing to do. exit 0 fi # --------------------------------------------------------------------------- # Drift detected. Run the label-only sync (NEVER --regen — lockfile regen is # an explicit operator step). sync-versions.sh is the single executor of # suffix-only label rewrites; this hook never duplicates that sed logic. # --------------------------------------------------------------------------- SYNC="$ROOT/local/scripts/sync-versions.sh" if [[ ! -x "$SYNC" ]]; then echo "post-checkout-version-sync: $SYNC not found or not executable — skipping label sync" >&2 echo " run manually: ./local/scripts/sync-versions.sh --no-regen" >&2 exit 0 fi echo "post-checkout-version-sync: branch '$BRANCH' root Cargo.toml at '$CURRENT_VERSION' -> '$BRANCH'" echo "post-checkout-version-sync: running sync-versions.sh --no-regen (labels only, no lockfile regen)" # Run label sync. Capture output so the operator sees a concise summary. # We do NOT propagate a non-zero exit: even if sync fails, the checkout # itself must not be blocked. SYNC_OUTPUT="$("$SYNC" --no-regen 2>&1)" || true if [[ -n "$SYNC_OUTPUT" ]]; then printf '%s\n' "$SYNC_OUTPUT" fi echo "post-checkout-version-sync: label sync complete for branch '$BRANCH'." # --------------------------------------------------------------------------- # Follow-up hint. The hook handles ONLY mechanical label rewrites. The # expensive half of a release bump — fork source upgrades to newer upstream # tags and external desktop-stack version refresh — is the operator's # explicit decision and is never triggered automatically. # --------------------------------------------------------------------------- cat >&2 <