Files
josh-sync/tests/unit/rename_e2e.bats
T
sbproandClaude Sonnet 5 27b0e2acaa 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
2026-07-16 10:10:40 +01:00

383 lines
14 KiB
Bash

#!/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" ]
}