Something went wrong. Try again.
atproto git client
Something went wrong. Try again.
Rust
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437//! 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<String>, /// 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<crate::clients::tangled::comments::Comment>), 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<String, String> { 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<Vec<CommentJson>> { 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<String> { 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<bool> { // 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}"); }}