feat(josh-sync): add rename command for safe target renames
Adds `josh-sync rename <target>` (PE-12): safely rename a sync target's name, subfolder, and/or subrepo_url in one resumable operation, editing .josh-sync.yml and migrating its state-branch files atomically instead of orphaning them. Bumps josh-sync to v2.3.0. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TNXgcFWVgSh3wkQS55cCX6
This commit is contained in:
+273
@@ -0,0 +1,273 @@
|
||||
#!/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 :/<subfolder>. 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 <old_subfolder> <new_subfolder> <exclude_json> <current_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 <target>/ 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"
|
||||
}
|
||||
|
||||
# ─── Stale Import-Branch Warning (best-effort, never fails) ────────
|
||||
# Onboarding's initial_import (lib/sync.sh) pushes staging branches named
|
||||
# auto-sync/import-<target>-<timestamp> 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 <given_name> <new_name> <new_subfolder> <new_subrepo_url>
|
||||
# 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 <given_name> <requested_new_name> <requested_new_subfolder> \
|
||||
# <requested_new_subrepo_url> <config_file> <dry_run> <assume_yes> <force>
|
||||
# (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} <branch>') before retrying."
|
||||
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 [ "$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."
|
||||
}
|
||||
@@ -73,3 +73,89 @@ write_state() {
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user