Release 2.2.0 — unified onboard strategy + safety hardening

Folds `josh-sync adopt` into `josh-sync onboard <target>` as the adopt
strategy alongside the original reset flow. Strategy resolves by
precedence: --mode flag > targets[].history_lock config (preserve|rewrite)
> auto-detect via `git ls-remote --heads`. `josh-sync adopt` is kept as
a thin alias. Adds the new `targets[].history_lock` config field
(validated at parse time) and folds `<target>/adopt.json` state into
`<target>/onboard.json` with a `.strategy` field; legacy state files
are read with a backward-compat fallback.

Safety hardening (from a multi-angle review of the unification):
- auto-detect distinguishes auth/network failure from empty repo
- resume validates --mode against the strategy saved in state
- `josh-sync adopt` rejects a conflicting --mode in the forwarded args
- missing import-PR lookup dies in both strategies (was WARN+continue
  for reset, which could create duplicate import PRs on resume)
- --restart durably removes the legacy adopt.json from the state branch
- adopt_branch is now a subshell function (EXIT trap can't clobber callers)
- strategy value validated after resolve/load (reset|adopt)
- --mode with missing/empty value dies with a usage hint
- migrate-pr against an adopt-strategy target dies with a specific hint
- reset importing asserts archived_url is present (no "null" → git clone)

End-to-end + CLI bats coverage added (tests/unit/adopt_e2e.bats,
tests/unit/cli.bats). 72 tests, shellcheck clean.

Makefile dist bundle header now correctly interpolates VERSION and
line count.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 03:32:04 +01:00
parent f2e39be5e1
commit dc2cdea360
13 changed files with 1284 additions and 439 deletions

View File

@@ -8,9 +8,9 @@
# sync Run forward and/or reverse sync
# preflight Validate config, connectivity, auth
# import <target> Initial import: pull subrepo into monorepo
# adopt <target> Adopt existing subrepo history without rewriting it
# onboard <target> Interactive onboarding (auto-picks reset|adopt strategy)
# adopt <target> Alias for `onboard --mode=adopt`
# reset <target> Reset subrepo to josh-filtered view
# onboard <target> Import existing subrepo into monorepo (interactive)
# 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
@@ -75,9 +75,13 @@ Commands:
sync Run forward and/or reverse sync
preflight Validate config, connectivity, auth, workflow coverage
import <target> Initial import: pull existing subrepo into monorepo (creates PR)
adopt <target> Adopt existing subrepo history without rewriting it
onboard <target> Onboard a subrepo (interactive, resumable). Picks a strategy:
reset — force-push josh-filtered history (replaces subrepo)
adopt — non-destructive merge that keeps subrepo history
Precedence: --mode flag > targets[].history_lock > auto-detect
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)
onboard <target> Import existing subrepo into monorepo (interactive, resumable)
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
@@ -96,6 +100,9 @@ Sync flags:
--target NAME Filter to target(s) — comma-separated for multiple (env: JOSH_SYNC_TARGET)
--branch BRANCH Filter to one branch
Onboard flags:
--mode={reset,adopt} Override the onboarding strategy
Environment:
JOSH_SYNC_TARGET Restrict to a single target name
JOSH_SYNC_STATE_BRANCH State branch name (default: josh-sync-state)
@@ -534,40 +541,33 @@ cmd_import() {
done
}
# ─── Adopt Command ─────────────────────────────────────────────────
# ─── Adopt Command (alias) ─────────────────────────────────────────
# `josh-sync adopt` is preserved as a thin alias for the unified onboard
# command. Effectively: `josh-sync onboard --mode=adopt <args...>`.
cmd_adopt() {
local config_file=".josh-sync.yml"
local target_name=""
local restart=false
while [ $# -gt 0 ]; do
case "$1" in
--config) config_file="$2"; shift 2 ;;
--debug) export JOSH_SYNC_DEBUG=1; shift ;;
--restart) restart=true; shift ;;
-*) die "Unknown flag: $1" ;;
*) target_name="$1"; shift ;;
# Reject a conflicting --mode in the forwarded args: the alias means adopt,
# so `josh-sync adopt … --mode=reset` is contradictory. Without this guard
# the user-supplied --mode would silently win (last --mode in cmd_onboard's
# parser is the one that takes effect).
local i=1 a
while [ $i -le $# ]; do
a="${!i}"
case "$a" in
--mode=adopt) ;;
--mode=*) die "'josh-sync adopt' is an alias for --mode=adopt; conflicting '${a}' given. Use 'josh-sync onboard <target>' instead." ;;
--mode)
i=$((i + 1))
if [ $i -gt $# ] || [ "${!i}" != "adopt" ]; then
die "'josh-sync adopt' is an alias for --mode=adopt; conflicting '--mode ${!i:-<missing>}' given. Use 'josh-sync onboard <target>' instead."
fi
;;
esac
i=$((i + 1))
done
if [ -z "$target_name" ]; then
echo "Usage: josh-sync adopt <target> [--restart]" >&2
parse_config "$config_file"
echo "Available targets:" >&2
echo "$JOSH_SYNC_TARGETS" | jq -r '.[].name' | sed 's/^/ /' >&2
exit 1
fi
parse_config "$config_file"
local target_json
target_json=$(echo "$JOSH_SYNC_TARGETS" | jq -c --arg n "$target_name" '.[] | select(.name == $n)')
[ -n "$target_json" ] || die "Target '${target_name}' not found in config"
log "INFO" "══════ Adopt target: ${target_name} ══════"
load_target "$target_json"
adopt_flow "$target_json" "$restart"
log "INFO" "(josh-sync adopt is now an alias for: josh-sync onboard --mode=adopt)"
cmd_onboard --mode=adopt "$@"
}
# ─── Reset Command ─────────────────────────────────────────────────
@@ -743,19 +743,25 @@ cmd_onboard() {
local config_file=".josh-sync.yml"
local target_name=""
local restart=false
local mode=""
while [ $# -gt 0 ]; do
case "$1" in
--config) config_file="$2"; shift 2 ;;
--debug) export JOSH_SYNC_DEBUG=1; shift ;;
--restart) restart=true; shift ;;
--mode=*) mode="${1#--mode=}"
[ -n "$mode" ] || die "Empty --mode= (use --mode=reset|adopt)"
shift ;;
--mode) [ $# -ge 2 ] || die "--mode requires a value (reset|adopt)"
mode="$2"; shift 2 ;;
-*) die "Unknown flag: $1" ;;
*) target_name="$1"; shift ;;
esac
done
if [ -z "$target_name" ]; then
echo "Usage: josh-sync onboard <target> [--restart]" >&2
echo "Usage: josh-sync onboard <target> [--mode=reset|adopt] [--restart]" >&2
parse_config "$config_file"
echo "Available targets:" >&2
echo "$JOSH_SYNC_TARGETS" | jq -r '.[].name' | sed 's/^/ /' >&2
@@ -770,7 +776,7 @@ cmd_onboard() {
log "INFO" "══════ Onboard target: ${target_name} ══════"
load_target "$target_json"
onboard_flow "$target_json" "$restart"
onboard_flow "$target_json" "$restart" "$mode"
}
# ─── Migrate PR Command ──────────────────────────────────────────