15 Commits
Author SHA1 Message Date
sbpro 2df5fd7d06 Merge remote-tracking branch 'origin/auto-sync/mono-main-20260716-091132'
# Conflicts:
#	CHANGELOG.md
#	VERSION
2026-07-16 10:19:44 +01:00
sbproandClaude Sonnet 5 27b0e2acaa feat(josh-sync): add rename command for safe target renames
Adds `josh-sync rename <target>` (PE-12): safely rename a sync target's
name, subfolder, and/or subrepo_url in one resumable operation, editing
.josh-sync.yml and migrating its state-branch files atomically instead of
orphaning them. Bumps josh-sync to v2.3.0.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TNXgcFWVgSh3wkQS55cCX6
2026-07-16 10:10:40 +01:00
sbpro c4bcbbdd4f Merge origin/main into auto-sync merge 2026-06-08 13:38:42 +01:00
sbpro 0128b66a2f Merge remote-tracking branch 'origin/auto-sync/subrepo-main-20260608-123127'
# Conflicts:
#	tools/josh-sync/CHANGELOG.md
2026-06-08 13:38:08 +01:00
sbpro ac0b9b4ea3 fix(action): allow forward sync from trailer commits 2026-06-08 13:29:31 +01:00
sbpro efe8a19fce fix(action): allow forward sync from trailer commits 2026-06-08 13:29:31 +01:00
itkan-sysadmin b936030606 Merge pull request '[Subrepo Sync] main → main' (#6) from auto-sync/subrepo-main-20260602-153914 into main
Reviewed-on: sdlc/sdlc#6
2026-06-02 15:39:39 +00:00
sbpro 427cc4c3f1 Merge remote-tracking branch 'origin/auto-sync/mono-main-20260602-120147'
# Conflicts:
#	CHANGELOG.md
2026-06-02 16:28:21 +01:00
sbpro e4b9ed31b3 Merge remote-tracking branch 'origin/auto-sync/mono-main-20260602-120147'
# Conflicts:
#	CHANGELOG.md
2026-06-02 16:28:21 +01:00
sbpro ad84a76f8d fix(import): include tracked ignored files 2026-06-02 15:02:19 +01:00
sbproandClaude Opus 4.7 8776ccf2d2 test: remove bidirectional sync smoke-test markers
Drops the test markers added during the 2026-05-28 onboarding-verification
round-trip (forward + reverse leg). The adopt strategy is live and the
CHANGELOG is back to its 2.2.0-release state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 05:30:33 +01:00
itkan-sysadmin b82311829c Merge pull request '[Subrepo Sync] main → main' (#5) from auto-sync/subrepo-main-20260528-042619 into main
Reviewed-on: sdlc/sdlc#5
2026-05-28 04:28:06 +00:00
sbpro 9071e17d31 Update CHANGELOG.md 2026-05-28 05:25:22 +01:00
sync-bot 5f942bced4 Sync from monorepo 2026-05-28T04:24:11Z
Sync-Origin: forward/main/2026-05-28T04:24:11Z
2026-05-28 04:24:11 +00:00
sync-bot 4df4044f20 Adopt josh-sync for josh-sync
Sync-Origin: adopt/main/2026-05-28T03:19:12Z
2026-05-28 04:19:12 +01:00
14 changed files with 1116 additions and 22 deletions
+16 -2
View File
@@ -1,8 +1,22 @@
# Changelog # Changelog
## Unreleased - REACHED the standalone repo ## 2.3.0
- Bidirectional sync smoke-test marker — forward leg (monorepo → subrepo), 2026-05-28. ### 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 ## 2.2.1
+1 -1
View File
@@ -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; \
+1
View File
@@ -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
+1 -1
View File
@@ -1 +1 @@
2.2.1 2.3.0
-14
View File
@@ -38,21 +38,7 @@ runs:
command -v "$cmd" &>/dev/null || { echo "::error::Missing required tool: $cmd"; exit 1; } command -v "$cmd" &>/dev/null || { echo "::error::Missing required tool: $cmd"; exit 1; }
done 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 - name: Sync
if: steps.guard.outputs.skip != 'true'
shell: bash shell: bash
env: env:
JOSH_SYNC_DEBUG: ${{ inputs.debug == 'true' && '1' || '0' }} JOSH_SYNC_DEBUG: ${{ inputs.debug == 'true' && '1' || '0' }}
+56
View File
@@ -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 "$@" ;;
+3 -3
View File
@@ -17,7 +17,7 @@ Bidirectional sync creates an infinite loop risk: forward sync pushes commit A t
## Decision ## 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 ### Format
@@ -32,7 +32,7 @@ The trailer value encodes: direction, branch, and timestamp. This aids debugging
### Filtering ### Filtering
- **Reverse sync**: `git log --invert-grep --grep="^${BOT_TRAILER}:"` excludes all commits with the trailer - **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 ### 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 - Reliable loop prevention — trailer is part of the immutable commit object
- Configurable key avoids conflicts between multiple sync bots - Configurable key avoids conflicts between multiple sync bots
- Human-readable — `git log` shows the trailer in commit messages - 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:** **Negative:**
- Commits with manually-added trailers matching the key would be incorrectly filtered - Commits with manually-added trailers matching the key would be incorrectly filtered
+26 -1
View File
@@ -554,7 +554,9 @@ Runs on a cron schedule (never triggered by subrepo pushes):
### Loop prevention ### 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 ### State tracking
@@ -671,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
View File
@@ -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."
}
+86
View File
@@ -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"
}
+17
View File
@@ -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"
}
+39
View File
@@ -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"* ]]
}
+215
View File
@@ -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 ]
}
+382
View File
@@ -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" ]
}