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:
2026-07-16 10:10:40 +01:00
co-authored by Claude Sonnet 5
parent c4bcbbdd4f
commit 27b0e2acaa
11 changed files with 1089 additions and 2 deletions
+12
View File
@@ -1,5 +1,17 @@
# Changelog # Changelog
## 2.3.0
### Features
- **`josh-sync rename <target>` command** ([PE-12](https://linear.app/noqta-ai/issue/PE-12)): safely rename a target's `name`, `subfolder`, and/or `subrepo_url` in one resumable operation. Renaming by hand-editing `.josh-sync.yml` previously orphaned sync state (keyed by target name on the `josh-sync-state` branch) and left `josh_filter` stale after a subfolder move. `rename` now:
- Edits `.josh-sync.yml` (scoped to the one matching `targets[]` element) and re-derives `josh_filter` when `--subfolder` changes, then re-validates the result through `parse_config`.
- Migrates every state file under the target's `<name>/` prefix on `josh-sync-state` (per-branch state, `onboard.json`/legacy `adopt.json`) to the new name in a single commit, rewriting `.last_forward.josh_filter` where present.
- Validates the new `subrepo_url` is reachable and that no state already exists under a new name before writing anything; supports `--dry-run` and `--yes`.
- Uses a best-effort concurrency heuristic (recent commits on `josh-sync-state` for the target) to warn against renaming while a sync may be in flight; `--force` bypasses only this check — a destination-name state conflict or an unreachable new URL is never forceable.
- Is fully resumable: safe to re-run after an interruption at any point (config-already-updated, state-partially-migrated, or fully complete).
- Does not rename repositories on the git host, and does not rewrite the transient `auto-sync/mono-*`/`auto-sync/subrepo-*` branch names (they never carry the target name); a stale `auto-sync/import-<old-name>-*` staging branch from an abandoned onboarding is flagged with a warning only.
## 2.2.2 ## 2.2.2
### Fixes ### Fixes
+1 -1
View File
@@ -23,7 +23,7 @@ dist/josh-sync: bin/josh-sync lib/*.sh VERSION
@echo '# Generated by: make build' >> dist/josh-sync @echo '# Generated by: make build' >> dist/josh-sync
@echo '' >> dist/josh-sync @echo '' >> dist/josh-sync
@# Inline all library modules (strip shebangs and source directives) @# Inline all library modules (strip shebangs and source directives)
@for f in lib/core.sh lib/config.sh lib/auth.sh lib/state.sh lib/sync.sh lib/adopt.sh lib/onboard.sh; do \ @for f in lib/core.sh lib/config.sh lib/auth.sh lib/state.sh lib/sync.sh lib/adopt.sh lib/onboard.sh lib/rename.sh; do \
echo "# --- $$f ---" >> dist/josh-sync; \ echo "# --- $$f ---" >> dist/josh-sync; \
grep -v '^#!/' "$$f" | grep -v '^# shellcheck source=' >> dist/josh-sync; \ grep -v '^#!/' "$$f" | grep -v '^# shellcheck source=' >> dist/josh-sync; \
echo '' >> dist/josh-sync; \ echo '' >> dist/josh-sync; \
+1
View File
@@ -87,6 +87,7 @@ josh-sync preflight
josh-sync import <target> josh-sync import <target>
josh-sync adopt <target> [--restart] josh-sync adopt <target> [--restart]
josh-sync reset <target> josh-sync reset <target>
josh-sync rename <target> [--name NEW] [--subfolder PATH] [--subrepo-url URL] [--dry-run] [--yes] [--force]
josh-sync onboard <target> [--restart] josh-sync onboard <target> [--restart]
josh-sync migrate-pr <target> [PR#...] [--all] josh-sync migrate-pr <target> [PR#...] [--all]
josh-sync status josh-sync status
+1 -1
View File
@@ -1 +1 @@
2.2.2 2.3.0
+56
View File
@@ -11,6 +11,7 @@
# onboard <target> Interactive onboarding (auto-picks reset|adopt strategy) # onboard <target> Interactive onboarding (auto-picks reset|adopt strategy)
# adopt <target> Alias for `onboard --mode=adopt` # adopt <target> Alias for `onboard --mode=adopt`
# reset <target> Reset subrepo to josh-filtered view # reset <target> Reset subrepo to josh-filtered view
# rename <target> Rename a target's name/subfolder/subrepo_url, migrating state
# migrate-pr <target> [PR#...] [--all] Move PRs from archived to new subrepo # migrate-pr <target> [PR#...] [--all] Move PRs from archived to new subrepo
# status Show target config and sync state # status Show target config and sync state
# state show|reset Manage sync state directly # state show|reset Manage sync state directly
@@ -46,6 +47,8 @@ source "${JOSH_LIB_DIR}/sync.sh"
source "${JOSH_LIB_DIR}/adopt.sh" source "${JOSH_LIB_DIR}/adopt.sh"
# shellcheck source=../lib/onboard.sh # shellcheck source=../lib/onboard.sh
source "${JOSH_LIB_DIR}/onboard.sh" source "${JOSH_LIB_DIR}/onboard.sh"
# shellcheck source=../lib/rename.sh
source "${JOSH_LIB_DIR}/rename.sh"
# ─── Version ──────────────────────────────────────────────────────── # ─── Version ────────────────────────────────────────────────────────
@@ -82,6 +85,10 @@ Commands:
via ls-remote (heads → adopt, empty → reset). via ls-remote (heads → adopt, empty → reset).
adopt <target> Alias for 'onboard --mode=adopt' (kept for back-compat) adopt <target> Alias for 'onboard --mode=adopt' (kept for back-compat)
reset <target> Reset subrepo to josh-filtered view (after merging import PR) reset <target> Reset subrepo to josh-filtered view (after merging import PR)
rename <target> Rename a target's name/subfolder/subrepo_url. Edits
.josh-sync.yml and migrates state on the josh-sync-state
branch in one resumable operation. Requires at least one
of --name/--subfolder/--subrepo-url.
migrate-pr <target> [PR#...] [--all] Move PRs from archived to new subrepo migrate-pr <target> [PR#...] [--all] Move PRs from archived to new subrepo
status Show target config and sync state status Show target config and sync state
state show <target> [branch] Show sync state JSON state show <target> [branch] Show sync state JSON
@@ -103,6 +110,14 @@ Sync flags:
Onboard flags: Onboard flags:
--mode={reset,adopt} Override the onboarding strategy --mode={reset,adopt} Override the onboarding strategy
Rename flags:
--name NAME New target name
--subfolder PATH New monorepo subfolder (re-derives josh_filter)
--subrepo-url URL New subrepo git URL
--dry-run Print the planned changes without writing anything
--yes Skip the confirmation prompt
--force Bypass the concurrency-heuristic warning (only)
Environment: Environment:
JOSH_SYNC_TARGET Restrict to a single target name JOSH_SYNC_TARGET Restrict to a single target name
JOSH_SYNC_STATE_BRANCH State branch name (default: josh-sync-state) JOSH_SYNC_STATE_BRANCH State branch name (default: josh-sync-state)
@@ -619,6 +634,46 @@ cmd_reset() {
done done
} }
# ─── Rename Command ─────────────────────────────────────────────────
cmd_rename() {
local config_file=".josh-sync.yml"
local target_name=""
local new_name="" new_subfolder="" new_subrepo_url=""
local dry_run=false assume_yes=false force=false
while [ $# -gt 0 ]; do
case "$1" in
--config) config_file="$2"; shift 2 ;;
--debug) export JOSH_SYNC_DEBUG=1; shift ;;
--name) [ $# -ge 2 ] || die "--name requires a value"; new_name="$2"; shift 2 ;;
--subfolder) [ $# -ge 2 ] || die "--subfolder requires a value"; new_subfolder="$2"; shift 2 ;;
--subrepo-url) [ $# -ge 2 ] || die "--subrepo-url requires a value"; new_subrepo_url="$2"; shift 2 ;;
--dry-run) dry_run=true; shift ;;
--yes) assume_yes=true; shift ;;
--force) force=true; shift ;;
-*) die "Unknown flag: $1" ;;
*) target_name="$1"; shift ;;
esac
done
if [ -z "$target_name" ]; then
echo "Usage: josh-sync rename <target> [--name NEW] [--subfolder PATH] [--subrepo-url URL] [--dry-run] [--yes] [--force]" >&2
parse_config "$config_file"
echo "Available targets:" >&2
echo "$JOSH_SYNC_TARGETS" | jq -r '.[].name' | sed 's/^/ /' >&2
exit 1
fi
if [ -z "$new_name" ] && [ -z "$new_subfolder" ] && [ -z "$new_subrepo_url" ]; then
die "At least one of --name, --subfolder, or --subrepo-url is required"
fi
parse_config "$config_file"
rename_target "$target_name" "$new_name" "$new_subfolder" "$new_subrepo_url" \
"$config_file" "$dry_run" "$assume_yes" "$force"
}
# ─── Status Command ──────────────────────────────────────────────── # ─── Status Command ────────────────────────────────────────────────
cmd_status() { cmd_status() {
@@ -934,6 +989,7 @@ main() {
import) cmd_import "$@" ;; import) cmd_import "$@" ;;
adopt) cmd_adopt "$@" ;; adopt) cmd_adopt "$@" ;;
reset) cmd_reset "$@" ;; reset) cmd_reset "$@" ;;
rename) cmd_rename "$@" ;;
onboard) cmd_onboard "$@" ;; onboard) cmd_onboard "$@" ;;
migrate-pr) cmd_migrate_pr "$@" ;; migrate-pr) cmd_migrate_pr "$@" ;;
status) cmd_status "$@" ;; status) cmd_status "$@" ;;
+23
View File
@@ -673,6 +673,29 @@ To add a new subrepo after initial setup:
``` ```
5. Verify with `josh-sync status` 5. Verify with `josh-sync status`
## Renaming a Target
Hand-editing `targets[].name`, `.subfolder`, or `.subrepo_url` in `.josh-sync.yml` orphans sync state — state on the `josh-sync-state` branch is keyed by target name, and a subfolder change leaves the stored `josh_filter` stale until reconciliation happens to catch it. `josh-sync rename <target>` does both the config edit and the state migration atomically:
```bash
josh-sync rename billing --name payments # rename only
josh-sync rename billing --subfolder services/payments # move + re-derive josh_filter
josh-sync rename billing --subrepo-url git@host:ext/payments.git # point at a moved/renamed subrepo
josh-sync rename billing --name payments --subfolder services/payments # combine in one call
josh-sync rename billing --name payments --dry-run # preview only, no writes
josh-sync rename billing --name payments --yes # skip the confirmation prompt
```
What it does, in order: resolves the target (and detects a resumed run if the config already shows the new name), validates the new name/URL, checks for recent sync activity on the target (best-effort concurrency heuristic — warns/aborts unless `--force`), refuses to proceed if state already exists under the new name (never forceable), edits `.josh-sync.yml` and re-validates it, then moves every file under `<target>/` on `josh-sync-state` to the new prefix in one commit.
**What it does NOT do:**
- Rename or move the repository on the git host — do that first (Gitea/GitHub UI or API), then run `josh-sync rename` to update josh-sync's own bookkeeping.
- Rename `auto-sync/mono-*`/`auto-sync/subrepo-*` conflict/staging branches — they never carry the target name, so there's nothing to rename. An abandoned onboarding's `auto-sync/import-<old-name>-*` branch (which does carry the name) is only flagged with a warning; merge or delete it manually.
- Guarantee `.josh-sync.yml`'s comments survive byte-for-byte — the edit is a scoped `yq -i`, which may reflow surrounding formatting.
**Concurrency**: the heuristic checks for a `josh-sync-state` commit under the target within the last 5 minutes. It's best-effort — a sync that starts after the check but before rename's own push can still race. `--force` bypasses only this check; a destination-name conflict or unreachable new URL always hard-fails.
## Troubleshooting ## Troubleshooting
### "Failed to clone through josh-proxy" ### "Failed to clone through josh-proxy"
+273
View File
@@ -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."
}
+86
View File
@@ -73,3 +73,89 @@ write_state() {
git worktree remove "$tmp_dir" 2>/dev/null || rm -rf "$tmp_dir" 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"
}
+39
View File
@@ -121,3 +121,42 @@ seed_minimal_config() {
[[ "$stderr" == *"alias for --mode=adopt"* ]] [[ "$stderr" == *"alias for --mode=adopt"* ]]
[[ "$stderr" == *"conflicting"* ]] [[ "$stderr" == *"conflicting"* ]]
} }
# ─── rename flag parsing & dispatch ─────────────────────────────────
# Dispatch-layer only, like the rest of this file — anything requiring git
# activity is covered by tests/unit/rename_e2e.bats.
@test "rename with no target exits non-zero and prints usage" {
seed_minimal_config
run --separate-stderr "$JOSH_BIN" rename
[ "$status" -ne 0 ]
[[ "$stderr" == *"Usage: josh-sync rename"* ]]
}
@test "rename with none of --name/--subfolder/--subrepo-url dies with a clear message" {
seed_minimal_config
run --separate-stderr "$JOSH_BIN" rename example
[ "$status" -ne 0 ]
[[ "$stderr" == *"At least one of --name, --subfolder, or --subrepo-url is required"* ]]
}
@test "rename against an unknown target dies with not-found, before any git/network activity" {
seed_minimal_config
run --separate-stderr "$JOSH_BIN" rename nonexistent --name renamed
[ "$status" -ne 0 ]
[[ "$stderr" == *"not found"* ]]
}
@test "rename --name (no value) fails with a helpful message instead of crashing on unbound \$2" {
seed_minimal_config
run --separate-stderr "$JOSH_BIN" rename example --name
[ "$status" -ne 0 ]
[[ "$stderr" == *"--name requires a value"* ]]
}
@test "--help advertises the rename command and its flags" {
run --separate-stderr "$JOSH_BIN" --help
[ "$status" -eq 0 ]
[[ "$stderr" == *"rename <target>"* ]]
[[ "$stderr" == *"--subrepo-url URL"* ]]
}
+215
View File
@@ -0,0 +1,215 @@
#!/usr/bin/env bats
# tests/unit/rename.bats — Config-only rename tests (name/subfolder edits,
# validation, resumability field checks).
#
# No real git remote is touched here: `--subrepo-url` renames (which need a
# reachability check) and all actual state-branch migration behavior are
# covered by tests/unit/rename_e2e.bats instead. Every call below either
# omits --subrepo-url or passes assume_yes=true with no state branch present,
# so the concurrency/state-conflict checks run against a non-git tmpdir and
# harmlessly no-op (git commands fail closed, caught by `|| return 0`/`|| true`
# in lib/state.sh and lib/rename.sh).
setup() {
export JOSH_SYNC_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)"
source "$JOSH_SYNC_ROOT/lib/core.sh"
source "$JOSH_SYNC_ROOT/lib/config.sh"
source "$JOSH_SYNC_ROOT/lib/auth.sh"
source "$JOSH_SYNC_ROOT/lib/state.sh"
source "$JOSH_SYNC_ROOT/lib/rename.sh"
FIXTURES="$JOSH_SYNC_ROOT/tests/fixtures"
}
# ─── _rename_validate_name ─────────────────────────────────────────
@test "_rename_validate_name rejects a name with a slash" {
run _rename_validate_name "foo/bar"
[ "$status" -ne 0 ]
}
@test "_rename_validate_name rejects a name starting with a dot" {
run _rename_validate_name ".hidden"
[ "$status" -ne 0 ]
}
@test "_rename_validate_name rejects '..'" {
run _rename_validate_name ".."
[ "$status" -ne 0 ]
}
@test "_rename_validate_name accepts a normal name" {
run _rename_validate_name "payments-v2"
[ "$status" -eq 0 ]
}
# ─── josh_filter derivation ─────────────────────────────────────────
@test "_rename_auto_filter derives plain filter with no exclude" {
result=$(_rename_auto_filter "services/app" "[]")
[ "$result" = ":/services/app" ]
}
@test "_rename_auto_filter derives exclude filter" {
result=$(_rename_auto_filter "services/app" '[".monorepo/"]')
[ "$result" = ":/services/app:exclude[::.monorepo/]" ]
}
@test "_rename_derive_new_josh_filter tracks an auto-derived filter across a subfolder rename" {
result=$(_rename_derive_new_josh_filter "services/old" "services/new" "[]" ":/services/old")
[ "$result" = ":/services/new" ]
}
@test "_rename_derive_new_josh_filter leaves an explicit override alone" {
result=$(_rename_derive_new_josh_filter "services/old" "services/new" "[]" ":/custom/filter")
[ "$result" = ":/custom/filter" ]
}
# ─── rename_target: config edits ───────────────────────────────────
@test "rename_target --name updates only the matching target in a multi-target config" {
cd "$(mktemp -d)"
cp "$FIXTURES/multi-target.yml" .josh-sync.yml
parse_config ".josh-sync.yml"
rename_target "app-a" "app-a-renamed" "" "" ".josh-sync.yml" false true false
parse_config ".josh-sync.yml"
names=$(echo "$JOSH_SYNC_TARGETS" | jq -r '[.[].name] | sort | join(",")')
[ "$names" = "app-a-renamed,app-b" ]
app_b_subfolder=$(echo "$JOSH_SYNC_TARGETS" | jq -r '.[] | select(.name == "app-b") | .subfolder')
[ "$app_b_subfolder" = "services/app-b" ]
}
@test "rename_target --subfolder re-derives josh_filter" {
cd "$(mktemp -d)"
cp "$FIXTURES/minimal.yml" .josh-sync.yml
parse_config ".josh-sync.yml"
rename_target "example" "" "services/relocated" "" ".josh-sync.yml" false true false
parse_config ".josh-sync.yml"
filter=$(echo "$JOSH_SYNC_TARGETS" | jq -r '.[0].josh_filter')
[ "$filter" = ":/services/relocated" ]
subfolder=$(echo "$JOSH_SYNC_TARGETS" | jq -r '.[0].subfolder')
[ "$subfolder" = "services/relocated" ]
}
@test "rename_target combining --name and --subfolder updates both fields" {
cd "$(mktemp -d)"
cp "$FIXTURES/minimal.yml" .josh-sync.yml
parse_config ".josh-sync.yml"
rename_target "example" "relocated" "services/relocated" "" ".josh-sync.yml" false true false
parse_config ".josh-sync.yml"
name=$(echo "$JOSH_SYNC_TARGETS" | jq -r '.[0].name')
subfolder=$(echo "$JOSH_SYNC_TARGETS" | jq -r '.[0].subfolder')
filter=$(echo "$JOSH_SYNC_TARGETS" | jq -r '.[0].josh_filter')
[ "$name" = "relocated" ]
[ "$subfolder" = "services/relocated" ]
[ "$filter" = ":/services/relocated" ]
}
@test "rename_target rejects a new name colliding with an existing target" {
cd "$(mktemp -d)"
cp "$FIXTURES/multi-target.yml" .josh-sync.yml
parse_config ".josh-sync.yml"
run rename_target "app-a" "app-b" "" "" ".josh-sync.yml" false true false
[ "$status" -ne 0 ]
[[ "$output" == *"already exists"* ]]
}
@test "rename_target rejects an unsafe new name" {
cd "$(mktemp -d)"
cp "$FIXTURES/minimal.yml" .josh-sync.yml
parse_config ".josh-sync.yml"
run rename_target "example" "bad/name" "" "" ".josh-sync.yml" false true false
[ "$status" -ne 0 ]
}
@test "rename_target --dry-run leaves the config file byte-identical" {
cd "$(mktemp -d)"
cp "$FIXTURES/minimal.yml" .josh-sync.yml
parse_config ".josh-sync.yml"
before=$(cat .josh-sync.yml)
rename_target "example" "renamed" "" "" ".josh-sync.yml" true true false
after=$(cat .josh-sync.yml)
[ "$before" = "$after" ]
}
@test "rename_target edited config re-validates independently through parse_config" {
cd "$(mktemp -d)"
cp "$FIXTURES/minimal.yml" .josh-sync.yml
parse_config ".josh-sync.yml"
rename_target "example" "renamed" "" "" ".josh-sync.yml" false true false
run parse_config ".josh-sync.yml"
[ "$status" -eq 0 ]
}
@test "rename_target with nothing changed is a no-op" {
cd "$(mktemp -d)"
cp "$FIXTURES/minimal.yml" .josh-sync.yml
parse_config ".josh-sync.yml"
before=$(cat .josh-sync.yml)
rename_target "example" "" "" "" ".josh-sync.yml" false true false
after=$(cat .josh-sync.yml)
[ "$before" = "$after" ]
}
@test "rename_target dies for an unknown target" {
cd "$(mktemp -d)"
cp "$FIXTURES/minimal.yml" .josh-sync.yml
parse_config ".josh-sync.yml"
run rename_target "does-not-exist" "renamed" "" "" ".josh-sync.yml" false true false
[ "$status" -ne 0 ]
[[ "$output" == *"not found"* ]]
}
# ─── _rename_resolve_target: resumability ──────────────────────────
@test "_rename_resolve_target finds the target under its current name" {
cd "$(mktemp -d)"
cp "$FIXTURES/minimal.yml" .josh-sync.yml
parse_config ".josh-sync.yml"
result=$(_rename_resolve_target "example" "renamed" "" "")
already=$(echo "$result" | jq -r '.already_configured')
old=$(echo "$result" | jq -r '.old_name')
[ "$already" = "false" ]
[ "$old" = "example" ]
}
@test "_rename_resolve_target detects a resumed run (config already shows the new name)" {
cd "$(mktemp -d)"
cp "$FIXTURES/minimal.yml" .josh-sync.yml
yq -i '(.targets[] | select(.name == "example")).name = "renamed"' .josh-sync.yml
parse_config ".josh-sync.yml"
result=$(_rename_resolve_target "example" "renamed" "" "")
already=$(echo "$result" | jq -r '.already_configured')
old=$(echo "$result" | jq -r '.old_name')
new_name=$(echo "$result" | jq -r '.target_json.name')
[ "$already" = "true" ]
[ "$old" = "example" ]
[ "$new_name" = "renamed" ]
}
@test "_rename_resolve_target dies when neither the old nor new name exists" {
cd "$(mktemp -d)"
cp "$FIXTURES/minimal.yml" .josh-sync.yml
parse_config ".josh-sync.yml"
run _rename_resolve_target "does-not-exist" "also-missing" "" ""
[ "$status" -ne 0 ]
}
+382
View File
@@ -0,0 +1,382 @@
#!/usr/bin/env bats
bats_require_minimum_version 1.5.0
# tests/unit/rename_e2e.bats — End-to-end `josh-sync rename` behaviour
#
# Covers: state_list_target_files / state_migrate_target_prefix primitives,
# rename_target's full validation order (concurrency heuristic, destination
# state conflict, URL reachability), and crash-resumability.
#
# Tests run against real local bare git repos — no mocking of the git layer,
# following the tests/unit/adopt_e2e.bats convention. Local filesystem paths
# work directly as subrepo_url/monorepo_url values (subrepo_auth_url()/
# mono_auth_url() only rewrite URLs starting with "https://"/"git@"/"ssh://",
# so a plain path passes through unchanged) — no auth shimming needed.
setup() {
export JOSH_SYNC_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)"
source "$JOSH_SYNC_ROOT/lib/core.sh"
source "$JOSH_SYNC_ROOT/lib/config.sh"
source "$JOSH_SYNC_ROOT/lib/auth.sh"
source "$JOSH_SYNC_ROOT/lib/state.sh"
source "$JOSH_SYNC_ROOT/lib/rename.sh"
TEST_ROOT="$(mktemp -d)"
# Needed by state_migrate_target_prefix/seed_state_commit when called
# directly (without parse_config first setting these from the config file).
export BOT_NAME="test-bot"
export BOT_EMAIL="test-bot@test.local"
}
teardown() {
# Restore cwd before rm -rf — setup_state_monorepo cd's into a dir about to
# be deleted, and bats doesn't reset cwd between tests.
cd "$BATS_TEST_DIRNAME" 2>/dev/null || true
rm -rf "$TEST_ROOT"
}
# ─── Helpers ────────────────────────────────────────────────────────
# Build a fresh bare repo seeded with one commit on main; prints the bare path.
make_bare_repo() {
local name="$1"
local work="${TEST_ROOT}/${name}-work"
local bare="${TEST_ROOT}/${name}.git"
git init -q "$work"
git -C "$work" checkout -q -b main
git -C "$work" config user.name "Test User"
git -C "$work" config user.email "test@test.local"
printf '%s\n' "$name" > "${work}/README.md"
git -C "$work" add README.md
git -C "$work" commit -q -m "initial"
git init -q --bare "$bare"
git -C "$bare" symbolic-ref HEAD refs/heads/main
git -C "$work" remote add origin "$bare"
git -C "$work" push -q origin main
printf '%s\n' "$bare"
}
# Stand up a local working "monorepo" with an `origin` bare remote and cd into
# it, so state.sh's worktree+push machinery has something real to push to.
setup_state_monorepo() {
local mono_work="${TEST_ROOT}/mono"
local mono_bare="${TEST_ROOT}/mono.git"
git init -q "$mono_work"
git -C "$mono_work" checkout -q -b main
git -C "$mono_work" config user.name "Test User"
git -C "$mono_work" config user.email "test@test.local"
printf 'mono\n' > "${mono_work}/README.md"
git -C "$mono_work" add README.md
git -C "$mono_work" commit -q -m "initial"
git init -q --bare "$mono_bare"
git -C "$mono_work" remote add origin "$mono_bare"
git -C "$mono_work" push -q origin main
cd "$mono_work"
}
# Write a minimal .josh-sync.yml with one target "billing" in the cwd.
#
# josh.monorepo_url is a well-formed PLACEHOLDER (parse_config validates its
# shape to derive a gitea host, but nothing in rename_target actually dials
# it — state.sh's fetch/push calls use the cwd's real "origin" remote, which
# setup_state_monorepo already points at the real bare repo).
write_billing_config() {
local subrepo_url="$1"
cat > .josh-sync.yml <<YAML
schema_version: 2
josh:
proxy_url: "https://josh.test.local"
monorepo_path: "org/monorepo"
monorepo_url: "git@gitea.test.local:org/monorepo.git"
targets:
- name: "billing"
subfolder: "services/billing"
subrepo_url: "${subrepo_url}"
branches:
main: main
bot:
name: "test-bot"
email: "test-bot@test.local"
trailer: "Josh-Sync-Origin"
YAML
}
# Seed one or more files directly onto the state branch in a single commit.
# Usage: seed_state_commit <author_date_or_empty> <path1> <content1> [<path2> <content2> ...]
seed_state_commit() {
local commit_date="$1"; shift
local tmp
tmp=$(mktemp -d)
if git rev-parse "origin/${STATE_BRANCH}" >/dev/null 2>&1; then
git worktree add "$tmp" "origin/${STATE_BRANCH}" >/dev/null 2>&1
else
git worktree add --detach "$tmp" >/dev/null 2>&1
( cd "$tmp" && git checkout -q --orphan "$STATE_BRANCH" && { git rm -rfq . 2>/dev/null || true; } )
fi
while [ $# -ge 2 ]; do
local path="$1" content="$2"; shift 2
mkdir -p "$(dirname "${tmp}/${path}")"
printf '%s' "$content" > "${tmp}/${path}"
done
(
cd "$tmp" || exit 1
git add -A
if [ -n "$commit_date" ]; then
GIT_AUTHOR_DATE="$commit_date" GIT_COMMITTER_DATE="$commit_date" \
git -c user.name="test-bot" -c user.email="test-bot@test.local" \
commit -q -m "seed state"
else
git -c user.name="test-bot" -c user.email="test-bot@test.local" \
commit -q -m "seed state"
fi
git push -q origin "HEAD:${STATE_BRANCH}"
)
git worktree remove "$tmp" 2>/dev/null || rm -rf "$tmp"
}
state_branch_commit_count() {
git fetch origin "$STATE_BRANCH" >/dev/null 2>&1 || true
git rev-parse -q --verify "origin/${STATE_BRANCH}" >/dev/null 2>&1 || { echo 0; return; }
git rev-list --count "origin/${STATE_BRANCH}"
}
# ─── state_list_target_files / state_migrate_target_prefix ─────────
@test "state_list_target_files lists every path under a target prefix" {
setup_state_monorepo
seed_state_commit "" "billing/main.json" '{}' "billing/onboard.json" '{"step":"complete"}'
files=$(state_list_target_files "billing")
[[ "$files" == *"billing/main.json"* ]]
[[ "$files" == *"billing/onboard.json"* ]]
}
@test "state_list_target_files is empty when the state branch doesn't exist" {
setup_state_monorepo
files=$(state_list_target_files "billing")
[ -z "$files" ]
}
@test "state_migrate_target_prefix moves per-branch state and onboard.json in one commit" {
setup_state_monorepo
seed_state_commit "" "billing/main.json" '{"last_forward":{"mono_sha":"abc"}}' \
"billing/onboard.json" '{"step":"complete"}'
before=$(state_branch_commit_count)
state_migrate_target_prefix "billing" "payments" ""
after=$(state_branch_commit_count)
[ "$after" -eq $((before + 1)) ]
old_files=$(state_list_target_files "billing")
new_files=$(state_list_target_files "payments")
[ -z "$old_files" ]
[[ "$new_files" == *"payments/main.json"* ]]
[[ "$new_files" == *"payments/onboard.json"* ]]
}
@test "state_migrate_target_prefix rewrites last_forward.josh_filter, leaves onboard.json untouched" {
setup_state_monorepo
seed_state_commit "" \
"billing/main.json" '{"last_forward":{"mono_sha":"abc","josh_filter":":/services/billing"}}' \
"billing/onboard.json" '{"step":"complete"}'
state_migrate_target_prefix "billing" "payments" ":/services/payments"
git fetch origin "$STATE_BRANCH" >/dev/null 2>&1
filter=$(git show "origin/${STATE_BRANCH}:payments/main.json" | jq -r '.last_forward.josh_filter')
mono_sha=$(git show "origin/${STATE_BRANCH}:payments/main.json" | jq -r '.last_forward.mono_sha')
onboard_step=$(git show "origin/${STATE_BRANCH}:payments/onboard.json" | jq -r '.step')
[ "$filter" = ":/services/payments" ]
[ "$mono_sha" = "abc" ]
[ "$onboard_step" = "complete" ]
}
@test "state_migrate_target_prefix is a no-op when the source prefix has no files" {
setup_state_monorepo
run state_migrate_target_prefix "ghost" "new-ghost" ""
[ "$status" -eq 0 ]
run git rev-parse "origin/${STATE_BRANCH}"
[ "$status" -ne 0 ]
}
@test "state_migrate_target_prefix dies without partial mutation when a destination already exists" {
setup_state_monorepo
seed_state_commit "" "billing/main.json" '{}' "billing/develop.json" '{}'
seed_state_commit "" "payments/main.json" '{"pre-existing":true}'
before=$(state_branch_commit_count)
run state_migrate_target_prefix "billing" "payments" ""
[ "$status" -ne 0 ]
after=$(state_branch_commit_count)
[ "$after" -eq "$before" ]
remaining=$(state_list_target_files "billing")
[[ "$remaining" == *"billing/main.json"* ]]
[[ "$remaining" == *"billing/develop.json"* ]]
}
# ─── rename_target: destination-state conflict (never forceable) ──
@test "rename_target aborts on a destination-state conflict, and --force does not bypass it" {
setup_state_monorepo
local subrepo_bare
subrepo_bare=$(make_bare_repo "billing-sub")
write_billing_config "$subrepo_bare"
parse_config ".josh-sync.yml"
seed_state_commit "" "payments/main.json" '{"pre-existing":true}'
run rename_target "billing" "payments" "" "" ".josh-sync.yml" false true false
[ "$status" -ne 0 ]
[[ "$output" == *"already exists"* ]] || [[ "$output" == *"refusing"* ]]
run rename_target "billing" "payments" "" "" ".josh-sync.yml" false true true
[ "$status" -ne 0 ]
}
# ─── rename_target: concurrency heuristic ──────────────────────────
@test "rename_target concurrency heuristic aborts on a recent state-branch commit, without --force" {
setup_state_monorepo
local subrepo_bare
subrepo_bare=$(make_bare_repo "billing-sub")
write_billing_config "$subrepo_bare"
parse_config ".josh-sync.yml"
seed_state_commit "" "billing/main.json" '{"last_forward":{"mono_sha":"abc"}}'
run rename_target "billing" "payments" "" "" ".josh-sync.yml" false true false
[ "$status" -ne 0 ]
[[ "$output" == *"Recent sync activity"* ]]
}
@test "rename_target concurrency heuristic: --force proceeds" {
setup_state_monorepo
local subrepo_bare
subrepo_bare=$(make_bare_repo "billing-sub")
write_billing_config "$subrepo_bare"
parse_config ".josh-sync.yml"
seed_state_commit "" "billing/main.json" '{"last_forward":{"mono_sha":"abc"}}'
rename_target "billing" "payments" "" "" ".josh-sync.yml" false true true
parse_config ".josh-sync.yml"
name=$(echo "$JOSH_SYNC_TARGETS" | jq -r '.[0].name')
[ "$name" = "payments" ]
}
@test "rename_target concurrency heuristic does not block on a commit older than the window" {
setup_state_monorepo
local subrepo_bare
subrepo_bare=$(make_bare_repo "billing-sub")
write_billing_config "$subrepo_bare"
parse_config ".josh-sync.yml"
local old_date
old_date=$(date -u -d '1 hour ago' +%Y-%m-%dT%H:%M:%S 2>/dev/null || date -u -v-1H +%Y-%m-%dT%H:%M:%S)
seed_state_commit "$old_date" "billing/main.json" '{"last_forward":{"mono_sha":"abc"}}'
rename_target "billing" "payments" "" "" ".josh-sync.yml" false true false
parse_config ".josh-sync.yml"
name=$(echo "$JOSH_SYNC_TARGETS" | jq -r '.[0].name')
[ "$name" = "payments" ]
}
# ─── rename_target: subrepo URL reachability ───────────────────────
@test "rename_target dies when the new subrepo URL is unreachable" {
setup_state_monorepo
local subrepo_bare
subrepo_bare=$(make_bare_repo "billing-sub")
write_billing_config "$subrepo_bare"
parse_config ".josh-sync.yml"
run rename_target "billing" "" "" "${TEST_ROOT}/does-not-exist.git" ".josh-sync.yml" false true false
[ "$status" -ne 0 ]
[[ "$output" == *"not reachable"* ]]
}
@test "rename_target succeeds when the new subrepo URL is reachable" {
setup_state_monorepo
local subrepo_bare new_bare
subrepo_bare=$(make_bare_repo "billing-sub")
new_bare=$(make_bare_repo "payments-sub")
write_billing_config "$subrepo_bare"
parse_config ".josh-sync.yml"
rename_target "billing" "" "" "$new_bare" ".josh-sync.yml" false true false
parse_config ".josh-sync.yml"
url=$(echo "$JOSH_SYNC_TARGETS" | jq -r '.[0].subrepo_url')
[ "$url" = "$new_bare" ]
}
# ─── rename_target: resumability ───────────────────────────────────
@test "rename_target resumes cleanly when config already shows the new name but state wasn't migrated" {
setup_state_monorepo
local subrepo_bare
subrepo_bare=$(make_bare_repo "billing-sub")
# Config already shows the NEW name — simulates a crash after the config
# edit landed but before state migration ran.
write_billing_config "$subrepo_bare"
sed -i 's/name: "billing"/name: "payments"/' .josh-sync.yml
parse_config ".josh-sync.yml"
seed_state_commit "" "billing/main.json" '{"last_forward":{"mono_sha":"abc"}}'
# --force: the seed above is itself a "recent" state-branch commit under
# billing/, which the concurrency heuristic correctly flags — this test is
# about resumability, not the heuristic (covered separately above).
rename_target "billing" "payments" "" "" ".josh-sync.yml" false true true
old_files=$(state_list_target_files "billing")
new_files=$(state_list_target_files "payments")
[ -z "$old_files" ]
[[ "$new_files" == *"payments/main.json"* ]]
}
# ─── rename_target: combined name+subfolder in one call ───────────
@test "rename_target combining --name and --subfolder produces exactly one state-branch commit" {
setup_state_monorepo
local subrepo_bare
subrepo_bare=$(make_bare_repo "billing-sub")
write_billing_config "$subrepo_bare"
parse_config ".josh-sync.yml"
seed_state_commit "" "billing/main.json" '{"last_forward":{"mono_sha":"abc","josh_filter":":/services/billing"}}'
# --force: the seed_state_commit above just wrote a "recent" commit under
# billing/, which the concurrency heuristic (correctly) flags — this test
# is about the combined name+subfolder migration, not the heuristic itself.
before=$(state_branch_commit_count)
rename_target "billing" "payments" "services/payments" "" ".josh-sync.yml" false true true
after=$(state_branch_commit_count)
[ "$after" -eq $((before + 1)) ]
parse_config ".josh-sync.yml"
name=$(echo "$JOSH_SYNC_TARGETS" | jq -r '.[0].name')
subfolder=$(echo "$JOSH_SYNC_TARGETS" | jq -r '.[0].subfolder')
[ "$name" = "payments" ]
[ "$subfolder" = "services/payments" ]
git fetch origin "$STATE_BRANCH" >/dev/null 2>&1
filter=$(git show "origin/${STATE_BRANCH}:payments/main.json" | jq -r '.last_forward.josh_filter')
[ "$filter" = ":/services/payments" ]
msg=$(git log "origin/${STATE_BRANCH}" -1 --format=%s)
[ "$msg" = "state: rename billing -> payments" ]
}