Import josh-sync from subrepo/main
Sync-Origin: import/josh-sync/20260528-041502
This commit is contained in:
92
tests/unit/adopt.bats
Normal file
92
tests/unit/adopt.bats
Normal file
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env bats
|
||||
# tests/unit/adopt.bats — Non-destructive adoption merge tests
|
||||
|
||||
setup() {
|
||||
export JOSH_SYNC_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)"
|
||||
source "$JOSH_SYNC_ROOT/lib/core.sh"
|
||||
source "$JOSH_SYNC_ROOT/lib/adopt.sh"
|
||||
|
||||
TEST_ROOT="$(mktemp -d)"
|
||||
export BOT_NAME="test-bot"
|
||||
export BOT_EMAIL="test-bot@test.local"
|
||||
export BOT_TRAILER="Josh-Sync-Origin"
|
||||
export JOSH_SYNC_TARGET_NAME="example"
|
||||
export SYNC_BRANCH_MONO="main"
|
||||
export SYNC_BRANCH_SUBREPO="main"
|
||||
}
|
||||
|
||||
teardown() {
|
||||
rm -rf "$TEST_ROOT"
|
||||
}
|
||||
|
||||
make_bare_repo() {
|
||||
local name="$1"
|
||||
local content="$2"
|
||||
local message="$3"
|
||||
local work="${TEST_ROOT}/${name}-work"
|
||||
local bare="${TEST_ROOT}/${name}.git"
|
||||
|
||||
git init "$work" >/dev/null
|
||||
git -C "$work" checkout -b main >/dev/null
|
||||
git -C "$work" config user.name "Test User"
|
||||
git -C "$work" config user.email "test@test.local"
|
||||
printf '%s\n' "$content" > "${work}/README.md"
|
||||
git -C "$work" add README.md
|
||||
git -C "$work" commit -m "$message" >/dev/null
|
||||
|
||||
git init --bare "$bare" >/dev/null
|
||||
git -C "$work" remote add origin "$bare"
|
||||
git -C "$work" push origin main >/dev/null
|
||||
printf '%s\n' "$bare"
|
||||
}
|
||||
|
||||
subrepo_auth_url() {
|
||||
printf '%s\n' "$SUBREPO_BARE"
|
||||
}
|
||||
|
||||
josh_auth_url() {
|
||||
printf '%s\n' "$JOSH_BARE"
|
||||
}
|
||||
|
||||
@test "adopt_branch creates merge with josh head first parent and subrepo head second parent" {
|
||||
SUBREPO_BARE="$(make_bare_repo subrepo "same tree" "subrepo history")"
|
||||
JOSH_BARE="$(make_bare_repo josh "same tree" "josh filtered history")"
|
||||
|
||||
local old_subrepo_head josh_head josh_tree result adopt_head
|
||||
old_subrepo_head=$(git --git-dir="$SUBREPO_BARE" rev-parse main)
|
||||
josh_head=$(git --git-dir="$JOSH_BARE" rev-parse main)
|
||||
josh_tree=$(git --git-dir="$JOSH_BARE" rev-parse 'main^{tree}')
|
||||
|
||||
result=$(adopt_branch)
|
||||
|
||||
[ "$result" = "adopted" ]
|
||||
adopt_head=$(git --git-dir="$SUBREPO_BARE" rev-parse main)
|
||||
[ "$adopt_head" != "$old_subrepo_head" ]
|
||||
[ "$(git --git-dir="$SUBREPO_BARE" rev-parse 'main^1')" = "$josh_head" ]
|
||||
[ "$(git --git-dir="$SUBREPO_BARE" rev-parse 'main^2')" = "$old_subrepo_head" ]
|
||||
[ "$(git --git-dir="$SUBREPO_BARE" rev-parse 'main^{tree}')" = "$josh_tree" ]
|
||||
}
|
||||
|
||||
@test "adopt_branch aborts when trees differ and leaves subrepo head unchanged" {
|
||||
SUBREPO_BARE="$(make_bare_repo subrepo "subrepo tree" "subrepo history")"
|
||||
JOSH_BARE="$(make_bare_repo josh "josh tree" "josh filtered history")"
|
||||
|
||||
local old_subrepo_head result
|
||||
old_subrepo_head=$(git --git-dir="$SUBREPO_BARE" rev-parse main)
|
||||
|
||||
result=$(adopt_branch)
|
||||
|
||||
[ "$result" = "tree-mismatch" ]
|
||||
[ "$(git --git-dir="$SUBREPO_BARE" rev-parse main)" = "$old_subrepo_head" ]
|
||||
}
|
||||
|
||||
@test "adopt_branch reports missing subrepo branch before cloning" {
|
||||
SUBREPO_BARE="$(make_bare_repo subrepo "same tree" "subrepo history")"
|
||||
JOSH_BARE="$(make_bare_repo josh "same tree" "josh filtered history")"
|
||||
export SYNC_BRANCH_SUBREPO="missing"
|
||||
|
||||
local result
|
||||
result=$(adopt_branch)
|
||||
|
||||
[ "$result" = "missing-branch" ]
|
||||
}
|
||||
697
tests/unit/adopt_e2e.bats
Normal file
697
tests/unit/adopt_e2e.bats
Normal 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" ]
|
||||
}
|
||||
75
tests/unit/auth.bats
Normal file
75
tests/unit/auth.bats
Normal file
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env bats
|
||||
# tests/unit/auth.bats — Auth URL construction tests
|
||||
|
||||
setup() {
|
||||
export JOSH_SYNC_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)"
|
||||
source "$JOSH_SYNC_ROOT/lib/core.sh"
|
||||
source "$JOSH_SYNC_ROOT/lib/auth.sh"
|
||||
|
||||
# Set up env vars as parse_config + load_target would
|
||||
export JOSH_PROXY_URL="https://josh.test.local"
|
||||
export MONOREPO_PATH="org/monorepo"
|
||||
export BOT_USER="sync-bot"
|
||||
export GITEA_TOKEN="test-token-123"
|
||||
export JOSH_FILTER=":/services/app-a"
|
||||
export SUBREPO_URL="https://gitea.test.local/ext/app-a.git"
|
||||
export SUBREPO_AUTH="https"
|
||||
export SUBREPO_TOKEN="subrepo-token-456"
|
||||
}
|
||||
|
||||
@test "josh_auth_url builds correct HTTPS URL with credentials and filter" {
|
||||
local url
|
||||
url=$(josh_auth_url)
|
||||
[ "$url" = "https://sync-bot:test-token-123@josh.test.local/org/monorepo.git:/services/app-a.git" ]
|
||||
}
|
||||
|
||||
@test "subrepo_auth_url injects credentials for HTTPS" {
|
||||
local url
|
||||
url=$(subrepo_auth_url)
|
||||
[ "$url" = "https://sync-bot:subrepo-token-456@gitea.test.local/ext/app-a.git" ]
|
||||
}
|
||||
|
||||
@test "subrepo_auth_url returns bare URL for SSH" {
|
||||
export SUBREPO_AUTH="ssh"
|
||||
export SUBREPO_URL="git@gitea.test.local:ext/app-a.git"
|
||||
|
||||
local url
|
||||
url=$(subrepo_auth_url)
|
||||
[ "$url" = "git@gitea.test.local:ext/app-a.git" ]
|
||||
}
|
||||
|
||||
@test "mono_auth_url returns bare URL for SSH" {
|
||||
export MONOREPO_AUTH="ssh"
|
||||
export MONOREPO_URL="git@code.example.io:org/monorepo.git"
|
||||
|
||||
local url
|
||||
url=$(mono_auth_url)
|
||||
[ "$url" = "git@code.example.io:org/monorepo.git" ]
|
||||
}
|
||||
|
||||
@test "mono_auth_url injects credentials for HTTPS git@-form monorepo_url" {
|
||||
export MONOREPO_AUTH="https"
|
||||
export MONOREPO_URL="git@code.example.io:org/monorepo.git"
|
||||
|
||||
local url
|
||||
url=$(mono_auth_url)
|
||||
[ "$url" = "https://sync-bot:test-token-123@code.example.io/org/monorepo.git" ]
|
||||
}
|
||||
|
||||
@test "mono_auth_url injects credentials for HTTPS https-form monorepo_url" {
|
||||
export MONOREPO_AUTH="https"
|
||||
export MONOREPO_URL="https://code.example.io/org/monorepo.git"
|
||||
|
||||
local url
|
||||
url=$(mono_auth_url)
|
||||
[ "$url" = "https://sync-bot:test-token-123@code.example.io/org/monorepo.git" ]
|
||||
}
|
||||
|
||||
@test "mono_auth_url defaults to HTTPS when MONOREPO_AUTH unset" {
|
||||
unset MONOREPO_AUTH
|
||||
export MONOREPO_URL="https://code.example.io/org/monorepo.git"
|
||||
|
||||
local url
|
||||
url=$(mono_auth_url)
|
||||
[ "$url" = "https://sync-bot:test-token-123@code.example.io/org/monorepo.git" ]
|
||||
}
|
||||
123
tests/unit/cli.bats
Normal file
123
tests/unit/cli.bats
Normal 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"* ]]
|
||||
}
|
||||
254
tests/unit/config.bats
Normal file
254
tests/unit/config.bats
Normal file
@@ -0,0 +1,254 @@
|
||||
#!/usr/bin/env bats
|
||||
# tests/unit/config.bats — Config parsing tests
|
||||
|
||||
setup() {
|
||||
export JOSH_SYNC_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)"
|
||||
source "$JOSH_SYNC_ROOT/lib/core.sh"
|
||||
source "$JOSH_SYNC_ROOT/lib/config.sh"
|
||||
|
||||
FIXTURES="$JOSH_SYNC_ROOT/tests/fixtures"
|
||||
}
|
||||
|
||||
@test "parse_config loads multi-target config" {
|
||||
cd "$(mktemp -d)"
|
||||
cp "$FIXTURES/multi-target.yml" .josh-sync.yml
|
||||
|
||||
parse_config ".josh-sync.yml"
|
||||
|
||||
[ "$JOSH_PROXY_URL" = "https://josh.test.local" ]
|
||||
[ "$MONOREPO_PATH" = "org/monorepo" ]
|
||||
[ "$BOT_NAME" = "test-bot" ]
|
||||
[ "$BOT_EMAIL" = "test-bot@test.local" ]
|
||||
[ "$BOT_TRAILER" = "Josh-Sync-Origin" ]
|
||||
}
|
||||
|
||||
@test "parse_config derives gitea_host from HTTPS URL" {
|
||||
cd "$(mktemp -d)"
|
||||
cp "$FIXTURES/multi-target.yml" .josh-sync.yml
|
||||
|
||||
parse_config ".josh-sync.yml"
|
||||
|
||||
local host
|
||||
host=$(echo "$JOSH_SYNC_TARGETS" | jq -r '.[0].gitea_host')
|
||||
[ "$host" = "gitea.test.local" ]
|
||||
}
|
||||
|
||||
@test "parse_config derives gitea_host from SSH URL" {
|
||||
cd "$(mktemp -d)"
|
||||
cp "$FIXTURES/multi-target.yml" .josh-sync.yml
|
||||
|
||||
parse_config ".josh-sync.yml"
|
||||
|
||||
local host
|
||||
host=$(echo "$JOSH_SYNC_TARGETS" | jq -r '.[1].gitea_host')
|
||||
[ "$host" = "gitea.test.local" ]
|
||||
}
|
||||
|
||||
@test "parse_config derives subrepo_repo_path from HTTPS URL" {
|
||||
cd "$(mktemp -d)"
|
||||
cp "$FIXTURES/multi-target.yml" .josh-sync.yml
|
||||
|
||||
parse_config ".josh-sync.yml"
|
||||
|
||||
local path
|
||||
path=$(echo "$JOSH_SYNC_TARGETS" | jq -r '.[0].subrepo_repo_path')
|
||||
[ "$path" = "ext/app-a" ]
|
||||
}
|
||||
|
||||
@test "parse_config derives subrepo_repo_path from SSH URL" {
|
||||
cd "$(mktemp -d)"
|
||||
cp "$FIXTURES/multi-target.yml" .josh-sync.yml
|
||||
|
||||
parse_config ".josh-sync.yml"
|
||||
|
||||
local path
|
||||
path=$(echo "$JOSH_SYNC_TARGETS" | jq -r '.[1].subrepo_repo_path')
|
||||
[ "$path" = "ext/app-b" ]
|
||||
}
|
||||
|
||||
@test "parse_config auto-derives josh_filter from subfolder when not set" {
|
||||
cd "$(mktemp -d)"
|
||||
cp "$FIXTURES/minimal.yml" .josh-sync.yml
|
||||
|
||||
parse_config ".josh-sync.yml"
|
||||
|
||||
local filter
|
||||
filter=$(echo "$JOSH_SYNC_TARGETS" | jq -r '.[0].josh_filter')
|
||||
[ "$filter" = ":/services/example" ]
|
||||
}
|
||||
|
||||
@test "parse_config preserves explicit josh_filter" {
|
||||
cd "$(mktemp -d)"
|
||||
cp "$FIXTURES/multi-target.yml" .josh-sync.yml
|
||||
|
||||
parse_config ".josh-sync.yml"
|
||||
|
||||
local filter
|
||||
filter=$(echo "$JOSH_SYNC_TARGETS" | jq -r '.[0].josh_filter')
|
||||
[ "$filter" = ":/services/app-a" ]
|
||||
}
|
||||
|
||||
@test "parse_config reports correct target count" {
|
||||
cd "$(mktemp -d)"
|
||||
cp "$FIXTURES/multi-target.yml" .josh-sync.yml
|
||||
|
||||
parse_config ".josh-sync.yml"
|
||||
|
||||
local count
|
||||
count=$(echo "$JOSH_SYNC_TARGETS" | jq 'length')
|
||||
[ "$count" -eq 2 ]
|
||||
}
|
||||
|
||||
@test "parse_config fails on missing config file" {
|
||||
cd "$(mktemp -d)"
|
||||
run bash -c 'source "$JOSH_SYNC_ROOT/lib/core.sh"; source "$JOSH_SYNC_ROOT/lib/config.sh"; parse_config "nonexistent.yml"'
|
||||
[ "$status" -ne 0 ]
|
||||
}
|
||||
|
||||
@test "parse_config fails when schema_version is missing (no v1 fallback)" {
|
||||
cd "$(mktemp -d)"
|
||||
cat > .josh-sync.yml <<'YAML'
|
||||
josh:
|
||||
proxy_url: "https://josh.test.local"
|
||||
monorepo_path: "org/repo"
|
||||
monorepo_url: "git@gitea.test.local:org/repo.git"
|
||||
targets:
|
||||
- name: "x"
|
||||
subfolder: "x"
|
||||
subrepo_url: "git@gitea.test.local:ext/x.git"
|
||||
branches: { main: main }
|
||||
bot:
|
||||
name: "b"
|
||||
email: "b@b"
|
||||
trailer: "T"
|
||||
YAML
|
||||
run bash -c 'source "$JOSH_SYNC_ROOT/lib/core.sh"; source "$JOSH_SYNC_ROOT/lib/config.sh"; parse_config ".josh-sync.yml"'
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"schema_version missing"* ]]
|
||||
}
|
||||
|
||||
@test "parse_config rejects schema_version: 1" {
|
||||
cd "$(mktemp -d)"
|
||||
cat > .josh-sync.yml <<'YAML'
|
||||
schema_version: 1
|
||||
josh:
|
||||
proxy_url: "https://josh.test.local"
|
||||
monorepo_path: "org/repo"
|
||||
monorepo_url: "git@gitea.test.local:org/repo.git"
|
||||
targets:
|
||||
- name: "x"
|
||||
subfolder: "x"
|
||||
subrepo_url: "git@gitea.test.local:ext/x.git"
|
||||
branches: { main: main }
|
||||
bot:
|
||||
name: "b"
|
||||
email: "b@b"
|
||||
trailer: "T"
|
||||
YAML
|
||||
run bash -c 'source "$JOSH_SYNC_ROOT/lib/core.sh"; source "$JOSH_SYNC_ROOT/lib/config.sh"; parse_config ".josh-sync.yml"'
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"Unsupported schema_version"* ]] || [[ "$output" == *"requires schema_version 2"* ]]
|
||||
}
|
||||
|
||||
@test "parse_config fails when josh.monorepo_url is missing" {
|
||||
cd "$(mktemp -d)"
|
||||
cat > .josh-sync.yml <<'YAML'
|
||||
schema_version: 2
|
||||
josh:
|
||||
proxy_url: "https://josh.test.local"
|
||||
monorepo_path: "org/repo"
|
||||
targets:
|
||||
- name: "x"
|
||||
subfolder: "x"
|
||||
subrepo_url: "git@gitea.test.local:ext/x.git"
|
||||
branches: { main: main }
|
||||
bot:
|
||||
name: "b"
|
||||
email: "b@b"
|
||||
trailer: "T"
|
||||
YAML
|
||||
run bash -c 'source "$JOSH_SYNC_ROOT/lib/core.sh"; source "$JOSH_SYNC_ROOT/lib/config.sh"; parse_config ".josh-sync.yml"'
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"monorepo_url"* ]]
|
||||
}
|
||||
|
||||
@test "parse_config exports MONOREPO_URL, MONOREPO_AUTH, MONOREPO_GITEA_HOST, MONOREPO_REPO_PATH from SSH form" {
|
||||
cd "$(mktemp -d)"
|
||||
cp "$FIXTURES/minimal.yml" .josh-sync.yml
|
||||
|
||||
parse_config ".josh-sync.yml"
|
||||
|
||||
[ "$MONOREPO_URL" = "git@gitea.test.local:org/repo.git" ]
|
||||
[ "$MONOREPO_AUTH" = "https" ]
|
||||
[ "$MONOREPO_GITEA_HOST" = "gitea.test.local" ]
|
||||
[ "$MONOREPO_REPO_PATH" = "org/repo" ]
|
||||
}
|
||||
|
||||
@test "parse_config derives MONOREPO_GITEA_HOST from HTTPS monorepo_url" {
|
||||
cd "$(mktemp -d)"
|
||||
cat > .josh-sync.yml <<'YAML'
|
||||
schema_version: 2
|
||||
josh:
|
||||
proxy_url: "https://josh.test.local"
|
||||
monorepo_path: "org/repo"
|
||||
monorepo_url: "https://gitea.example.io/org/repo.git"
|
||||
targets:
|
||||
- name: "x"
|
||||
subfolder: "x"
|
||||
subrepo_url: "git@gitea.test.local:ext/x.git"
|
||||
branches: { main: main }
|
||||
bot:
|
||||
name: "b"
|
||||
email: "b@b"
|
||||
trailer: "T"
|
||||
YAML
|
||||
parse_config ".josh-sync.yml"
|
||||
[ "$MONOREPO_GITEA_HOST" = "gitea.example.io" ]
|
||||
[ "$MONOREPO_REPO_PATH" = "org/repo" ]
|
||||
}
|
||||
|
||||
@test "parse_config derives MONOREPO_API from monorepo_url's host (cross-host: monorepo and target on different hosts)" {
|
||||
cd "$(mktemp -d)"
|
||||
cat > .josh-sync.yml <<'YAML'
|
||||
schema_version: 2
|
||||
josh:
|
||||
proxy_url: "https://josh.example.io"
|
||||
monorepo_path: "org/repo"
|
||||
monorepo_url: "git@code.example.io:org/repo.git"
|
||||
targets:
|
||||
- name: "x"
|
||||
subfolder: "x"
|
||||
subrepo_url: "git@other-host.io:ext/x.git"
|
||||
branches: { main: main }
|
||||
bot:
|
||||
name: "b"
|
||||
email: "b@b"
|
||||
trailer: "T"
|
||||
YAML
|
||||
parse_config ".josh-sync.yml"
|
||||
[ "$MONOREPO_API" = "https://code.example.io/api/v1/repos/org/repo" ]
|
||||
}
|
||||
|
||||
@test "parse_config rejects invalid monorepo_auth value" {
|
||||
cd "$(mktemp -d)"
|
||||
cat > .josh-sync.yml <<'YAML'
|
||||
schema_version: 2
|
||||
josh:
|
||||
proxy_url: "https://josh.test.local"
|
||||
monorepo_path: "org/repo"
|
||||
monorepo_url: "git@gitea.test.local:org/repo.git"
|
||||
monorepo_auth: "weird"
|
||||
targets:
|
||||
- name: "x"
|
||||
subfolder: "x"
|
||||
subrepo_url: "git@gitea.test.local:ext/x.git"
|
||||
branches: { main: main }
|
||||
bot:
|
||||
name: "b"
|
||||
email: "b@b"
|
||||
trailer: "T"
|
||||
YAML
|
||||
run bash -c 'source "$JOSH_SYNC_ROOT/lib/core.sh"; source "$JOSH_SYNC_ROOT/lib/config.sh"; parse_config ".josh-sync.yml"'
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"monorepo_auth"* ]]
|
||||
}
|
||||
40
tests/unit/state.bats
Normal file
40
tests/unit/state.bats
Normal file
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env bats
|
||||
# tests/unit/state.bats — State key generation tests
|
||||
|
||||
setup() {
|
||||
export JOSH_SYNC_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)"
|
||||
source "$JOSH_SYNC_ROOT/lib/core.sh"
|
||||
source "$JOSH_SYNC_ROOT/lib/state.sh"
|
||||
|
||||
export JOSH_SYNC_TARGET_NAME="billing"
|
||||
}
|
||||
|
||||
@test "state_key generates target/branch format" {
|
||||
local key
|
||||
key=$(state_key "main")
|
||||
[ "$key" = "billing/main" ]
|
||||
}
|
||||
|
||||
@test "state_key converts slashes to hyphens in branch name" {
|
||||
local key
|
||||
key=$(state_key "feature/my-branch")
|
||||
[ "$key" = "billing/feature-my-branch" ]
|
||||
}
|
||||
|
||||
@test "state_key works with different target names" {
|
||||
export JOSH_SYNC_TARGET_NAME="auth-service"
|
||||
local key
|
||||
key=$(state_key "develop")
|
||||
[ "$key" = "auth-service/develop" ]
|
||||
}
|
||||
|
||||
@test "STATE_BRANCH defaults to josh-sync-state" {
|
||||
[ "$STATE_BRANCH" = "josh-sync-state" ]
|
||||
}
|
||||
|
||||
@test "STATE_BRANCH can be overridden via env" {
|
||||
export JOSH_SYNC_STATE_BRANCH="custom-state"
|
||||
# Re-source to pick up the new value
|
||||
source "$JOSH_SYNC_ROOT/lib/state.sh"
|
||||
[ "$STATE_BRANCH" = "custom-state" ]
|
||||
}
|
||||
Reference in New Issue
Block a user