atproto git client
atgc docs module-layout.md
6.0 kB

Module layout #

Where code goes. The folders are visible in src/; what is not visible is the rules that put them there.

cmd/       one module per `atgc <verb>` family
clients/   everything that talks to a counterpart: PDS, knot, appview, git, HTTP
lexicon/   the wire: what strings and records *are*. Pure: opens no socket
model/     what a record *means* to every verb that reads one
config/    ~/.config/atgc: the directory, the lock, the accounts
logging/   the log writers
html/      every page atgc serves a browser
term/      how output reaches a person or a pipe

Four rules #

  1. lexicon/ imports nothing from clients/. The moment the pure layer opens a socket it stops being testable without one. This is why identity::did_document_url takes the PLC directory as an argument rather than looking it up. clients/endpoints.rs holds every compiled-in hostname and the ATGC_* variable that moves it; env.rs, beside it at the top of src/, holds the one reading of every ATGC_* switch, so that ATGC_NO_INPUT=false and ATGC_USE_BOBBIN=false cannot mean opposite things. README.md's Environment section lists all of them.
  2. Nothing outside cmd/ imports cmd/. A command is the top of the tree; anything two commands both need belongs lower down. The compiler enforces this now: items are pub(super) or pub(in crate::cmd), and pub(crate) is left only where main.rs or a doc link reaches in.
  3. clients/git/ owns every interaction with git. Nothing else builds a Command::new("git"), which is what makes the non-interactive gate universal rather than per-caller.
  4. A rule about what a record means lives in model/, not in the verb that happened to need it. See below; this is the newest rule and the one that had been broken the most times.

model/ and why it exists #

atgc is organised by verb and its bugs are about nouns. cmd/ is over forty thousand lines, so a rule you cannot find is a rule you rewrite — and the same rules were rewritten until copies of them disagreed:

Rule Copies How they differed
Where a pull targets 6 one defaulted to main, on the merge path
Which state record wins 5 three decided state, and ordered three ways
How many rounds a pull has 2 an empty array read as 0 and as 1
Who may write a status 2 pulls and issues drew the same line twice

None of that was carelessness. Two of the state orderings carried doc comments asserting they agreed with each other. The fix is not more care, it is somewhere to look: the counter-example is cmd/stack's chain reader, which became a model module by accident, and chain rules stopped diverging the moment there was an obvious place for them.

lexicon/ is the wire — the NSIDs, the field names, the shape a record has on disk — and stays pure. model/ is what a record means: which of two statuses is newer, where a pull lands, whether an account's write will be honoured. It may use clients/, because deciding standing means asking who owns a repo, but it must not know about arguments, output or exit codes. Those stay in cmd/, which is left to parse, call, and print.

config/ may import clients/ #

Not a back-edge. ~/.config/atgc does not hold opaque bytes, it holds client specifications: which account, which PDS, which session. Deciding whether the thing on disk is still valid is client-specific validation. Pushing those calls up into cmd/ would make every command repeat them, to keep a diagram tidy.

The constraint that remains is narrower, and it is the one that matters: no client asks config/ who we are. A client takes the account, the DID or the session as an argument, from the command that already knows. It may reach for config/dir, which is a path, not an identity. The difference is testability: a client handed a DID can be driven with any DID, while a client that looks one up can only be driven by the machine it is running on.

When a file becomes a folder #

Roughly 2,000 lines and somewhere to divide it. Size says look; a real dividing line says split. Splitting by verb does not count, since pr comment does not get its own file for being a different word. The divisions that exist are read vs write vs somebody else's pull, and knot-only vs config-only. Inside cmd/pr/read/ there is one more, and it is the same rule applied twice over: deciding what the set of pull requests is is one subject, naming and printing it is another, and the lookups that turn a DID into something a column can show are a third that neither of the first two owns.

Each cmd/ module carries its own clap definitions, with help text as doc comments. There is no parallel structure mirroring the args.

Where the auth split fell #

auth.rs used to be two subjects in one file at the crate root, and it is the worked example of the rule above. The OAuth client is clients/atproto/oauth/: client.rs for the jacquard wiring and the client metadata a grant is bound to, sessions.rs for what is on disk and what state it says an account's credentials are in, store.rs for jacquard's own typed view of that file, login.rs for the loopback callback server and the state one in-flight login owns, and metadata.rs for the transport wrapper that keeps the authorization server's well-known document out of the refresh critical section. The verbs are cmd/auth.rs, which is where every printed line and every clap definition went.

The dividing line is the config/ constraint, not the verbs. login and agent_for_did read crate::config::account to decide which DID to act for, so they are commands; the start_auth, callback and restore calls they make take that DID as an argument, so those are the client. Splitting by verb would have put login on the other side of the line and dragged the account registry into clients/ with it.