diff --git a/CHANGELOG.md b/CHANGELOG.md index 711a774..31ad6e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## 2.1.0 + +### Breaking Changes + +_None._ + +### Features + +- **`adopt` command for non-destructive existing subrepos**: `josh-sync adopt ` imports existing subrepo content into the monorepo, waits for the import PR to merge, then creates an adoption merge commit on the subrepo. The adoption merge uses the Josh-filtered monorepo commit as first parent and the existing subrepo commit as second parent, preserving old subrepo history while establishing Josh-compatible ancestry. +- **Resumable adoption state**: adoption progress is stored on `josh-sync-state` at `/adopt.json`, separate from sync and onboard state. + +### Safety + +- Adoption requires the subrepo tree to match the Josh-filtered monorepo tree before creating the merge commit. +- Adoption pushes with a normal fast-forward push only. It never force-pushes. + ## 2.0.0 ### Breaking Changes diff --git a/Makefile b/Makefile index a748e3f..3b4de3e 100644 --- a/Makefile +++ b/Makefile @@ -23,7 +23,7 @@ dist/josh-sync: bin/josh-sync lib/*.sh VERSION @echo '# Generated by: make build' >> dist/josh-sync @echo '' >> dist/josh-sync @# Inline all library modules (strip shebangs and source directives) - @for f in lib/core.sh lib/config.sh lib/auth.sh lib/state.sh lib/sync.sh lib/onboard.sh; do \ + @for f in lib/core.sh lib/config.sh lib/auth.sh lib/state.sh lib/sync.sh lib/adopt.sh lib/onboard.sh; do \ echo "# --- $$f ---" >> dist/josh-sync; \ grep -v '^#!/' "$$f" | grep -v '^# shellcheck source=' >> dist/josh-sync; \ echo '' >> dist/josh-sync; \ diff --git a/README.md b/README.md index 3eb74d9..01966b2 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,7 @@ CI workflows pin to a floating major tag (e.g. `@v1`). A breaking change bumps t josh-sync sync [--forward|--reverse] [--force] [--target NAME[,NAME]] [--branch BRANCH] josh-sync preflight josh-sync import +josh-sync adopt [--restart] josh-sync reset josh-sync onboard [--restart] josh-sync migrate-pr [PR#...] [--all] @@ -97,6 +98,7 @@ josh-sync state reset [branch] - **Forward sync** (mono → subrepo): pushes directly if clean, creates conflict PR if not. Uses `--force-with-lease` for safety. - **Reverse sync** (subrepo → mono): creates a PR by default. Add `--force` to bypass the PR and push directly to the mono branch (destructive). +- **Adoption**: existing active subrepos can be connected to josh-sync with a normal fast-forward merge commit, preserving old subrepo history and avoiding force-push. - **File exclusion**: `exclude` patterns are embedded inline in the josh-proxy URL. Excluded files exist only in the monorepo. - **Filter reconciliation**: Changing the exclude list auto-creates a merge commit that connects old and new histories — no force-push needed. - **Loop prevention**: `Josh-Sync-Origin:` git trailer filters out bot commits. diff --git a/VERSION b/VERSION index 227cea2..7ec1d6d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.0.0 +2.1.0 diff --git a/bin/josh-sync b/bin/josh-sync index 2d089b3..d126e3b 100755 --- a/bin/josh-sync +++ b/bin/josh-sync @@ -8,6 +8,7 @@ # sync Run forward and/or reverse sync # preflight Validate config, connectivity, auth # import Initial import: pull subrepo into monorepo +# adopt Adopt existing subrepo history without rewriting it # reset Reset subrepo to josh-filtered view # onboard Import existing subrepo into monorepo (interactive) # migrate-pr [PR#...] [--all] Move PRs from archived to new subrepo @@ -41,6 +42,8 @@ source "${JOSH_LIB_DIR}/auth.sh" source "${JOSH_LIB_DIR}/state.sh" # shellcheck source=../lib/sync.sh source "${JOSH_LIB_DIR}/sync.sh" +# shellcheck source=../lib/adopt.sh +source "${JOSH_LIB_DIR}/adopt.sh" # shellcheck source=../lib/onboard.sh source "${JOSH_LIB_DIR}/onboard.sh" @@ -72,6 +75,7 @@ Commands: sync Run forward and/or reverse sync preflight Validate config, connectivity, auth, workflow coverage import Initial import: pull existing subrepo into monorepo (creates PR) + adopt Adopt existing subrepo history without rewriting it reset Reset subrepo to josh-filtered view (after merging import PR) onboard Import existing subrepo into monorepo (interactive, resumable) migrate-pr [PR#...] [--all] Move PRs from archived to new subrepo @@ -530,6 +534,42 @@ cmd_import() { done } +# ─── Adopt Command ───────────────────────────────────────────────── + +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 ;; + esac + done + + if [ -z "$target_name" ]; then + echo "Usage: josh-sync adopt [--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" +} + # ─── Reset Command ───────────────────────────────────────────────── cmd_reset() { @@ -886,6 +926,7 @@ main() { sync) cmd_sync "$@" ;; preflight) cmd_preflight "$@" ;; import) cmd_import "$@" ;; + adopt) cmd_adopt "$@" ;; reset) cmd_reset "$@" ;; onboard) cmd_onboard "$@" ;; migrate-pr) cmd_migrate_pr "$@" ;; diff --git a/docs/adr/013-non-destructive-adoption.md b/docs/adr/013-non-destructive-adoption.md new file mode 100644 index 0000000..a744b77 --- /dev/null +++ b/docs/adr/013-non-destructive-adoption.md @@ -0,0 +1,55 @@ +# ADR-013: Non-destructive adoption merge for existing subrepos + +**Status:** Accepted +**Date:** 2026-04 + +## Context + +Existing subrepos often already have real history, open PRs, and developer clones. +The original `import` then `reset` onboarding path establishes Josh-compatible +history by force-pushing the Josh-filtered monorepo history onto the subrepo. +That is correct for empty replacement repos, but it rewrites the active subrepo +branch and invalidates local clones. + +We need a path for active subrepos where history must remain available on the +same branch and where open PR branches should stay based on the existing history. + +## Decision + +Add `josh-sync adopt ` as a separate workflow from `onboard` and `reset`. +Adoption imports the subrepo content into the monorepo, waits for the import PR +to merge, then creates one merge commit on each configured subrepo branch: + +1. First parent: Josh-filtered monorepo HEAD +2. Second parent: existing subrepo HEAD +3. Tree: Josh-filtered monorepo tree + +Josh follows first-parent history back to the monorepo, while Git still keeps +the old subrepo history reachable through parent 2. The push is a normal +fast-forward push from the existing subrepo HEAD to the adoption merge commit. +No force-push is used. + +Before creating the adoption merge, josh-sync requires the existing subrepo tree +to match the Josh-filtered monorepo tree. If the trees differ, adoption aborts +and the user must merge the import PR or reconcile the branch contents first. + +Adoption state is stored separately at `/adopt.json` on the +`josh-sync-state` branch. + +## Consequences + +**Positive:** + +- Existing subrepo history remains on the active branch. +- Existing clones can fast-forward instead of hard-resetting or re-cloning. +- Open PR branches remain based on reachable history. +- Josh-compatible ancestry is established without a destructive rewrite. + +**Negative:** + +- Adoption adds one synthetic merge commit to each adopted subrepo branch. +- Strict "no subrepo commit at all" adoption is impossible if the existing + subrepo branch must become connected to the Josh-filtered history without a + rewrite. A commit is needed to join the two histories. +- Tree equality is strict. If the monorepo import differs from the subrepo + content, adoption stops until the user resolves the mismatch. diff --git a/docs/adr/README.md b/docs/adr/README.md index 36add1d..a570fc1 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -18,3 +18,4 @@ This directory contains Architecture Decision Records (ADRs) for josh-sync. Each | [010](010-onboard-checkpoint-resume.md) | Onboard workflow with checkpoint/resume | Accepted | | [011](011-linearize-fallback-reverse.md) | Linearize fallback for reverse sync | Accepted | | [012](012-explicit-monorepo-url.md) | Explicit `monorepo_url` (drop first-target-host inference) | Accepted | +| [013](013-non-destructive-adoption.md) | Non-destructive adoption merge for existing subrepos | Accepted | diff --git a/docs/guide.md b/docs/guide.md index a54be32..540b685 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -224,14 +224,40 @@ For a new monorepo before import, preflight may warn that subfolders don't exist ## Step 5: Import Existing Subrepos -This is the critical onboarding step. There are two approaches: +This is the critical onboarding step. There are three approaches: -- **`josh-sync onboard`** (recommended) — interactive, resumable, preserves open PRs -- **Manual `import` → merge → `reset`** — lower-level, for automation or when there are no open PRs to preserve +- **`josh-sync adopt`** (recommended for active existing subrepos) — non-destructive, resumable, preserves existing subrepo history +- **`josh-sync onboard`** — destructive replacement-repo workflow for teams that intentionally archive the old repo +- **Manual `import` → merge → `reset`** — lower-level destructive path for automation or empty replacement repos -### Option A: Onboard (recommended) +### Option A: Adopt (recommended for active subrepos) -The `onboard` command walks you through the entire process interactively, with checkpoint/resume at every step. +Use `adopt` when the subrepo already exists, developers already clone it, or open PR branches should remain based on existing history. + +```bash +josh-sync adopt billing +``` + +The command will: +1. **Import** — copies current subrepo content into the monorepo and creates import PRs (one per branch) +2. **Wait for merge** — shows PR numbers and waits for you to merge them +3. **Verify trees** — requires the existing subrepo tree to match the Josh-filtered monorepo tree +4. **Adopt** — creates a merge commit on the subrepo with Josh-filtered HEAD as parent 1 and existing subrepo HEAD as parent 2 +5. **Push** — pushes the adoption merge with a normal fast-forward push, never force-push + +The adoption merge preserves the old subrepo history while giving Josh a first-parent path back to the monorepo. If interrupted, re-run `josh-sync adopt billing` to resume. Use `--restart` to start over. + +After adoption, developers can update existing clones with a normal fast-forward: + +```bash +git fetch origin +git checkout main +git merge --ff-only origin/main +``` + +### Option B: Onboard with replacement repo + +The `onboard` command walks through the destructive replacement-repo process interactively, with checkpoint/resume at every step. **Before you start:** @@ -272,13 +298,13 @@ josh-sync migrate-pr billing 5 8 12 PR migration works by fetching the diff from the archived repo's PR, applying it to the new repo, and creating a new PR. File content is identical after reset, so patches apply cleanly. -### Option B: Manual import → merge → reset +### Option C: Manual import → merge → reset -Use this when the subrepo has no open PRs to preserve, or for scripted automation. +Use this for scripted automation or when you intentionally want to replace subrepo history. > Do this **one target at a time** to keep PRs reviewable. -#### 5b-1. Import +#### 5c-1. Import ```bash josh-sync import billing @@ -293,13 +319,13 @@ This: Review the import PR — check for leaked credentials, environment-specific config, or files that shouldn't be in the monorepo. -#### 5b-2. Merge the import PR +#### 5c-2. Merge the import PR Merge the PR using your Git platform's UI. This lands the subrepo content into the monorepo's main branch. > At this point, the monorepo has the content but the histories are disconnected. Sync will **not** work until you complete the reset step. -#### 5b-3. Reset +#### 5c-3. Reset ```bash josh-sync reset billing @@ -326,7 +352,7 @@ git checkout stage && git reset --hard origin/stage # repeat for each branch Or simply delete and re-clone the subrepo. Local-only branches (not pushed to the remote) will be lost either way. -#### 5b-4. Repeat for each target +#### 5c-4. Repeat for each target ``` For each target: @@ -337,13 +363,13 @@ For each target: ### Verify -After all targets are imported and reset (whichever option you used): +After all targets are adopted or reset: ```bash # Check all targets show state josh-sync status -# Test forward sync — should return "skip" (trees are identical after reset) +# Test forward sync — should return "skip" (trees are identical after adoption/reset) josh-sync sync --forward --target billing # Test reverse sync — should return "skip" (no new human commits) @@ -620,9 +646,12 @@ To add a new subrepo after initial setup: 1. Add the target to `.josh-sync.yml` 2. Update the forward workflow's `paths:` list to include the new subfolder 3. Commit and push -4. Import the target: +4. Import or adopt the target: ```bash - # Recommended: interactive onboard (preserves open PRs) + # Recommended for an existing active subrepo + josh-sync adopt new-target + + # Replacement-repo workflow josh-sync onboard new-target # Or manual: import → merge PR → reset diff --git a/lib/adopt.sh b/lib/adopt.sh new file mode 100644 index 0000000..b779aa7 --- /dev/null +++ b/lib/adopt.sh @@ -0,0 +1,335 @@ +#!/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 +} diff --git a/tests/unit/adopt.bats b/tests/unit/adopt.bats new file mode 100644 index 0000000..5a3d06c --- /dev/null +++ b/tests/unit/adopt.bats @@ -0,0 +1,92 @@ +#!/usr/bin/env bats +# tests/unit/adopt.bats — Non-destructive adoption merge tests + +setup() { + export JOSH_SYNC_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" + source "$JOSH_SYNC_ROOT/lib/core.sh" + source "$JOSH_SYNC_ROOT/lib/adopt.sh" + + TEST_ROOT="$(mktemp -d)" + export BOT_NAME="test-bot" + export BOT_EMAIL="test-bot@test.local" + export BOT_TRAILER="Josh-Sync-Origin" + export JOSH_SYNC_TARGET_NAME="example" + export SYNC_BRANCH_MONO="main" + export SYNC_BRANCH_SUBREPO="main" +} + +teardown() { + rm -rf "$TEST_ROOT" +} + +make_bare_repo() { + local name="$1" + local content="$2" + local message="$3" + local work="${TEST_ROOT}/${name}-work" + local bare="${TEST_ROOT}/${name}.git" + + git init "$work" >/dev/null + git -C "$work" checkout -b main >/dev/null + git -C "$work" config user.name "Test User" + git -C "$work" config user.email "test@test.local" + printf '%s\n' "$content" > "${work}/README.md" + git -C "$work" add README.md + git -C "$work" commit -m "$message" >/dev/null + + git init --bare "$bare" >/dev/null + git -C "$work" remote add origin "$bare" + git -C "$work" push origin main >/dev/null + printf '%s\n' "$bare" +} + +subrepo_auth_url() { + printf '%s\n' "$SUBREPO_BARE" +} + +josh_auth_url() { + printf '%s\n' "$JOSH_BARE" +} + +@test "adopt_branch creates merge with josh head first parent and subrepo head second parent" { + SUBREPO_BARE="$(make_bare_repo subrepo "same tree" "subrepo history")" + JOSH_BARE="$(make_bare_repo josh "same tree" "josh filtered history")" + + local old_subrepo_head josh_head josh_tree result adopt_head + old_subrepo_head=$(git --git-dir="$SUBREPO_BARE" rev-parse main) + josh_head=$(git --git-dir="$JOSH_BARE" rev-parse main) + josh_tree=$(git --git-dir="$JOSH_BARE" rev-parse 'main^{tree}') + + result=$(adopt_branch) + + [ "$result" = "adopted" ] + adopt_head=$(git --git-dir="$SUBREPO_BARE" rev-parse main) + [ "$adopt_head" != "$old_subrepo_head" ] + [ "$(git --git-dir="$SUBREPO_BARE" rev-parse 'main^1')" = "$josh_head" ] + [ "$(git --git-dir="$SUBREPO_BARE" rev-parse 'main^2')" = "$old_subrepo_head" ] + [ "$(git --git-dir="$SUBREPO_BARE" rev-parse 'main^{tree}')" = "$josh_tree" ] +} + +@test "adopt_branch aborts when trees differ and leaves subrepo head unchanged" { + SUBREPO_BARE="$(make_bare_repo subrepo "subrepo tree" "subrepo history")" + JOSH_BARE="$(make_bare_repo josh "josh tree" "josh filtered history")" + + local old_subrepo_head result + old_subrepo_head=$(git --git-dir="$SUBREPO_BARE" rev-parse main) + + result=$(adopt_branch) + + [ "$result" = "tree-mismatch" ] + [ "$(git --git-dir="$SUBREPO_BARE" rev-parse main)" = "$old_subrepo_head" ] +} + +@test "adopt_branch reports missing subrepo branch before cloning" { + SUBREPO_BARE="$(make_bare_repo subrepo "same tree" "subrepo history")" + JOSH_BARE="$(make_bare_repo josh "same tree" "josh filtered history")" + export SYNC_BRANCH_SUBREPO="missing" + + local result + result=$(adopt_branch) + + [ "$result" = "missing-branch" ] +}