#!/usr/bin/env bash # lib/adopt.sh — Non-destructive adoption of existing subrepo history # # Provides: # adopt_flow() — Import → wait for merge → adoption merge per branch # adopt_branch() — Create the adoption merge and push it fast-forward only # # Adopt state is stored on the josh-sync-state branch at /adopt.json. # Steps: start → importing → waiting-for-merge → adopting → complete # # 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" } # ─── Adopt Branch ──────────────────────────────────────────────── # Establishes shared ancestry without rewriting the existing subrepo branch. # # Preconditions: # - The subrepo branch and josh-filtered monorepo branch must have identical trees. # # Resulting commit: # - parent 1: josh-filtered HEAD, so josh can follow first-parent history # - parent 2: existing subrepo HEAD, so old subrepo history stays reachable # - tree: josh-filtered tree # # Returns: adopted | already-adopted | tree-mismatch | push-rejected | missing-branch adopt_branch() { local mono_branch="$SYNC_BRANCH_MONO" local subrepo_branch="$SYNC_BRANCH_SUBREPO" local work_dir work_dir=$(mktemp -d) # shellcheck disable=SC2064 # Intentional early expansion — work_dir is local trap "rm -rf '$work_dir'" EXIT log "INFO" "=== Adopt: subrepo/${subrepo_branch} ↔ mono/${mono_branch} ===" local remote_subrepo_sha remote_subrepo_sha=$(git ls-remote "$(subrepo_auth_url)" "refs/heads/${subrepo_branch}" | awk '{print $1}') \ || die "Failed to reach subrepo (check SSH key / auth)" if [ -z "$remote_subrepo_sha" ]; then log "ERROR" "Subrepo branch ${subrepo_branch} does not exist" echo "missing-branch" return fi git clone "$(subrepo_auth_url)" \ --branch "$subrepo_branch" --single-branch \ "${work_dir}/subrepo" || die "Failed to clone subrepo" cd "${work_dir}/subrepo" || exit git config user.name "$BOT_NAME" git config user.email "$BOT_EMAIL" local subrepo_head subrepo_tree subrepo_head=$(git rev-parse HEAD) # shellcheck disable=SC1083 # {tree} is git syntax, not shell brace expansion subrepo_tree=$(git rev-parse "HEAD^{tree}") log "INFO" "Subrepo HEAD: ${subrepo_head:0:12}" git remote add josh-filtered "$(josh_auth_url)" git fetch josh-filtered "$mono_branch" || die "Failed to fetch from josh-proxy" local josh_head josh_tree josh_head=$(git rev-parse "josh-filtered/${mono_branch}") # shellcheck disable=SC1083 josh_tree=$(git rev-parse "josh-filtered/${mono_branch}^{tree}") log "INFO" "Josh-filtered HEAD: ${josh_head:0:12}" if [ "$subrepo_tree" != "$josh_tree" ]; then log "ERROR" "Tree mismatch: merge the import PR and ensure subrepo/${subrepo_branch} matches mono/${mono_branch} before adopting" echo "tree-mismatch" return fi if git merge-base --is-ancestor "$josh_head" "$subrepo_head" \ && git merge-base --is-ancestor "$subrepo_head" "$josh_head"; then log "INFO" "Subrepo and josh-filtered histories already point to the same commit" echo "already-adopted" return fi if git merge-base --is-ancestor "$josh_head" "$subrepo_head"; then log "INFO" "Josh-filtered HEAD is already an ancestor of subrepo HEAD" echo "already-adopted" return fi local merge_commit merge_commit=$(git commit-tree "$josh_tree" \ -p "$josh_head" \ -p "$subrepo_head" \ -m "Adopt ${JOSH_SYNC_TARGET_NAME} for josh-sync ${BOT_TRAILER}: adopt/${mono_branch}/$(date -u +%Y-%m-%dT%H:%M:%SZ)") git reset --hard "$merge_commit" >&2 log "INFO" "Created adoption merge: ${merge_commit:0:12}" if git push "$(subrepo_auth_url)" "HEAD:refs/heads/${subrepo_branch}"; then log "INFO" "Adoption complete: pushed fast-forward merge to subrepo/${subrepo_branch}" echo "adopted" else log "WARN" "Fast-forward push rejected — subrepo changed during adoption" echo "push-rejected" fi } # ─── Adopt Flow ────────────────────────────────────────────────── # Interactive orchestrator with checkpoint/resume. # Usage: adopt_flow 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 }