From 27b0e2acaa07054d033c4d8fc12c21eeaf59ea68 Mon Sep 17 00:00:00 2001 From: SBPro Date: Thu, 16 Jul 2026 10:10:40 +0100 Subject: [PATCH] feat(josh-sync): add rename command for safe target renames Adds `josh-sync rename ` (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 Claude-Session: https://claude.ai/code/session_01TNXgcFWVgSh3wkQS55cCX6 --- CHANGELOG.md | 12 ++ Makefile | 2 +- README.md | 1 + VERSION | 2 +- bin/josh-sync | 56 ++++++ docs/guide.md | 23 +++ lib/rename.sh | 273 ++++++++++++++++++++++++++ lib/state.sh | 86 +++++++++ tests/unit/cli.bats | 39 ++++ tests/unit/rename.bats | 215 +++++++++++++++++++++ tests/unit/rename_e2e.bats | 382 +++++++++++++++++++++++++++++++++++++ 11 files changed, 1089 insertions(+), 2 deletions(-) create mode 100644 lib/rename.sh create mode 100644 tests/unit/rename.bats create mode 100644 tests/unit/rename_e2e.bats diff --git a/CHANGELOG.md b/CHANGELOG.md index 1114ceb..0abdc8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 2.3.0 + +### Features + +- **`josh-sync rename ` 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 `/` 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--*` staging branch from an abandoned onboarding is flagged with a warning only. + ## 2.2.2 ### Fixes diff --git a/Makefile b/Makefile index 754132c..0066708 100644 --- a/Makefile +++ b/Makefile @@ -23,7 +23,7 @@ dist/josh-sync: bin/josh-sync lib/*.sh VERSION @echo '# Generated by: make build' >> dist/josh-sync @echo '' >> dist/josh-sync @# 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; \ grep -v '^#!/' "$$f" | grep -v '^# shellcheck source=' >> dist/josh-sync; \ echo '' >> dist/josh-sync; \ diff --git a/README.md b/README.md index 01966b2..eeaac7e 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,7 @@ josh-sync preflight josh-sync import josh-sync adopt [--restart] josh-sync reset +josh-sync rename [--name NEW] [--subfolder PATH] [--subrepo-url URL] [--dry-run] [--yes] [--force] josh-sync onboard [--restart] josh-sync migrate-pr [PR#...] [--all] josh-sync status diff --git a/VERSION b/VERSION index b1b25a5..276cbf9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.2.2 +2.3.0 diff --git a/bin/josh-sync b/bin/josh-sync index bba810b..9ed2a28 100755 --- a/bin/josh-sync +++ b/bin/josh-sync @@ -11,6 +11,7 @@ # onboard Interactive onboarding (auto-picks reset|adopt strategy) # adopt Alias for `onboard --mode=adopt` # reset Reset subrepo to josh-filtered view +# rename Rename a target's name/subfolder/subrepo_url, migrating state # migrate-pr [PR#...] [--all] Move PRs from archived to new subrepo # status Show target config and sync state # state show|reset Manage sync state directly @@ -46,6 +47,8 @@ source "${JOSH_LIB_DIR}/sync.sh" source "${JOSH_LIB_DIR}/adopt.sh" # shellcheck source=../lib/onboard.sh source "${JOSH_LIB_DIR}/onboard.sh" +# shellcheck source=../lib/rename.sh +source "${JOSH_LIB_DIR}/rename.sh" # ─── Version ──────────────────────────────────────────────────────── @@ -82,6 +85,10 @@ Commands: via ls-remote (heads → adopt, empty → reset). adopt Alias for 'onboard --mode=adopt' (kept for back-compat) reset Reset subrepo to josh-filtered view (after merging import PR) + rename 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 [PR#...] [--all] Move PRs from archived to new subrepo status Show target config and sync state state show [branch] Show sync state JSON @@ -103,6 +110,14 @@ Sync flags: Onboard flags: --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: JOSH_SYNC_TARGET Restrict to a single target name JOSH_SYNC_STATE_BRANCH State branch name (default: josh-sync-state) @@ -619,6 +634,46 @@ cmd_reset() { 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 [--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 ──────────────────────────────────────────────── cmd_status() { @@ -934,6 +989,7 @@ main() { import) cmd_import "$@" ;; adopt) cmd_adopt "$@" ;; reset) cmd_reset "$@" ;; + rename) cmd_rename "$@" ;; onboard) cmd_onboard "$@" ;; migrate-pr) cmd_migrate_pr "$@" ;; status) cmd_status "$@" ;; diff --git a/docs/guide.md b/docs/guide.md index 6ce78fd..5c04b3c 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -673,6 +673,29 @@ To add a new subrepo after initial setup: ``` 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 ` 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 `/` 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--*` 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 ### "Failed to clone through josh-proxy" diff --git a/lib/rename.sh b/lib/rename.sh new file mode 100644 index 0000000..ec461e0 --- /dev/null +++ b/lib/rename.sh @@ -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 :/. 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" +} + +# ─── 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 [ "$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." +} diff --git a/lib/state.sh b/lib/state.sh index 2410fac..1e89cbc 100644 --- a/lib/state.sh +++ b/lib/state.sh @@ -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 "/" 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 / 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 / to / (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 / has no files. +# Dies before mutating anything if any destination path already exists. +# +# Usage: state_migrate_target_prefix [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" +} diff --git a/tests/unit/cli.bats b/tests/unit/cli.bats index 7f08b8e..d789314 100644 --- a/tests/unit/cli.bats +++ b/tests/unit/cli.bats @@ -121,3 +121,42 @@ seed_minimal_config() { [[ "$stderr" == *"alias for --mode=adopt"* ]] [[ "$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 "* ]] + [[ "$stderr" == *"--subrepo-url URL"* ]] +} diff --git a/tests/unit/rename.bats b/tests/unit/rename.bats new file mode 100644 index 0000000..97fd9be --- /dev/null +++ b/tests/unit/rename.bats @@ -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 ] +} diff --git a/tests/unit/rename_e2e.bats b/tests/unit/rename_e2e.bats new file mode 100644 index 0000000..201afc9 --- /dev/null +++ b/tests/unit/rename_e2e.bats @@ -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 < [ ...] +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" ] +}