2026-07-16 10:10:40 +01:00
#!/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 "
}
2026-07-17 11:58:55 +01:00
# ─── 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 <old_subfolder> <new_subfolder>
_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 <old_subfolder> <new_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)"
}
2026-07-16 10:10:40 +01:00
# ─── 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
2026-07-17 11:58:55 +01:00
if [ " $subfolder_changed " = true ] ; then
_rename_validate_subfolder_move " $old_subfolder " " $new_subfolder "
fi
2026-07-16 10:10:40 +01:00
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
2026-07-17 11:58:55 +01:00
if [ " $subfolder_changed " = true ] ; then
_rename_move_subfolder " $old_subfolder " " $new_subfolder "
fi
2026-07-16 10:10:40 +01:00
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."
}