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

@@ -1,52 +1,14 @@
#!/usr/bin/env bash
# lib/adopt.sh — Non-destructive adoption of existing subrepo history
# lib/adopt.sh — Non-destructive adoption merge primitive
#
# Provides:
# adopt_flow() — Import → wait for merge → adoption merge per branch
# adopt_branch() — Create the adoption merge and push it fast-forward only
# adopt_branch() — Create one adoption merge commit and push it fast-forward
#
# Adopt state is stored on the josh-sync-state branch at <target>/adopt.json.
# Steps: start → importing → waiting-for-merge → adopting → complete
# The orchestration (state machine, import → wait → finalize) lives in
# lib/onboard.sh under strategy="adopt". `josh-sync adopt` is now a CLI alias
# for `josh-sync onboard --mode=adopt`; see ADR-013 for design rationale.
#
# Requires: lib/core.sh, lib/config.sh, lib/auth.sh, lib/state.sh, lib/sync.sh sourced
# ─── Adopt State Helpers ─────────────────────────────────────────
read_adopt_state() {
local target_name="${1:-$JOSH_SYNC_TARGET_NAME}"
git fetch origin "$STATE_BRANCH" 2>/dev/null || true
git show "origin/${STATE_BRANCH}:${target_name}/adopt.json" 2>/dev/null || echo '{}'
}
write_adopt_state() {
local target_name="${1:-$JOSH_SYNC_TARGET_NAME}"
local state_json="$2"
local key="${target_name}/adopt"
local tmp_dir
tmp_dir=$(mktemp -d)
if git rev-parse "origin/${STATE_BRANCH}" >/dev/null 2>&1; then
git worktree add "$tmp_dir" "origin/${STATE_BRANCH}" 2>/dev/null
else
git worktree add --detach "$tmp_dir" 2>/dev/null
(cd "$tmp_dir" && git checkout --orphan "$STATE_BRANCH" && git rm -rf . 2>/dev/null || true)
fi
mkdir -p "$(dirname "${tmp_dir}/${key}.json")"
echo "$state_json" | jq '.' > "${tmp_dir}/${key}.json"
(
cd "$tmp_dir" || exit
git add -A
if ! git diff --cached --quiet 2>/dev/null; then
git -c user.name="$BOT_NAME" -c user.email="$BOT_EMAIL" \
commit -m "adopt: update ${target_name}"
git push origin "HEAD:${STATE_BRANCH}" || log "WARN" "Failed to push adopt state"
fi
)
git worktree remove "$tmp_dir" 2>/dev/null || rm -rf "$tmp_dir"
}
# Requires: lib/core.sh, lib/config.sh, lib/auth.sh sourced
# ─── Adopt Branch ────────────────────────────────────────────────
# Establishes shared ancestry without rewriting the existing subrepo branch.
@@ -61,7 +23,11 @@ write_adopt_state() {
#
# Returns: adopted | already-adopted | tree-mismatch | push-rejected | missing-branch
adopt_branch() {
adopt_branch() (
# Subshell function: contains the function-level EXIT trap, the internal cd,
# and any set -e propagation so it cannot pollute the caller's shell. Without
# this, the trap below would clobber callers' EXIT handlers (bats's own test
# reporting in particular silently disappears).
local mono_branch="$SYNC_BRANCH_MONO"
local subrepo_branch="$SYNC_BRANCH_SUBREPO"
local work_dir
@@ -140,196 +106,4 @@ ${BOT_TRAILER}: adopt/${mono_branch}/$(date -u +%Y-%m-%dT%H:%M:%SZ)")
log "WARN" "Fast-forward push rejected — subrepo changed during adoption"
echo "push-rejected"
fi
}
# ─── Adopt Flow ──────────────────────────────────────────────────
# Interactive orchestrator with checkpoint/resume.
# Usage: adopt_flow <target_json> <restart>
adopt_flow() {
local target_json="$1"
local restart="${2:-false}"
local target_name="$JOSH_SYNC_TARGET_NAME"
local adopt_state current_step
adopt_state=$(read_adopt_state "$target_name")
current_step=$(echo "$adopt_state" | jq -r '.step // "start"')
if [ "$restart" = true ]; then
log "INFO" "Restarting adopt from scratch"
current_step="start"
adopt_state='{}'
fi
log "INFO" "Adopt step: ${current_step}"
if [ "$current_step" = "start" ]; then
echo "" >&2
echo "=== Adopting ${target_name} ===" >&2
echo "" >&2
echo "This keeps the existing subrepo history and adds one adoption merge commit per branch." >&2
echo "No force-push is used. Existing open PR branches remain based on the old history." >&2
echo "" >&2
adopt_state=$(jq -n \
--arg step "importing" \
--arg mode "adopt" \
--arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'{step:$step, mode:$mode, import_prs:{}, adopted_branches:[], timestamp:$ts}')
write_adopt_state "$target_name" "$adopt_state"
current_step="importing"
fi
if [ "$current_step" = "importing" ]; then
echo "" >&2
log "INFO" "Step 1: Importing current subrepo content into monorepo..."
local branches import_prs
branches=$(echo "$target_json" | jq -r '.branches | keys[]')
import_prs=$(echo "$adopt_state" | jq -r '.import_prs // {}')
for branch in $branches; do
local mapped
mapped=$(echo "$target_json" | jq -r --arg b "$branch" '.branches[$b] // empty')
[ -z "$mapped" ] && continue
if echo "$import_prs" | jq -e --arg b "$branch" 'has($b)' >/dev/null 2>&1; then
log "INFO" "Import PR already recorded for ${branch} — skipping"
continue
fi
export SYNC_BRANCH_MONO="$branch"
export SYNC_BRANCH_SUBREPO="$mapped"
local result
result=$(initial_import)
log "INFO" "Import result for ${branch}: ${result}"
if [ "$result" = "pr-created" ]; then
local prs pr_number
prs=$(list_open_prs "$MONOREPO_API" "$GITEA_TOKEN")
pr_number=$(echo "$prs" | jq -r --arg t "$target_name" --arg b "$branch" \
'[.[] | select(.title | test("\\[Import\\] " + $t + ":")) | select(.base.ref == $b)] | .[0].number // empty')
[ -n "$pr_number" ] || die "Could not find import PR number for ${branch}; cannot safely continue adoption"
import_prs=$(echo "$import_prs" | jq --arg b "$branch" --arg n "$pr_number" '. + {($b): ($n | tonumber)}')
log "INFO" "Import PR for ${branch}: #${pr_number}"
fi
adopt_state=$(echo "$adopt_state" | jq --argjson prs "$import_prs" '.import_prs = $prs')
write_adopt_state "$target_name" "$adopt_state"
done
adopt_state=$(echo "$adopt_state" | jq \
--arg step "waiting-for-merge" \
--argjson prs "$import_prs" \
'.step = $step | .import_prs = $prs')
write_adopt_state "$target_name" "$adopt_state"
current_step="waiting-for-merge"
fi
if [ "$current_step" = "waiting-for-merge" ]; then
echo "" >&2
log "INFO" "Step 2: Waiting for import PR(s) to be merged..."
local import_prs pr_count
import_prs=$(echo "$adopt_state" | jq -r '.import_prs')
pr_count=$(echo "$import_prs" | jq 'length')
if [ "$pr_count" -gt 0 ]; then
echo "" >&2
echo "Import PRs to merge:" >&2
echo "$import_prs" | jq -r 'to_entries[] | " \(.key): PR #\(.value)"' >&2
echo "" >&2
echo "Merge the import PR(s) on the monorepo, then press Enter..." >&2
read -r
local all_merged=true
for branch in $(echo "$import_prs" | jq -r 'keys[]'); do
local pr_number pr_json merged
pr_number=$(echo "$import_prs" | jq -r --arg b "$branch" '.[$b]')
pr_json=$(get_pr "$MONOREPO_API" "$GITEA_TOKEN" "$pr_number")
merged=$(echo "$pr_json" | jq -r '.merged // false')
if [ "$merged" = "true" ]; then
log "INFO" "PR #${pr_number} (${branch}): merged"
else
log "ERROR" "PR #${pr_number} (${branch}): NOT merged — merge it first"
all_merged=false
fi
done
if [ "$all_merged" = false ]; then
die "Not all import PRs are merged. Re-run 'josh-sync adopt ${target_name}' after merging."
fi
else
log "INFO" "No import PRs recorded — subfolder already matched subrepo"
fi
adopt_state=$(echo "$adopt_state" | jq '.step = "adopting"')
write_adopt_state "$target_name" "$adopt_state"
current_step="adopting"
fi
if [ "$current_step" = "adopting" ]; then
echo "" >&2
log "INFO" "Step 3: Creating adoption merge commit(s)..."
local branches already_adopted
branches=$(echo "$target_json" | jq -r '.branches | keys[]')
already_adopted=$(echo "$adopt_state" | jq -r '.adopted_branches // []')
for branch in $branches; do
if echo "$already_adopted" | jq -e --arg b "$branch" 'index($b) != null' >/dev/null 2>&1; then
log "INFO" "Branch ${branch} already adopted — skipping"
continue
fi
local mapped
mapped=$(echo "$target_json" | jq -r --arg b "$branch" '.branches[$b] // empty')
[ -z "$mapped" ] && continue
export SYNC_BRANCH_MONO="$branch"
export SYNC_BRANCH_SUBREPO="$mapped"
local result
result=$(adopt_branch)
log "INFO" "Adopt result for ${branch}: ${result}"
case "$result" in
adopted|already-adopted)
adopt_state=$(echo "$adopt_state" | jq --arg b "$branch" '.adopted_branches += [$b]')
write_adopt_state "$target_name" "$adopt_state"
;;
tree-mismatch)
die "Tree mismatch for ${branch}. Merge the import PR and make sure subrepo/${mapped} matches the Josh-filtered monorepo tree."
;;
push-rejected)
die "Subrepo branch ${mapped} changed during adoption. Re-run 'josh-sync adopt ${target_name}' to retry."
;;
missing-branch)
die "Subrepo branch ${mapped} does not exist."
;;
*)
die "Unexpected adopt result: ${result}"
;;
esac
done
adopt_state=$(echo "$adopt_state" | jq \
--arg step "complete" \
--arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'.step = $step | .timestamp = $ts')
write_adopt_state "$target_name" "$adopt_state"
current_step="complete"
fi
if [ "$current_step" = "complete" ]; then
echo "" >&2
echo "=== Adoption complete! ===" >&2
echo "" >&2
echo "The subrepo keeps its existing history and now has a josh-sync adoption merge." >&2
echo "Developers can keep their clones; they only need to fast-forward active branches:" >&2
echo " git fetch origin && git checkout main && git merge --ff-only origin/main" >&2
fi
}
)