Release 2.2.0 — unified onboard strategy + safety hardening

Folds `josh-sync adopt` into `josh-sync onboard <target>` as the adopt
strategy alongside the original reset flow. Strategy resolves by
precedence: --mode flag > targets[].history_lock config (preserve|rewrite)
> auto-detect via `git ls-remote --heads`. `josh-sync adopt` is kept as
a thin alias. Adds the new `targets[].history_lock` config field
(validated at parse time) and folds `<target>/adopt.json` state into
`<target>/onboard.json` with a `.strategy` field; legacy state files
are read with a backward-compat fallback.

Safety hardening (from a multi-angle review of the unification):
- auto-detect distinguishes auth/network failure from empty repo
- resume validates --mode against the strategy saved in state
- `josh-sync adopt` rejects a conflicting --mode in the forwarded args
- missing import-PR lookup dies in both strategies (was WARN+continue
  for reset, which could create duplicate import PRs on resume)
- --restart durably removes the legacy adopt.json from the state branch
- adopt_branch is now a subshell function (EXIT trap can't clobber callers)
- strategy value validated after resolve/load (reset|adopt)
- --mode with missing/empty value dies with a usage hint
- migrate-pr against an adopt-strategy target dies with a specific hint
- reset importing asserts archived_url is present (no "null" → git clone)

End-to-end + CLI bats coverage added (tests/unit/adopt_e2e.bats,
tests/unit/cli.bats). 72 tests, shellcheck clean.

Makefile dist bundle header now correctly interpolates VERSION and
line count.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 03:32:04 +01:00
parent f2e39be5e1
commit dc2cdea360
13 changed files with 1284 additions and 439 deletions

View File

@@ -1,5 +1,33 @@
# Changelog
## 2.2.0
### Changes
- **Unified onboarding via `josh-sync onboard <target>`.** The previously separate `adopt` workflow is now an onboarding strategy alongside the original `reset` flow. Strategy is resolved by precedence: `--mode={reset,adopt}` flag → `targets[].history_lock` config (`preserve` → adopt, `rewrite` → reset) → auto-detect via `git ls-remote --heads` on the subrepo (heads → adopt, empty → reset). The chosen strategy is logged at preflight (and re-announced on every resume).
- **`josh-sync adopt` kept as a back-compat CLI alias** for `josh-sync onboard --mode=adopt`. Existing in-flight adoptions (state at `<target>/adopt.json` on the `josh-sync-state` branch) continue to resume — `read_onboard_state` falls back to that legacy file with an implicit `strategy: adopt` and strips the legacy `.mode` field. Pre-unification `onboard.json` files (no `.strategy`) are read as `strategy: reset`.
- **New `targets[].history_lock` config field** (`preserve` | `rewrite`) — pins the onboarding strategy independently of the subrepo's current emptiness. Validated at config parse time. See [Config Reference](docs/config-reference.md#targets-section).
- **End-to-end bats coverage** — tree-mismatch surfacing, idempotency, merge shape (ADR-005 trailer, ADR-008 first-parent ordering), fast-forward push semantics, reverse-sync loop guard, linearize-fallback selection, resume from every checkpoint, strategy resolution precedence, legacy-state migration, and the CLI surface (flag parsing, alias forwarding, error messages).
### Safety
- **Auto-detect now distinguishes auth/network failures from genuinely empty repos.** A failing `git ls-remote` previously fell through to `reset` (destructive force-push); it now dies with a clear "pass --mode explicitly" message.
- **Resume validates `--mode` against the strategy already saved in state.** Conflicting flags die with a `--restart` hint instead of being silently ignored.
- **`josh-sync adopt` rejects a conflicting `--mode`.** Previously the user-supplied flag silently won.
- **Missing import-PR lookup now `die`s in both strategies.** The reset path used to `WARN` and continue without recording the PR number, leading to duplicate import PRs on resume.
- **`--restart` durably removes the legacy `<target>/adopt.json`** from the state branch so it can't silently resurrect via the read fallback.
- **`adopt_branch` is now a subshell function** — its EXIT trap can no longer clobber callers' traps (test reporting, etc.).
- **Strategy value is validated** after resolve/load (`reset|adopt`); unknown/corrupt values die with a `--restart` hint.
- **`--mode` with a missing/empty value** dies with a usage hint instead of crashing under `set -u`.
- **`migrate-pr` against an adopt-strategy target** dies with a specific message rather than the misleading "Run onboard first" loop.
- **Reset import step asserts `archived_url` is present** instead of silently flowing the literal string `"null"` into `git clone`.
ADR-013 updated with a status note pointing at the unified command.
### Fixes
- Makefile `dist/josh-sync` bundle header now correctly interpolates VERSION and line count (was emitting empty values due to make's `$(...)` swallowing the shell command substitution).
## 2.1.0
### Breaking Changes

View File

@@ -19,7 +19,7 @@ dist/josh-sync: bin/josh-sync lib/*.sh VERSION
@mkdir -p dist
@echo "Bundling josh-sync..."
@echo '#!/usr/bin/env bash' > dist/josh-sync
@echo "# josh-sync $(cat VERSION) — bundled distribution" >> dist/josh-sync
@echo "# josh-sync $$(cat VERSION) — bundled distribution" >> dist/josh-sync
@echo '# Generated by: make build' >> dist/josh-sync
@echo '' >> dist/josh-sync
@# Inline all library modules (strip shebangs and source directives)
@@ -36,7 +36,7 @@ dist/josh-sync: bin/josh-sync lib/*.sh VERSION
| sed '/^JOSH_LIB_DIR=/,/^source/d' \
>> dist/josh-sync
@chmod +x dist/josh-sync
@echo "Built: dist/josh-sync ($(wc -l < dist/josh-sync) lines)"
@echo "Built: dist/josh-sync ($$(wc -l < dist/josh-sync) lines)"
clean:
rm -rf dist/

View File

@@ -1 +1 @@
2.1.0
2.2.0

View File

@@ -8,9 +8,9 @@
# sync Run forward and/or reverse sync
# preflight Validate config, connectivity, auth
# import <target> Initial import: pull subrepo into monorepo
# adopt <target> Adopt existing subrepo history without rewriting it
# onboard <target> Interactive onboarding (auto-picks reset|adopt strategy)
# adopt <target> Alias for `onboard --mode=adopt`
# reset <target> Reset subrepo to josh-filtered view
# onboard <target> Import existing subrepo into monorepo (interactive)
# migrate-pr <target> [PR#...] [--all] Move PRs from archived to new subrepo
# status Show target config and sync state
# state show|reset Manage sync state directly
@@ -75,9 +75,13 @@ Commands:
sync Run forward and/or reverse sync
preflight Validate config, connectivity, auth, workflow coverage
import <target> Initial import: pull existing subrepo into monorepo (creates PR)
adopt <target> Adopt existing subrepo history without rewriting it
onboard <target> Onboard a subrepo (interactive, resumable). Picks a strategy:
reset — force-push josh-filtered history (replaces subrepo)
adopt — non-destructive merge that keeps subrepo history
Precedence: --mode flag > targets[].history_lock > auto-detect
via ls-remote (heads → adopt, empty → reset).
adopt <target> Alias for 'onboard --mode=adopt' (kept for back-compat)
reset <target> Reset subrepo to josh-filtered view (after merging import PR)
onboard <target> Import existing subrepo into monorepo (interactive, resumable)
migrate-pr <target> [PR#...] [--all] Move PRs from archived to new subrepo
status Show target config and sync state
state show <target> [branch] Show sync state JSON
@@ -96,6 +100,9 @@ Sync flags:
--target NAME Filter to target(s) — comma-separated for multiple (env: JOSH_SYNC_TARGET)
--branch BRANCH Filter to one branch
Onboard flags:
--mode={reset,adopt} Override the onboarding strategy
Environment:
JOSH_SYNC_TARGET Restrict to a single target name
JOSH_SYNC_STATE_BRANCH State branch name (default: josh-sync-state)
@@ -534,40 +541,33 @@ cmd_import() {
done
}
# ─── Adopt Command ─────────────────────────────────────────────────
# ─── Adopt Command (alias) ─────────────────────────────────────────
# `josh-sync adopt` is preserved as a thin alias for the unified onboard
# command. Effectively: `josh-sync onboard --mode=adopt <args...>`.
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 ;;
# Reject a conflicting --mode in the forwarded args: the alias means adopt,
# so `josh-sync adopt … --mode=reset` is contradictory. Without this guard
# the user-supplied --mode would silently win (last --mode in cmd_onboard's
# parser is the one that takes effect).
local i=1 a
while [ $i -le $# ]; do
a="${!i}"
case "$a" in
--mode=adopt) ;;
--mode=*) die "'josh-sync adopt' is an alias for --mode=adopt; conflicting '${a}' given. Use 'josh-sync onboard <target>' instead." ;;
--mode)
i=$((i + 1))
if [ $i -gt $# ] || [ "${!i}" != "adopt" ]; then
die "'josh-sync adopt' is an alias for --mode=adopt; conflicting '--mode ${!i:-<missing>}' given. Use 'josh-sync onboard <target>' instead."
fi
;;
esac
i=$((i + 1))
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"
log "INFO" "(josh-sync adopt is now an alias for: josh-sync onboard --mode=adopt)"
cmd_onboard --mode=adopt "$@"
}
# ─── Reset Command ─────────────────────────────────────────────────
@@ -743,19 +743,25 @@ cmd_onboard() {
local config_file=".josh-sync.yml"
local target_name=""
local restart=false
local mode=""
while [ $# -gt 0 ]; do
case "$1" in
--config) config_file="$2"; shift 2 ;;
--debug) export JOSH_SYNC_DEBUG=1; shift ;;
--restart) restart=true; shift ;;
--mode=*) mode="${1#--mode=}"
[ -n "$mode" ] || die "Empty --mode= (use --mode=reset|adopt)"
shift ;;
--mode) [ $# -ge 2 ] || die "--mode requires a value (reset|adopt)"
mode="$2"; shift 2 ;;
-*) die "Unknown flag: $1" ;;
*) target_name="$1"; shift ;;
esac
done
if [ -z "$target_name" ]; then
echo "Usage: josh-sync onboard <target> [--restart]" >&2
echo "Usage: josh-sync onboard <target> [--mode=reset|adopt] [--restart]" >&2
parse_config "$config_file"
echo "Available targets:" >&2
echo "$JOSH_SYNC_TARGETS" | jq -r '.[].name' | sed 's/^/ /' >&2
@@ -770,7 +776,7 @@ cmd_onboard() {
log "INFO" "══════ Onboard target: ${target_name} ══════"
load_target "$target_json"
onboard_flow "$target_json" "$restart"
onboard_flow "$target_json" "$restart" "$mode"
}
# ─── Migrate PR Command ──────────────────────────────────────────

View File

@@ -2,6 +2,7 @@
**Status:** Accepted
**Date:** 2026-04
**Update (2026-05):** The standalone `josh-sync adopt <target>` command described below has been folded into `josh-sync onboard <target>` as the `adopt` strategy (selectable via `--mode=adopt`, `targets[].history_lock: preserve`, or auto-detection when the subrepo has any branches). `josh-sync adopt` is retained as a thin CLI alias. The state file moved from `<target>/adopt.json` to `<target>/onboard.json` with a `.strategy` field; the legacy file is still read for backward compatibility. See the guide's Onboarding section.
## Context

View File

@@ -42,6 +42,7 @@ Each target maps a monorepo subfolder to an external subrepo.
| `branches` | object | Yes | — | Branch mapping: `mono_branch: subrepo_branch`. Each key-value pair syncs those branches bidirectionally. |
| `forward_only` | string[] | No | `[]` | Branches that only sync mono → subrepo, never reverse. |
| `exclude` | string[] | No | `[]` | File/directory patterns to exclude from sync via josh `:exclude` filter. Excluded files exist only in the monorepo, never in the subrepo. See [Excluding Files](guide.md#excluding-files-from-sync). |
| `history_lock` | string | No | (auto-detect) | Onboarding strategy hint. `"preserve"` selects the non-destructive [adopt](guide.md#onboarding) strategy (keeps subrepo history via an adoption merge — ADR-013); `"rewrite"` selects the destructive reset strategy (force-pushes josh-filtered history). When omitted, `josh-sync onboard` auto-detects: subrepos with branches → adopt, empty subrepos → reset. The CLI flag `--mode={reset,adopt}` overrides this. |
## `bot` Section

View File

@@ -222,20 +222,31 @@ This validates:
For a new monorepo before import, preflight may warn that subfolders don't exist yet — that's expected.
## Step 5: Import Existing Subrepos
## Step 5: Onboard Existing Subrepos
This is the critical onboarding step. There are three approaches:
`josh-sync onboard <target>` is the single entry point for connecting an existing subrepo to the monorepo. It runs interactively with checkpoint/resume and picks one of two strategies:
- **`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
- **adopt** (non-destructive) — keeps the subrepo's existing history and joins it to josh-filtered history via a single adoption merge commit. No force-push. Developers fast-forward; open PR branches stay valid.
- **reset** (destructive) — force-pushes josh-filtered history onto the subrepo, replacing its prior history. Use this for empty replacement repos (typically paired with renaming the old repo to `*-archived`).
### Option A: Adopt (recommended for active subrepos)
### Strategy selection
Use `adopt` when the subrepo already exists, developers already clone it, or open PR branches should remain based on existing history.
`josh-sync onboard` resolves the strategy in this order (highest precedence first):
1. **CLI flag**`--mode=adopt` or `--mode=reset` overrides everything.
2. **Config**`targets[].history_lock: preserve` selects adopt; `rewrite` selects reset.
3. **Auto-detect**`git ls-remote --heads` on the subrepo. Branches present → adopt. Empty repo → reset.
The chosen strategy is logged at preflight so you can see what will happen before any side effects.
`josh-sync adopt <target>` is kept as a back-compat alias for `josh-sync onboard --mode=adopt`.
### Adopt strategy (auto-selected for active subrepos)
```bash
josh-sync adopt billing
josh-sync onboard billing # auto-detect — picks adopt for non-empty subrepo
josh-sync onboard billing --mode=adopt # force adopt explicitly
josh-sync adopt billing # equivalent back-compat alias
```
The command will:
@@ -245,7 +256,7 @@ The command will:
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.
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 onboard billing` to resume. Use `--restart` to start over. See [ADR-013](adr/013-non-destructive-adoption.md) for the design rationale.
After adoption, developers can update existing clones with a normal fast-forward:
@@ -255,9 +266,9 @@ git checkout main
git merge --ff-only origin/main
```
### Option B: Onboard with replacement repo
### Reset strategy (auto-selected for empty replacement repos)
The `onboard` command walks through the destructive replacement-repo process interactively, with checkpoint/resume at every step.
Use the reset strategy when you intentionally want to archive the old subrepo and start fresh from a new empty repo.
**Before you start:**
@@ -269,7 +280,8 @@ The rename preserves the archived repo with all its history and open PRs. The ne
**Run onboard:**
```bash
josh-sync onboard billing
josh-sync onboard billing # auto-detect — picks reset for empty subrepo
josh-sync onboard billing --mode=reset # force reset explicitly
```
The command will:
@@ -298,13 +310,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 C: Manual import → merge → reset
### Manual: import → merge → reset
Use this for scripted automation or when you intentionally want to replace subrepo history.
Use this for scripted automation or when you intentionally want to replace subrepo history without the interactive wrapper.
> Do this **one target at a time** to keep PRs reviewable.
#### 5c-1. Import
#### Manual: Import
```bash
josh-sync import billing
@@ -319,13 +331,13 @@ This:
Review the import PR — check for leaked credentials, environment-specific config, or files that shouldn't be in the monorepo.
#### 5c-2. Merge the import PR
#### Manual: 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.
#### 5c-3. Reset
#### Manual: Reset
```bash
josh-sync reset billing
@@ -352,7 +364,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.
#### 5c-4. Repeat for each target
#### Manual: Repeat for each target
```
For each target:
@@ -646,15 +658,13 @@ 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 or adopt the target:
4. Onboard the target — `josh-sync onboard` auto-picks `adopt` for non-empty subrepos and `reset` for empty ones; override with `--mode={reset,adopt}` or pin via the target's `history_lock` config field.
```bash
# Recommended for an existing active subrepo
josh-sync adopt new-target
josh-sync onboard new-target # auto-detect strategy
josh-sync onboard new-target --mode=adopt # force adopt
josh-sync onboard new-target --mode=reset # force reset
# Replacement-repo workflow
josh-sync onboard new-target
# Or manual: import → merge PR → reset
# Manual lower-level path (when scripting):
josh-sync import new-target
# merge the PR
josh-sync reset new-target

View File

@@ -1,52 +1,14 @@
#!/usr/bin/env bash
# lib/adopt.sh — Non-destructive adoption of existing subrepo history
# lib/adopt.sh — Non-destructive adoption merge primitive
#
# Provides:
# adopt_flow() — Import → wait for merge → adoption merge per branch
# adopt_branch() — Create the adoption merge and push it fast-forward only
# adopt_branch() — Create one adoption merge commit and push it fast-forward
#
# Adopt state is stored on the josh-sync-state branch at <target>/adopt.json.
# Steps: start → importing → waiting-for-merge → adopting → complete
# The orchestration (state machine, import → wait → finalize) lives in
# lib/onboard.sh under strategy="adopt". `josh-sync adopt` is now a CLI alias
# for `josh-sync onboard --mode=adopt`; see ADR-013 for design rationale.
#
# Requires: lib/core.sh, lib/config.sh, lib/auth.sh, lib/state.sh, lib/sync.sh sourced
# ─── Adopt State Helpers ─────────────────────────────────────────
read_adopt_state() {
local target_name="${1:-$JOSH_SYNC_TARGET_NAME}"
git fetch origin "$STATE_BRANCH" 2>/dev/null || true
git show "origin/${STATE_BRANCH}:${target_name}/adopt.json" 2>/dev/null || echo '{}'
}
write_adopt_state() {
local target_name="${1:-$JOSH_SYNC_TARGET_NAME}"
local state_json="$2"
local key="${target_name}/adopt"
local tmp_dir
tmp_dir=$(mktemp -d)
if git rev-parse "origin/${STATE_BRANCH}" >/dev/null 2>&1; then
git worktree add "$tmp_dir" "origin/${STATE_BRANCH}" 2>/dev/null
else
git worktree add --detach "$tmp_dir" 2>/dev/null
(cd "$tmp_dir" && git checkout --orphan "$STATE_BRANCH" && git rm -rf . 2>/dev/null || true)
fi
mkdir -p "$(dirname "${tmp_dir}/${key}.json")"
echo "$state_json" | jq '.' > "${tmp_dir}/${key}.json"
(
cd "$tmp_dir" || exit
git add -A
if ! git diff --cached --quiet 2>/dev/null; then
git -c user.name="$BOT_NAME" -c user.email="$BOT_EMAIL" \
commit -m "adopt: update ${target_name}"
git push origin "HEAD:${STATE_BRANCH}" || log "WARN" "Failed to push adopt state"
fi
)
git worktree remove "$tmp_dir" 2>/dev/null || rm -rf "$tmp_dir"
}
# Requires: lib/core.sh, lib/config.sh, lib/auth.sh sourced
# ─── Adopt Branch ────────────────────────────────────────────────
# Establishes shared ancestry without rewriting the existing subrepo branch.
@@ -61,7 +23,11 @@ write_adopt_state() {
#
# Returns: adopted | already-adopted | tree-mismatch | push-rejected | missing-branch
adopt_branch() {
adopt_branch() (
# Subshell function: contains the function-level EXIT trap, the internal cd,
# and any set -e propagation so it cannot pollute the caller's shell. Without
# this, the trap below would clobber callers' EXIT handlers (bats's own test
# reporting in particular silently disappears).
local mono_branch="$SYNC_BRANCH_MONO"
local subrepo_branch="$SYNC_BRANCH_SUBREPO"
local work_dir
@@ -140,196 +106,4 @@ ${BOT_TRAILER}: adopt/${mono_branch}/$(date -u +%Y-%m-%dT%H:%M:%SZ)")
log "WARN" "Fast-forward push rejected — subrepo changed during adoption"
echo "push-rejected"
fi
}
# ─── Adopt Flow ──────────────────────────────────────────────────
# Interactive orchestrator with checkpoint/resume.
# Usage: adopt_flow <target_json> <restart>
adopt_flow() {
local target_json="$1"
local restart="${2:-false}"
local target_name="$JOSH_SYNC_TARGET_NAME"
local adopt_state current_step
adopt_state=$(read_adopt_state "$target_name")
current_step=$(echo "$adopt_state" | jq -r '.step // "start"')
if [ "$restart" = true ]; then
log "INFO" "Restarting adopt from scratch"
current_step="start"
adopt_state='{}'
fi
log "INFO" "Adopt step: ${current_step}"
if [ "$current_step" = "start" ]; then
echo "" >&2
echo "=== Adopting ${target_name} ===" >&2
echo "" >&2
echo "This keeps the existing subrepo history and adds one adoption merge commit per branch." >&2
echo "No force-push is used. Existing open PR branches remain based on the old history." >&2
echo "" >&2
adopt_state=$(jq -n \
--arg step "importing" \
--arg mode "adopt" \
--arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'{step:$step, mode:$mode, import_prs:{}, adopted_branches:[], timestamp:$ts}')
write_adopt_state "$target_name" "$adopt_state"
current_step="importing"
fi
if [ "$current_step" = "importing" ]; then
echo "" >&2
log "INFO" "Step 1: Importing current subrepo content into monorepo..."
local branches import_prs
branches=$(echo "$target_json" | jq -r '.branches | keys[]')
import_prs=$(echo "$adopt_state" | jq -r '.import_prs // {}')
for branch in $branches; do
local mapped
mapped=$(echo "$target_json" | jq -r --arg b "$branch" '.branches[$b] // empty')
[ -z "$mapped" ] && continue
if echo "$import_prs" | jq -e --arg b "$branch" 'has($b)' >/dev/null 2>&1; then
log "INFO" "Import PR already recorded for ${branch} — skipping"
continue
fi
export SYNC_BRANCH_MONO="$branch"
export SYNC_BRANCH_SUBREPO="$mapped"
local result
result=$(initial_import)
log "INFO" "Import result for ${branch}: ${result}"
if [ "$result" = "pr-created" ]; then
local prs pr_number
prs=$(list_open_prs "$MONOREPO_API" "$GITEA_TOKEN")
pr_number=$(echo "$prs" | jq -r --arg t "$target_name" --arg b "$branch" \
'[.[] | select(.title | test("\\[Import\\] " + $t + ":")) | select(.base.ref == $b)] | .[0].number // empty')
[ -n "$pr_number" ] || die "Could not find import PR number for ${branch}; cannot safely continue adoption"
import_prs=$(echo "$import_prs" | jq --arg b "$branch" --arg n "$pr_number" '. + {($b): ($n | tonumber)}')
log "INFO" "Import PR for ${branch}: #${pr_number}"
fi
adopt_state=$(echo "$adopt_state" | jq --argjson prs "$import_prs" '.import_prs = $prs')
write_adopt_state "$target_name" "$adopt_state"
done
adopt_state=$(echo "$adopt_state" | jq \
--arg step "waiting-for-merge" \
--argjson prs "$import_prs" \
'.step = $step | .import_prs = $prs')
write_adopt_state "$target_name" "$adopt_state"
current_step="waiting-for-merge"
fi
if [ "$current_step" = "waiting-for-merge" ]; then
echo "" >&2
log "INFO" "Step 2: Waiting for import PR(s) to be merged..."
local import_prs pr_count
import_prs=$(echo "$adopt_state" | jq -r '.import_prs')
pr_count=$(echo "$import_prs" | jq 'length')
if [ "$pr_count" -gt 0 ]; then
echo "" >&2
echo "Import PRs to merge:" >&2
echo "$import_prs" | jq -r 'to_entries[] | " \(.key): PR #\(.value)"' >&2
echo "" >&2
echo "Merge the import PR(s) on the monorepo, then press Enter..." >&2
read -r
local all_merged=true
for branch in $(echo "$import_prs" | jq -r 'keys[]'); do
local pr_number pr_json merged
pr_number=$(echo "$import_prs" | jq -r --arg b "$branch" '.[$b]')
pr_json=$(get_pr "$MONOREPO_API" "$GITEA_TOKEN" "$pr_number")
merged=$(echo "$pr_json" | jq -r '.merged // false')
if [ "$merged" = "true" ]; then
log "INFO" "PR #${pr_number} (${branch}): merged"
else
log "ERROR" "PR #${pr_number} (${branch}): NOT merged — merge it first"
all_merged=false
fi
done
if [ "$all_merged" = false ]; then
die "Not all import PRs are merged. Re-run 'josh-sync adopt ${target_name}' after merging."
fi
else
log "INFO" "No import PRs recorded — subfolder already matched subrepo"
fi
adopt_state=$(echo "$adopt_state" | jq '.step = "adopting"')
write_adopt_state "$target_name" "$adopt_state"
current_step="adopting"
fi
if [ "$current_step" = "adopting" ]; then
echo "" >&2
log "INFO" "Step 3: Creating adoption merge commit(s)..."
local branches already_adopted
branches=$(echo "$target_json" | jq -r '.branches | keys[]')
already_adopted=$(echo "$adopt_state" | jq -r '.adopted_branches // []')
for branch in $branches; do
if echo "$already_adopted" | jq -e --arg b "$branch" 'index($b) != null' >/dev/null 2>&1; then
log "INFO" "Branch ${branch} already adopted — skipping"
continue
fi
local mapped
mapped=$(echo "$target_json" | jq -r --arg b "$branch" '.branches[$b] // empty')
[ -z "$mapped" ] && continue
export SYNC_BRANCH_MONO="$branch"
export SYNC_BRANCH_SUBREPO="$mapped"
local result
result=$(adopt_branch)
log "INFO" "Adopt result for ${branch}: ${result}"
case "$result" in
adopted|already-adopted)
adopt_state=$(echo "$adopt_state" | jq --arg b "$branch" '.adopted_branches += [$b]')
write_adopt_state "$target_name" "$adopt_state"
;;
tree-mismatch)
die "Tree mismatch for ${branch}. Merge the import PR and make sure subrepo/${mapped} matches the Josh-filtered monorepo tree."
;;
push-rejected)
die "Subrepo branch ${mapped} changed during adoption. Re-run 'josh-sync adopt ${target_name}' to retry."
;;
missing-branch)
die "Subrepo branch ${mapped} does not exist."
;;
*)
die "Unexpected adopt result: ${result}"
;;
esac
done
adopt_state=$(echo "$adopt_state" | jq \
--arg step "complete" \
--arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'.step = $step | .timestamp = $ts')
write_adopt_state "$target_name" "$adopt_state"
current_step="complete"
fi
if [ "$current_step" = "complete" ]; then
echo "" >&2
echo "=== Adoption complete! ===" >&2
echo "" >&2
echo "The subrepo keeps its existing history and now has a josh-sync adoption merge." >&2
echo "Developers can keep their clones; they only need to fast-forward active branches:" >&2
echo " git fetch origin && git checkout main && git merge --ff-only origin/main" >&2
fi
}
)

View File

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

View File

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

View File

@@ -91,6 +91,11 @@
"items": { "type": "string" },
"default": [],
"description": "File/directory patterns to exclude from sync via josh :exclude filter. Josh pattern syntax: 'dir/' for directories, '*.ext' for globs, '**/dir/' for nested matches. Patterns are embedded inline in the josh-proxy URL."
},
"history_lock": {
"type": "string",
"enum": ["rewrite", "preserve"],
"description": "Onboarding strategy override. 'preserve' selects the non-destructive adopt strategy (keeps subrepo history via an adoption merge); 'rewrite' selects the destructive reset strategy (force-pushes josh-filtered history). When omitted, `josh-sync onboard` auto-detects: subrepos with branches → adopt, empty subrepos → reset. The CLI flag `--mode={reset,adopt}` overrides this."
}
}
}

