This commit is contained in:
2026-05-26 10:38:20 +03:00
parent b28662d3b4
commit f2e39be5e1
10 changed files with 588 additions and 17 deletions

View File

@@ -1,5 +1,21 @@
# Changelog # Changelog
## 2.1.0
### Breaking Changes
_None._
### Features
- **`adopt` command for non-destructive existing subrepos**: `josh-sync adopt <target>` 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 `<target>/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 ## 2.0.0
### Breaking Changes ### Breaking Changes

View File

@@ -23,7 +23,7 @@ dist/josh-sync: bin/josh-sync lib/*.sh VERSION
@echo '# Generated by: make build' >> dist/josh-sync @echo '# Generated by: make build' >> dist/josh-sync
@echo '' >> dist/josh-sync @echo '' >> dist/josh-sync
@# Inline all library modules (strip shebangs and source directives) @# 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; \ echo "# --- $$f ---" >> dist/josh-sync; \
grep -v '^#!/' "$$f" | grep -v '^# shellcheck source=' >> dist/josh-sync; \ grep -v '^#!/' "$$f" | grep -v '^# shellcheck source=' >> dist/josh-sync; \
echo '' >> dist/josh-sync; \ echo '' >> dist/josh-sync; \

View File

@@ -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 sync [--forward|--reverse] [--force] [--target NAME[,NAME]] [--branch BRANCH]
josh-sync preflight josh-sync preflight
josh-sync import <target> josh-sync import <target>
josh-sync adopt <target> [--restart]
josh-sync reset <target> josh-sync reset <target>
josh-sync onboard <target> [--restart] josh-sync onboard <target> [--restart]
josh-sync migrate-pr <target> [PR#...] [--all] josh-sync migrate-pr <target> [PR#...] [--all]
@@ -97,6 +98,7 @@ josh-sync state reset <target> [branch]
- **Forward sync** (mono → subrepo): pushes directly if clean, creates conflict PR if not. Uses `--force-with-lease` for safety. - **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). - **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. - **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. - **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. - **Loop prevention**: `Josh-Sync-Origin:` git trailer filters out bot commits.

View File

@@ -1 +1 @@
2.0.0 2.1.0

View File

@@ -8,6 +8,7 @@
# sync Run forward and/or reverse sync # sync Run forward and/or reverse sync
# preflight Validate config, connectivity, auth # preflight Validate config, connectivity, auth
# import <target> Initial import: pull subrepo into monorepo # import <target> Initial import: pull subrepo into monorepo
# adopt <target> Adopt existing subrepo history without rewriting it
# reset <target> Reset subrepo to josh-filtered view # reset <target> Reset subrepo to josh-filtered view
# onboard <target> Import existing subrepo into monorepo (interactive) # onboard <target> Import existing subrepo into monorepo (interactive)
# migrate-pr <target> [PR#...] [--all] Move PRs from archived to new subrepo # migrate-pr <target> [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" source "${JOSH_LIB_DIR}/state.sh"
# shellcheck source=../lib/sync.sh # shellcheck source=../lib/sync.sh
source "${JOSH_LIB_DIR}/sync.sh" source "${JOSH_LIB_DIR}/sync.sh"
# shellcheck source=../lib/adopt.sh
source "${JOSH_LIB_DIR}/adopt.sh"
# shellcheck source=../lib/onboard.sh # shellcheck source=../lib/onboard.sh
source "${JOSH_LIB_DIR}/onboard.sh" source "${JOSH_LIB_DIR}/onboard.sh"
@@ -72,6 +75,7 @@ Commands:
sync Run forward and/or reverse sync sync Run forward and/or reverse sync
preflight Validate config, connectivity, auth, workflow coverage preflight Validate config, connectivity, auth, workflow coverage
import <target> Initial import: pull existing subrepo into monorepo (creates PR) import <target> Initial import: pull existing subrepo into monorepo (creates PR)
adopt <target> Adopt existing subrepo history without rewriting it
reset <target> Reset subrepo to josh-filtered view (after merging import PR) reset <target> Reset subrepo to josh-filtered view (after merging import PR)
onboard <target> Import existing subrepo into monorepo (interactive, resumable) onboard <target> Import existing subrepo into monorepo (interactive, resumable)
migrate-pr <target> [PR#...] [--all] Move PRs from archived to new subrepo migrate-pr <target> [PR#...] [--all] Move PRs from archived to new subrepo
@@ -530,6 +534,42 @@ cmd_import() {
done 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 <target> [--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 ───────────────────────────────────────────────── # ─── Reset Command ─────────────────────────────────────────────────
cmd_reset() { cmd_reset() {
@@ -886,6 +926,7 @@ main() {
sync) cmd_sync "$@" ;; sync) cmd_sync "$@" ;;
preflight) cmd_preflight "$@" ;; preflight) cmd_preflight "$@" ;;
import) cmd_import "$@" ;; import) cmd_import "$@" ;;
adopt) cmd_adopt "$@" ;;
reset) cmd_reset "$@" ;; reset) cmd_reset "$@" ;;
onboard) cmd_onboard "$@" ;; onboard) cmd_onboard "$@" ;;
migrate-pr) cmd_migrate_pr "$@" ;; migrate-pr) cmd_migrate_pr "$@" ;;

View File

@@ -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 <target>` 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 `<target>/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.

View File

@@ -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 | | [010](010-onboard-checkpoint-resume.md) | Onboard workflow with checkpoint/resume | Accepted |
| [011](011-linearize-fallback-reverse.md) | Linearize fallback for reverse sync | 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 | | [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 |

View File

@@ -224,14 +224,40 @@ For a new monorepo before import, preflight may warn that subfolders don't exist
## Step 5: Import Existing Subrepos ## 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 - **`josh-sync adopt`** (recommended for active existing subrepos) — non-destructive, resumable, preserves existing subrepo history
- **Manual `import` → merge → `reset`** — lower-level, for automation or when there are no open PRs to preserve - **`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:** **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. 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. > Do this **one target at a time** to keep PRs reviewable.
#### 5b-1. Import #### 5c-1. Import
```bash ```bash
josh-sync import billing 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. 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. 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. > 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 ```bash
josh-sync reset billing 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. 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: For each target:
@@ -337,13 +363,13 @@ For each target:
### Verify ### Verify
After all targets are imported and reset (whichever option you used): After all targets are adopted or reset:
```bash ```bash
# Check all targets show state # Check all targets show state
josh-sync status 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 josh-sync sync --forward --target billing
# Test reverse sync — should return "skip" (no new human commits) # 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` 1. Add the target to `.josh-sync.yml`
2. Update the forward workflow's `paths:` list to include the new subfolder 2. Update the forward workflow's `paths:` list to include the new subfolder
3. Commit and push 3. Commit and push
4. Import the target: 4. Import or adopt the target:
```bash ```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 josh-sync onboard new-target
# Or manual: import → merge PR → reset # Or manual: import → merge PR → reset

335
lib/adopt.sh Normal file
View File

@@ -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 <target>/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 <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
}

92
tests/unit/adopt.bats Normal file
View File

@@ -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" ]
}