#!/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 repo with no # "origin" remote and harmlessly no-op (git commands fail closed, caught by # `|| return 0`/`|| true` in lib/state.sh and lib/rename.sh). # # `--subfolder` renames DO need a real (local, remote-less) git repo, since # _rename_validate_subfolder_move/_rename_move_subfolder use `git ls-files` # and `git mv` against the actual working tree — see init_local_git_repo. 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" } # Init a plain local git repo in cwd — no remote, just enough for # `git ls-files`/`git mv` to work. Use for any test touching --subfolder. init_local_git_repo() { git init -q . git config user.name "Test User" git config user.email "test@test.local" } # ─── _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 and git mv's the directory" { cd "$(mktemp -d)" cp "$FIXTURES/minimal.yml" .josh-sync.yml init_local_git_repo mkdir -p services/example echo "content" > services/example/file.txt git add -A git commit -q -m "seed" 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" ] [ ! -e services/example ] [ -f services/relocated/file.txt ] # git mv stages the rename — should be staged, not left as an untracked file. staged=$(git diff --cached --name-only) [[ "$staged" == *"relocated/file.txt"* ]] } @test "rename_target combining --name and --subfolder updates both fields and moves the directory" { cd "$(mktemp -d)" cp "$FIXTURES/minimal.yml" .josh-sync.yml init_local_git_repo mkdir -p services/example echo "content" > services/example/file.txt git add -A git commit -q -m "seed" 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" ] [ ! -e services/example ] [ -f services/relocated/file.txt ] } # ─── subfolder move validation/idempotency ───────────────────────── @test "_rename_validate_subfolder_move dies when the old subfolder doesn't exist" { cd "$(mktemp -d)" init_local_git_repo run _rename_validate_subfolder_move "services/ghost" "services/new" [ "$status" -ne 0 ] [[ "$output" == *"does not exist"* ]] } @test "_rename_validate_subfolder_move dies when the old subfolder isn't tracked by git" { cd "$(mktemp -d)" init_local_git_repo mkdir -p services/untracked echo "x" > services/untracked/file.txt run _rename_validate_subfolder_move "services/untracked" "services/new" [ "$status" -ne 0 ] [[ "$output" == *"not managed by josh-sync"* ]] } @test "_rename_validate_subfolder_move dies when the new subfolder already exists" { cd "$(mktemp -d)" init_local_git_repo mkdir -p services/old services/new echo "x" > services/old/file.txt echo "y" > services/new/file.txt git add -A git commit -q -m "seed" run _rename_validate_subfolder_move "services/old" "services/new" [ "$status" -ne 0 ] [[ "$output" == *"already exists"* ]] } @test "_rename_validate_subfolder_move is a no-op when the move already happened" { cd "$(mktemp -d)" init_local_git_repo mkdir -p services/new echo "x" > services/new/file.txt git add -A git commit -q -m "seed" run _rename_validate_subfolder_move "services/old" "services/new" [ "$status" -eq 0 ] } @test "_rename_move_subfolder is idempotent when re-run after the move already happened" { cd "$(mktemp -d)" init_local_git_repo mkdir -p services/new echo "x" > services/new/file.txt git add -A git commit -q -m "seed" run _rename_move_subfolder "services/old" "services/new" [ "$status" -eq 0 ] [ -f services/new/file.txt ] } @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 ] }