Something went wrong. Try again.
atproto git client
Something went wrong. Try again.
Rust
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140// The field of DNA both `about` and the post-login page draw, and the canvas// it is drawn on. Neither medium owns it, so it sits above both.mod art;// What this binary is, past its version number: the commit, target, profile// and compiler that build.rs recorded while they were still knowable.mod build_info;mod clients;mod cmd;mod config;// Documentation only: empty modules carrying the narrative pages under// docs/, so `cargo docs` renders prose and generated reference as one thing// and a link from prose into code is checked by the build.mod docs;mod model;// One reading of every boolean atgc takes from the environment, so that// `ATGC_NO_INPUT=false` and `ATGC_USE_BOBBIN=false` cannot mean opposite// things.mod env;// What atgc's exit status means, and how a failure comes to carry one.mod exit;mod help;// Every page atgc serves a browser, and the helpers they share.mod html;mod lexicon;mod logging;mod term;#[cfg(test)]mod testutil;
use clap::{Parser, Subcommand};
#[derive(Parser)]// `version` and `long_version` are two spellings of the same flag: clap// prints the first for `-V` and the second for `--version`. The short one// stays a bare version number so a script reading it needs no knowledge of// how many lines to expect.// The last line of `--help` is the project's page, from `CARGO_PKG_HOMEPAGE`// like every other rendering of the address. A pointer and not an// instruction: nothing here fetches it, and no failure anywhere in atgc// tells anybody to go there for the fix.#[command( name = "atgc", about = "ATproto Git Client: a CLI for Tangled", version, long_version = build_info::long_version(), after_help = concat!("atgc on the web: ", env!("CARGO_PKG_HOMEPAGE")))]struct Cli { /// Print HTTP traffic and full error details (also: ATGC_DEBUG=1) #[arg(long, global = true)] debug: bool, /// Say less on stderr: -q drops notes and progress, -qq drops /// warnings too. Never touches the answer on stdout, or errors. /// `ATGC_LOG=note,pds=debug` is the per-topic form #[arg( short, long, global = true, action = clap::ArgAction::Count, conflicts_with = "debug" )] quiet: u8, /// Act as this account: handle or DID (also: ATGC_ACCOUNT) #[arg(long, global = true, value_name = "HANDLE|DID")] account: Option<String>, /// Do not wait for user input. Automatic when stdin is not a /// terminal, or when ATGC_NO_INPUT=1 or CI is set #[arg(long, global = true)] no_input: bool, /// Seconds to wait for a host to answer at all: DNS, TCP and TLS /// together (default 5; also: ATGC_CONNECT_TIMEOUT) /// /// Raise it on a slow link, where the default reports a reachable host /// as unreachable. It cannot be switched off: an unbounded connect waits /// out the kernel's own retry schedule, which is minutes on some hosts. #[arg(long, global = true, value_name = "SECONDS")] connect_timeout: Option<u64>, /// Seconds a response may go without progress before it counts as /// stalled (default 30; also: ATGC_READ_TIMEOUT) /// /// Bounds the gap between bytes rather than the whole transfer, so a /// large patch or blob is not cut off for being large. #[arg(long, global = true, value_name = "SECONDS")] read_timeout: Option<u64>, #[command(subcommand)] command: Command,}
#[derive(Subcommand)]enum Command { /// Show the version, where atgc lives, and a helix About, /// Print working notes for AI agents driving atgc /// /// A short, plain-text briefing on what Tangled does differently from /// the forges an agent already knows: how pull requests actually update, /// images in bodies, stacks, identity. Compiled into the binary, so it /// always describes the version that prints it. Humans are welcome too. Agent, /// Manage authentication /// /// The OAuth event log used to be `auth log` and is now `atgc logs /// oauth`. Said here because clap's own "unrecognized subcommand" for /// the old spelling suggests `login` and `logout`: the two words /// nearest it, and both wrong, and this page is where that error sends /// people next. #[command( after_long_help = "The OAuth event log moved: `atgc auth log` is now `atgc logs oauth`." )] Auth { #[command(subcommand)] command: cmd::auth::Command, }, /// Open the repo's Tangled page in a browser Browse(cmd::browse::BrowseArgs), /// Print a shell completion script to stdout /// /// Generated from this binary's own command tree, so it always matches /// the atgc you are running. Re-run it after an upgrade to pick up new /// flags. Nothing is written to disk and nothing is checked in; where the /// script goes is the shell's business, and one line per shell puts it /// there: /// /// bash atgc completion bash > ~/.local/share/bash-completion/completions/atgc /// zsh atgc completion zsh > ~/.zfunc/_atgc /// fish atgc completion fish > ~/.config/fish/completions/atgc.fish /// powershell atgc completion powershell >> $PROFILE /// elvish atgc completion elvish > ~/.config/elvish/lib/atgc.elv /// /// Create the directory first if it is not already there. bash also needs /// the bash-completion package installed; zsh needs ~/.zfunc on its fpath /// and `autoload -U compinit && compinit` in ~/.zshrc; elvish needs `use /// atgc` added to ~/.config/elvish/rc.elv. Open a new shell afterwards. /// README.md spells all of that out. #[command(verbatim_doc_comment)] Completion { /// Shell to generate for: bash, zsh, fish, powershell, or elvish #[arg(value_name = "SHELL")] shell: clap_complete::Shell, }, /// Search Tangled's records by full text /// /// Bobbin (api.tangled.org) indexes every record it has seen on the /// firehose, and `sh.tangled.search.query` is the only way to ask across /// all of them at once. Repos, pull requests, issues, comments and /// pasted strings all come back from one query, so a hit's collection is /// a column rather than something to choose in advance: `--nsid` narrows /// it when you already know. /// /// Inside a Tangled checkout this searches that repo; `--all` widens it /// to every repo. Outside one it is already every repo. /// /// This is a public read. It needs no session, no account and no OAuth /// scope, and it works logged out, as does every other read atgc makes /// of Bobbin. /// /// Examples: /// atgc search 'index lag' /// atgc search rebase --nsid repo.pull --since 7d /// atgc search --all typo --author permadeath.com --json #[command(verbatim_doc_comment)] Search(cmd::search::SearchArgs), /// Call an XRPC method directly, as you, and print what came back /// /// The escape hatch. Tangled's lexicon is much larger than the part atgc /// has commands for: issues, labels, stars, follows, collaborators, /// secrets, artifacts, pipelines, notifications, and this reaches all of /// it today, in whatever spelling the service itself uses. /// /// --host says which service, and atgc sends the credential that service /// takes. That is the whole model, and it is a fact about where a method /// lives rather than a question about secrets: /// /// pds your PDS, as you. Its OAuth access token, DPoP-bound /// knot:HOST a knot. A query is public; a procedure carries a /// service-auth JWT minted for that one method /// appview tangled.org. Public /// bobbin api.tangled.org. Public /// /// A knot is named by hostname because a repo's knot is a field of its /// record rather than anything a checkout knows; `atgc repo view` prints /// it. /// /// Parameters are `gh api`'s: -F guesses a type (true, false, null and /// numbers), -f never does. They go in the query string of a query and in /// the JSON body of a procedure. --input FILE (or - for stdin) sends a /// whole body instead. /// /// XRPC distinguishes queries from procedures and a method name does not /// say which it is, so --input means a procedure and everything else means /// a query; -X post or -X get settles it. A wrong guess comes back as a /// 404 or 405 with a line saying to try the other one. /// /// Output is the service's own answer on stdout and nothing else, so there /// is no --json to ask for: this command has no other rendering. Notes and /// errors are on stderr as always, and a refusal exits with the status /// that kind of refusal deserves (see `atgc about`). /// /// This is authenticated, and `com.atproto.repo.deleteRecord` is as /// reachable through it as anything else. --dry-run prints the request it /// would send: method, URL, headers and body, with the credential /// replaced by a fingerprint of itself, and sends nothing. /// /// Examples: /// atgc api com.atproto.repo.listRecords \ /// -f repo=did:plc:nlzmjyfv6loqtxyzvdcznwgf \ /// -f collection=sh.tangled.repo.pull -F limit=5 /// atgc api sh.tangled.search.query --host bobbin -f q='index lag' /// atgc api sh.tangled.repo.forkSync --host knot:knot1.tangled.sh \ /// --input sync.json --dry-run #[command(verbatim_doc_comment)] Api(cmd::api::ApiArgs), /// Check whether this end and the other end are working /// /// Two reports, split by which end they are about. `doctor local` asks /// whether *you* are set up, this machine, this account, this checkout, /// and every bad row carries a command you can run. `doctor remote` asks /// whether *they* are up, so no row is about you and none has a remedy. /// /// `remote` is the question left over when `local` says everything is fine /// and the thing you were doing still fails. Both exit non-zero when a /// check found something broken: a warning is not. /// Write the generated parts of atgc.codes /// /// Behind the off-by-default `www` feature, so a released atgc does not /// have this command at all. It is a build step for this repository — /// the site is an Astro project in `www/`, and this hands it the facts, /// the palette and the drawings it cannot produce for itself. Nothing at /// runtime reads what it writes. #[cfg(feature = "www")] #[command(name = "www-assets", hide = true)] Www(cmd::www::WwwArgs), Doctor { /// Omitted, this is the setup check: `atgc doctor` on its own asks /// whether atgc itself is ready, which is the question somebody has /// when nothing is working yet and the one they typed `doctor` to /// ask. It needs no checkout and no network. `local` and `remote` /// are the fuller sweeps. #[command(subcommand)] command: Option<cmd::doctor::Command>, }, /// Work with issues /// /// A Tangled issue is a record in the PDS of whoever filed it, naming the /// repo by that repo's own DID, so opening one needs no permission on /// the repo, and your own issues are readable the instant they are /// written. State is a separate record and the newest one wins, which is /// why `issue reopen` appends rather than deleting. /// /// `issue list` reads one account's PDS and says so on stderr every time. /// Listing every issue on a repo, whoever filed it, needs the appview /// index, and atgc does not read one for issues yet. Issue { #[command(subcommand)] command: cmd::issue::Command, }, /// Work with pull requests /// /// A Tangled pull request is a record in its author's PDS carrying the /// patches themselves, not a pointer to a branch. Pushing therefore /// never updates one: `pr resubmit` appends a new round after an amend /// or rebase, and `pr edit` changes the title or body. Reading commands /// (`view`, `diff`, `checkout`) work on anyone's pull, logged out. /// /// Every verb here is scoped to the repo you are in, `pr list --all` /// excepted: that is your own pull requests wherever you filed them, /// however many repos that spans, and it needs no checkout. Pr { #[command(subcommand)] command: cmd::pr::Command, }, /// Work with stacked pull requests /// /// A stack is a chain of pull requests, each dependent on the one /// beneath it, so they are reviewed and landed in order. A stack is not /// how you file a branch of several commits: any one pull request /// carries as many commits as its change needs, and `atgc pr create` is /// the command for it. Stack a branch only when its parts are separate /// changes that must land bottom-up. These commands are for stacked /// branches and refuse, pointing at the right `pr` command, when the /// branch's pull is not stacked. The `pr` commands keep working on /// stack members. Stack { #[command(subcommand)] command: cmd::stack::Command, }, /// Work with repositories /// /// A Tangled repo is a `sh.tangled.repo` record naming the knot that /// hosts its git data. `create`, `clone` and `configure` also write /// the ATProto identity: @handle as user.name, DID as user.email: /// into the checkout's local git config, so commits are attributed to /// the account rather than to whatever the global config says. Repo { #[command(subcommand)] command: cmd::repo::Command, }, /// Say something about atgc: a bug, or feedback on its board /// /// Two destinations, chosen by the kind. `report bug` files a Tangled /// issue on atgc's own repo — the same record `issue create` writes, on /// a tracker that can be assigned, closed and linked to a commit — and /// needs a body, because tangled.org drops an issue that has none. /// Every other kind becomes an `app.userinput.discussion` record in your /// own PDS, public and yours to edit or delete, pointing at atgc's /// board: that is how userinput.app works, with no server holding /// anyone's posts. The board declares what kinds it takes (feature, /// question, …); naming none, or a wrong one, answers with the list. /// /// Unless --no-diagnostics, a few fixed lines ride along in the body: /// atgc's version, the platform, git's version, and your PDS host: /// names of software and one public hostname, never paths or /// environment. The full record is printed before anything is written, /// and --dry-run stops there. There is no editor: --body, or /// --body-file - to read stdin. A local image path in a bug's body is /// uploaded and embedded, exactly as in an issue. /// /// --agent marks the report as an agent's, which is what an AI agent /// filing one should pass: `[agent]` in front of the title, a line in /// the body, and the repo's `agent` label. The label is a second record /// in your own PDS, as it is when the web UI applies one, and /// tangled.org shows one only from an account with push on the repo — /// so the title and body are the marks that always survive, and the /// label is written anyway for the accounts it works for. /// /// The repo is tangled.org/permadeath.com/atgc and the board is /// userinput.app/s/did:plc:nlzmjyfv6loqtxyzvdcznwgf/3msrnb776772b: /// reports on either are public, as is the reading of them. /// /// Examples: /// atgc report bug --title 'pr list hangs' --body 'on a 40k-commit repo' /// atgc report bug --agent --title 'stack view panics' --body '…' /// atgc report bug --title 'it looks like this' --body '' /// atgc report feature --title 'stack sync --continue' /// atgc report question --title 'what does round mean here?' --body '…' #[command(verbatim_doc_comment)] Report(cmd::report::ReportArgs), /// Manage the SSH keys Tangled accepts your pushes from /// /// A knot decides who is pushing from the SSH key that opened the /// connection and from nothing else, so every account you push as needs /// its own key published as a `sh.tangled.publicKey` record. Logging in /// does not do it: `atgc auth login` and `key add` are separate acts, /// and until the second one has run for an account, every push as it is /// refused, with an ssh error about permissions that never mentions a /// key. /// /// `ssh -T git@<your-handle>.tangled` is the quick check. It answers /// "Hi @you!" when a registered key made the connection, and "Hi there!" /// when the key it offered belongs to nobody Tangled knows. /// /// Examples: /// atgc key list /// atgc key add /// atgc key add ~/.ssh/id_ed25519.pub --name 'laptop' /// atgc --account other.example.com key add /// atgc key delete laptop #[command(verbatim_doc_comment)] Key { #[command(subcommand)] command: cmd::key::Command, }, /// Read the logs atgc keeps about its own behaviour /// /// These are local files in atgc's config directory: $XDG_CONFIG_HOME/atgc /// when that variable holds an absolute path, ~/.config/atgc otherwise: /// written as atgc runs and read by nothing else: no network, no account, /// no session. One subcommand per log, named for the log it reads: `logs /// oauth` is the record of every OAuth operation, in `oauth.jsonl`, `logs /// pds` is every write atgc has sent to a PDS, in `pds.jsonl`, and `logs /// git` is every git subprocess atgc started, in `git.jsonl`. /// /// They share a rendering, and the flags that go with it: events grouped /// by the invocation that wrote them, `--since`/`--until`/`--failures` /// to narrow, `-f` to follow, `--json` for jq. Learning to read one is /// learning to read the others. Logs { #[command(subcommand)] command: cmd::logs::Command, },}
/// Restore SIGPIPE, run the command, then turn what it returned into a status.////// Two things `main` has to do that a `main` returning `anyhow::Result` cannot.////// **SIGPIPE.** Rust's runtime sets it to `SIG_IGN` before `main`, so a write/// to a closed pipe comes back as `EPIPE` instead of ending the process, and/// `println!` answers an `EPIPE` by panicking. Every one of atgc's ~240/// `println!` sites is therefore a panic waiting for a reader that stops/// reading, and so is `clap_complete`'s own writer:////// ```text/// $ atgc completion zsh | head -c 10/// thread 'main' panicked at clap_complete/src/aot/shells/shell.rs:86:14:/// failed to write completion file: Os { code: 32, kind: BrokenPipe }/// $ echo ${PIPESTATUS[0]}/// 101/// ```////// `atgc pr list | head` and `atgc logs oauth | less` are the same shape./// Restoring the default disposition makes all of them die quietly the way/// `git`, `ls` and `grep` do, and it is the only fix that reaches a panic/// thrown inside a dependency: [`crate::term::say`]'s rule that a failure is/// a `Result` reaching `main` cannot cover a macro that panics on its way to/// stdout.////// **The status.** See [`crate::exit`] for what each one means and [`report`]/// for how the message is written.////// The reset is the first statement, and [`run`] is a separate function, so/// that it provably lands before tokio builds a runtime and spawns a worker./// The disposition is process-wide, so this only matters against a thread that/// could write before it, but *provably none* is cheaper to keep true than/// *none that currently does*.fn main() -> std::process::ExitCode { sigpipe::reset(); install_panic_hook(); match run() { Ok(()) => std::process::ExitCode::SUCCESS, Err(err) => { report(&err); exit::classify(&err).into() } }}
/// Turn a panic into a bug report with a status somebody can branch on.////// atgc had no panic policy at all: the default hook wrote/// `thread 'main' panicked at src/…` and a note about `RUST_BACKTRACE` to/// stderr, and the process exited `101` — Rust's number, which appears/// nowhere in [`crate::exit`]'s table. So the one failure a caller most wants/// to tell apart from the ordinary ones was the one that answered with a/// status the documented interface does not define, in the shape of a/// compiler diagnostic rather than of anything this tool says.////// A hook is worth more than fixing the sites it would have caught. Two were/// found by audit and fixed beside this, but the value here is the ones/// nobody has found: every future slice on a boundary that is not one, every/// arithmetic edge, every `expect` whose invariant stops holding. Each of/// them now lands as a sentence naming the version and asking for a report,/// and exits [`exit::Exit::Bug`].////// **It exits from inside the hook rather than unwinding.** Returning would/// let the panic continue up through `block_on` and out of `main`, where the/// runtime's own handling decides the status again — so the code would not be/// ours. Exiting here skips the remaining destructors, which for a process/// that has already lost an invariant is the safer half of the trade: nothing/// in atgc writes on `Drop`, and the two things that must not be left/// half-written — the session store and the account registry — are replaced/// by rename under a lock, so an abandoned write leaves the old file whole.////// The panic's own message is kept, because it is the most specific thing/// anybody has. The location is kept for the same reason. A backtrace stays/// behind `RUST_BACKTRACE`, which is where a reader already looks for one.fn install_panic_hook() { let previous = std::panic::take_hook(); std::panic::set_hook(Box::new(move |info| { // The default hook, first and only when it was asked for: it is what // prints the backtrace, and a bug report is worth more with one. if std::env::var_os("RUST_BACKTRACE").is_some_and(|v| v != "0") { previous(info); } let label = term::say::label(term::style::BAD, "error: "); let where_ = info .location() .map(|l| format!(" at {}:{}", l.file(), l.line())); eprintln!("{label}{}", panic_report(info.payload(), where_.as_deref())); std::process::exit(exit::Exit::Bug.code().into()); }));}
/// The sentence a panic prints, without the label or the exit.////// The "how to report it" half comes from [`exit::how_to_report`], which is/// shared with [`exit::bug`] — the same event reported two ways would be two/// answers to one question.////// Split out because the payload is the one part of a hook that can be wrong:/// `panic!("x")` carries a `&'static str` and `panic!("{x}")` carries a/// `String`, and reading only one of them turns the most specific thing/// anybody has into "panicked". A `PanicHookInfo` cannot be constructed/// outside the runtime, so the payload is taken as `&dyn Any` and the test/// hands it each shape directly.fn panic_report(payload: &dyn std::any::Any, at: Option<&str>) -> String { let what = payload .downcast_ref::<&str>() .map(|s| (*s).to_string()) .or_else(|| payload.downcast_ref::<String>().cloned()) .unwrap_or_else(|| "panicked with a payload of an unknown type".to_string()); format!( "atgc {} panicked{}: {what}\n\ \x20 {}\n\ \x20 Add the output of RUST_BACKTRACE=1 if you can run it again.", env!("CARGO_PKG_VERSION"), at.unwrap_or_default(), exit::how_to_report(&format!("panicked: {what}")), )}
/// A failure, on stderr, in the shape [`crate::term::say`]'s prefixes/// established: a lower-case label, painted through that module's gate, then/// the sentence. The gate is stderr's, which `--json` does not veto, so the/// label keeps its colour in that mode too.////// One line by default: `anyhow`'s alternate form, the chain joined by `: `,/// which is how a person reads "what failed, doing what, while doing what"./// `--debug` prints `anyhow`'s own multi-line form instead, which keeps the/// causes on separate lines and carries a backtrace when `RUST_BACKTRACE` asks/// for one. That is the flag's existing promise of "full error details", now/// true of errors and not only of HTTP bodies.fn report(err: &anyhow::Error) { let label = term::say::label(term::style::BAD, "error: "); if logging::debug::enabled() { eprintln!("{label}{err:?}"); } else { eprintln!("{label}{err:#}"); }}
#[tokio::main]async fn run() -> anyhow::Result<()> { // Before anything else, and before clap can exit on a bad argument: the // OAuth log wants to have recorded the invocation even when the command // itself never ran. It takes the raw argv rather than the parsed command // so that it records only the leading positional words — no flag value, // and so no PR body or title, can reach the log by this route. let args: Vec<String> = std::env::args().collect(); // `completion` is exempt, and it is the only exemption. It is the one // subcommand that can end up running on every shell startup: the README // tells people to write the script to a file once, but the `eval "$(atgc // completion bash)"` form is common enough that somebody will put it in an // rc file anyway. That would append an invocation record per terminal // opened, which buries the history `atgc logs oauth --incident` reads and // drives the 8 MiB rotation that throws the old generation away. The // command reads no session, mints no token, opens no socket and writes no // record, so it has nothing to contribute to either log in the first // place. // // Matching argv[1] only, deliberately: it catches the rc-file shape, which // never has a global flag in front of it, and cannot be fooled into // silencing a log by a flag *value* that happens to be "completion". if args.get(1).map(String::as_str) != Some("completion") { logging::oauth::init(&args); logging::pds::init(&args); logging::git::init(&args); }
// `help::parse` and not `Cli::parse`: same parse, but with the top-level // command list rendered in groups rather than as one flat column. let cli = help::parse(); term::say::init(cli.quiet, cli.debug); term::noinput::init(cli.no_input); // Before any command runs, and fallible because a bad value is a bad // command line rather than something to fall back from. clients::http::init(cli.connect_timeout, cli.read_timeout)?; config::account::init(cli.account); match cli.command { Command::About => cmd::about::about(), Command::Agent => { cmd::agent::agent(); Ok(()) } Command::Auth { command } => cmd::auth::run(command).await, // Not `async`: these only read a local file, and `--follow` blocks on // a sleep loop rather than on a socket, so there is nothing for the // runtime to interleave. Command::Logs { command } => cmd::logs::run(command), Command::Browse(args) => cmd::browse::browse(args).await, Command::Completion { shell } => { cmd::completion::completion(cmd::completion::Args { shell }) } Command::Search(args) => cmd::search::search(args).await, Command::Api(args) => cmd::api::api(args).await, Command::Stack { command } => cmd::stack::run(command).await, Command::Issue { command } => cmd::issue::run(command).await, Command::Pr { command } => cmd::pr::run(command).await, Command::Repo { command } => cmd::repo::run(command).await, Command::Report(args) => cmd::report::report(args).await, Command::Key { command } => cmd::key::run(command).await, Command::Doctor { command } => cmd::doctor::run(command).await, // Not `async`: it draws a page and writes a file. #[cfg(feature = "www")] Command::Www(args) => cmd::www::www(args), }}
#[cfg(test)]mod tests {
/// Both shapes a panic payload comes in reach the message. /// /// `panic!("x")` carries a `&'static str` and `panic!("{x}")` carries a /// `String`, and a hook that reads only one of them replaces the most /// specific thing anybody has with the word "panicked" — on the one /// failure whose whole value is the specific thing. #[test] fn a_panic_report_keeps_the_message_whichever_shape_it_arrived_in() { let borrowed: &dyn std::any::Any = &"slice index out of bounds"; let owned: &dyn std::any::Any = &String::from("attempt to multiply with overflow");
let from_str = super::panic_report(borrowed, Some(" at src/x.rs:12")); assert!(from_str.contains("slice index out of bounds"), "{from_str}"); assert!(from_str.contains("at src/x.rs:12"), "{from_str}");
let from_string = super::panic_report(owned, None); assert!( from_string.contains("attempt to multiply with overflow"), "{from_string}" ); }
/// It says it is a bug and names the command that reports it, because /// that is the only action available to whoever is reading it. #[test] fn a_panic_report_asks_for_a_report() { let payload: &dyn std::any::Any = &"boom"; let text = super::panic_report(payload, None); assert!(text.contains("this is a bug in atgc"), "{text}"); assert!(text.contains(env!("CARGO_PKG_VERSION")), "{text}"); // The command, not a URL: `atgc report bug` attaches the version, // the platform, git's version and the PDS host by itself, and it is // the same sentence `exit::bug` prints for an invariant that did not // hold — one event, one answer. assert!(text.contains("atgc report bug --title"), "{text}"); assert!(text.contains("boom"), "the payload has to reach it: {text}"); } use super::{Cli, Command}; use crate::cmd::auth::Command as AuthCommand; use crate::cmd::logs::Command as LogsCommand; use crate::cmd::pr::Command as PrCommand; use clap::{CommandFactory, Parser};
/// clap's own audit of the command tree: duplicate argument ids, /// two arguments claiming one short flag, a `conflicts_with` or /// `requires` naming an argument that does not exist. /// /// `debug_assert` walks every subcommand, which is the point: atgc's /// clap definitions live in ten-plus modules under `cmd/`, and none of /// them can see what the others registered. The `--account` collision /// that `auth_switch_and_logout_do_not_collide_with_the_global_account_flag` /// documents is exactly this class of bug, and it was found by hand, /// months after it shipped, by someone wondering where a global flag had /// gone from `--help`. /// /// Nothing else in the suite reaches most of the tree: parsing a line /// only builds the subcommands on it, so a malformed definition three /// commands away stays invisible until somebody types it. #[test] fn no_duplicate_ids_or_dangling_references_anywhere_in_the_command_tree() { Cli::command().debug_assert(); }
/// `browse --pr` keeps working bare and now takes a pull as well. /// /// The inconsistency this pins: `--pr` is a value flag on every `pr` /// verb (`pr view --pr <PULL>`) and used to be a boolean on `browse`, /// so one spelling had two arities and `browse` had no way to name a /// pull at all. Changing a flag's arity is a breaking change after /// 1.0, and an *optional* value is the shape that adds the missing /// half without taking the existing one away — so the bare form /// parsing is as much the assertion here as the valued one. #[test] fn browse_takes_a_pull_by_name_and_still_takes_none() { let bare = Cli::try_parse_from(["atgc", "browse", "--pr"]).expect("`--pr` bare must parse"); let Command::Browse(args) = bare.command else { panic!("not browse") }; assert_eq!(args.pr, Some(None), "`--pr` bare is the branch's pull");
let named = Cli::try_parse_from(["atgc", "browse", "--pr", "23"]).expect("`--pr 23` must parse"); let Command::Browse(args) = named.command else { panic!("not browse") }; assert_eq!(args.pr, Some(Some("23".to_string())));
let none = Cli::try_parse_from(["atgc", "browse"]).expect("no `--pr` must parse"); let Command::Browse(args) = none.command else { panic!("not browse") }; assert_eq!(args.pr, None, "no `--pr` is the repo page"); }
/// A pull and a section are still two different answers to one question. /// /// `--pr` taking a value makes it look like `browse --pr pulls` could be /// meant either way; `conflicts_with` settles it, and the refusal has to /// survive the arity change or the flag quietly swallows a section. #[test] fn browse_refuses_a_pull_and_a_section_at_once() { assert!( Cli::try_parse_from(["atgc", "browse", "pulls", "--pr"]).is_err(), "a section and --pr name two different pages" ); assert!( Cli::try_parse_from(["atgc", "browse", "pulls", "--pr", "23"]).is_err(), "a section and --pr name two different pages" ); }
/// docs/output.md promises `--json` on every command that reads or /// writes something, and the `auth` family was the standing exception: /// only `status` had it, so scripting a login-and-act sequence meant /// parsing sentences for a DID. Pinned as a parse, since the shapes /// themselves are checked where they are built. /// /// `login` is left out on purpose and asserted so. The reason used to be /// that it waits for a human, which is no longer true — a headless caller /// logs in by opening the URL itself, and is exactly a caller that would /// want JSON. The reason that survives is `jsonout`'s first rule: stdout /// carries *one* value. What such a caller needs is the authorization /// URL, and it needs it while the command is still waiting for the /// callback, which is before there is any result to put in an object /// beside it. So off a terminal it gets the whole of stdout as one line /// instead, and no second value is ever printed there. #[test] fn every_auth_verb_but_login_takes_json() { for verb in [ vec!["status"], vec!["default", "target.example.com"], vec!["refresh"], vec!["token"], vec!["logout"], ] { let line: Vec<&str> = [vec!["atgc", "auth"], verb.clone(), vec!["--json"]].concat(); Cli::try_parse_from(&line) .unwrap_or_else(|e| panic!("`atgc auth {} --json` must parse: {e}", verb[0])); } assert!( Cli::try_parse_from(["atgc", "auth", "login", "me.example.com", "--json"]).is_err(), "`auth login` needs a browser, so it takes no --json" ); }
/// `auth default` and `auth logout` used to name their positional field /// `account`, the same id clap derives for the global `--account` /// selector, so `--account` silently landed in the positional instead /// of choosing who to act as, and the flag disappeared from these two /// subcommands' `--help` entirely. Renamed to `owner`, as `repo list` /// and `key list` were for the same reason; this pins that both the /// global flag and the positional now parse, and independently. #[test] fn auth_default_and_logout_do_not_collide_with_the_global_account_flag() { let cli = Cli::try_parse_from([ "atgc", "--account", "global.example.com", "auth", "default", "target.example.com", ]) .expect("global --account and the default target coexist"); assert_eq!(cli.account.as_deref(), Some("global.example.com")); match cli.command { Command::Auth { command: AuthCommand::Default { owner, .. }, } => assert_eq!(owner, "target.example.com"), _ => panic!("parsed into something other than auth default"), }
// `auth logout --account <handle>` used to be a hard parse error // ("unexpected argument '--account' found"), because the id // collision left no `--account` flag registered on this subcommand // at all. It now parses as the global selector: with no positional, // `owner` stays `None` and `cmd::auth::logout` falls back to whichever // account `--account` selected, which is how a bare `--account // <handle>` reads as "log out this one." let cli = Cli::try_parse_from(["atgc", "auth", "logout", "--account", "target.example.com"]) .expect("`auth logout --account <handle>` must parse"); assert_eq!(cli.account.as_deref(), Some("target.example.com")); match cli.command { Command::Auth { command: AuthCommand::Logout { owner, .. }, } => assert_eq!(owner, None), _ => panic!("parsed into something other than auth logout"), }
// The positional still names a *different* account than the global // selector, proving the two are independent again rather than // sharing one clap argument id. let cli = Cli::try_parse_from([ "atgc", "--account", "global.example.com", "auth", "logout", "target.example.com", ]) .expect("global --account and a logout target coexist"); assert_eq!(cli.account.as_deref(), Some("global.example.com")); match cli.command { Command::Auth { command: AuthCommand::Logout { owner, .. }, } => assert_eq!(owner.as_deref(), Some("target.example.com")), _ => panic!("parsed into something other than auth logout"), } }
/// `auth log` is gone, not hidden. /// /// It was kept for one release as a hidden alias that printed the new /// spelling. That was the wrong trade for a command whose whole audience /// is people reading their own machine's logs: the alias cost a variant, /// a wrapper struct and a note on stderr, to save a reader from typing a /// word they can see in `atgc logs --help`. /// /// Asserted as a parse failure rather than by inspecting the command /// tree, because that is what somebody replaying an old shell line /// actually meets. What they meet is not self-explanatory: clap offers /// the two nearest words, `logout` and `login`, and neither is the /// answer, which is why `auth` carries a line of long help saying where /// the log went, and why this pins that the error still sends them /// there. #[test] fn auth_no_longer_answers_log() { let Err(err) = Cli::try_parse_from(["atgc", "auth", "log", "--incident"]) else { panic!("`auth log` is not a command any more"); }; let text = err.to_string(); assert!(text.contains("unrecognized subcommand"), "{text}"); assert!( text.contains("--help"), "the error has to point somewhere: {text}" );
let auth = Cli::command() .get_subcommands() .find(|c| c.get_name() == "auth") .expect("atgc has an auth command") .clone(); let after = auth .get_after_long_help() .expect("auth's long help says where the log went") .to_string(); assert!(after.contains("atgc logs oauth"), "{after}");
// The flags went with it: `--incident` is the OAuth log's and is // reachable only through `logs oauth`. let cli = Cli::try_parse_from(["atgc", "logs", "oauth", "--incident"]) .expect("`logs oauth` is the spelling"); match cli.command { Command::Logs { command: LogsCommand::Oauth { args }, } => assert!(args.incident), _ => panic!("parsed into something other than logs oauth"), } }
/// Your pull requests across every repo are `pr list --all`, and the /// scope rides on a flag rather than on a second verb. /// /// `list` and `status` were sibling words that carried nothing of the /// difference between them: one a repo's pulls, the other an account's /// across every repo, so a reader had to be told which was which. A flag /// named for the scope sits among the other filters and says it outright. /// /// `--author` composes with it rather than depending on it. Widening is /// still per author and not per repo — a pull record lives in the PDS of /// whoever wrote it, so "every repo" is answerable for one account and /// there is no listing of everyone's pulls everywhere. But *narrowing* a /// repo listing to one account is exactly a repo listing read from that /// account's PDS, which is what one has always been. #[test] fn your_pulls_across_every_repo_are_pr_list_all() { let cli = Cli::try_parse_from([ "atgc", "pr", "list", "--all", "--limit", "5", "--author", "bob.example.com", "--json", ]) .expect("`pr list --all` is the spelling"); match cli.command { Command::Pr { command: PrCommand::List(args), } => { assert!(args.all); assert_eq!(args.limit, 5); assert_eq!(args.author.as_deref(), Some("bob.example.com")); assert!(args.json); } _ => panic!("parsed into something other than pr list"), }
// Without `--all` it is this repo's pulls, and the flag defaults off. let cli = Cli::try_parse_from(["atgc", "pr", "list"]).expect("the bare listing"); match cli.command { Command::Pr { command: PrCommand::List(args), } => assert!(!args.all), _ => panic!("parsed into something other than pr list"), }
// And without `--all` it is this repo, narrowed to that account — // which used to be a usage error for want of an implementation // rather than for want of a meaning. let cli = Cli::try_parse_from(["atgc", "pr", "list", "--author", "bob.example.com"]) .expect("--author narrows this repo"); match cli.command { Command::Pr { command: PrCommand::List(args), } => { assert!(!args.all); assert_eq!(args.author.as_deref(), Some("bob.example.com")); } _ => panic!("parsed into something other than pr list"), }
// The verb that used to carry this is gone, both spellings of it. assert!(Cli::try_parse_from(["atgc", "status", "pr"]).is_err()); assert!(Cli::try_parse_from(["atgc", "pr", "status"]).is_err()); }
/// `pr view 23` used to be the one refusal in the pull-taking family: /// `diff`, `checkout`, `merge`, `edit` and `comment` all accept a /// positional pull, and symmetry is what people actually type. Hold it. #[test] fn pr_view_takes_a_pull_like_its_siblings() { let cli = Cli::try_parse_from(["atgc", "pr", "view", "23"]).expect("a pull positional"); match cli.command { Command::Pr { command: PrCommand::View(args), } => assert_eq!(args.pull(), Some("23")), _ => panic!("parsed into something other than pr view"), } // The flag spelling is kept everywhere for the callers that already // type it, but naming the pull twice is a contradiction, not a // preference. assert!( Cli::try_parse_from(["atgc", "pr", "view", "23", "--pr", "24"]).is_err(), "positional and --pr name one pull twice" ); }
/// `pr resubmit --pr 23` was the last verb in the family that would not /// take its pull positionally, so the one shape an agent types from /// habit, `atgc pr resubmit 23`, was the one shape that failed. The /// flag stays: scripts and this repo's own older instructions use it. #[test] fn pr_resubmit_takes_a_pull_like_its_siblings() { for argv in [ &["atgc", "pr", "resubmit", "23"][..], &["atgc", "pr", "resubmit", "--pr", "23"][..], ] { let cli = Cli::try_parse_from(argv).expect("both spellings name the pull"); match cli.command { Command::Pr { command: PrCommand::Resubmit(args), } => assert_eq!(args.pull(), Some("23"), "{argv:?}"), _ => panic!("parsed into something other than pr resubmit"), } } assert!( Cli::try_parse_from(["atgc", "pr", "resubmit", "23", "--pr", "24"]).is_err(), "positional and --pr name one pull twice" ); // Neither spelling is no longer clap's refusal to make: the command // parses, and `resubmit` answers with the explanation clap cannot. let cli = Cli::try_parse_from(["atgc", "pr", "resubmit"]).expect("parses with no pull"); match cli.command { Command::Pr { command: PrCommand::Resubmit(args), } => assert_eq!(args.pull(), None), _ => panic!("parsed into something other than pr resubmit"), } }
/// Every argument that names an account must be spelled `HANDLE|DID`. /// /// The rule is the tool's, not clap's: an account is named by a handle /// *or* by a DID everywhere in atgc — one classifier /// ([`crate::lexicon::identity::classify`]) decides which arrived, and it /// is the same one at every call site. A placeholder that says `HANDLE` /// therefore documents half of what the argument takes, which is how /// `auth login` came to be believed to reject a DID by its own error /// messages. Asserted over the whole command tree, by argument id, so /// that the next command to take an account cannot pick the other name. #[test] fn every_argument_that_names_an_account_says_so_the_same_way() { /// The ids account-naming arguments use. `account` is the global /// selector, `owner` the positional its id collides with, `author` /// the "whose records" filter on the listings. const NAMES_AN_ACCOUNT: [&str; 3] = ["account", "owner", "author"];
fn walk(cmd: &clap::Command, path: &str) { for arg in cmd.get_arguments() { if !NAMES_AN_ACCOUNT.contains(&arg.get_id().as_str()) { continue; } let name = arg .get_value_names() .and_then(|names| names.first()) .map(clap::builder::Str::as_str) .unwrap_or_else(|| panic!("`{path}` names an account with no placeholder")); assert_eq!( name, "HANDLE|DID", "`{path}` calls its account {name}, not HANDLE|DID" ); } for sub in cmd.get_subcommands() { walk(sub, &format!("{path} {}", sub.get_name())); } }
walk(&Cli::command(), "atgc"); }
/// Every `pr` verb that names a pull must call it `PULL`. /// /// Four of the nine printed `[PULL]` and five printed `[PR]`, for one /// argument taking one set of four spellings. That split lasted because /// no single `--help` shows both halves of it: you have to run two /// commands and remember the first, and it grew rather than shrank: /// `pr resubmit` joined the `[PR]` side the moment it gained a /// positional. Asserted over the command tree rather than per verb so /// the tenth one cannot quietly pick the other name. #[test] fn every_pr_verb_names_its_pull_the_same_way() { let cli = Cli::command(); let pr = cli .get_subcommands() .find(|c| c.get_name() == "pr") .expect("`atgc pr` is in the tree"); for verb in [ "view", "diff", "checkout", "comment", "merge", "resubmit", "close", "reopen", "edit", ] { let cmd = pr .get_subcommands() .find(|c| c.get_name() == verb) .unwrap_or_else(|| panic!("`atgc pr {verb}` is in the tree")); // The positional and the `--pr` flag are two spellings of the // same argument, so both placeholders are checked: `--pr <PR>` // beside a `[PULL]` would be the same split one line down. let mut checked = 0; for arg in cmd .get_arguments() .filter(|a| a.is_positional() || a.get_long() == Some("pr")) { let name = arg .get_value_names() .and_then(|names| names.first()) .map(clap::builder::Str::as_str) .unwrap_or_else(|| panic!("`pr {verb}` names its pull with a placeholder")); assert_eq!(name, "PULL", "`pr {verb}` calls its pull {name}"); checked += 1; } assert_eq!(checked, 2, "`pr {verb}` takes its pull both ways"); } }
/// `--branch` and `--worktree` both say something about the branch /// `pr checkout` creates, and the codebase's habit with two flags that /// overlap is to have clap refuse the pair outright (`browse --pr` /// against its section, `pr view --pr` against its positional). These are /// the other case and must not be declared that way: they name different /// halves of one act: what the branch is called, and which directory it /// is checked out in, so `--worktree ../review --branch theirs` is an /// ordinary thing to type, not a contradiction. Nothing gives precedence /// to either at runtime, and this is what stops someone adding a /// `conflicts_with` for symmetry's sake. #[test] fn pr_checkout_takes_a_branch_name_and_a_worktree_together() { let cli = Cli::try_parse_from([ "atgc", "pr", "checkout", "23", "--worktree", "../review-23", "--branch", "theirs", ]) .expect("--worktree and --branch name different things"); match cli.command { Command::Pr { command: PrCommand::Checkout(args), } => { assert_eq!(args.pull(), Some("23")); assert_eq!(args.worktree.as_deref(), Some("../review-23")); assert_eq!(args.branch.as_deref(), Some("theirs")); } _ => panic!("parsed into something other than pr checkout"), } // The flag takes a path; it is not a bare switch onto some directory // atgc would pick, because where a worktree lands is the caller's // decision and a wrong guess is a directory in the wrong place. assert!( Cli::try_parse_from(["atgc", "pr", "checkout", "23", "--worktree"]).is_err(), "--worktree needs the path to create" ); }
/// The kind rides in front like a subcommand (`report bug`), because /// that is what the board's tag *is*, but it must stay an optional /// positional, which kinds exist is the board's data, not this enum's, /// and a board with no kinds takes a bare `report --title`. #[test] fn report_takes_its_kind_as_an_optional_positional() { let cli = Cli::try_parse_from(["atgc", "report", "bug", "--title", "it broke"]) .expect("kind then flags"); match cli.command { Command::Report(args) => { assert_eq!(args.kind.as_deref(), Some("bug")); assert_eq!(args.title, "it broke"); } _ => panic!("parsed into something other than report"), } assert!( Cli::try_parse_from(["atgc", "report", "--title", "t"]).is_ok(), "a board without kinds takes a kindless report" ); assert!( Cli::try_parse_from(["atgc", "report", "bug"]).is_err(), "a title is not optional" ); assert!( Cli::try_parse_from([ "atgc", "report", "bug", "--title", "t", "--body", "b", "--body-file", "-" ]) .is_err(), "one body, not two" ); }}