feat(josh-sync): rename --subfolder now git mv's the actual directory
Previously --subfolder only repointed .josh-sync.yml, leaving the working tree out of sync with the new path until a later filter-change reconciliation happened to catch it. Now validates the old subfolder exists and is tracked by git (proof it's actually managed by josh-sync) and the new one doesn't already exist, then git mv's it — staged, not committed, alongside the config edit. Idempotent on resume. Bumps to v2.4.0. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TNXgcFWVgSh3wkQS55cCX6
This commit is contained in:
+93
-6
@@ -2,13 +2,17 @@
|
||||
# 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
|
||||
# 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).
|
||||
# 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)"
|
||||
@@ -21,6 +25,14 @@ setup() {
|
||||
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" {
|
||||
@@ -82,9 +94,14 @@ setup() {
|
||||
[ "$app_b_subfolder" = "services/app-b" ]
|
||||
}
|
||||
|
||||
@test "rename_target --subfolder re-derives josh_filter" {
|
||||
@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
|
||||
@@ -94,11 +111,22 @@ setup() {
|
||||
[ "$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" {
|
||||
@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
|
||||
@@ -110,6 +138,65 @@ setup() {
|
||||
[ "$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" {
|
||||
|
||||
@@ -356,6 +356,11 @@ state_branch_commit_count() {
|
||||
write_billing_config "$subrepo_bare"
|
||||
parse_config ".josh-sync.yml"
|
||||
|
||||
mkdir -p services/billing
|
||||
echo "content" > services/billing/file.txt
|
||||
git add -A
|
||||
git commit -q -m "seed billing subfolder"
|
||||
|
||||
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
|
||||
@@ -379,4 +384,65 @@ state_branch_commit_count() {
|
||||
|
||||
msg=$(git log "origin/${STATE_BRANCH}" -1 --format=%s)
|
||||
[ "$msg" = "state: rename billing -> payments" ]
|
||||
|
||||
[ ! -e services/billing ]
|
||||
[ -f services/payments/file.txt ]
|
||||
}
|
||||
|
||||
# ─── rename_target: subfolder move (git mv in the monorepo tree) ──
|
||||
|
||||
@test "rename_target git mv's the subfolder and dies if the old path isn't tracked" {
|
||||
setup_state_monorepo
|
||||
local subrepo_bare
|
||||
subrepo_bare=$(make_bare_repo "billing-sub")
|
||||
write_billing_config "$subrepo_bare"
|
||||
parse_config ".josh-sync.yml"
|
||||
|
||||
# services/billing was never created/tracked — not "managed by josh-sync".
|
||||
run rename_target "billing" "" "services/relocated" "" ".josh-sync.yml" false true false
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"does not exist"* ]]
|
||||
}
|
||||
|
||||
@test "rename_target dies when the new subfolder path already exists" {
|
||||
setup_state_monorepo
|
||||
local subrepo_bare
|
||||
subrepo_bare=$(make_bare_repo "billing-sub")
|
||||
write_billing_config "$subrepo_bare"
|
||||
parse_config ".josh-sync.yml"
|
||||
|
||||
mkdir -p services/billing services/relocated
|
||||
echo "old" > services/billing/file.txt
|
||||
echo "existing" > services/relocated/other.txt
|
||||
git add -A
|
||||
git commit -q -m "seed"
|
||||
|
||||
run rename_target "billing" "" "services/relocated" "" ".josh-sync.yml" false true false
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"already exists"* ]]
|
||||
# nothing should have moved
|
||||
[ -f services/billing/file.txt ]
|
||||
}
|
||||
|
||||
@test "rename_target moves the subfolder via git mv, staged but not committed" {
|
||||
setup_state_monorepo
|
||||
local subrepo_bare
|
||||
subrepo_bare=$(make_bare_repo "billing-sub")
|
||||
write_billing_config "$subrepo_bare"
|
||||
parse_config ".josh-sync.yml"
|
||||
|
||||
mkdir -p services/billing
|
||||
echo "content" > services/billing/file.txt
|
||||
git add -A
|
||||
git commit -q -m "seed"
|
||||
|
||||
rename_target "billing" "" "services/relocated" "" ".josh-sync.yml" false true false
|
||||
|
||||
[ ! -e services/billing ]
|
||||
[ -f services/relocated/file.txt ]
|
||||
staged=$(git diff --cached --name-only)
|
||||
[[ "$staged" == *"relocated/file.txt"* ]]
|
||||
# Not committed — mirrors the config edit, left for the user to review.
|
||||
head_files=$(git show --name-only --format= HEAD)
|
||||
[[ "$head_files" != *"relocated"* ]]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user