atproto git client
atgc docs output.md
8.9 kB

Output contracts #

Five rules that hold for every command. Everything else about output, like which flags exist and what a command prints, is in atgc <command> --help or in the output itself.

stdout is the answer; stderr is everything else #

On every flag. Notes, warnings and progress go to stderr, so -q/-qq and ATGC_LOG can turn them down without touching the answer or the exit code.

--json is one value on stdout #

Every command that reads or writes something takes it. about, agent and completion do not: their output is already the whole answer and there is no second rendering to choose.

Reads emit the derived view rather than the raw record. State, number and resolved handle are computed from several sources and are not fields on anything. Writes emit what they did, always carrying dry_run; under --dry-run the identifiers a write would have minted are null rather than guessed.

pr checkout is in neither group. It writes no record, so it takes no --dry-run and carries no dry_run. What it did is local git: a branch, and under --worktree a directory. Those are what it prints.

Two more sit outside it. pr diff prints a patch, and a patch is the thing git am and git apply read: wrapping it in an object would mean every caller unwrapping it again, and --interdiff hands the whole job to git range-diff besides. auth login has non-interactive callers and still takes no --json, because the one value stdout may carry would have to be the authorization URL, which is printed long before the login has a result to report. Off a terminal it prints that URL alone on a line instead — no prose and no link escape, since whatever is reading is going to open it — and the login's own lines move to stderr. A CI job is the one session it refuses, because there is nothing there that could open the page. The other five auth verbs take the flag.

auth status --json says resolved, not active. The field marks the account that answered, at whichever of the five ranks answered — which is a different thing from the default, one rank down, that answers only when nothing else does. A row can be resolved without being the default and the other way round, and one word for both is how that got confused. The rename is a breaking change to the shape and has no alias: unlike the registry on disk, nothing reads an old --json document back.

api is the one read-or-write command with no --json. Its output is the service's own answer, which is already JSON, so there is nothing for the flag to switch between. What --json would buy is on by default there: one value on stdout, no colour, no padding. Under --dry-run that value is the request it would have sent, carrying dry_run like every other write.

The three logs readers are the one place that prints JSON Lines: one log record per line, as the bytes are on disk, and nothing else on stdout. They cannot be one value, because --follow is an unbounded stream that ends when somebody interrupts it — the closing ] of an array is a byte that never arrives, and a caller that read to EOF before parsing would hang on the case the flag is for. Emitting an array without --follow and lines with it would be worse: the same flag on the same command returning two shapes depending on another flag. So it is lines on every flag, and an empty result is zero lines rather than []. Everything else about --json still holds here: the unreadable-line warnings, the "no log yet" note and the --follow "waiting …" step are all on stderr.

Being the bytes on disk has one further consequence, and it is deliberate: the log record shape is the schema. ts, inv and seq, the snake_case event tag, and the fields of each event are a public interface under the stability rules below, and crate::logging::file's Record and the three Event enums say so where somebody renaming a field will see it. The alternative was to re-serialize each line through this build's own enum, which would silently drop any field a newer atgc had added — exactly during the incident where an old binary is reading a new machine's log.

Unknown is null, never "?". No @ on handles. No colour.

Stability: within a minor version, a field may be added in a feat. Removing or renaming one is a breaking !. See CONTRIBUTING.md's versioning rules.

A date column is UTC #

Every listing that shows a created or opened date shows the UTC calendar date of the instant, whatever offset the record was written with. Records carry a whole RFC 3339 timestamp and a column shows ten characters of it, so something has to choose between three dates: the writer's own offset, the reader's local zone, and UTC.

UTC is the only one that makes two rows comparable, which is what a column is for. The writer's offset means two rows of one listing can disagree by a day about the same instant, and the reader's zone means the same listing prints differently on two machines and a piped listing depends on TZ.

The visible consequence is intended: a record written at +03:00 just before midnight prints the previous day. A timestamp that does not parse falls back to its first ten characters rather than erroring — a malformed record must not stop a listing rendering — and an empty one prints empty rather than a placeholder. --json is unaffected: it emits the stamp as the record carries it, and the date column is a rendering.

Somebody else's text is not an instruction #

Every title, body, comment and excerpt a command prints came out of a record written by an account atgc has never met, and opening a pull or an issue against a repo needs no permission on it. A terminal does not draw a string, it interprets one, so anything printed at a person first goes through term::text: C0 control characters except \n and \t are dropped, and so are U+007F and the C1 range. A value going into a fixed-width column loses its \n and \t as well, because a title holding a newline prints a second line that reads as a second row.

Dropped rather than escaped. \x1b[2J prints as [2J: the reader sees that something was there and nothing acts on it.

Two outputs are exempt, both because they are not lines for a person. --json emits what the record holds — serde escapes control characters, and a caller reading the document back wants the value, not a rendering of it. pr diff emits a patch, which is bytes for git am; filtering one would corrupt it, and git's own pager decides what to do with what is in there.

Exit status says which kind of failure #

code meaning
0 it worked
1 unclassified failure; the message is the only description
2 the command line does not make sense, including "this is not a checkout of the repo you named"
3 no usable session for the account this would act as
4 there is a session and it was refused: a missing scope, somebody else's record, a knot that said no
5 an identifier resolved to nothing
6 a host did not answer. The one code that means try again unchanged
7 state moved underneath the command: a lost compare-and-swap, a held lock

The numbers are a public interface. crate::exit::Exit spells them out as a match so that inserting a variant cannot renumber the rest.

atgc doctor local and atgc doctor remote are the two reports with an answer and a non-zero status. The report is the answer, so it goes on stdout, and the status summarises the report rather than standing in for it. Each failing row carries the status the operation it stands for would have exited with: a missing session is 3, a scope gap or an unregistered push key 4, a host that did not answer 6. With more than one, the first row wins. A warning never decides it, or doctor local would fail in every checkout that has never been near Tangled.

The two ask opposite questions, and only one of them can say what to do about the answer. doctor local orders its rows by what depends on what, so the first broken row is the one to fix first and it says so. doctor remote reports on services that need nothing from each other, so it names the row the status came from and stops there. That is why every remedy in a doctor remote report is null: no command here mends somebody else's host.

doctor remote --json adds one field beside the rows: lag_seconds, how far Bobbin's newest pull request of yours trails the newest in your PDS. It is the index row as a number, because the audience for that object is something that alerts, and a threshold cannot be read out of a sentence. It is null wherever the row is not a measurement, meaning no account selected, no pull records of your own, or either side declining to answer. It is negative where Bobbin is ahead, which is not lag: its listing spans every author and the comparison counts only yours.