//! One module per command family. //! //! A file per family, and a folder once one outgrows the file: roughly two //! thousand lines *and* a seam. Size says look; a seam says split. Splitting //! by verb is not a seam: `pr comment` does not get its own file for being a //! different word. //! //! The seams that exist, and what each one is: //! //! - [`mod@pr`]: reading pulls (public, no session), writing them (records //! in your own PDS), and reviewing somebody else's (a patch, not a report). //! - [`mod@issue`]: the same read/write line, drawn from the start rather //! than arrived at: an issue is a record in its reporter's PDS with its //! state in a separate log, which is a pull request's shape minus the //! patches. //! - [`mod@stack`]: read and write, on `pr`'s terms. //! - [`mod@logs`]: one reader per log, over a shared renderer. These read; //! the writers are [`crate::logging`]. //! //! [`mod@api`] is the one that is not a family and never will be: a single //! verb over every method in the lexicon, including the ones the modules above //! exist to spell properly. //! //! [`mod@images`] is the other exception, and is not a command at all. Local //! images in a markdown body are a property of the two *records* that carry //! a body — `sh.tangled.repo.pull` and `sh.tangled.repo.issue` — rather than //! of either family, so it sits beside them instead of inside one. It began //! in `pr/` and was reached as `crate::cmd::pr::images` from `issue/` for //! exactly one commit, which is the sideways reach rule 2 in //! [Module layout](crate::docs::module_layout) exists to stop. //! //! Everything a command needs from the outside world it asks a client for. //! Nothing here builds an HTTP request or a git `Command` of its own: see //! [`crate::clients`] and [Module layout](crate::docs::module_layout). pub(crate) mod about; pub(crate) mod agent; pub(crate) mod api; pub(crate) mod auth; pub(crate) mod browse; pub(crate) mod completion; pub(crate) mod doctor; pub(crate) mod images; pub(crate) mod issue; pub(crate) mod key; pub(in crate::cmd) mod listing; pub(crate) mod logs; pub(crate) mod pr; pub(crate) mod repo; pub(crate) mod report; pub(crate) mod search; pub(crate) mod stack; // The site builder. `test` as well as the feature so `cargo test` still // covers it — see the `[features]` note in Cargo.toml. #[cfg(any(feature = "www", test))] pub(crate) mod www; use anyhow::{Result, bail}; // --------------------------------------------------------------------------- // The discussion on a pull or an issue // --------------------------------------------------------------------------- /// One comment, as `--json` prints it. /// /// The same shape whichever `view` produced it, because a comment on a pull /// and a comment on an issue are the same `sh.tangled.feed.comment` record /// replying to a different subject, and a caller reading both should not /// have to learn two spellings. #[derive(serde::Serialize, Debug, PartialEq)] pub(crate) struct CommentJson { pub uri: String, pub author_did: String, /// Without the leading `@`, as everywhere else in `--json`. pub author_handle: Option, /// RFC 3339 as the record spells it, which is the commenter's own offset /// and not necessarily UTC. pub created_at: String, pub body: String, } /// What a `view` knows about the discussion it was asked for. /// /// Three states, not two, and the difference is the whole reason this is not /// a `Vec`: nobody asked, the index was asked and answered, and the index /// was asked and could not be reached. Flattening the first two would make a /// thread nobody read look like one with nothing in it. pub(crate) enum Thread { /// `--comments` was not passed, or was passed with no index opted in. NotAsked { wanted: bool, }, Read(Vec), Failed(anyhow::Error), } /// Read the discussion on `subject_uri`, if it was asked for and can be. /// /// A failure here is never fatal. The comments are an enrichment of a view /// that was already complete without them, and an appview outage should not /// take down `issue view`; it is reported where it happened and the rest of /// the answer still prints. pub(crate) async fn read_thread( wanted: bool, source: pr::read::Source, subject_uri: &str, ) -> Thread { if !wanted || !source.uses_bobbin() { return Thread::NotAsked { wanted }; } match crate::clients::tangled::comments::of(subject_uri, THREAD_LIMIT).await { Ok(comments) => Thread::Read(comments), Err(e) => Thread::Failed(e), } } /// How many comments a `view` asks the index for. /// /// A ceiling rather than paging: a thread past this is not a thread anybody /// reads in a terminal, and a `--cursor` on a display command would be a /// knob with a scroll bar behind it. const THREAD_LIMIT: u32 = 100; /// The handles for a thread, resolved per unique commenter. /// /// A thread is where the shared memo pays best: the view above it has /// usually just resolved the author, and a conversation is a handful of /// people saying several things each. async fn thread_handles( comments: &[crate::clients::tangled::comments::Comment], ) -> std::collections::HashMap { crate::clients::atproto::handles::handles(comments.iter().map(|c| c.author_did.clone())).await } /// The thread as `--json` carries it, or `None` when nobody asked. pub(crate) async fn thread_json(thread: &Thread) -> Option> { let comments = match thread { Thread::Read(comments) => comments, // A failed read is `null` rather than `[]` for the same reason an // unasked one is: `[]` claims the index answered and had nothing. // The failure itself is on stderr. Thread::NotAsked { .. } | Thread::Failed(_) => return None, }; let handles = thread_handles(comments).await; Some( comments .iter() .map(|c| CommentJson { uri: c.uri.clone(), author_handle: handles.get(&c.author_did).cloned(), author_did: c.author_did.clone(), created_at: c.created_at.clone(), body: c.body.clone(), }) .collect(), ) } /// Print the thread under a view's body. pub(crate) async fn print_thread(thread: &Thread) { let comments = match thread { Thread::Read(comments) => comments, // Asked for, with no index to ask. Worth saying: the alternative is // a `--comments` that silently shows none, which reads as "there are // none" — the failure this tree opts out of everywhere else. Thread::NotAsked { wanted: true } => { crate::term::say::note!( Index, "a discussion can only come from the appview index, and none is opted in: \n\ add --source bobbin (alpha, its ingest stalls)" ); return; } Thread::NotAsked { wanted: false } => return, Thread::Failed(e) => { crate::term::say::warning!(Index, "could not read the discussion: {e:#}"); return; } }; if comments.is_empty() { crate::term::say::note!(Index, "no comments on this one, as far as the index knows"); return; } let handles = thread_handles(comments).await; for c in comments { let who = crate::term::hyperlink::account( handles.get(&c.author_did).map(String::as_str), &c.author_did, ); println!(); println!("--- {who} {}", crate::term::column::day(&c.created_at)); // The body as written, minus what a terminal would act on rather // than draw (`term::text`). It is markdown, and rendering it would // mean choosing a renderer for somebody else's prose; `pr diff` makes // the same call about a patch, and does not filter one either, // because a patch is bytes for `git am` rather than a line for a // person. `thread_json` above carries the body the record holds. println!("{}", crate::term::text::clean(&c.body)); } crate::term::say::note!( Index, "{} comment(s), as far as Bobbin has indexed. Its ingest stalls, so a recent \n\ one can be missing.", comments.len() ); } // --------------------------------------------------------------------------- // Publishing the branch a record claims // --------------------------------------------------------------------------- /// Put `branch` on `remote` at `head`, without overwriting anything the /// caller did not predict. /// /// Every branch-based pull record — a flat one's `source: {branch}`, and /// every member of a stack — is a claim that the knot has this branch. The /// claim is made true by pushing, which is why `pr create` and `stack /// create` push before they write anything. A *round* has to keep it true, /// and a round is a rewritten branch by definition, so the push it needs is /// one that can move a diverged ref. /// /// `expected` is where the remote should be if nothing but this checkout has /// touched it: read off the last round's own patch by /// [`crate::clients::git::patch::head_sha`], not off a local remote-tracking /// ref, which a fetch can silently advance onto the very commits worth /// protecting. Three answers come out of comparing it to what `remote` /// actually holds: /// /// * the remote is already at `head` — nothing to do, and this is the /// ordinary case for anyone who pushed by hand a moment ago; /// * the remote is where the record says it should be — push, leased against /// that sha so a write racing this one loses rather than gets clobbered; /// * the remote is somewhere else entirely — somebody or something else /// moved it, so nothing is sent and the refusal names both shas. /// /// With no `expected` (a record whose patch names no head, or a create, /// which has no prior round) the push is unleased and therefore /// fast-forward-only: exactly what this did before leases existed, failing /// on a diverged branch rather than deciding for the person. /// /// The comparison is a courtesy and the lease is the guarantee, which is why /// a remote that cannot be reached for the *question* still gets the lease /// on the push: the server checks it there, atomically, and a rejection is /// the same refusal arriving one round trip later. /// /// Returns whether it pushed. `advice` is the caller's line about what to do /// when the branch cannot be published at all — the two callers have /// different ones, and only they know which. /// The scope string of the session this process would actually act with. /// /// The one answer the scope pre-flight in /// [`crate::clients::tangled::scope`] needs and cannot work out for itself: /// it lives under `clients/`, which may not read `config::account`, and an /// account can hold a person's login and an agent's at once. Resolved here, /// on this process's plane, so that a command is checked against the grant it /// is about to sign with rather than whichever session happened to expire /// latest. /// /// `None` means the store recorded no scope string for that grant, which the /// pre-flight reads as "allowed to try" rather than "carries nothing". pub(in crate::cmd) fn acting_scope(did: &str) -> Option { crate::config::account::session_for(did, crate::config::account::Plane::current()) .and_then(|s| s.scope) } pub(in crate::cmd) fn publish_branch( remote: &str, branch: &str, head: &str, expected: Option<&str>, advice: &str, ) -> Result { // Asked of the URL the push will use, which a `pushInsteadOf` rewrite // can make a different machine entirely from the one `remote` fetches // from: looking at the fetch side would be a question about another // server's copy of the ref this is about to move. let target = crate::clients::git::run::push_url(remote).unwrap_or_else(|| remote.to_string()); // The outer `None` is "could not ask" — no network, a URL git cannot // reach — which is not an answer about the branch. The inner one is the // branch itself. let observed = crate::clients::git::run::remote_head(&target, branch); crate::logging::debug::log(format!( "publish {branch}: local {head}, {target} {}, record expects {}", match &observed { Some(Some(sha)) => sha.as_str(), Some(None) => "(no such branch)", None => "(could not ask)", }, expected.unwrap_or("(nothing)"), )); if observed.as_ref().and_then(Option::as_deref) == Some(head) { crate::term::say::step!( Git, "{remote} already has {branch} at {}; nothing to push", short(head) ); return Ok(false); } if let (Some(Some(observed)), Some(expected)) = (&observed, expected) && observed != expected { bail!( "{remote} has {branch} at {}, and the last round of this pull was cut from \ {}\n\ something else moved the branch — another checkout, or a resubmit from \ tangled.org — so nothing has been sent\n\ `git fetch {remote} {branch}` and look at what is there; publishing over it \ is `git push --force {remote} {branch}` once you are sure", short(observed), short(expected), ); } crate::term::say::step!(Git, "pushing {branch} to {remote}..."); // A lease is only meaningful against a ref that is there: leasing a sha // at a branch the remote is *known* not to have refuses the push that // would create it. "Could not ask" is not that knowledge, and keeps the // lease — an expectation that cannot be checked locally is exactly the // one worth sending to the server. let lease = expected.filter(|_| observed != Some(None)); crate::clients::git::run::push(remote, branch, lease).map_err(|e| { anyhow::anyhow!( "{e}\n\ {advice}\n\ A `stale info` rejection above is the lease: {remote} has {branch} somewhere \n\ other than the {} this pull's last round recorded. Look before forcing.\n\ If the push should have worked, `atgc key add` is what registers an SSH key \ with a knot.", expected.map(short).unwrap_or("(nothing recorded)"), ) })?; Ok(true) } /// A sha at the width every listing prints them. fn short(sha: &str) -> &str { &sha[..7.min(sha.len())] } #[cfg(test)] mod tests { use super::publish_branch; use crate::clients::git::run::{add_remote, remote_head}; use crate::testutil::TempRepo; use std::path::Path; /// A bare repo beside the checkout, added as `origin`: a real remote to /// push at, with no network in sight. fn with_remote(repo: &TempRepo) -> String { let bare = std::env::current_dir() .expect("inside the temp repo") .join("remote.git"); repo.git(&["init", "-q", "--bare", &bare.to_string_lossy()]); add_remote(Path::new("."), "origin", &bare.to_string_lossy()).unwrap(); repo.git(&["rev-parse", "HEAD"]).trim().to_string() } /// The whole point of the lease. A round rewrites its branch, so the /// push has to move a ref that has diverged — but only past the commit /// the last round recorded. Anything else on that ref is somebody /// else's work, and the refusal happens before the push, not after it. #[test] fn a_lease_moves_a_rewritten_branch_and_stops_at_an_unexpected_one() { let repo = TempRepo::new("publish-lease"); repo.commit("a.txt", "one\n", "Add a"); repo.git(&["checkout", "-q", "-b", "feature"]); repo.commit("b.txt", "two\n", "Add b"); let published = with_remote(&repo); assert!( publish_branch("origin", "feature", &published, None, "advice").unwrap(), "the first publish has nothing to lease against and pushes" ); // Rerunning against a remote already holding this head sends // nothing: the ordinary state of anyone who pushed by hand first. assert!( !publish_branch("origin", "feature", &published, Some(&published), "advice").unwrap(), "nothing to do when the remote is already at this head" ); // The rewrite a round is: same commit, new sha. repo.git(&["commit", "-q", "--amend", "-m", "Add b, better"]); let rewritten = repo.git(&["rev-parse", "HEAD"]).trim().to_string(); assert!( publish_branch("origin", "feature", &rewritten, Some(&published), "advice").unwrap(), "the lease matches what the remote holds, so the rewrite lands" ); assert_eq!( remote_head("origin", "feature").flatten().as_deref(), Some(rewritten.as_str()) ); // Somebody else's commit on the branch, and a round that still // expects the sha it published two rewrites ago. repo.commit("c.txt", "three\n", "Add c"); let theirs = repo.git(&["rev-parse", "HEAD"]).trim().to_string(); repo.git(&["push", "-q", "origin", "feature"]); repo.git(&["reset", "-q", "--hard", &rewritten]); repo.git(&["commit", "-q", "--allow-empty", "-m", "Mine"]); let mine = repo.git(&["rev-parse", "HEAD"]).trim().to_string(); let err = publish_branch("origin", "feature", &mine, Some(&published), "advice") .unwrap_err() .to_string(); assert!(err.contains(&theirs[..7]), "names what is there: {err}"); assert!( err.contains(&published[..7]), "names what was expected: {err}" ); assert_eq!( remote_head("origin", "feature").flatten().as_deref(), Some(theirs.as_str()), "nothing was sent" ); } /// With no expectation the push is fast-forward-only, which is what /// `create` wants: a branch nothing has recorded yet is not one to /// overwrite on the strength of having been asked to publish it. #[test] fn no_lease_means_no_force() { let repo = TempRepo::new("publish-unleased"); repo.commit("a.txt", "one\n", "Add a"); repo.git(&["checkout", "-q", "-b", "feature"]); repo.commit("b.txt", "two\n", "Add b"); with_remote(&repo); let head = repo.git(&["rev-parse", "HEAD"]).trim().to_string(); publish_branch("origin", "feature", &head, None, "advice").unwrap(); repo.git(&["commit", "-q", "--amend", "-m", "Diverged"]); let head = repo.git(&["rev-parse", "HEAD"]).trim().to_string(); let err = publish_branch("origin", "feature", &head, None, "no push access, perhaps") .unwrap_err() .to_string(); assert!(err.contains("no push access, perhaps"), "got: {err}"); } }