Import josh-sync from subrepo/main

Sync-Origin: import/josh-sync/20260528-041502
This commit is contained in:
sync-bot
2026-05-28 04:15:02 +01:00
commit b35703d271
47 changed files with 6418 additions and 0 deletions

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

@@ -0,0 +1,92 @@
#!/usr/bin/env bats
# tests/unit/adopt.bats — Non-destructive adoption merge tests
setup() {
export JOSH_SYNC_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)"
source "$JOSH_SYNC_ROOT/lib/core.sh"
source "$JOSH_SYNC_ROOT/lib/adopt.sh"
TEST_ROOT="$(mktemp -d)"
export BOT_NAME="test-bot"
export BOT_EMAIL="test-bot@test.local"
export BOT_TRAILER="Josh-Sync-Origin"
export JOSH_SYNC_TARGET_NAME="example"
export SYNC_BRANCH_MONO="main"
export SYNC_BRANCH_SUBREPO="main"
}
teardown() {
rm -rf "$TEST_ROOT"
}
make_bare_repo() {
local name="$1"
local content="$2"
local message="$3"
local work="${TEST_ROOT}/${name}-work"
local bare="${TEST_ROOT}/${name}.git"
git init "$work" >/dev/null
git -C "$work" checkout -b main >/dev/null
git -C "$work" config user.name "Test User"
git -C "$work" config user.email "test@test.local"
printf '%s\n' "$content" > "${work}/README.md"
git -C "$work" add README.md
git -C "$work" commit -m "$message" >/dev/null
git init --bare "$bare" >/dev/null
git -C "$work" remote add origin "$bare"
git -C "$work" push origin main >/dev/null
printf '%s\n' "$bare"
}
subrepo_auth_url() {
printf '%s\n' "$SUBREPO_BARE"
}
josh_auth_url() {
printf '%s\n' "$JOSH_BARE"
}
@test "adopt_branch creates merge with josh head first parent and subrepo head second parent" {
SUBREPO_BARE="$(make_bare_repo subrepo "same tree" "subrepo history")"
JOSH_BARE="$(make_bare_repo josh "same tree" "josh filtered history")"
local old_subrepo_head josh_head josh_tree result adopt_head
old_subrepo_head=$(git --git-dir="$SUBREPO_BARE" rev-parse main)
josh_head=$(git --git-dir="$JOSH_BARE" rev-parse main)
josh_tree=$(git --git-dir="$JOSH_BARE" rev-parse 'main^{tree}')
result=$(adopt_branch)
[ "$result" = "adopted" ]
adopt_head=$(git --git-dir="$SUBREPO_BARE" rev-parse main)
[ "$adopt_head" != "$old_subrepo_head" ]
[ "$(git --git-dir="$SUBREPO_BARE" rev-parse 'main^1')" = "$josh_head" ]
[ "$(git --git-dir="$SUBREPO_BARE" rev-parse 'main^2')" = "$old_subrepo_head" ]
[ "$(git --git-dir="$SUBREPO_BARE" rev-parse 'main^{tree}')" = "$josh_tree" ]
}
@test "adopt_branch aborts when trees differ and leaves subrepo head unchanged" {
SUBREPO_BARE="$(make_bare_repo subrepo "subrepo tree" "subrepo history")"
JOSH_BARE="$(make_bare_repo josh "josh tree" "josh filtered history")"
local old_subrepo_head result
old_subrepo_head=$(git --git-dir="$SUBREPO_BARE" rev-parse main)
result=$(adopt_branch)
[ "$result" = "tree-mismatch" ]
[ "$(git --git-dir="$SUBREPO_BARE" rev-parse main)" = "$old_subrepo_head" ]
}
@test "adopt_branch reports missing subrepo branch before cloning" {
SUBREPO_BARE="$(make_bare_repo subrepo "same tree" "subrepo history")"
JOSH_BARE="$(make_bare_repo josh "same tree" "josh filtered history")"
export SYNC_BRANCH_SUBREPO="missing"
local result
result=$(adopt_branch)
[ "$result" = "missing-branch" ]
}

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

75
tests/unit/auth.bats Normal file
View 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
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"* ]]
}

254
tests/unit/config.bats Normal file
View 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
View 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" ]
}