#!/usr/bin/env bash # lib/rename.sh — Safe rename workflow for a sync target (name/subfolder/URL) # # Renaming a target by hand-editing .josh-sync.yml orphans its sync state # (state.sh keys everything by target name) and, for subfolder changes, # leaves josh_filter stale until the next sync's filter-change reconciliation # happens to catch it. This module edits the config AND migrates state in # one resumable operation. # # Requires: lib/core.sh, lib/config.sh, lib/auth.sh, lib/state.sh sourced # Expects: JOSH_SYNC_TARGETS (from parse_config) RENAME_CONCURRENCY_WINDOW="5 minutes ago" # ─── Josh Filter Derivation ───────────────────────────────────────── # Mirrors the auto-derivation rule in parse_config (lib/config.sh:66-73): # exclude patterns always win; otherwise :/. Used only to decide # whether a target's CURRENT josh_filter looks auto-derived (so it should # track a subfolder rename) or looks like an explicit override (left alone, # same as parse_config would leave it alone on a plain re-parse). _rename_auto_filter() { local subfolder="$1" exclude_json="$2" jq -nr --arg subfolder "$subfolder" --argjson exclude "$exclude_json" ' if ($exclude | length) > 0 then (":/" + $subfolder + ":exclude[" + ($exclude | map("::" + .) | join(",")) + "]") else (":/" + $subfolder) end' } # Usage: _rename_derive_new_josh_filter _rename_derive_new_josh_filter() { local old_subfolder="$1" new_subfolder="$2" exclude_json="$3" current_filter="$4" local auto_old auto_old=$(_rename_auto_filter "$old_subfolder" "$exclude_json") if [ "$current_filter" = "$auto_old" ]; then _rename_auto_filter "$new_subfolder" "$exclude_json" else echo "$current_filter" fi } # ─── Name Safety ───────────────────────────────────────────────────── _rename_validate_name() { local name="$1" [ -n "$name" ] || die "Target name cannot be empty" [[ "$name" =~ ^[A-Za-z0-9._-]+$ ]] || die "Invalid target name '${name}' (must match [A-Za-z0-9._-]+, no slashes)" [[ "$name" != .* ]] || die "Target name '${name}' cannot start with '.'" [ "$name" != ".." ] || die "Target name cannot be '..'" } # ─── Concurrency Heuristic ────────────────────────────────────────── # Best-effort: every successful sync appends a commit touching / on # the state branch. A recent one is a strong (not certain) signal that a # sync is mid-flight. See docs/guide.md's Renaming a Target section for the # documented race this cannot close. _rename_check_concurrency() { local target_name="$1" force="$2" git fetch origin "$STATE_BRANCH" 2>/dev/null || true git rev-parse -q --verify "origin/${STATE_BRANCH}" >/dev/null 2>&1 || return 0 local recent recent=$(git log "origin/${STATE_BRANCH}" --since="${RENAME_CONCURRENCY_WINDOW}" \ --format='%h %ci %s' -- "${target_name}/" 2>/dev/null || echo "") [ -n "$recent" ] || return 0 if [ "$force" = true ]; then log "WARN" "Recent sync activity detected for '${target_name}' (--force, proceeding anyway):" echo "$recent" >&2 else log "ERROR" "Recent sync activity detected for '${target_name}' within ${RENAME_CONCURRENCY_WINDOW}:" echo "$recent" >&2 die "Refusing to rename while a sync may be in flight. Re-run with --force once you've confirmed it's safe." fi } # ─── Reachability (new subrepo URL only) ──────────────────────────── _rename_check_url_reachable() { local new_url="$1" local saved_url="$SUBREPO_URL" SUBREPO_URL="$new_url" local ok=0 git ls-remote "$(subrepo_auth_url)" HEAD >/dev/null 2>&1 || ok=1 SUBREPO_URL="$saved_url" return "$ok" } # ─── Subfolder Move ────────────────────────────────────────────── # Renaming a subfolder must move the actual directory in the monorepo # working tree via `git mv`, not just repoint the config — otherwise the # tree stops matching `.josh-sync.yml` until a later sync's filter-change # reconciliation happens to catch it. Both functions are idempotent against # a resumed run: if new_subfolder already exists and old_subfolder doesn't, # the move already happened, so there's nothing left to validate or do. # Usage: _rename_validate_subfolder_move _rename_validate_subfolder_move() { local old_subfolder="$1" new_subfolder="$2" if [ -e "$new_subfolder" ] && [ ! -e "$old_subfolder" ]; then return 0 fi [ -e "$old_subfolder" ] || die "Old subfolder '${old_subfolder}' does not exist in the working tree" [ -n "$(git ls-files -- "$old_subfolder")" ] \ || die "Old subfolder '${old_subfolder}' has no files tracked by git — not managed by josh-sync" [ ! -e "$new_subfolder" ] || die "New subfolder '${new_subfolder}' already exists — refusing to overwrite" } # Usage: _rename_move_subfolder _rename_move_subfolder() { local old_subfolder="$1" new_subfolder="$2" if [ -e "$new_subfolder" ] && [ ! -e "$old_subfolder" ]; then log "INFO" "Subfolder already moved to '${new_subfolder}' — skipping git mv" return 0 fi mkdir -p "$(dirname "$new_subfolder")" git mv "$old_subfolder" "$new_subfolder" \ || die "Failed to git mv '${old_subfolder}' -> '${new_subfolder}'" log "INFO" "Moved ${old_subfolder} -> ${new_subfolder} (git mv, uncommitted)" } # ─── Stale Import-Branch Warning (best-effort, never fails) ──────── # Onboarding's initial_import (lib/sync.sh) pushes staging branches named # auto-sync/import-- to the MONOREPO. These are normally # short-lived (merged then deleted), but an abandoned onboarding can leave # one behind carrying the old target name. Never fails the rename. _rename_warn_stale_import_branches() { local old_name="$1" local refs refs=$(git ls-remote --heads "$(mono_auth_url)" 2>/dev/null \ | grep "refs/heads/auto-sync/import-${old_name}-" || true) [ -n "$refs" ] || return 0 log "WARN" "Leftover import-staging branch(es) on the monorepo still reference the old target name (these are meant to be transient — merge or delete them):" echo "$refs" >&2 } # ─── Resolve Target (handles crash-resume detection) ──────────────── # Usage: _rename_resolve_target # Prints a JSON object on stdout: {target_json, old_name, already_configured} # Dies if the target cannot be found under either the given or new name. _rename_resolve_target() { local given_name="$1" new_name="$2" new_subfolder="$3" new_subrepo_url="$4" local target_json target_json=$(echo "$JOSH_SYNC_TARGETS" | jq -c --arg n "$given_name" '.[] | select(.name == $n)') if [ -n "$target_json" ]; then jq -cn --argjson t "$target_json" --arg old "$given_name" \ '{target_json:$t, old_name:$old, already_configured:false}' return 0 fi [ -n "$new_name" ] || die "Target '${given_name}' not found in config" local resumed resumed=$(echo "$JOSH_SYNC_TARGETS" | jq -c --arg n "$new_name" '.[] | select(.name == $n)') [ -n "$resumed" ] || die "Target '${given_name}' not found in config" if [ -n "$new_subfolder" ]; then local cur_subfolder cur_subfolder=$(echo "$resumed" | jq -r '.subfolder') [ "$cur_subfolder" = "$new_subfolder" ] || \ die "Config already shows target '${new_name}' but with subfolder '${cur_subfolder}', not the requested '${new_subfolder}'. Resolve manually before retrying." fi if [ -n "$new_subrepo_url" ]; then local cur_url cur_url=$(echo "$resumed" | jq -r '.subrepo_url') [ "$cur_url" = "$new_subrepo_url" ] || \ die "Config already shows target '${new_name}' but with subrepo_url '${cur_url}', not the requested '${new_subrepo_url}'. Resolve manually before retrying." fi log "INFO" "Config already shows '${new_name}' (was '${given_name}') — resuming state migration only" jq -cn --argjson t "$resumed" --arg old "$given_name" \ '{target_json:$t, old_name:$old, already_configured:true}' } # ─── Main Entry Point ──────────────────────────────────────────────── # Usage: rename_target \ # # (empty string for any of the three "requested" args = unchanged) rename_target() { local given_name="$1" req_new_name="$2" req_new_subfolder="$3" req_new_subrepo_url="$4" local config_file="$5" dry_run="$6" assume_yes="$7" force="$8" local resolved target_json old_name already_configured resolved=$(_rename_resolve_target "$given_name" "$req_new_name" "$req_new_subfolder" "$req_new_subrepo_url") target_json=$(echo "$resolved" | jq -c '.target_json') old_name=$(echo "$resolved" | jq -r '.old_name') already_configured=$(echo "$resolved" | jq -r '.already_configured') load_target "$target_json" local old_subfolder old_subrepo_url current_filter exclude_json target_json_name old_subfolder=$(echo "$target_json" | jq -r '.subfolder') old_subrepo_url=$(echo "$target_json" | jq -r '.subrepo_url') current_filter=$(echo "$target_json" | jq -r '.josh_filter') exclude_json=$(echo "$target_json" | jq -c '.exclude // []') target_json_name=$(echo "$target_json" | jq -r '.name') local new_name="${req_new_name:-$target_json_name}" local new_subfolder="${req_new_subfolder:-$old_subfolder}" local new_subrepo_url="${req_new_subrepo_url:-$old_subrepo_url}" local name_changed=false subfolder_changed=false url_changed=false [ "$new_name" != "$old_name" ] && name_changed=true [ "$new_subfolder" != "$old_subfolder" ] && subfolder_changed=true [ "$new_subrepo_url" != "$old_subrepo_url" ] && url_changed=true local new_josh_filter="" if [ "$subfolder_changed" = true ]; then new_josh_filter=$(_rename_derive_new_josh_filter "$old_subfolder" "$new_subfolder" "$exclude_json" "$current_filter") fi if [ "$name_changed" = false ] && [ "$subfolder_changed" = false ] && [ "$url_changed" = false ]; then log "INFO" "Nothing to rename — target '${old_name}' already matches the requested values" return 0 fi log "INFO" "══════ Rename target: ${old_name} ══════" [ "$name_changed" = true ] && log "INFO" " name: ${old_name} -> ${new_name}" [ "$subfolder_changed" = true ] && log "INFO" " subfolder: ${old_subfolder} -> ${new_subfolder}" [ "$subfolder_changed" = true ] && log "INFO" " josh_filter: ${current_filter} -> ${new_josh_filter}" [ "$url_changed" = true ] && log "INFO" " subrepo_url: ${old_subrepo_url} -> ${new_subrepo_url}" # Validation if [ "$name_changed" = true ]; then _rename_validate_name "$new_name" # Exclude the target being renamed by object identity (not by old_name): # on a resumed run target_json IS the already-renamed "$new_name" entry, # so comparing against old_name (no longer present in config at all) # would make it collide with itself. local collision collision=$(echo "$JOSH_SYNC_TARGETS" | jq -r --argjson t "$target_json" --arg n "$new_name" \ '[.[] | select(.name == $n) | select(. != $t)] | length') [ "$collision" -eq 0 ] || die "Target name '${new_name}' already exists in config" fi _rename_check_concurrency "$old_name" "$force" if [ "$url_changed" = true ]; then _rename_check_url_reachable "$new_subrepo_url" \ || die "New subrepo URL is not reachable: ${new_subrepo_url}" log "INFO" "New subrepo URL reachable" fi if [ "$name_changed" = true ]; then local existing existing=$(state_list_target_files "$new_name") [ -z "$existing" ] || die "State already exists for target '${new_name}' on ${STATE_BRANCH} — refusing to overwrite. Resolve manually (e.g. 'josh-sync state reset ${new_name} ') before retrying." fi if [ "$subfolder_changed" = true ]; then _rename_validate_subfolder_move "$old_subfolder" "$new_subfolder" fi if [ "$dry_run" = true ]; then log "INFO" "--dry-run: no changes written" return 0 fi if [ "$assume_yes" != true ]; then echo "Proceed with this rename? (y/N):" >&2 local confirm read -r confirm [ "$confirm" = "y" ] || [ "$confirm" = "Y" ] || die "Aborted" fi if [ "$subfolder_changed" = true ]; then _rename_move_subfolder "$old_subfolder" "$new_subfolder" fi if [ "$already_configured" != true ]; then log "INFO" "Updating ${config_file}..." # mikefarah/yq (v4, Go) has no jq-style --arg; pass values via env vars and # strenv(NAME) in the expression instead. # shellcheck disable=SC2016 # strenv(...) is a yq expression, not shell expansion RENAME_OLD_NAME="$old_name" \ RENAME_NEW_NAME="$new_name" \ RENAME_NEW_SUBFOLDER="$new_subfolder" \ RENAME_NEW_FILTER="${new_josh_filter:-$current_filter}" \ RENAME_NEW_URL="$new_subrepo_url" \ yq -i \ '(.targets[] | select(.name == strenv(RENAME_OLD_NAME))) |= (.name = strenv(RENAME_NEW_NAME) | .subfolder = strenv(RENAME_NEW_SUBFOLDER) | .josh_filter = strenv(RENAME_NEW_FILTER) | .subrepo_url = strenv(RENAME_NEW_URL))' \ "$config_file" # Re-validate through the real config parser rather than trusting the # yq edit blindly — reuses schema_version/required-field validation. parse_config "$config_file" local revalidated revalidated=$(echo "$JOSH_SYNC_TARGETS" | jq -c --arg n "$new_name" '.[] | select(.name == $n)') [ -n "$revalidated" ] || die "Config edit did not produce a valid target named '${new_name}' — check ${config_file} manually" else log "INFO" "Config already updated — skipping edit" fi state_migrate_target_prefix "$old_name" "$new_name" "$new_josh_filter" if [ "$name_changed" = true ]; then _rename_warn_stale_import_branches "$old_name" fi log "INFO" "Rename complete." }