ADR-014: Bidirectional checkout synchronization is over sync, scoped to an overlay's root git entry¶
Status: accepted
Context¶
ADR-011/012 both explicitly deferred "turning Checkout entries into real
Git worktrees/checkouts" to #110, leaving MaterializerRegistry::find
return None for MaterializationIntent::Checkout and Plan::build
classify every such entry as Operation::Deferred. Issue #110 asks for
more than materialization, though: a checkout, once it exists, must be
editable with normal git tooling at its target and reconciled back to its
source — "the overlay itself materialized as a checkout is a bidirectional
synchronization surface" — while explicitly treating "git repositories
declared inside an overlay" (the same overlay.git map, used for e.g.
plugin-manager checkouts at a subpath) as an opaque resource whose content
is never synchronized.
Today, overlay.git is a single, undifferentiated HashMap<String,
GitRepoConfig>: every entry — whether it's the overlay's own root content
or a nested plugin repo — is cloned/configured identically by
actions::git::clone_repositories/EnsureGitRepository, called directly
and unconditionally by Overlay::apply_inner, entirely outside
Plan/Materializer.
Decision¶
Scope: only the root entry (ROOT_PATH, ".") is a bidirectional sync
surface. ROOT_PATH already exists as the sentinel for "this overlay's
own content, cloned to its target root" (the shorthand single-URL/single-
object git form resolves to it). No new config field is introduced to
distinguish "sync surface" from "opaque resource": the existing key
already carries that meaning, and it maps exactly onto the issue's own
two-concern split. Any non-root git entry keeps today's behavior
unchanged — ensured present/configured, never content-synced.
CheckoutMaterializer fills the registry seam, but only for presence,
not sync. src/materialize/checkout.rs registers a real Materializer
for MaterializationIntent::Checkout: classify reuses
status::git::inspect (no duplicated git-state logic) and maps
Status::Missing → Operation::Create, every other status (Applied,
Modified, Ahead, Behind, Diverged, Broken, Conflict) →
Operation::Noop. Mapping non-Missing states to Operation::Conflict
was rejected: that would route a git checkout through the filesystem
conflict-resolution UI (--force/--no-prompt/interactive
skip-overwrite-absorb-diff) built for symlinks and files, which could
overwrite or prompt to overwrite a checkout's real content — precisely
what the issue asks never to do implicitly. materialize delegates to the
existing, unchanged EnsureGitRepository action.
Overlay::apply_inner's direct, parallel actions::git::clone_repositories
call is deliberately left in place. By the time Plan::build
classifies a Checkout entry during a normal apply, the repository
already exists (clone happened first, synchronously), so
CheckoutMaterializer::materialize is naturally a no-op fallback there —
it only does real work for any other caller of Plan::execute (e.g.
tests, or a future direct caller). This keeps apply's existing
parallel-clone performance for overlays with multiple git entries,
rather than serializing them through Plan::execute's sequential step
loop for no behavioral benefit today.
Bidirectional sync is a new, separate over sync command — never part
of apply. apply's job stays "ensure presence/configuration"; it never
pulls or pushes checkout content. src/actions/git/sync.rs adds the
mutating primitives (fetch_origin, pull, push_branch,
abort_merge), all built on git2's own merge machinery
(merge_analysis/merge/cleanup_state) rather than a custom merge
algorithm — real conflicts are left as real conflict markers in the
working tree and index, exactly as git merge would leave them, for the
user to resolve with ordinary git tooling. src/sync/mod.rs orchestrates:
it finds every root Checkout entry in a DesiredTree, expands a
worktree/worktrees bare-repo config into one independently-synced unit
per worktree directory that exists on disk (satisfying "define behavior
for multiple materialized checkouts from the same overlay source" without
needing per-worktree DesiredEntrys — a documented future refinement,
same as the existing aggregation-by-severity in status::git::inspect),
and refuses to touch a dirty working tree at the pull step.
Merge only, no rebase, no auto-stash, for this iteration. The pull
direction always produces a fast-forward or a real merge commit
(git pull --no-rebase semantics) — rebase-based sync is a documented
future option, not built now. A dirty checkout blocks the pull step with a
clear error rather than being auto-stashed: "never discard uncommitted
changes implicitly" is read literally.
over sync --abort never needs a persisted "pre-merge HEAD". A
conflicted git2::Repository::merge never moves HEAD (confirmed against
git2's own docs) — only the index/working directory and MERGE_HEAD
change. Aborting is therefore just reset(HEAD, Hard) +
cleanup_state(), using whatever HEAD already is.
XDG state (src/sync/state.rs) is informational only, never
authoritative. It persists a SyncState { checkouts: HashMap<path,
CheckoutRecord> } — overlay/repo-key/worktree association, last synced
commit, last outcome — read back only to enrich over sync's own output.
Nothing in the pull/push/abort decision tree reads it: conflict/resume
correctness comes entirely from git's own repo.state()/MERGE_HEAD/
index, per the issue's own constraint ("state is operational metadata...
not a second Git database"). Deleting the file is always harmless.
Consequences¶
over apply, over status, and over diff are unchanged in behavior:
status::git/diff already classified git checkouts independently of the
Materializer registry and continue to do so. Plan/PlanStep/
Operation/ActualState are unchanged — no new variants were needed,
confirming ADR-012's registry design absorbs #110 without touching the
shared Plan vocabulary. The cost of leaving apply_inner's parallel
clone path untouched is that CheckoutMaterializer::materialize's
"ensure repository presence" logic is exercised twice in slightly
different code paths (the direct call, and the registry-mediated
fallback) — accepted as low risk since both ultimately call the same,
unchanged EnsureGitRepository action.
This does not implement rebase-based sync, auto-stash, per-worktree
DesiredEntrys, or content synchronization for non-root git entries —
all documented, deliberate non-goals left for later issues. unapply
(#64) and materialization-rule migrations (#113) are untouched.