Design note

The provider abstraction, and the state machine behind comparison and sync.

Scope

This note records the decisions that shape Minato and the reasoning behind them. It is updated as each phase lands, so it describes what exists rather than what is planned.

Why shell out to git

Minato invokes the system git binary rather than linking a Git library.

The user’s SSH agent, credential helpers, and git configuration then apply unchanged, so authentication for clone and fetch needs no separate implementation. It also keeps the dependency tree free of native build requirements.

The cost is process spawning per repository, which is mitigated by running the scan in parallel. Ahead and behind counts are read from remote-tracking refs with git rev-list --left-right --count, so comparison after a fetch needs no network access at all.

Provider abstraction

The data model is provider-agnostic from the first commit: a repository is identified by RepoId { provider, owner, name }, and the metadata structure carries only fields that any provider can reasonably supply.

The Provider trait itself is deliberately not extracted yet. GitHub is implemented concretely behind a module boundary, because a trait designed against a single implementation encodes that implementation’s assumptions. The trait is extracted when a second provider makes the real shape of the abstraction visible, at which point the differences are evidence rather than guesswork.

Fields a provider cannot supply degrade to absent rather than causing an error.

GitHub transport

Repository enumeration uses the GraphQL API through a plain HTTP client.

One paginated query per owner returns identity, fork parent, and all surfaced metadata together. That keeps request counts low, which is also the cheapest option against the rate limit. The REST API is used only where GraphQL is awkward.

Rate limiting is handled by honouring the reset and retry headers with exponential backoff, and by reporting exhaustion with the reset time rather than failing opaquely.

Comparison state machine

The framing behind comparison is that local clones are a backup of remote state: what a provider hosts must also exist on disk, and comparison reports where the two have fallen out of step. That is why RemoteOnly matters at all, and why bulk clone exists: a hosted repository with no clone is not backed up locally.

Comparison is a pure function. It takes an optional remote repository, an optional local clone, and the upstream relationship, and returns exactly one primary state plus a set of orthogonal flags.

Primary states:

State Meaning
RemoteOnly Hosted by the provider, with no local clone under any configured root, so it is not backed up locally.
LocalOnly A local clone with no reported repository behind it, so it is the only copy.
InSync The default branch matches its remote-tracking ref.
Ahead Local commits not present on the remote.
Behind Remote commits not present locally, with no local divergence.
Diverged Both sides carry commits the other lacks.

A seventh state, Incomparable, covers a clone that matches a reported repository but cannot be compared against it: it has no commits, a commit rather than a branch is checked out, or the checked-out branch follows nothing. Reporting why is more useful than forcing such a repository into a state it does not occupy.

Flags are orthogonal to the primary state, because a repository can be behind and dirty, and both facts matter. They are split by where they come from, and are absent rather than false when that side has nothing to say, so that “not a fork” stays distinguishable from “there is nothing to ask”:

  • From the clone: dirty when tracked files hold uncommitted changes, untracked when the working tree holds files git is not tracking, and detached_head when no branch is checked out.
  • From the provider: archived, private, and fork.

dirty and untracked are deliberately separate. An untracked file is usually a build artefact, and git fast-forwards happily alongside one, refusing by itself in the single case where the incoming commit overwrites it. Treating the two alike marks a repository unupdatable for as long as a stray log file sits in it.

LocalOnly is subdivided by cause, since the remedies differ: the clone has no remote at all, its remote points at a host Minato does not support, its owner is not configured so the provider was never asked, or the owner is configured and the provider did not report it. That last case covers deletion, renaming, and a repository turning private. These are not told apart, because nothing gathered so far can distinguish them: doing so needs the provider’s stable repository identifier, which is not yet fetched.

How a fork stands against its parent is reported where the provider can say. It is asked for in a second, batched request covering a page of forks at once, because the parent’s default branch is only known once the first answer has arrived.

The two counts are deliberately swapped on the way in. The comparison runs from the fork’s branch to the parent’s, so the provider’s “ahead” counts commits the parent holds that the fork lacks, which from the fork’s point of view is being behind. This was checked against the REST comparison endpoint rather than inferred, after a fork of a busy upstream was reported as hundreds of commits ahead of it.

An unanswerable comparison leaves the standing absent rather than level, so a fork whose parent has been deleted is never reported as up to date with something that is no longer there. Failure to compare is not fatal: a listing that is otherwise complete is worth more than losing it over a detail.

Sync safety

The safety rules live in the comparison layer, not in the actions. Actions ask whether an operation is permitted. They do not decide it.

A fast-forward is offered only when a repository is Behind, not dirty, and not detached_head. Every other combination is reported with its reason rather than skipped silently, because a repository that cannot be updated is exactly the one the user needs to know about.

The following never happen implicitly, under any command:

  • Force-pushing.
  • Rebasing.
  • Discarding or stashing local changes.

Bulk actions are failure-isolated. One repository failing does not abort the batch. Each result is captured, a summary is printed, and the process exits non-zero if any repository failed.

Manifest

The scan derives the tree from disk, which answers what is here rather than what should be. The manifest is the other half: .minato.toml, in the root it describes, naming each repository and the place it occupies beneath that root.

The file sits in the tree rather than in the configuration, so the file itself is the root reference. Paths are relative to the directory holding it, restoring targets that directory, and a tree carries its own layout between machines without anything agreeing on absolute paths. A command run from inside a tree finds the nearest manifest above it, so a restored tree needs no configuration to be usable.

Writing is additive. An entry whose clone is absent is kept, because a machine holding part of a tree must not erase the rest from a file shared with the machines that hold it. Removing a repository is minato manifest forget, which is asked for.

Restoring clones what is recorded and absent, and reports a clone sitting somewhere else rather than moving it, since something put it there. --relocate moves it.

A manifest arrives over whatever the user syncs with, so it is treated as input: an absolute path, a .., a duplicate identity, or a version this build does not know is refused when the file is read, rather than surprising a clone or a rename later.

Cache

Provider responses and the local scan are cached as JSON under the platform cache directory, one file per provider and owner plus one for the scan.

Each file carries the time it was fetched and a schema version. A version mismatch discards the file rather than attempting migration, since the data is a reproducible cache and not a source of truth.

Cached data stays browsable without network access and is marked as potentially stale, with its age shown.

Back to top