698 lines
28 KiB
Bash
698 lines
28 KiB
Bash
#!/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" ]
|
|
}
|