Context
spec-kitty merge is a local operation: it sequences WP branches onto a
target branch using the local git graph only. No push is implied by the
core merge operation unless the caller explicitly requests --push.
Prior to this ADR, merge.py called _enforce_target_branch_sync_preflight
unconditionally before any local mutation. That function performed a live
git fetch origin against the remote and raised MergeError when the local
target branch was ahead of, behind, or diverged from origin. This blocked
issue #1706: a
repository where the local main was legitimately ahead of origin/main
(e.g., post-merge with unsynced remote) could not run spec-kitty merge at
all, even when no push was intended.
The root cause is a layer violation: push-safety is a publish concern and belongs in the publish layer, not in the domain merge layer. The domain layer should operate on the local git graph and remain entirely network-free.
Decision
All remote-state inspection lives in
push_preflight.py(publish layer). This module owns thegit fetchcall, the tracking-branch comparison, and theis_safe_to_pushpredicate.preflight.pyis domain-only — local git graph checks (worktree cleanliness, branch existence, conflict detection) with no network I/O. Legacy remote-state re-export names are exposed lazily for transition compatibility; importingpreflight.pymust not importpush_preflight.pyat runtime.merge.pyimportspush_preflightconditionally, only insideif push:branches. The local merge path never touchespush_preflight.is_safe_to_pushis the correct predicate for push-safety decisions. It returnsFalsefor"behind"and"diverged"states. Both indicate that remote commits are missing locally, somerge --pushwould perform local merge/bookkeeping mutations before a known non-fast-forward push rejection. The states"ahead","in_sync", and"no_tracking_branch"are safe-to-push: ahead means the push will advance the remote normally; no tracking branch means there is no remote to conflict with.is_safeis a deprecated alias onTargetBranchSyncStatusthat always returnsTrue. It existed to gate local merge operations on remote state, which was incorrect — local merges do not require remote sync. Callers making push decisions must migrate tois_safe_to_push.
Consequences
- Domain layer is network-free.
spec-kitty mergewithout--pushperforms nogit fetchand does not block when the local target is ahead of or behind the remote. - Push-safety fires only when push is requested.
check_push_safety()inpush_preflight.pyis called only whenmerge.pyis about to push to the remote. - Issue #1706 is resolved. A repository with a local
mainahead oforigin/maincan runspec-kitty mergewithout--pushwithout error. - The
is_safepredicate is deprecated. It always returnsTrueto unblock callers during the transition; callers making push decisions must switch tois_safe_to_push.
Rejected Alternatives
Add
"ahead"and"behind"to theis_safewhitelist inpreflight.py: bandaid. This would suppress the specific error in #1706 but leaves the network call (git fetch) in the domain layer on every local merge invocation, regardless of whether push is intended. The coupling between local-merge and remote-state is the root cause; a whitelist change does not remove it.Guard the
_enforce_target_branch_sync_preflightcall withif push:inmerge.py, without relocating the module**: this corrects the call-site behavior but does not enforce the architectural boundary. The fetch logic remains importable frompreflight.py, making it easy for future contributors to re-introduce the coupling accidentally. Relocating topush_preflight.pymakes the boundary structural and enforced by the module's import identity.
References
- Issue: #1706
- Publish-layer module:
src/specify_cli/merge/push_preflight.py - Domain-layer preflight:
src/specify_cli/merge/preflight.py - Publish preflight tests:
tests/merge/test_push_preflight.py