Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2df5fd7d06 | ||
|
|
27b0e2acaa | ||
|
|
c4bcbbdd4f | ||
|
|
0128b66a2f | ||
|
|
efe8a19fce | ||
|
|
b936030606 | ||
|
|
e4b9ed31b3 | ||
|
|
ad84a76f8d |
@@ -1,5 +1,17 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2.3.0
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **`josh-sync rename <target>` 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 `<name>/` 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-<old-name>-*` staging branch from an abandoned onboarding is flagged with a warning only.
|
||||||
|
|
||||||
## 2.2.2
|
## 2.2.2
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ dist/josh-sync: bin/josh-sync lib/*.sh VERSION
|
|||||||
@echo '# Generated by: make build' >> dist/josh-sync
|
@echo '# Generated by: make build' >> dist/josh-sync
|
||||||
@echo '' >> dist/josh-sync
|
@echo '' >> dist/josh-sync
|
||||||
@# Inline all library modules (strip shebangs and source directives)
|
@# 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; \
|
echo "# --- $$f ---" >> dist/josh-sync; \
|
||||||
grep -v '^#!/' "$$f" | grep -v '^# shellcheck source=' >> dist/josh-sync; \
|
grep -v '^#!/' "$$f" | grep -v '^# shellcheck source=' >> dist/josh-sync; \
|
||||||
echo '' >> dist/josh-sync; \
|
echo '' >> dist/josh-sync; \
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ josh-sync preflight
|
|||||||
josh-sync import <target>
|
josh-sync import <target>
|
||||||
josh-sync adopt <target> [--restart]
|
josh-sync adopt <target> [--restart]
|
||||||
josh-sync reset <target>
|
josh-sync reset <target>
|
||||||
|
josh-sync rename <target> [--name NEW] [--subfolder PATH] [--subrepo-url URL] [--dry-run] [--yes] [--force]
|
||||||
josh-sync onboard <target> [--restart]
|
josh-sync onboard <target> [--restart]
|
||||||
josh-sync migrate-pr <target> [PR#...] [--all]
|
josh-sync migrate-pr <target> [PR#...] [--all]
|
||||||
josh-sync status
|
josh-sync status
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
# onboard <target> Interactive onboarding (auto-picks reset|adopt strategy)
|
# onboard <target> Interactive onboarding (auto-picks reset|adopt strategy)
|
||||||
# adopt <target> Alias for `onboard --mode=adopt`
|
# adopt <target> Alias for `onboard --mode=adopt`
|
||||||
# reset <target> Reset subrepo to josh-filtered view
|
# reset <target> Reset subrepo to josh-filtered view
|
||||||
|
# rename <target> Rename a target's name/subfolder/subrepo_url, migrating state
|
||||||
# migrate-pr <target> [PR#...] [--all] Move PRs from archived to new subrepo
|
# migrate-pr <target> [PR#...] [--all] Move PRs from archived to new subrepo
|
||||||
# status Show target config and sync state
|
# status Show target config and sync state
|
||||||
# state show|reset Manage sync state directly
|
# state show|reset Manage sync state directly
|
||||||
@@ -46,6 +47,8 @@ source "${JOSH_LIB_DIR}/sync.sh"
|
|||||||
source "${JOSH_LIB_DIR}/adopt.sh"
|
source "${JOSH_LIB_DIR}/adopt.sh"
|
||||||
# shellcheck source=../lib/onboard.sh
|
# shellcheck source=../lib/onboard.sh
|
||||||
source "${JOSH_LIB_DIR}/onboard.sh"
|
source "${JOSH_LIB_DIR}/onboard.sh"
|
||||||
|
# shellcheck source=../lib/rename.sh
|
||||||
|
source "${JOSH_LIB_DIR}/rename.sh"
|
||||||
|
|
||||||
# ─── Version ────────────────────────────────────────────────────────
|
# ─── Version ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -82,6 +85,10 @@ Commands:
|
|||||||
via ls-remote (heads → adopt, empty → reset).
|
via ls-remote (heads → adopt, empty → reset).
|
||||||
adopt <target> Alias for 'onboard --mode=adopt' (kept for back-compat)
|
adopt <target> Alias for 'onboard --mode=adopt' (kept for back-compat)
|
||||||
reset <target> Reset subrepo to josh-filtered view (after merging import PR)
|
reset <target> Reset subrepo to josh-filtered view (after merging import PR)
|
||||||
|
rename <target> 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 <target> [PR#...] [--all] Move PRs from archived to new subrepo
|
migrate-pr <target> [PR#...] [--all] Move PRs from archived to new subrepo
|
||||||
status Show target config and sync state
|
status Show target config and sync state
|
||||||
state show <target> [branch] Show sync state JSON
|
state show <target> [branch] Show sync state JSON
|
||||||
@@ -103,6 +110,14 @@ Sync flags:
|
|||||||
Onboard flags:
|
Onboard flags:
|
||||||
--mode={reset,adopt} Override the onboarding strategy
|
--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:
|
Environment:
|
||||||
JOSH_SYNC_TARGET Restrict to a single target name
|
JOSH_SYNC_TARGET Restrict to a single target name
|
||||||
JOSH_SYNC_STATE_BRANCH State branch name (default: josh-sync-state)
|
JOSH_SYNC_STATE_BRANCH State branch name (default: josh-sync-state)
|
||||||
@@ -619,6 +634,46 @@ cmd_reset() {
|
|||||||
done
|
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 <target> [--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 ────────────────────────────────────────────────
|
# ─── Status Command ────────────────────────────────────────────────
|
||||||
|
|
||||||
cmd_status() {
|
cmd_status() {
|
||||||
@@ -934,6 +989,7 @@ main() {
|
|||||||
import) cmd_import "$@" ;;
|
import) cmd_import "$@" ;;
|
||||||
adopt) cmd_adopt "$@" ;;
|
adopt) cmd_adopt "$@" ;;
|
||||||
reset) cmd_reset "$@" ;;
|
reset) cmd_reset "$@" ;;
|
||||||
|
rename) cmd_rename "$@" ;;
|
||||||
onboard) cmd_onboard "$@" ;;
|
onboard) cmd_onboard "$@" ;;
|
||||||
migrate-pr) cmd_migrate_pr "$@" ;;
|
migrate-pr) cmd_migrate_pr "$@" ;;
|
||||||
status) cmd_status "$@" ;;
|
status) cmd_status "$@" ;;
|
||||||
|
|||||||
@@ -673,6 +673,29 @@ To add a new subrepo after initial setup:
|
|||||||
```
|
```
|
||||||
5. Verify with `josh-sync status`
|
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 <target>` 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 `<target>/` 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-<old-name>-*` 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
|
## Troubleshooting
|
||||||
|
|
||||||
### "Failed to clone through josh-proxy"
|
### "Failed to clone through josh-proxy"
|
||||||
|
|||||||
+273
@@ -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 :/<subfolder>. 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 <old_subfolder> <new_subfolder> <exclude_json> <current_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 <target>/ 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-<target>-<timestamp> 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 <given_name> <new_name> <new_subfolder> <new_subrepo_url>
|
||||||
|
# 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 <given_name> <requested_new_name> <requested_new_subfolder> \
|
||||||
|
# <requested_new_subrepo_url> <config_file> <dry_run> <assume_yes> <force>
|
||||||
|
# (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} <branch>') 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."
|
||||||
|
}
|
||||||
@@ -73,3 +73,89 @@ write_state() {
|
|||||||
|
|
||||||
git worktree remove "$tmp_dir" 2>/dev/null || rm -rf "$tmp_dir"
|
git worktree remove "$tmp_dir" 2>/dev/null || rm -rf "$tmp_dir"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ─── Target-Prefix Migration (used by `rename`) ────────────────────
|
||||||
|
# Generic helpers over the whole "<target_name>/" 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 <target_name>/ 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 <old_name>/ to <new_name>/ (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 <old_name>/ has no files.
|
||||||
|
# Dies before mutating anything if any destination path already exists.
|
||||||
|
#
|
||||||
|
# Usage: state_migrate_target_prefix <old_name> <new_name> [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"
|
||||||
|
}
|
||||||
|
|||||||
@@ -121,3 +121,42 @@ seed_minimal_config() {
|
|||||||
[[ "$stderr" == *"alias for --mode=adopt"* ]]
|
[[ "$stderr" == *"alias for --mode=adopt"* ]]
|
||||||
[[ "$stderr" == *"conflicting"* ]]
|
[[ "$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 <target>"* ]]
|
||||||
|
[[ "$stderr" == *"--subrepo-url URL"* ]]
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 ]
|
||||||
|
}
|
||||||
@@ -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