Compare commits
9
Commits
427cc4c3f1
...
v2.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2df5fd7d06 | ||
|
|
27b0e2acaa | ||
|
|
c4bcbbdd4f | ||
|
|
0128b66a2f | ||
|
|
ac0b9b4ea3 | ||
|
|
efe8a19fce | ||
|
|
b936030606 | ||
|
|
e4b9ed31b3 | ||
|
|
ad84a76f8d |
@@ -1,5 +1,23 @@
|
||||
# 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
|
||||
|
||||
### Fixes
|
||||
|
||||
- Forward sync no longer skips the entire action when the checked-out HEAD commit has the configured sync trailer. This allows valid multi-hop setups where one repo receives sync commits from an upstream repository and forwards its filtered subtree to another repository. Reverse sync still filters bot commits by trailer for loop prevention.
|
||||
|
||||
## 2.2.1
|
||||
|
||||
### Fixes
|
||||
|
||||
@@ -23,7 +23,7 @@ dist/josh-sync: bin/josh-sync lib/*.sh VERSION
|
||||
@echo '# Generated by: make build' >> dist/josh-sync
|
||||
@echo '' >> dist/josh-sync
|
||||
@# 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; \
|
||||
grep -v '^#!/' "$$f" | grep -v '^# shellcheck source=' >> dist/josh-sync; \
|
||||
echo '' >> dist/josh-sync; \
|
||||
|
||||
@@ -87,6 +87,7 @@ josh-sync preflight
|
||||
josh-sync import <target>
|
||||
josh-sync adopt <target> [--restart]
|
||||
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 migrate-pr <target> [PR#...] [--all]
|
||||
josh-sync status
|
||||
|
||||
-14
@@ -38,21 +38,7 @@ runs:
|
||||
command -v "$cmd" &>/dev/null || { echo "::error::Missing required tool: $cmd"; exit 1; }
|
||||
done
|
||||
|
||||
- name: Loop guard (forward)
|
||||
id: guard
|
||||
if: inputs.direction == 'forward' || inputs.direction == 'both'
|
||||
shell: bash
|
||||
run: |
|
||||
TRAILER=$(yq '.bot.trailer' "${{ inputs.config }}" 2>/dev/null || echo "Josh-Sync-Origin")
|
||||
if git log -1 --format=%B | grep -q "^${TRAILER}:"; then
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Skipping sync — HEAD commit has sync trailer (loop prevention)"
|
||||
else
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Sync
|
||||
if: steps.guard.outputs.skip != 'true'
|
||||
shell: bash
|
||||
env:
|
||||
JOSH_SYNC_DEBUG: ${{ inputs.debug == 'true' && '1' || '0' }}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
# onboard <target> Interactive onboarding (auto-picks reset|adopt strategy)
|
||||
# adopt <target> Alias for `onboard --mode=adopt`
|
||||
# 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
|
||||
# status Show target config and sync state
|
||||
# state show|reset Manage sync state directly
|
||||
@@ -46,6 +47,8 @@ source "${JOSH_LIB_DIR}/sync.sh"
|
||||
source "${JOSH_LIB_DIR}/adopt.sh"
|
||||
# shellcheck source=../lib/onboard.sh
|
||||
source "${JOSH_LIB_DIR}/onboard.sh"
|
||||
# shellcheck source=../lib/rename.sh
|
||||
source "${JOSH_LIB_DIR}/rename.sh"
|
||||
|
||||
# ─── Version ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -82,6 +85,10 @@ Commands:
|
||||
via ls-remote (heads → adopt, empty → reset).
|
||||
adopt <target> Alias for 'onboard --mode=adopt' (kept for back-compat)
|
||||
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
|
||||
status Show target config and sync state
|
||||
state show <target> [branch] Show sync state JSON
|
||||
@@ -103,6 +110,14 @@ Sync flags:
|
||||
Onboard flags:
|
||||
--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:
|
||||
JOSH_SYNC_TARGET Restrict to a single target name
|
||||
JOSH_SYNC_STATE_BRANCH State branch name (default: josh-sync-state)
|
||||
@@ -619,6 +634,46 @@ cmd_reset() {
|
||||
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 ────────────────────────────────────────────────
|
||||
|
||||
cmd_status() {
|
||||
@@ -934,6 +989,7 @@ main() {
|
||||
import) cmd_import "$@" ;;
|
||||
adopt) cmd_adopt "$@" ;;
|
||||
reset) cmd_reset "$@" ;;
|
||||
rename) cmd_rename "$@" ;;
|
||||
onboard) cmd_onboard "$@" ;;
|
||||
migrate-pr) cmd_migrate_pr "$@" ;;
|
||||
status) cmd_status "$@" ;;
|
||||
|
||||
@@ -17,7 +17,7 @@ Bidirectional sync creates an infinite loop risk: forward sync pushes commit A t
|
||||
|
||||
## Decision
|
||||
|
||||
All bot commits include a git trailer with a configurable key (default: `Josh-Sync-Origin`). Both sync directions filter out commits containing this trailer.
|
||||
All bot commits include a git trailer with a configurable key (default: `Josh-Sync-Origin`). Reverse sync filters out commits containing this trailer so bot-generated forward-sync commits are not proposed back to the monorepo.
|
||||
|
||||
### Format
|
||||
|
||||
@@ -32,7 +32,7 @@ The trailer value encodes: direction, branch, and timestamp. This aids debugging
|
||||
### Filtering
|
||||
|
||||
- **Reverse sync**: `git log --invert-grep --grep="^${BOT_TRAILER}:"` excludes all commits with the trailer
|
||||
- **CI loop guard**: The composite action checks if HEAD commit has the trailer before running sync at all
|
||||
- **Forward sync**: the composite action still runs even when HEAD has the trailer; forward sync uses tree comparison, sync state, and merge checks to decide whether to push
|
||||
|
||||
### Configuration
|
||||
|
||||
@@ -44,7 +44,7 @@ The trailer key is set in `.josh-sync.yml` under `bot.trailer`. This allows mult
|
||||
- Reliable loop prevention — trailer is part of the immutable commit object
|
||||
- Configurable key avoids conflicts between multiple sync bots
|
||||
- Human-readable — `git log` shows the trailer in commit messages
|
||||
- CI loop guard prevents unnecessary sync runs entirely
|
||||
- Repositories can act as middle hops, receiving a sync commit from one relationship and forwarding relevant changes to another
|
||||
|
||||
**Negative:**
|
||||
- Commits with manually-added trailers matching the key would be incorrectly filtered
|
||||
|
||||
+26
-1
@@ -554,7 +554,9 @@ Runs on a cron schedule (never triggered by subrepo pushes):
|
||||
|
||||
### Loop prevention
|
||||
|
||||
Bot commits include a git trailer like `Josh-Sync-Origin: forward/main/2024-02-12T10:30:00Z`. Each sync direction filters out commits with this trailer, preventing changes from bouncing back and forth. The CI action also has a loop guard that skips entirely if the HEAD commit has the trailer.
|
||||
Bot commits include a git trailer like `Josh-Sync-Origin: forward/main/2024-02-12T10:30:00Z`. Reverse sync filters out commits with this trailer, preventing changes from bouncing back from the target repo into the source repo.
|
||||
|
||||
The CI action does not skip forward sync solely because the checked-out HEAD commit has a sync trailer. A repo can be a valid middle hop: it may receive a sync commit from one repository and still need to forward its filtered subtree to another repository. Forward sync relies on tree comparison, sync state, and merge checks to decide whether anything should be pushed.
|
||||
|
||||
### State tracking
|
||||
|
||||
@@ -671,6 +673,29 @@ To add a new subrepo after initial setup:
|
||||
```
|
||||
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
|
||||
|
||||
### "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"
|
||||
}
|
||||
|
||||
# ─── 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"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env bats
|
||||
# tests/unit/action.bats — Composite action regression tests
|
||||
|
||||
setup() {
|
||||
JOSH_SYNC_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)"
|
||||
ACTION_FILE="${JOSH_SYNC_ROOT}/action.yml"
|
||||
}
|
||||
|
||||
@test "forward sync is not globally skipped when HEAD has the sync trailer" {
|
||||
! grep -q "Loop guard (forward)" "$ACTION_FILE"
|
||||
! grep -q "HEAD commit has sync trailer" "$ACTION_FILE"
|
||||
! grep -q "steps.guard.outputs.skip" "$ACTION_FILE"
|
||||
}
|
||||
|
||||
@test "composite action still invokes the josh-sync CLI" {
|
||||
grep -q "josh-sync sync" "$ACTION_FILE"
|
||||
}
|
||||
@@ -121,3 +121,42 @@ seed_minimal_config() {
|
||||
[[ "$stderr" == *"alias for --mode=adopt"* ]]
|
||||
[[ "$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