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.