Release 2.2.0 — unified onboard strategy + safety hardening

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

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

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

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

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

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