697
tests/unit/adopt_e2e.bats Normal file
View File

@@ -0,0 +1,697 @@
#!/usr/bin/env bats
bats_require_minimum_version 1.5.0
# tests/unit/adopt_e2e.bats — End-to-end adopt behaviour and ADR cross-cuts
#
# Covers the goal-1 scenarios from the adopt unification plan:
# - tree-equality rejection (clear hint + no commit on subrepo)
# - already-adopted idempotency
# - merge commit shape (ADR-005 trailer, ADR-008 first-parent walk)
# - fast-forward push semantics (success + push-rejected surfacing)
# - reverse-sync loop guard (ADR-005)
# - linearize-fallback selection invariant (ADR-011)
# - checkpoint/resume at each adopt_flow step (ADR-010)
#
# Tests run against real local bare repos — no mocking of the git layer.
setup() {
export JOSH_SYNC_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)"
source "$JOSH_SYNC_ROOT/lib/core.sh"
source "$JOSH_SYNC_ROOT/lib/state.sh"
source "$JOSH_SYNC_ROOT/lib/auth.sh"
source "$JOSH_SYNC_ROOT/lib/adopt.sh"
source "$JOSH_SYNC_ROOT/lib/onboard.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"
# Test-local auth shims must be defined AFTER sourcing lib/auth.sh, otherwise
# the lib's real subrepo_auth_url / josh_auth_url (which need SUBREPO_URL,
# BOT_USER, etc.) overrides our bare-repo shortcut.
subrepo_auth_url() { printf '%s\n' "${SUBREPO_BARE:-}"; }
josh_auth_url() { printf '%s\n' "${JOSH_BARE:-}"; }
}
teardown() {
# Restore cwd before the rm — setup_state_monorepo cd's into a dir that's
# about to be deleted, and bats doesn't reset cwd between tests.
cd "$BATS_TEST_DIRNAME" 2>/dev/null || true
rm -rf "$TEST_ROOT"
}
# ─── Helpers ────────────────────────────────────────────────────────
# Build a fresh bare repo seeded with one commit; returns the bare path on stdout.
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
# Make HEAD on the bare point at main so later clones don't land on master.
git -C "$bare" symbolic-ref HEAD refs/heads/main
git -C "$work" remote add origin "$bare"
git -C "$work" push origin main >/dev/null
printf '%s\n' "$bare"
}
# Append a commit to an existing bare repo. If $3 is empty, makes an empty commit
# (tree unchanged) so first-parent walks have something to observe.
add_commit_to_bare() {
local bare="$1"
local message="$2"
local content="${3:-}"
local work="${TEST_ROOT}/add-${RANDOM}-$$"
git clone "$bare" "$work" >/dev/null 2>&1
git -C "$work" config user.name "Test User"
git -C "$work" config user.email "test@test.local"
if [ -n "$content" ]; then
printf '%s\n' "$content" >> "${work}/README.md"
git -C "$work" commit -am "$message" >/dev/null
else
git -C "$work" commit --allow-empty -m "$message" >/dev/null
fi
git -C "$work" push origin main >/dev/null 2>&1
}
# Stand up a local working "monorepo" with an `origin` bare remote and cd into
# it. Gives lib/state.sh helpers (worktree + push to origin) something real to
# work against.
setup_state_monorepo() {
local mono_work="${TEST_ROOT}/mono"
local mono_bare="${TEST_ROOT}/mono.git"
git init -q "$mono_work"
git -C "$mono_work" checkout -q -b main
git -C "$mono_work" config user.name "Test User"
git -C "$mono_work" config user.email "test@test.local"
printf 'mono\n' > "${mono_work}/README.md"
git -C "$mono_work" add README.md
git -C "$mono_work" commit -q -m "initial"
git init -q --bare "$mono_bare"
git -C "$mono_work" remote add origin "$mono_bare"
git -C "$mono_work" push -q origin main
cd "$mono_work"
}
# adopt_branch is defined as a subshell function (`adopt_branch() ( ... )`) in
# lib/adopt.sh so its EXIT trap stays contained — no helper needed here.
adopt_branch_quietly() {
adopt_branch >/dev/null
}
# ─── Tree-mismatch surfacing ────────────────────────────────────────
@test "adopt_branch tree-mismatch error message points the user at the import PR" {
SUBREPO_BARE="$(make_bare_repo subrepo "subrepo content" "subrepo history")"
JOSH_BARE="$(make_bare_repo josh "josh content" "josh filtered history")"
local combined
combined=$(adopt_branch 2>&1)
[[ "$combined" == *"merge the import PR"* ]]
}
@test "adopt_branch tree-mismatch creates no commit on the subrepo (clean abort)" {
SUBREPO_BARE="$(make_bare_repo subrepo "subrepo content" "subrepo history")"
JOSH_BARE="$(make_bare_repo josh "josh content" "josh filtered history")"
local before_count before_head after_count after_head
before_count=$(git --git-dir="$SUBREPO_BARE" rev-list --count main)
before_head=$(git --git-dir="$SUBREPO_BARE" rev-parse main)
adopt_branch_quietly 2>&1
after_count=$(git --git-dir="$SUBREPO_BARE" rev-list --count main)
after_head=$(git --git-dir="$SUBREPO_BARE" rev-parse main)
[ "$before_count" = "$after_count" ]
[ "$before_head" = "$after_head" ]
}
# ─── Idempotency ────────────────────────────────────────────────────
@test "adopt_branch is idempotent — second invocation returns already-adopted, no new commit" {
SUBREPO_BARE="$(make_bare_repo subrepo "same tree" "subrepo history")"
JOSH_BARE="$(make_bare_repo josh "same tree" "josh filtered history")"
local first_result first_head first_count
first_result=$(adopt_branch)
first_head=$(git --git-dir="$SUBREPO_BARE" rev-parse main)
first_count=$(git --git-dir="$SUBREPO_BARE" rev-list --count main)
local second_result second_head second_count
second_result=$(adopt_branch)
second_head=$(git --git-dir="$SUBREPO_BARE" rev-parse main)
second_count=$(git --git-dir="$SUBREPO_BARE" rev-list --count main)
[ "$first_result" = "adopted" ]
[ "$second_result" = "already-adopted" ]
[ "$first_head" = "$second_head" ]
[ "$first_count" = "$second_count" ]
}
# ─── Merge commit shape ─────────────────────────────────────────────
@test "adopt_branch merge carries the configured bot trailer (ADR-005)" {
SUBREPO_BARE="$(make_bare_repo subrepo "same tree" "subrepo history")"
JOSH_BARE="$(make_bare_repo josh "same tree" "josh filtered history")"
adopt_branch_quietly
local merge_msg
merge_msg=$(git --git-dir="$SUBREPO_BARE" log -1 --format=%B main)
[[ "$merge_msg" == *"${BOT_TRAILER}: adopt/${SYNC_BRANCH_MONO}/"* ]]
}
@test "adopt_branch merge places josh-filtered HEAD as parent 1 — first-parent walk reaches monorepo (ADR-008)" {
SUBREPO_BARE="$(make_bare_repo subrepo "same tree" "subrepo c1")"
add_commit_to_bare "$SUBREPO_BARE" "subrepo c2"
JOSH_BARE="$(make_bare_repo josh "same tree" "josh c1")"
add_commit_to_bare "$JOSH_BARE" "josh c2"
adopt_branch_quietly
# First-parent walk from the adoption merge follows the josh chain (parent 1)
# and never visits subrepo-only history. If parent ordering ever flipped, the
# subrepo commits would appear here — which is exactly the failure mode that
# corrupted the monorepo branch in ADR-008.
local first_parent_msgs
first_parent_msgs=$(git --git-dir="$SUBREPO_BARE" log --first-parent --format=%s main)
[[ "$first_parent_msgs" == *"josh c1"* ]]
[[ "$first_parent_msgs" == *"josh c2"* ]]
[[ "$first_parent_msgs" != *"subrepo c1"* ]]
[[ "$first_parent_msgs" != *"subrepo c2"* ]]
}
# ─── Fast-forward push semantics ────────────────────────────────────
@test "adopt_branch fast-forwards without --force, leaving old subrepo HEAD reachable" {
SUBREPO_BARE="$(make_bare_repo subrepo "same tree" "subrepo history")"
JOSH_BARE="$(make_bare_repo josh "same tree" "josh filtered history")"
local old_head result new_head
old_head=$(git --git-dir="$SUBREPO_BARE" rev-parse main)
result=$(adopt_branch)
new_head=$(git --git-dir="$SUBREPO_BARE" rev-parse main)
[ "$result" = "adopted" ]
[ "$new_head" != "$old_head" ]
# The original subrepo HEAD must still be an ancestor of the new HEAD —
# nothing was rewritten. This is what makes the push a real fast-forward.
git --git-dir="$SUBREPO_BARE" merge-base --is-ancestor "$old_head" "$new_head"
}
@test "adopt_branch surfaces push-rejected when the bare subrepo rejects the push (simulated 'subrepo moved')" {
SUBREPO_BARE="$(make_bare_repo subrepo "same tree" "subrepo history")"
JOSH_BARE="$(make_bare_repo josh "same tree" "josh filtered history")"
# Equivalent observable behaviour to "subrepo HEAD changed mid-flight": the
# fast-forward push fails. Install a pre-receive hook that rejects
# deterministically — adopt_branch's else-branch should surface push-rejected.
printf '%s\n' \
'#!/usr/bin/env bash' \
'echo "subrepo changed during adoption" >&2' \
'exit 1' \
> "${SUBREPO_BARE}/hooks/pre-receive"
chmod +x "${SUBREPO_BARE}/hooks/pre-receive"
local before_head result after_head
before_head=$(git --git-dir="$SUBREPO_BARE" rev-parse main)
result=$(adopt_branch 2>/dev/null)
after_head=$(git --git-dir="$SUBREPO_BARE" rev-parse main)
[ "$result" = "push-rejected" ]
[ "$before_head" = "$after_head" ]
}
# ─── Reverse-sync loop guard (ADR-005) ──────────────────────────────
@test "adoption merge is invisible to reverse-sync bot-trailer filter (no re-ingestion loop)" {
SUBREPO_BARE="$(make_bare_repo subrepo "same tree" "subrepo history")"
JOSH_BARE="$(make_bare_repo josh "same tree" "josh filtered history")"
local josh_head
josh_head=$(git --git-dir="$JOSH_BARE" rev-parse main)
adopt_branch_quietly
# Mirror reverse_sync's exact query (lib/sync.sh):
# base = merge-base(mono-filtered/<branch>, HEAD)
# human_commits = git log --ancestry-path "$base..HEAD" \
# --invert-grep --grep="^${BOT_TRAILER}:"
# --ancestry-path is critical: without it, the subrepo's parent-2 orphan
# commit (no trailer, real user commit) would show up as a "human commit"
# and reverse sync would try to re-ingest the adoption merge's other side.
local merge_sha base human_commits
merge_sha=$(git --git-dir="$SUBREPO_BARE" rev-parse main)
base=$(git --git-dir="$SUBREPO_BARE" merge-base "$josh_head" "$merge_sha")
human_commits=$(git --git-dir="$SUBREPO_BARE" log \
--ancestry-path "${base}..${merge_sha}" \
--invert-grep --grep="^${BOT_TRAILER}:" \
--oneline)
[ -z "$human_commits" ]
}
# ─── Linearize fallback (ADR-011) ───────────────────────────────────
@test "linearize-fallback commit selection excludes the adopt merge but keeps human commits on top" {
SUBREPO_BARE="$(make_bare_repo subrepo "same tree" "subrepo history")"
JOSH_BARE="$(make_bare_repo josh "same tree" "josh filtered history")"
local josh_head
josh_head=$(git --git-dir="$JOSH_BARE" rev-parse main)
adopt_branch_quietly
# Real human commit on top of the adoption merge.
add_commit_to_bare "$SUBREPO_BARE" "human: add note" "human change"
# Replicate reverse_sync's linearize walk exactly (lib/sync.sh):
# base = merge-base(mono-filtered/<branch>, HEAD)
# commits = git log --ancestry-path "$base..HEAD" --reverse \
# --format=%H --invert-grep --grep="^${BOT_TRAILER}:"
# With messy upstream (adopt-merge in the path), linearize must skip the
# merge (trailer-filtered) and cherry-pick only the human commit.
local work base picked
work="${TEST_ROOT}/linearize-walk"
git clone "$SUBREPO_BARE" "$work" >/dev/null 2>&1
base=$(git -C "$work" merge-base "$josh_head" HEAD)
picked=$(git -C "$work" log --ancestry-path "${base}..HEAD" \
--reverse --format=%s --invert-grep --grep="^${BOT_TRAILER}:")
[[ "$picked" == *"human: add note"* ]]
[[ "$picked" != *"Adopt ${JOSH_SYNC_TARGET_NAME} for josh-sync"* ]]
}
# ─── Checkpoint / resume (ADR-010) — strategy=adopt path through onboard_flow
# Stubs that run inside $(...) subshells can't increment shell variables in
# the parent. Track invocations via append-only files instead and count bytes.
call_log() { printf '.' >> "${TEST_ROOT}/${1}.log"; }
call_count() {
local f="${TEST_ROOT}/${1}.log"
if [ -f "$f" ]; then wc -c < "$f" | tr -d ' '; else echo 0; fi
}
@test "write_onboard_state then read_onboard_state round-trips the JSON" {
setup_state_monorepo
write_onboard_state "$JOSH_SYNC_TARGET_NAME" \
'{"step":"adopting","strategy":"adopt","import_prs":{"main":42},"adopted_branches":[]}'
local got
got=$(read_onboard_state "$JOSH_SYNC_TARGET_NAME")
[ "$(echo "$got" | jq -r '.step')" = "adopting" ]
[ "$(echo "$got" | jq -r '.strategy')" = "adopt" ]
[ "$(echo "$got" | jq -r '.import_prs.main')" = "42" ]
}
@test "read_onboard_state falls back to legacy <target>/adopt.json with implicit strategy=adopt" {
setup_state_monorepo
# Simulate a state branch where only the v2.1-era adopt.json exists (e.g.,
# an adoption started before the unification). Write directly to the state
# branch under the legacy filename, without the new strategy field.
local tmp
tmp=$(mktemp -d)
git worktree add --detach "$tmp" >/dev/null 2>&1
( cd "$tmp" && git checkout -q --orphan "$STATE_BRANCH" && { git rm -rfq . 2>/dev/null || true; }
mkdir -p "$JOSH_SYNC_TARGET_NAME"
printf '%s\n' '{"step":"adopting","import_prs":{},"adopted_branches":[]}' \
> "${JOSH_SYNC_TARGET_NAME}/adopt.json"
git add -A
git -c user.name="$BOT_NAME" -c user.email="$BOT_EMAIL" \
commit -q -m "legacy adopt state"
git push -q origin "HEAD:${STATE_BRANCH}" )
git worktree remove "$tmp" 2>/dev/null || rm -rf "$tmp"
local got
got=$(read_onboard_state "$JOSH_SYNC_TARGET_NAME")
[ "$(echo "$got" | jq -r '.step')" = "adopting" ]
[ "$(echo "$got" | jq -r '.strategy')" = "adopt" ]
}
@test "onboard_flow resumes from step=importing (strategy=adopt) — runs import, falls through wait, finalizes via adopt_branch" {
setup_state_monorepo
write_onboard_state "$JOSH_SYNC_TARGET_NAME" \
'{"step":"importing","strategy":"adopt","import_prs":{},"adopted_branches":[]}'
initial_import() { call_log import; echo "skip"; }
adopt_branch() { call_log adopt; echo "adopted"; }
subrepo_reset() { call_log reset; echo "reset"; }
get_pr() { echo '{"merged":true}'; }
list_open_prs() { echo '[]'; }
local target_json='{"name":"example","branches":{"main":"main"}}'
onboard_flow "$target_json" "false" "" >/dev/null 2>&1
[ "$(call_count import)" = "1" ]
[ "$(call_count adopt)" = "1" ]
[ "$(call_count reset)" = "0" ]
local final
final=$(read_onboard_state "$JOSH_SYNC_TARGET_NAME")
[ "$(echo "$final" | jq -r '.step')" = "complete" ]
[ "$(echo "$final" | jq -r '.strategy')" = "adopt" ]
}
@test "onboard_flow resumes from step=waiting-for-merge (strategy=adopt) — confirms merged PR, finalizes via adopt_branch" {
setup_state_monorepo
write_onboard_state "$JOSH_SYNC_TARGET_NAME" \
'{"step":"waiting-for-merge","strategy":"adopt","import_prs":{"main":42},"adopted_branches":[]}'
initial_import() { call_log import; echo "skip"; }
adopt_branch() { call_log adopt; echo "adopted"; }
subrepo_reset() { call_log reset; echo "reset"; }
get_pr() { echo '{"merged":true}'; }
list_open_prs() { echo '[]'; }
export MONOREPO_API="http://stub" GITEA_TOKEN="stub"
local target_json='{"name":"example","branches":{"main":"main"}}'
onboard_flow "$target_json" "false" "" <<< "" >/dev/null 2>&1
[ "$(call_count import)" = "0" ]
[ "$(call_count adopt)" = "1" ]
[ "$(call_count reset)" = "0" ]
local final
final=$(read_onboard_state "$JOSH_SYNC_TARGET_NAME")
[ "$(echo "$final" | jq -r '.step')" = "complete" ]
}
@test "onboard_flow resumes from step=adopting (strategy=adopt) — skips importing and waiting-for-merge entirely" {
setup_state_monorepo
write_onboard_state "$JOSH_SYNC_TARGET_NAME" \
'{"step":"adopting","strategy":"adopt","import_prs":{},"adopted_branches":[]}'
initial_import() { call_log import; echo "skip"; }
adopt_branch() { call_log adopt; echo "adopted"; }
subrepo_reset() { call_log reset; echo "reset"; }
get_pr() { echo '{"merged":true}'; }
list_open_prs() { echo '[]'; }
local target_json='{"name":"example","branches":{"main":"main"}}'
onboard_flow "$target_json" "false" "" >/dev/null 2>&1
[ "$(call_count import)" = "0" ]
[ "$(call_count adopt)" = "1" ]
[ "$(call_count reset)" = "0" ]
local final
final=$(read_onboard_state "$JOSH_SYNC_TARGET_NAME")
[ "$(echo "$final" | jq -r '.step')" = "complete" ]
}
@test "onboard_flow with step=complete is a no-op (no import, no finalize)" {
setup_state_monorepo
write_onboard_state "$JOSH_SYNC_TARGET_NAME" \
'{"step":"complete","strategy":"adopt","import_prs":{},"adopted_branches":["main"]}'
initial_import() { call_log import; echo "skip"; }
adopt_branch() { call_log adopt; echo "adopted"; }
subrepo_reset() { call_log reset; echo "reset"; }
local target_json='{"name":"example","branches":{"main":"main"}}'
onboard_flow "$target_json" "false" "" >/dev/null 2>&1
[ "$(call_count import)" = "0" ]
[ "$(call_count adopt)" = "0" ]
[ "$(call_count reset)" = "0" ]
}
@test "onboard_flow --restart with explicit strategy=adopt wipes state and re-runs the full sequence" {
setup_state_monorepo
write_onboard_state "$JOSH_SYNC_TARGET_NAME" \
'{"step":"complete","strategy":"adopt","import_prs":{"main":7},"adopted_branches":["main"]}'
initial_import() { call_log import; echo "skip"; }
adopt_branch() { call_log adopt; echo "adopted"; }
subrepo_reset() { call_log reset; echo "reset"; }
get_pr() { echo '{"merged":true}'; }
list_open_prs() { echo '[]'; }
local target_json='{"name":"example","branches":{"main":"main"}}'
onboard_flow "$target_json" "true" "adopt" >/dev/null 2>&1
[ "$(call_count import)" = "1" ]
[ "$(call_count adopt)" = "1" ]
[ "$(call_count reset)" = "0" ]
}
# ─── Goal 2: strategy resolution precedence ─────────────────────────
@test "resolve_onboard_strategy honours explicit --mode flag above config and auto-detect" {
# Config says preserve (would map to adopt); flag should win.
local target_json='{"name":"x","history_lock":"preserve"}'
local picked
picked=$(resolve_onboard_strategy "$target_json" "reset")
[ "$picked" = "reset" ]
}
@test "resolve_onboard_strategy maps history_lock=preserve to adopt" {
local target_json='{"name":"x","history_lock":"preserve"}'
local picked
picked=$(resolve_onboard_strategy "$target_json" "")
[ "$picked" = "adopt" ]
}
@test "resolve_onboard_strategy maps history_lock=rewrite to reset" {
local target_json='{"name":"x","history_lock":"rewrite"}'
local picked
picked=$(resolve_onboard_strategy "$target_json" "")
[ "$picked" = "reset" ]
}
@test "resolve_onboard_strategy auto-detects adopt when subrepo has any branches" {
SUBREPO_BARE="$(make_bare_repo subrepo "any" "any")"
local target_json='{"name":"x"}'
local picked
picked=$(resolve_onboard_strategy "$target_json" "")
[ "$picked" = "adopt" ]
}
@test "resolve_onboard_strategy auto-detects reset when subrepo has no branches (empty repo)" {
SUBREPO_BARE="${TEST_ROOT}/empty.git"
git init -q --bare "$SUBREPO_BARE"
local target_json='{"name":"x"}'
local picked
picked=$(resolve_onboard_strategy "$target_json" "")
[ "$picked" = "reset" ]
}
@test "resolve_onboard_strategy rejects unknown --mode value" {
local target_json='{"name":"x"}'
run resolve_onboard_strategy "$target_json" "weird"
[ "$status" -ne 0 ]
[[ "$output" == *"Invalid --mode"* ]]
}
@test "resolve_onboard_strategy rejects unknown history_lock value" {
local target_json='{"name":"x","history_lock":"freeze"}'
run resolve_onboard_strategy "$target_json" ""
[ "$status" -ne 0 ]
[[ "$output" == *"Invalid history_lock"* ]]
}
# ─── Goal 2: onboard_flow with strategy=reset (regression-preserving) ──
@test "onboard_flow resumes from step=resetting (strategy=reset) — finalizes via subrepo_reset" {
setup_state_monorepo
write_onboard_state "$JOSH_SYNC_TARGET_NAME" \
'{"step":"resetting","strategy":"reset","import_prs":{},"reset_branches":[]}'
initial_import() { call_log import; echo "skip"; }
adopt_branch() { call_log adopt; echo "adopted"; }
subrepo_reset() { call_log reset; echo "reset"; }
local target_json='{"name":"example","branches":{"main":"main"}}'
onboard_flow "$target_json" "false" "" >/dev/null 2>&1
[ "$(call_count import)" = "0" ]
[ "$(call_count adopt)" = "0" ]
[ "$(call_count reset)" = "1" ]
local final
final=$(read_onboard_state "$JOSH_SYNC_TARGET_NAME")
[ "$(echo "$final" | jq -r '.step')" = "complete" ]
[ "$(echo "$final" | jq -r '.strategy')" = "reset" ]
}
@test "onboard_flow finalize step (strategy=adopt) drives the real adopt_branch end-to-end against bare repos" {
# No stubbing of adopt_branch: this test exercises the full unified
# dispatch by pre-seeding state at step=adopting and letting onboard_flow
# call adopt_branch for real against local bare repos via the auth-URL
# shims. Closes the spec's end-to-end criterion for `onboard --mode=adopt`.
setup_state_monorepo
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
old_subrepo_head=$(git --git-dir="$SUBREPO_BARE" rev-parse main)
josh_head=$(git --git-dir="$JOSH_BARE" rev-parse main)
write_onboard_state "$JOSH_SYNC_TARGET_NAME" \
'{"step":"adopting","strategy":"adopt","import_prs":{},"adopted_branches":[]}'
local target_json='{"name":"example","branches":{"main":"main"}}'
onboard_flow "$target_json" "false" "" >/dev/null 2>&1
# 1. Subrepo HEAD advanced to a real adoption merge.
local new_subrepo_head
new_subrepo_head=$(git --git-dir="$SUBREPO_BARE" rev-parse main)
[ "$new_subrepo_head" != "$old_subrepo_head" ]
# 2. Merge shape matches ADR-013: parent 1 = josh_head, parent 2 = 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" ]
# 3. Merge message carries the ADR-005 bot trailer.
local merge_msg
merge_msg=$(git --git-dir="$SUBREPO_BARE" log -1 --format=%B main)
[[ "$merge_msg" == *"${BOT_TRAILER}: adopt/${SYNC_BRANCH_MONO}/"* ]]
# 4. State advanced to complete and recorded the adopted branch.
local final
final=$(read_onboard_state "$JOSH_SYNC_TARGET_NAME")
[ "$(echo "$final" | jq -r '.step')" = "complete" ]
[ "$(echo "$final" | jq -r '.strategy')" = "adopt" ]
[ "$(echo "$final" | jq -r '.adopted_branches[0]')" = "main" ]
}
# ─── Goal 2: legacy adopt.json → onboard.json migration on write ────
@test "after read_onboard_state falls back to legacy adopt.json, the next write lands in onboard.json" {
setup_state_monorepo
# Seed the state branch with ONLY the legacy file (no onboard.json yet).
local tmp
tmp=$(mktemp -d)
git worktree add --detach "$tmp" >/dev/null 2>&1
( cd "$tmp" && git checkout -q --orphan "$STATE_BRANCH" && { git rm -rfq . 2>/dev/null || true; }
mkdir -p "$JOSH_SYNC_TARGET_NAME"
printf '%s\n' '{"step":"adopting","import_prs":{},"adopted_branches":[]}' \
> "${JOSH_SYNC_TARGET_NAME}/adopt.json"
git add -A
git -c user.name="$BOT_NAME" -c user.email="$BOT_EMAIL" \
commit -q -m "legacy adopt state"
git push -q origin "HEAD:${STATE_BRANCH}" )
git worktree remove "$tmp" 2>/dev/null || rm -rf "$tmp"
# Read → mutate → write back via the unified helpers.
local state
state=$(read_onboard_state "$JOSH_SYNC_TARGET_NAME")
state=$(echo "$state" | jq '.step = "complete"')
write_onboard_state "$JOSH_SYNC_TARGET_NAME" "$state"
# Fetch a fresh copy of the state branch to see what's actually committed.
git fetch origin "$STATE_BRANCH" >/dev/null 2>&1
# onboard.json now exists with the updated step, strategy preserved.
local onboard_json
onboard_json=$(git show "origin/${STATE_BRANCH}:${JOSH_SYNC_TARGET_NAME}/onboard.json")
[ "$(echo "$onboard_json" | jq -r '.step')" = "complete" ]
[ "$(echo "$onboard_json" | jq -r '.strategy')" = "adopt" ]
# Legacy adopt.json is left in place (harmless; read fallback still resolves).
git show "origin/${STATE_BRANCH}:${JOSH_SYNC_TARGET_NAME}/adopt.json" >/dev/null
}
@test "onboard_flow importing step dies (does not warn-and-continue) when pr_number lookup fails — prevents duplicate import PR on resume" {
setup_state_monorepo
write_onboard_state "$JOSH_SYNC_TARGET_NAME" \
'{"step":"importing","strategy":"adopt","import_prs":{},"adopted_branches":[]}'
# initial_import claims a PR was created, but list_open_prs returns nothing
# matching → pr_number ends up empty. The fix turns the old WARN-and-continue
# into a die so a subsequent resume can't re-import and duplicate the PR.
initial_import() { echo "pr-created"; }
list_open_prs() { echo '[]'; }
adopt_branch() { echo "adopted"; }
export MONOREPO_API="http://stub" GITEA_TOKEN="stub"
local target_json='{"name":"example","branches":{"main":"main"}}'
run --separate-stderr onboard_flow "$target_json" "false" ""
[ "$status" -ne 0 ]
[[ "$stderr" == *"Could not find import PR number"* ]]
[[ "$stderr" == *"duplicate import PR"* ]]
}
@test "resolve_onboard_strategy dies (does not silently pick reset) when ls-remote against the subrepo fails" {
# Point auth at a nonexistent local path so ls-remote fails (exit non-zero).
SUBREPO_BARE="${TEST_ROOT}/this-does-not-exist.git"
local target_json='{"name":"x"}'
run --separate-stderr resolve_onboard_strategy "$target_json" ""
[ "$status" -ne 0 ]
[[ "$stderr" == *"Cannot auto-detect strategy"* ]]
}
@test "onboard_flow rejects --mode that conflicts with a strategy already saved in state" {
setup_state_monorepo
write_onboard_state "$JOSH_SYNC_TARGET_NAME" \
'{"step":"adopting","strategy":"adopt","import_prs":{},"adopted_branches":[]}'
initial_import() { echo "skip"; }
adopt_branch() { echo "adopted"; }
subrepo_reset() { echo "reset"; }
local target_json='{"name":"example","branches":{"main":"main"}}'
run --separate-stderr onboard_flow "$target_json" "false" "reset"
[ "$status" -ne 0 ]
[[ "$stderr" == *"Saved strategy is 'adopt'"* ]]
[[ "$stderr" == *"--restart"* ]]
}
@test "read_onboard_state injects strategy=reset for legacy v2.1 onboard.json (no .strategy field)" {
setup_state_monorepo
# Seed only a legacy-format onboard.json (no .strategy field).
local tmp
tmp=$(mktemp -d)
git worktree add --detach "$tmp" >/dev/null 2>&1
( cd "$tmp" && git checkout -q --orphan "$STATE_BRANCH" && { git rm -rfq . 2>/dev/null || true; }
mkdir -p "$JOSH_SYNC_TARGET_NAME"
printf '%s\n' '{"step":"resetting","import_prs":{"main":42},"reset_branches":["main"]}' \
> "${JOSH_SYNC_TARGET_NAME}/onboard.json"
git add -A
git -c user.name="$BOT_NAME" -c user.email="$BOT_EMAIL" \
commit -q -m "legacy onboard state"
git push -q origin "HEAD:${STATE_BRANCH}" )
git worktree remove "$tmp" 2>/dev/null || rm -rf "$tmp"
local got
got=$(read_onboard_state "$JOSH_SYNC_TARGET_NAME")
[ "$(echo "$got" | jq -r '.step')" = "resetting" ]
[ "$(echo "$got" | jq -r '.strategy')" = "reset" ]
}
@test "onboard_flow resumes from step=waiting-for-merge (strategy=reset) — transitions to resetting" {
setup_state_monorepo
write_onboard_state "$JOSH_SYNC_TARGET_NAME" \
'{"step":"waiting-for-merge","strategy":"reset","import_prs":{"main":42},"reset_branches":[],"archived_url":"git@host:org/old.git","archived_api":"https://host/api/v1/repos/org/old","archived_auth":"https"}'
initial_import() { call_log import; echo "skip"; }
adopt_branch() { call_log adopt; echo "adopted"; }
subrepo_reset() { call_log reset; echo "reset"; }
get_pr() { echo '{"merged":true}'; }
list_open_prs() { echo '[]'; }
export MONOREPO_API="http://stub" GITEA_TOKEN="stub"
local target_json='{"name":"example","branches":{"main":"main"}}'
onboard_flow "$target_json" "false" "" <<< "" >/dev/null 2>&1
[ "$(call_count adopt)" = "0" ]
[ "$(call_count reset)" = "1" ]
}

