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:
@@ -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" ]
|
||||
}
|
||||
Reference in New Issue
Block a user