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
}
)

View File

@@ -88,6 +88,15 @@ parse_config() {
)
]')
# Validate targets[].history_lock against the schema enum (preserve|rewrite).
# Caught at config parse time so typos don't sit silently until `onboard` runs.
local bad_lock
bad_lock=$(echo "$JOSH_SYNC_TARGETS" | jq -r '
.[] | select(.history_lock != null)
| select(.history_lock != "preserve" and .history_lock != "rewrite")
| "\(.name): \(.history_lock)"' | head -1)
[ -z "$bad_lock" ] || die "Invalid targets[].history_lock value (must be 'preserve' or 'rewrite'): ${bad_lock}"
# Load .env credentials (if present, not required — CI sets these via secrets)
if [ -f .env ]; then
# shellcheck source=/dev/null

View File

@@ -1,23 +1,56 @@
#!/usr/bin/env bash
# lib/onboard.sh — Onboard orchestration and PR migration
# lib/onboard.sh — Unified onboard orchestration (reset + adopt strategies)
#
# Provides:
# onboard_flow() — Interactive: import → wait for merge → reset to new repo
# migrate_one_pr() — Migrate a single PR from archived repo to new repo
# onboard_flow() — Interactive: import → wait → finalize
# finalize = subrepo_reset (rewrite) or
# adopt_branch (preserve)
# resolve_onboard_strategy() — Pick reset|adopt from --mode / config /
# ls-remote auto-detection
# migrate_one_pr() — Migrate one PR from archived to new subrepo
#
# Onboard state is stored on the josh-sync-state branch at <target>/onboard.json.
# Steps: start → importing → waiting-for-merge → resetting → complete
# State is stored on the josh-sync-state branch at <target>/onboard.json:
# { step, strategy, import_prs, ... }
# read_onboard_state falls back to legacy <target>/adopt.json (with implicit
# strategy="adopt") so in-flight v2.1 adoptions continue to resume correctly.
#
# Requires: lib/core.sh, lib/config.sh, lib/auth.sh, lib/state.sh, lib/sync.sh sourced
# Expects: JOSH_SYNC_TARGET_NAME, BOT_NAME, BOT_EMAIL, SUBREPO_API, SUBREPO_TOKEN, etc.
# Steps:
# start → importing → waiting-for-merge → resetting | adopting → complete
#
# Requires: lib/core.sh, lib/config.sh, lib/auth.sh, lib/state.sh, lib/sync.sh,
# lib/adopt.sh sourced
# Expects: JOSH_SYNC_TARGET_NAME, BOT_NAME, BOT_EMAIL, SUBREPO_API,
# SUBREPO_TOKEN, etc.
# ─── Onboard State Helpers ────────────────────────────────────────
# Follow the same pattern as read_state()/write_state() in lib/state.sh.
# ─── State Helpers ────────────────────────────────────────────────
# Same pattern as read_state()/write_state() in lib/state.sh.
read_onboard_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}/onboard.json" 2>/dev/null || echo '{}'
local json
json=$(git show "origin/${STATE_BRANCH}:${target_name}/onboard.json" 2>/dev/null || echo "")
if [ -n "$json" ]; then
# Pre-unification onboard.json had no .strategy field — the only flow that
# existed was the destructive reset path; treat missing/empty .strategy as
# reset so resume doesn't silently flip via re-resolution.
echo "$json" | jq '. + {strategy: ((.strategy // "") | if . == "" then "reset" else . end)}'
return
fi
# Backward-compat: fall back to v2.1 <target>/adopt.json so in-flight
# adoptions started before the unification continue to resume. The legacy
# schema lacks `.strategy`; inject "adopt" so resolve/dispatch downstream
# treats it correctly. Also drop the legacy `.mode` field (v2.1 carried it
# redundantly) to avoid future schema collisions.
json=$(git show "origin/${STATE_BRANCH}:${target_name}/adopt.json" 2>/dev/null || echo "")
if [ -n "$json" ]; then
echo "$json" | jq 'del(.mode) + {strategy: ((.strategy // "") | if . == "" then "adopt" else . end)}'
return
fi
echo '{}'
}
write_onboard_state() {
@@ -50,18 +83,97 @@ write_onboard_state() {
git worktree remove "$tmp_dir" 2>/dev/null || rm -rf "$tmp_dir"
}
# Remove the v2.1 <target>/adopt.json from the state branch if present, so
# that a --restart durably clears legacy state on the next read. Called by
# onboard_flow's restart path.
_onboard_state_remove_legacy() {
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" >/dev/null 2>&1 || return 0
local tmp_dir
tmp_dir=$(mktemp -d)
if ! git worktree add "$tmp_dir" "origin/${STATE_BRANCH}" 2>/dev/null; then
rm -rf "$tmp_dir"
return 0
fi
(
cd "$tmp_dir" || exit
git rm -q "${target_name}/adopt.json" 2>/dev/null || true
if ! git diff --cached --quiet 2>/dev/null; then
git -c user.name="$BOT_NAME" -c user.email="$BOT_EMAIL" \
commit -q -m "onboard: drop legacy ${target_name}/adopt.json on --restart"
git push -q origin "HEAD:${STATE_BRANCH}" || log "WARN" "Failed to push legacy adopt.json removal"
fi
)
git worktree remove "$tmp_dir" 2>/dev/null || rm -rf "$tmp_dir"
}
# ─── Strategy Resolution ──────────────────────────────────────────
# Precedence (highest first):
# 1. explicit --mode flag
# 2. targets[].history_lock (preserve → adopt, rewrite → reset)
# 3. auto-detect: subrepo has any branches → adopt, otherwise reset
#
# Prints the chosen strategy (reset|adopt) on stdout; logs the source on
# stderr so the preflight line in cmd_onboard surfaces the choice.
resolve_onboard_strategy() {
local target_json="$1"
local explicit_mode="${2:-}"
if [ -n "$explicit_mode" ]; then
case "$explicit_mode" in
reset|adopt) ;;
*) die "Invalid --mode '${explicit_mode}' (must be 'reset' or 'adopt')" ;;
esac
log "INFO" "Strategy: ${explicit_mode} (from --mode flag)"
echo "$explicit_mode"
return
fi
local history_lock
history_lock=$(echo "$target_json" | jq -r '.history_lock // empty')
if [ -n "$history_lock" ]; then
case "$history_lock" in
preserve)
log "INFO" "Strategy: adopt (from config history_lock: preserve)"
echo "adopt"; return ;;
rewrite)
log "INFO" "Strategy: reset (from config history_lock: rewrite)"
echo "reset"; return ;;
*)
die "Invalid history_lock '${history_lock}' in target config (must be 'preserve' or 'rewrite')" ;;
esac
fi
# Critical: distinguish a genuinely empty subrepo from a failed ls-remote.
# Treating an auth/network failure as "empty" would silently pick reset and
# force-push a live subrepo's history away.
local heads
if heads=$(git ls-remote --heads "$(subrepo_auth_url)" 2>/dev/null); then
if [ -n "$heads" ]; then
log "INFO" "Strategy: adopt (auto-detected — subrepo has branches)"
echo "adopt"
else
log "INFO" "Strategy: reset (auto-detected — subrepo has no branches)"
echo "reset"
fi
else
die "Cannot auto-detect strategy: 'git ls-remote' against the subrepo failed (auth/network). Pass --mode=reset|adopt explicitly, or set targets[].history_lock for this target."
fi
}
# ─── Derive Archived API URL ─────────────────────────────────────
# Given a URL like "git@host:org/repo-archived.git" or
# "https://host/org/repo-archived.git", derive the Gitea API URL.
_archived_api_from_url() {
local url="$1"
# Strip .git suffix first — avoids non-greedy regex issues in POSIX ERE
url="${url%.git}"
local host repo_path
if echo "$url" | grep -qE '^(ssh://|git@)'; then
# SSH URL
if echo "$url" | grep -q '^ssh://'; then
host=$(echo "$url" | sed -E 's|ssh://[^@]*@([^/]+)/.*|\1|')
repo_path=$(echo "$url" | sed -E 's|ssh://[^@]*@[^/]+/(.+)$|\1|')
@@ -70,7 +182,6 @@ _archived_api_from_url() {
repo_path=$(echo "$url" | sed -E 's|git@[^:/]+[:/](.+)$|\1|')
fi
else
# HTTPS URL
host=$(echo "$url" | sed -E 's|https?://([^/]+)/.*|\1|')
repo_path=$(echo "$url" | sed -E 's|https?://[^/]+/(.+)$|\1|')
fi
@@ -80,104 +191,131 @@ _archived_api_from_url() {
# ─── Onboard Flow ────────────────────────────────────────────────
# Interactive orchestrator with checkpoint/resume.
# Usage: onboard_flow <target_json> <restart>
# Usage: onboard_flow <target_json> <restart> [explicit_mode]
# explicit_mode — "reset", "adopt", or "" for auto-resolve.
onboard_flow() {
local target_json="$1"
local restart="${2:-false}"
local explicit_mode="${3:-}"
local target_name="$JOSH_SYNC_TARGET_NAME"
# Load existing onboard state (or empty)
local onboard_state
onboard_state=$(read_onboard_state "$target_name")
local current_step
current_step=$(echo "$onboard_state" | jq -r '.step // "start"')
local state current_step strategy
state=$(read_onboard_state "$target_name")
current_step=$(echo "$state" | jq -r '.step // "start"')
strategy=$(echo "$state" | jq -r '.strategy // empty')
if [ "$restart" = true ]; then
log "INFO" "Restarting onboard from scratch"
current_step="start"
onboard_state='{}'
state='{}'
strategy=""
# Durably clear any legacy v2.1 adopt.json so the next read doesn't
# silently fall back to it.
_onboard_state_remove_legacy "$target_name"
fi
if [ -z "$strategy" ]; then
strategy=$(resolve_onboard_strategy "$target_json" "$explicit_mode")
elif [ -n "$explicit_mode" ] && [ "$strategy" != "$explicit_mode" ]; then
die "Saved strategy is '${strategy}' (mid-flow); cannot switch to --mode=${explicit_mode} without --restart."
else
log "INFO" "Strategy: ${strategy} (resumed from state)"
fi
case "$strategy" in
reset|adopt) ;;
*) die "Unknown strategy '${strategy}' in state (corrupt?). Re-run with --restart to start over." ;;
esac
log "INFO" "Onboard step: ${current_step}"
# ── Step 1: Prerequisites + archived repo info ──
# ── Step 1: Strategy-specific intro and (for reset) archived repo info ──
if [ "$current_step" = "start" ]; then
echo "" >&2
echo "=== Onboarding ${target_name} ===" >&2
echo "" >&2
echo "Before proceeding, you should have:" >&2
echo " 1. Renamed the existing subrepo (e.g., storefront → storefront-archived)" >&2
echo " 2. Created a new EMPTY repo at the original URL" >&2
echo "=== Onboarding ${target_name} (strategy: ${strategy}) ===" >&2
echo "" >&2
# Verify the new (empty) subrepo is reachable (no HEAD ref — works on empty repos)
if git ls-remote "$(subrepo_auth_url)" >/dev/null 2>&1; then
# shellcheck disable=SC2001 # sed is clearer for URL pattern replacement
log "INFO" "New subrepo is reachable at $(echo "$SUBREPO_URL" | sed 's|://[^@]*@|://***@|')"
if [ "$strategy" = "reset" ]; then
echo "Before proceeding, you should have:" >&2
echo " 1. Renamed the existing subrepo (e.g., storefront → storefront-archived)" >&2
echo " 2. Created a new EMPTY repo at the original URL" >&2
echo "" >&2
if git ls-remote "$(subrepo_auth_url)" >/dev/null 2>&1; then
# shellcheck disable=SC2001 # sed is clearer for URL pattern replacement
log "INFO" "New subrepo is reachable at $(echo "$SUBREPO_URL" | sed 's|://[^@]*@|://***@|')"
else
log "WARN" "New subrepo is not reachable — make sure you created the new empty repo"
fi
echo "Enter the archived repo URL (e.g., git@host:org/repo-archived.git):" >&2
local archived_url
read -r archived_url
[ -n "$archived_url" ] || die "Archived URL is required"
local archived_auth="${SUBREPO_AUTH:-https}"
local archived_api
archived_api=$(_archived_api_from_url "$archived_url")
if curl -sf -H "Authorization: token ${SUBREPO_TOKEN}" \
"${archived_api}" >/dev/null 2>&1; then
log "INFO" "Archived repo reachable: ${archived_api}"
else
log "WARN" "Cannot reach archived repo API — check URL and token"
echo "Continue anyway? (y/N):" >&2
local confirm
read -r confirm
[ "$confirm" = "y" ] || [ "$confirm" = "Y" ] || die "Aborted"
fi
state=$(jq -n \
--arg step "importing" \
--arg strategy "reset" \
--arg archived_api "$archived_api" \
--arg archived_url "$archived_url" \
--arg archived_auth "$archived_auth" \
--arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'{step:$step, strategy:$strategy, archived_api:$archived_api,
archived_url:$archived_url, archived_auth:$archived_auth,
import_prs:{}, reset_branches:[], migrated_prs:[], timestamp:$ts}')
else
log "WARN" "New subrepo is not reachable — make sure you created the new empty repo"
# strategy = adopt
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
state=$(jq -n \
--arg step "importing" \
--arg strategy "adopt" \
--arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'{step:$step, strategy:$strategy, import_prs:{}, adopted_branches:[], timestamp:$ts}')
fi
echo "Enter the archived repo URL (e.g., git@host:org/repo-archived.git):" >&2
local archived_url
read -r archived_url
[ -n "$archived_url" ] || die "Archived URL is required"
# Determine auth type for archived repo (same as current subrepo)
local archived_auth="${SUBREPO_AUTH:-https}"
# Derive API URL
local archived_api
archived_api=$(_archived_api_from_url "$archived_url")
# Verify archived repo is reachable via API
if curl -sf -H "Authorization: token ${SUBREPO_TOKEN}" \
"${archived_api}" >/dev/null 2>&1; then
log "INFO" "Archived repo reachable: ${archived_api}"
else
log "WARN" "Cannot reach archived repo API — check URL and token"
echo "Continue anyway? (y/N):" >&2
local confirm
read -r confirm
[ "$confirm" = "y" ] || [ "$confirm" = "Y" ] || die "Aborted"
fi
# Save state
onboard_state=$(jq -n \
--arg step "importing" \
--arg archived_api "$archived_api" \
--arg archived_url "$archived_url" \
--arg archived_auth "$archived_auth" \
--arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'{step:$step, archived_api:$archived_api, archived_url:$archived_url,
archived_auth:$archived_auth, import_prs:{}, reset_branches:[],
migrated_prs:[], timestamp:$ts}')
write_onboard_state "$target_name" "$onboard_state"
write_onboard_state "$target_name" "$state"
current_step="importing"
fi
# ── Step 2: Import (reuses initial_import()) ──
# ── Step 2: Import (reset uses archived clone URL; adopt uses subrepo URL) ──
if [ "$current_step" = "importing" ]; then
echo "" >&2
log "INFO" "Step 2: Importing subrepo content into monorepo..."
local branches
local branches import_prs
branches=$(echo "$target_json" | jq -r '.branches | keys[]')
import_prs=$(echo "$state" | jq -r '.import_prs // {}')
# Load existing import_prs from state (resume support)
local import_prs
import_prs=$(echo "$onboard_state" | jq -r '.import_prs // {}')
# Build the archived repo clone URL for initial_import().
# The content lives in the archived repo — the new repo at SUBREPO_URL is empty.
local archived_url archived_clone_url
archived_url=$(echo "$onboard_state" | jq -r '.archived_url')
if [ "${SUBREPO_AUTH:-https}" = "ssh" ]; then
archived_clone_url="$archived_url"
else
# shellcheck disable=SC2001
archived_clone_url=$(echo "$archived_url" | sed "s|https://|https://${BOT_USER}:${SUBREPO_TOKEN}@|")
local archived_clone_url=""
if [ "$strategy" = "reset" ]; then
local archived_url
archived_url=$(echo "$state" | jq -r '.archived_url // empty')
[ -n "$archived_url" ] || die "Reset strategy: archived_url missing from state (corrupt or interrupted before start step finished). Re-run with --restart."
if [ "${SUBREPO_AUTH:-https}" = "ssh" ]; then
archived_clone_url="$archived_url"
else
# shellcheck disable=SC2001
archived_clone_url=$(echo "$archived_url" | sed "s|https://|https://${BOT_USER}:${SUBREPO_TOKEN}@|")
fi
fi
for branch in $branches; do
@@ -185,7 +323,6 @@ onboard_flow() {
mapped=$(echo "$target_json" | jq -r --arg b "$branch" '.branches[$b] // empty')
[ -z "$mapped" ] && continue
# Skip branches that already have an import PR recorded
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
@@ -196,11 +333,14 @@ onboard_flow() {
log "INFO" "Importing branch: ${branch} (subrepo: ${mapped})"
local result
result=$(initial_import "$archived_clone_url")
if [ -n "$archived_clone_url" ]; then
result=$(initial_import "$archived_clone_url")
else
result=$(initial_import)
fi
log "INFO" "Import result for ${branch}: ${result}"
if [ "$result" = "pr-created" ]; then
# Find the import PR number via API
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" \
@@ -210,37 +350,35 @@ onboard_flow() {
import_prs=$(echo "$import_prs" | jq --arg b "$branch" --arg n "$pr_number" '. + {($b): ($n | tonumber)}')
log "INFO" "Import PR for ${branch}: #${pr_number}"
else
log "WARN" "Could not find import PR number for ${branch} — check monorepo PRs"
# Same hazard for both strategies: continuing without recording the
# PR number means the next resume re-runs initial_import and creates
# a duplicate import PR. Bail out and let the user reconcile.
die "Could not find import PR number for ${branch} on monorepo. Re-running would create a duplicate import PR — check the monorepo's open PRs first."
fi
fi
# Save progress after each branch (resume support)
onboard_state=$(echo "$onboard_state" | jq --argjson prs "$import_prs" '.import_prs = $prs')
write_onboard_state "$target_name" "$onboard_state"
state=$(echo "$state" | jq --argjson prs "$import_prs" '.import_prs = $prs')
write_onboard_state "$target_name" "$state"
done
# Update state
onboard_state=$(echo "$onboard_state" | jq \
state=$(echo "$state" | jq \
--arg step "waiting-for-merge" \
--argjson prs "$import_prs" \
'.step = $step | .import_prs = $prs')
write_onboard_state "$target_name" "$onboard_state"
write_onboard_state "$target_name" "$state"
current_step="waiting-for-merge"
fi
# ── Step 3: Wait for merge ──
# ── Step 3: Wait for import PR(s) to merge ──
if [ "$current_step" = "waiting-for-merge" ]; then
echo "" >&2
log "INFO" "Step 3: Waiting for import PR(s) to be merged..."
local import_prs
import_prs=$(echo "$onboard_state" | jq -r '.import_prs')
local pr_count
local import_prs pr_count
import_prs=$(echo "$state" | jq -r '.import_prs')
pr_count=$(echo "$import_prs" | jq 'length')
if [ "$pr_count" -eq 0 ]; then
log "WARN" "No import PRs recorded — skipping merge check"
else
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
@@ -248,12 +386,10 @@ onboard_flow() {
echo "Merge the import PR(s) on the monorepo, then press Enter..." >&2
read -r
# Verify each PR is merged
local all_merged=true
for branch in $(echo "$import_prs" | jq -r 'keys[]'); do
local pr_number
local pr_number pr_json merged
pr_number=$(echo "$import_prs" | jq -r --arg b "$branch" '.[$b]')
local pr_json merged
pr_json=$(get_pr "$MONOREPO_API" "$GITEA_TOKEN" "$pr_number")
merged=$(echo "$pr_json" | jq -r '.merged // false')
@@ -268,26 +404,31 @@ onboard_flow() {
if [ "$all_merged" = false ]; then
die "Not all import PRs are merged. Re-run 'josh-sync onboard ${target_name}' after merging."
fi
else
log "INFO" "No import PRs recorded — subfolder already matched subrepo"
fi
# Update state
onboard_state=$(echo "$onboard_state" | jq '.step = "resetting"')
write_onboard_state "$target_name" "$onboard_state"
current_step="resetting"
local next_step
if [ "$strategy" = "reset" ]; then
next_step="resetting"
else
next_step="adopting"
fi
state=$(echo "$state" | jq --arg s "$next_step" '.step = $s')
write_onboard_state "$target_name" "$state"
current_step="$next_step"
fi
# ── Step 4: Reset (pushes josh-filtered history to new repo) ──
# ── Step 4a: Finalize via reset (force-push josh-filtered history) ──
if [ "$current_step" = "resetting" ]; then
echo "" >&2
log "INFO" "Step 4: Pushing josh-filtered history to new subrepo..."
local branches
local branches already_reset
branches=$(echo "$target_json" | jq -r '.branches | keys[]')
local already_reset
already_reset=$(echo "$onboard_state" | jq -r '.reset_branches // []')
already_reset=$(echo "$state" | jq -r '.reset_branches // []')
for branch in $branches; do
# Skip branches already reset (resume support)
if echo "$already_reset" | jq -e --arg b "$branch" 'index($b) != null' >/dev/null 2>&1; then
log "INFO" "Branch ${branch} already reset — skipping"
continue
@@ -304,18 +445,69 @@ onboard_flow() {
result=$(subrepo_reset)
log "INFO" "Reset result for ${branch}: ${result}"
# Track progress
onboard_state=$(echo "$onboard_state" | jq --arg b "$branch" \
'.reset_branches += [$b]')
write_onboard_state "$target_name" "$onboard_state"
state=$(echo "$state" | jq --arg b "$branch" '.reset_branches += [$b]')
write_onboard_state "$target_name" "$state"
done
# Update state
onboard_state=$(echo "$onboard_state" | jq \
state=$(echo "$state" | jq \
--arg step "complete" \
--arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'.step = $step | .timestamp = $ts')
write_onboard_state "$target_name" "$onboard_state"
write_onboard_state "$target_name" "$state"
current_step="complete"
fi
# ── Step 4b: Finalize via adoption merge ──
if [ "$current_step" = "adopting" ]; then
echo "" >&2
log "INFO" "Step 4: Creating adoption merge commit(s)..."
local branches already_adopted
branches=$(echo "$target_json" | jq -r '.branches | keys[]')
already_adopted=$(echo "$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)
state=$(echo "$state" | jq --arg b "$branch" '.adopted_branches += [$b]')
write_onboard_state "$target_name" "$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 onboard ${target_name} --mode=adopt' to retry."
;;
missing-branch)
die "Subrepo branch ${mapped} does not exist."
;;
*)
die "Unexpected adopt result: ${result}"
;;
esac
done
state=$(echo "$state" | jq \
--arg step "complete" \
--arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'.step = $step | .timestamp = $ts')
write_onboard_state "$target_name" "$state"
current_step="complete"
fi
@@ -324,14 +516,20 @@ onboard_flow() {
echo "" >&2
echo "=== Onboarding complete! ===" >&2
echo "" >&2
echo "The new subrepo now has josh-filtered history." >&2
echo "Developers should re-clone or reset their local copies:" >&2
echo " git fetch origin && git reset --hard origin/main" >&2
echo "" >&2
echo "To migrate open PRs from the archived repo:" >&2
echo " josh-sync migrate-pr ${target_name} # interactive picker" >&2
echo " josh-sync migrate-pr ${target_name} --all # migrate all" >&2
echo " josh-sync migrate-pr ${target_name} 5 8 12 # specific PRs" >&2
if [ "$strategy" = "reset" ]; then
echo "The new subrepo now has josh-filtered history." >&2
echo "Developers should re-clone or reset their local copies:" >&2
echo " git fetch origin && git reset --hard origin/main" >&2
echo "" >&2
echo "To migrate open PRs from the archived repo:" >&2
echo " josh-sync migrate-pr ${target_name} # interactive picker" >&2
echo " josh-sync migrate-pr ${target_name} --all # migrate all" >&2
echo " josh-sync migrate-pr ${target_name} 5 8 12 # specific PRs" >&2
else
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
fi
}
@@ -346,15 +544,17 @@ migrate_one_pr() {
local pr_number="$1"
local target_name="$JOSH_SYNC_TARGET_NAME"
# Read archived repo info from onboard state
local onboard_state archived_api
local onboard_state strategy archived_api
onboard_state=$(read_onboard_state "$target_name")
strategy=$(echo "$onboard_state" | jq -r '.strategy // "reset"')
if [ "$strategy" = "adopt" ]; then
die "Target '${target_name}' was onboarded with the adopt strategy, which does not record an archived repo. migrate-pr only applies to the reset strategy."
fi
archived_api=$(echo "$onboard_state" | jq -r '.archived_api')
if [ -z "$archived_api" ] || [ "$archived_api" = "null" ]; then
die "No archived repo info found. Run 'josh-sync onboard ${target_name}' first."
die "No archived repo info found for '${target_name}'. Run 'josh-sync onboard ${target_name} --mode=reset' first."
fi
# Check if this PR was already migrated
local already_migrated
already_migrated=$(echo "$onboard_state" | jq -r \
--argjson num "$pr_number" '.migrated_prs // [] | map(select(.old_number == $num)) | length')
@@ -363,10 +563,8 @@ migrate_one_pr() {
return 0
fi
# Same credentials — the repo was just renamed
local archived_token="$SUBREPO_TOKEN"
# 1. Get PR metadata from archived repo
local pr_json title base head body
pr_json=$(get_pr "$archived_api" "$archived_token" "$pr_number") \
|| die "Failed to fetch PR #${pr_number} from archived repo"
@@ -377,8 +575,6 @@ migrate_one_pr() {
log "INFO" "Migrating PR #${pr_number}: \"${title}\" (${base} <- ${head})"
# 2. Clone new subrepo, add archived repo as second remote
# Save cwd so we can restore it (function runs in caller's shell, not subshell)
local original_dir
original_dir=$(pwd)
@@ -394,7 +590,6 @@ migrate_one_pr() {
git config user.name "$BOT_NAME"
git config user.email "$BOT_EMAIL"
# Build authenticated URL for the archived repo
local archived_url archived_clone_url
archived_url=$(echo "$onboard_state" | jq -r '.archived_url')
if [ "${SUBREPO_AUTH:-https}" = "ssh" ]; then
@@ -404,12 +599,10 @@ migrate_one_pr() {
archived_clone_url=$(echo "$archived_url" | sed "s|https://|https://${BOT_USER}:${SUBREPO_TOKEN}@|")
fi
# Fetch the PR's head and base branches from the archived repo
git remote add archived "$archived_clone_url"
git fetch archived "$head" "$base" 2>&1 \
|| die "Failed to fetch branches from archived repo"
# 3. Compute diff locally and apply with --3way
git checkout -B "$head" >&2
local diff
@@ -428,13 +621,11 @@ Migrated from archived repo PR #${pr_number}" >&2
git push "$(subrepo_auth_url)" "$head" >&2 \
|| die "Failed to push branch ${head}"
# 4. Create PR on new repo
local new_number
new_number=$(create_pr_number "$SUBREPO_API" "$SUBREPO_TOKEN" \
"$base" "$head" "$title" "$body")
log "INFO" "Migrated PR #${pr_number} -> #${new_number}: \"${title}\""
# 5. Record in onboard state
cd "$original_dir" || true
onboard_state=$(read_onboard_state "$target_name")
onboard_state=$(echo "$onboard_state" | jq \