123
tests/unit/cli.bats Normal file
View File

@@ -0,0 +1,123 @@
#!/usr/bin/env bats
bats_require_minimum_version 1.5.0
# tests/unit/cli.bats — bin/josh-sync CLI surface tests
#
# Invokes the CLI as a subprocess (not by sourcing libs) so flag parsing,
# command dispatch, help/usage text, and the `adopt → onboard --mode=adopt`
# alias regress cleanly. These tests deliberately stay at the parse/dispatch
# layer — anything that would reach out to git remotes is set up to fail at
# target-lookup time, before any side effects.
#
# Uses `run --separate-stderr` so we can assert on stderr separately (most of
# josh-sync's output is logged via core.sh's `log()` which writes to stderr).
setup() {
JOSH_SYNC_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)"
JOSH_BIN="${JOSH_SYNC_ROOT}/bin/josh-sync"
FIXTURES="${JOSH_SYNC_ROOT}/tests/fixtures"
TEST_ROOT="$(mktemp -d)"
}
teardown() {
cd "$BATS_TEST_DIRNAME" 2>/dev/null || true
rm -rf "$TEST_ROOT"
}
# Drop the minimal fixture config into a clean cwd. Most cmd_* paths read
# .josh-sync.yml from the working directory.
seed_minimal_config() {
cp "${FIXTURES}/minimal.yml" "${TEST_ROOT}/.josh-sync.yml"
cd "$TEST_ROOT"
}
# ─── Global flags ───────────────────────────────────────────────────
@test "--version exits 0 and prints the version" {
run "$JOSH_BIN" --version
[ "$status" -eq 0 ]
[[ "$output" == *"josh-sync"* ]]
}
@test "--help exits 0 and advertises onboard, the adopt alias, and --mode" {
run "$JOSH_BIN" --help
[ "$status" -eq 0 ]
# Help text is printed to stderr via `cat >&2`, so combine with --separate.
run --separate-stderr "$JOSH_BIN" --help
[ "$status" -eq 0 ]
[[ "$stderr" == *"onboard <target>"* ]]
[[ "$stderr" == *"adopt <target>"* ]]
[[ "$stderr" == *"Alias for 'onboard --mode=adopt'"* ]]
[[ "$stderr" == *"--mode={reset,adopt}"* ]]
}
# ─── onboard flag parsing & dispatch ────────────────────────────────
@test "onboard with no target exits non-zero and prints usage including --mode" {
seed_minimal_config
run --separate-stderr "$JOSH_BIN" onboard
[ "$status" -ne 0 ]
[[ "$stderr" == *"Usage: josh-sync onboard"* ]]
[[ "$stderr" == *"--mode=reset|adopt"* ]]
}
@test "onboard --mode=invalid is rejected with a clear error" {
seed_minimal_config
run --separate-stderr "$JOSH_BIN" onboard --mode=invalid example
[ "$status" -ne 0 ]
[[ "$stderr" == *"Invalid --mode"* ]]
}
@test "onboard --mode=adopt against an unknown target reports target-not-found" {
seed_minimal_config
# Proves --mode parses correctly: parse_config succeeds, then cmd_onboard
# fails at target lookup, before any git/network activity.
run --separate-stderr "$JOSH_BIN" onboard --mode=adopt nonexistent
[ "$status" -ne 0 ]
[[ "$stderr" == *"not found"* ]]
}
# ─── adopt alias ────────────────────────────────────────────────────
@test "adopt alias forwards to onboard --mode=adopt (visible via alias log line + target lookup)" {
seed_minimal_config
run --separate-stderr "$JOSH_BIN" adopt nonexistent
[ "$status" -ne 0 ]
# Alias notice from cmd_adopt:
[[ "$stderr" == *"alias for: josh-sync onboard --mode=adopt"* ]]
# And the forwarded call lands in cmd_onboard's target lookup:
[[ "$stderr" == *"not found"* ]]
}
@test "onboard usage listing shows --restart and --mode flags together" {
seed_minimal_config
run --separate-stderr "$JOSH_BIN" onboard
[ "$status" -ne 0 ]
[[ "$stderr" == *"--restart"* ]]
[[ "$stderr" == *"--mode"* ]]
}
# ─── --mode value-parsing guards ────────────────────────────────────
@test "onboard --mode (no value) fails with a helpful message instead of crashing on unbound \$2" {
seed_minimal_config
run --separate-stderr "$JOSH_BIN" onboard example --mode
[ "$status" -ne 0 ]
[[ "$stderr" == *"--mode requires a value"* ]]
}
@test "onboard --mode= (empty after equals) is rejected explicitly, not silently ignored" {
seed_minimal_config
run --separate-stderr "$JOSH_BIN" onboard example --mode=
[ "$status" -ne 0 ]
[[ "$stderr" == *"Empty --mode="* ]]
}
@test "adopt alias dies when a conflicting --mode=reset is also passed (instead of silently honouring reset)" {
seed_minimal_config
run --separate-stderr "$JOSH_BIN" adopt example --mode=reset
[ "$status" -ne 0 ]
[[ "$stderr" == *"alias for --mode=adopt"* ]]
[[ "$stderr" == *"conflicting"* ]]
}