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>
110 lines
4.2 KiB
Bash
110 lines
4.2 KiB
Bash
#!/usr/bin/env bash
|
|
# lib/adopt.sh — Non-destructive adoption merge primitive
|
|
#
|
|
# Provides:
|
|
# adopt_branch() — Create one adoption merge commit and push it fast-forward
|
|
#
|
|
# 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 sourced
|
|
|
|
# ─── 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() (
|
|
# 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
|
|
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
|
|
)
|