#!/usr/bin/env bash # lib/onboard.sh — Unified onboard orchestration (reset + adopt strategies) # # Provides: # 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 # # State is stored on the josh-sync-state branch at /onboard.json: # { step, strategy, import_prs, ... } # read_onboard_state falls back to legacy /adopt.json (with implicit # strategy="adopt") so in-flight v2.1 adoptions continue to resume correctly. # # 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. # ─── 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 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 /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() { local target_name="${1:-$JOSH_SYNC_TARGET_NAME}" local state_json="$2" local key="${target_name}/onboard" 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 "onboard: update ${target_name}" git push origin "HEAD:${STATE_BRANCH}" || log "WARN" "Failed to push onboard state" fi ) git worktree remove "$tmp_dir" 2>/dev/null || rm -rf "$tmp_dir" } # Remove the v2.1 /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" url="${url%.git}" local host repo_path if echo "$url" | grep -qE '^(ssh://|git@)'; then if echo "$url" | grep -q '^ssh://'; then host=$(echo "$url" | sed -E 's|ssh://[^@]*@([^/]+)/.*|\1|') repo_path=$(echo "$url" | sed -E 's|ssh://[^@]*@[^/]+/(.+)$|\1|') else host=$(echo "$url" | sed -E 's|git@([^:/]+)[:/].*|\1|') repo_path=$(echo "$url" | sed -E 's|git@[^:/]+[:/](.+)$|\1|') fi else host=$(echo "$url" | sed -E 's|https?://([^/]+)/.*|\1|') repo_path=$(echo "$url" | sed -E 's|https?://[^/]+/(.+)$|\1|') fi echo "https://${host}/api/v1/repos/${repo_path}" } # ─── Onboard Flow ──────────────────────────────────────────────── # Interactive orchestrator with checkpoint/resume. # Usage: onboard_flow [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" 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" 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: Strategy-specific intro and (for reset) archived repo info ── if [ "$current_step" = "start" ]; then echo "" >&2 echo "=== Onboarding ${target_name} (strategy: ${strategy}) ===" >&2 echo "" >&2 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 # 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 write_onboard_state "$target_name" "$state" current_step="importing" fi # ── 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 import_prs branches=$(echo "$target_json" | jq -r '.branches | keys[]') import_prs=$(echo "$state" | jq -r '.import_prs // {}') 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 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" log "INFO" "Importing branch: ${branch} (subrepo: ${mapped})" local result 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 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') if [ -n "$pr_number" ]; then 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 # 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 state=$(echo "$state" | jq --argjson prs "$import_prs" '.import_prs = $prs') write_onboard_state "$target_name" "$state" done state=$(echo "$state" | jq \ --arg step "waiting-for-merge" \ --argjson prs "$import_prs" \ '.step = $step | .import_prs = $prs') write_onboard_state "$target_name" "$state" current_step="waiting-for-merge" fi # ── 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 pr_count import_prs=$(echo "$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 onboard ${target_name}' after merging." fi else log "INFO" "No import PRs recorded — subfolder already matched subrepo" fi 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 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 already_reset branches=$(echo "$target_json" | jq -r '.branches | keys[]') already_reset=$(echo "$state" | jq -r '.reset_branches // []') for branch in $branches; do 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 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=$(subrepo_reset) log "INFO" "Reset result for ${branch}: ${result}" state=$(echo "$state" | jq --arg b "$branch" '.reset_branches += [$b]') write_onboard_state "$target_name" "$state" 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 # ── 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 # ── Step 5: Done ── if [ "$current_step" = "complete" ]; then echo "" >&2 echo "=== Onboarding complete! ===" >&2 echo "" >&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 } # ─── Migrate One PR ────────────────────────────────────────────── # Fetches the PR's branch from the archived repo, computes a local diff, # and applies it to the new subrepo with --3way for resilience. # Usage: migrate_one_pr # # Expects: JOSH_SYNC_TARGET_NAME, SUBREPO_API, SUBREPO_TOKEN, BOT_NAME, BOT_EMAIL loaded migrate_one_pr() { local pr_number="$1" local target_name="$JOSH_SYNC_TARGET_NAME" 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 for '${target_name}'. Run 'josh-sync onboard ${target_name} --mode=reset' first." fi local already_migrated already_migrated=$(echo "$onboard_state" | jq -r \ --argjson num "$pr_number" '.migrated_prs // [] | map(select(.old_number == $num)) | length') if [ "$already_migrated" -gt 0 ]; then log "INFO" "PR #${pr_number} already migrated — skipping" return 0 fi local archived_token="$SUBREPO_TOKEN" 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" title=$(echo "$pr_json" | jq -r '.title') base=$(echo "$pr_json" | jq -r '.base.ref') head=$(echo "$pr_json" | jq -r '.head.ref') body=$(echo "$pr_json" | jq -r '.body // ""') log "INFO" "Migrating PR #${pr_number}: \"${title}\" (${base} <- ${head})" local original_dir original_dir=$(pwd) local work_dir work_dir=$(mktemp -d) # shellcheck disable=SC2064 # Intentional early expansion trap "cd '$original_dir' 2>/dev/null; rm -rf '$work_dir'" RETURN git clone "$(subrepo_auth_url)" --branch "$base" --single-branch \ "${work_dir}/subrepo" 2>&1 || die "Failed to clone new subrepo (branch: ${base})" cd "${work_dir}/subrepo" || exit git config user.name "$BOT_NAME" git config user.email "$BOT_EMAIL" 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}@|") fi git remote add archived "$archived_clone_url" git fetch archived "$head" "$base" 2>&1 \ || die "Failed to fetch branches from archived repo" git checkout -B "$head" >&2 local diff diff=$(git diff "archived/${base}..archived/${head}") if [ -z "$diff" ]; then log "WARN" "Empty diff for PR #${pr_number} — skipping" return 1 fi if echo "$diff" | git apply --3way 2>&1; then git add -A git commit -m "${title} Migrated from archived repo PR #${pr_number}" >&2 git push "$(subrepo_auth_url)" "$head" >&2 \ || die "Failed to push branch ${head}" 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}\"" cd "$original_dir" || true onboard_state=$(read_onboard_state "$target_name") onboard_state=$(echo "$onboard_state" | jq \ --argjson old "$pr_number" \ --argjson new_num "${new_number}" \ --arg title "$title" \ '.migrated_prs += [{"old_number":$old, "new_number":$new_num, "title":$title}]') write_onboard_state "$target_name" "$onboard_state" else log "ERROR" "Could not apply changes for PR #${pr_number} even with 3-way merge" log "ERROR" "Manual migration needed: branch '${head}' from archived repo" return 1 fi }