//! Reading a stack: `stack view`. //! //! The read half of [`crate::cmd::stack`], on the same terms as //! [`crate::cmd::pr::read`]: every request is public, no session is needed, and //! the listing it walks is the merged PDS-plus-Bobbin one, so a stack is //! visible the moment its records are written. use anyhow::{Result, bail}; use super::{Chain, chain_containing}; use crate::clients::git::run as git; use crate::clients::tangled::resolve; use crate::clients::tangled::web::pulls; use crate::cmd::pr::read::{Source, repo_rows}; #[derive(clap::Args, Debug)] pub(crate) struct ViewArgs { /// Git remote pointing at the repo #[arg(long, default_value = "origin")] pub remote: String, /// Where to read from: pds (default), bobbin, web, auto for all three, /// or a comma-separated combination. Both indexes are opt-in; /// ATGC_USE_BOBBIN=1 and ATGC_USE_WEB=1 add one to the default #[arg(long)] pub source: Option, /// Also open the stack's top pull in the browser #[arg(long)] pub web: bool, /// Print one JSON object for the chain instead of a table (see /// --help for the shape) #[arg(long)] pub json: bool, } /// One member of `stack view --json`. /// /// Richer than [`crate::cmd::pr::read::StackMemberJson`], which is the same chain /// seen from beside a single pull: there the chain is a cross-reference, and /// four fields answer "what else is in this stack"; here the chain *is* the /// command, and the identifiers it prints — the record key a `pr` command /// takes, the appview number, how many rounds each member has had — are the /// answer rather than a pointer to it. Two shapes because they are two /// questions, and widening the smaller one would mean `pr view` sweeping the /// appview once per chain member for a field nobody asked it for. #[derive(serde::Serialize, Debug, PartialEq)] pub(crate) struct MemberJson { /// 1-based, counted from the *bottom* of the stack, matching the /// `position/total` the text view prints — while the array itself is /// ordered top first, as both that view and `pr view --json` list them. pub position: usize, pub uri: String, /// The record key, which is what `pr view`, `pr diff` and `pr checkout` /// take when the appview has no number for a member yet. Always present: /// it is in the at-uri and needs no lookup, which is exactly why the /// text view falls back to it. pub rkey: String, /// The appview's `/pulls/`, `null` when the sweep could not place /// this member. pub number: Option, pub state: Option, pub title: String, pub rounds: usize, /// How many commits this member's latest round carries. `null` when its /// patch could not be read — a listing with a gap in it beats a listing /// that failed. pub commits: Option, } /// `stack view --json`'s whole object. #[derive(serde::Serialize, Debug, PartialEq)] pub(crate) struct StackViewJson { /// The checked-out branch the chain was found from. Every member shares /// it — a stack is one branch's commits — so it is a property of the /// stack rather than of any member. pub branch: String, /// The branch the bottom of the stack targets, `null` for a record that /// names none rather than the `?` the header prints. pub target: Option, pub total: usize, /// Top first: the pull nothing depends on comes first, the same order /// the text view prints and `pr view --json` uses. pub members: Vec, /// A `dependentOn` the listing did not contain — the chain continues /// below what is shown. See [`crate::cmd::stack::Chain::missing_below`]. pub missing_below: Option, /// The top pull's page, which is where Tangled shows the stack, and /// `null` when the appview has no number for it yet — the same rule /// `pr view --json`'s `url` follows, and for the same reason: the repo's /// `/pulls` listing is not the stack. pub url: Option, } /// Build a [`StackViewJson`] from what `view` has already resolved. /// /// Pure — no I/O, no formatting left to decide — so a test can hold a whole /// chain still without a network, the same way [`crate::cmd::pr::read`]'s row /// builders are shaped. `chain` is bottom first, as [`Chain`] always is; the /// `members` array comes out top first. fn stack_view_json( branch: &str, chain: &Chain<'_>, rows: &[crate::cmd::pr::read::StackRow], numbers: &std::collections::HashMap, carried: &std::collections::HashMap>, url: Option<&str>, ) -> StackViewJson { let total = chain.members.len(); let members = chain .members .iter() .enumerate() .rev() .map(|(i, member)| { let uri = member["uri"].as_str().unwrap_or_default(); MemberJson { position: i + 1, uri: uri.to_string(), rkey: uri.rsplit('/').next().unwrap_or_default().to_string(), number: numbers.get(uri).copied(), state: crate::cmd::pr::read::known_state(&crate::cmd::stack::state_of(rows, uri)), title: member["value"]["title"] .as_str() .unwrap_or("(untitled)") .to_string(), rounds: crate::model::pull::round_count(&member["value"]), commits: carried.get(uri).copied().flatten(), } }) .collect(); StackViewJson { branch: branch.to_string(), target: crate::model::pull::target_branch_of(&chain.top()["value"]).map(str::to_string), total, members, missing_below: chain.missing_below.clone(), url: url.map(str::to_string), } } /// The current branch's stack, top to bottom. /// /// Bails — with a pointer, not an apology — when the branch's pull is not /// stacked, because `atgc pr view` is the single-pull command and two /// commands that answer the same question differently is how the wrong one /// gets scripted. pub(crate) async fn view(args: ViewArgs) -> Result<()> { crate::term::jsonout::init(args.json); let source = Source::parse_arg(args.source.as_deref())?; let branch = git::current_branch()?; let remote_url = git::remote_url(&args.remote)?; let repo = resolve::repo_ref(&remote_url).await?; let listing = repo_rows(source, &repo.did).await?; if !listing.complete() { crate::term::say::warning!( Pds, "the pull listing hit its page cap, so this chain may be missing \ members; the write commands will refuse until it fits" ); } let rows = listing.rows; let items: Vec<&serde_json::Value> = rows.iter().map(|r| &r.item).collect(); let closed = crate::cmd::stack::closed_uris(&rows); let Some(mine) = crate::cmd::stack::entry_for_branch(&items, &branch, &closed) else { // The same answer `pr view` gives a branch with no pull: an // identifier that resolved to nothing. return Err(crate::exit::fail( crate::exit::Exit::NotFound, format!( "no pull request found for branch {branch}\n\ open one with `atgc pr create`, or, when its commits are separate \ changes that must land in order, `atgc stack create`" ), )); }; let uri = mine["uri"].as_str().unwrap_or_default(); let Some(chain) = chain_containing(&items, uri, &closed)? else { // The pull exists and was found; `stack view` is the wrong command // for it, and the message names the right one. return Err(crate::exit::fail( crate::exit::Exit::Usage, format!( "branch {branch}'s pull request is not stacked\n\ `atgc pr view` is the command for a single pull{}", crate::cmd::stack::unstacked_caveat(source) ), )); }; let state_of = |member: &serde_json::Value| -> &str { let member_uri = member["uri"].as_str().unwrap_or_default(); rows.iter() .find(|r| r.item["uri"].as_str() == Some(member_uri)) .map(|r| r.state.as_str()) .unwrap_or("?") }; // One sweep of the appview's listings numbers the whole chain; a member // it cannot settle keeps its rkey, which is the identifier that always // exists. let pairs: Vec<(&str, &str)> = chain .members .iter() .map(|m| { ( m["uri"].as_str().unwrap_or_default(), m["value"]["title"].as_str().unwrap_or(""), ) }) .collect(); let numbers = pulls::numbers_for_records(&repo.web_url, &pairs).await; // Resolved before either rendering, since both end on it: the top pull's // page is where Tangled shows the stack. let top_uri = chain.top()["uri"].as_str().unwrap_or_default(); let top_title = chain.top()["value"]["title"].as_str().unwrap_or(""); let link = pulls::pull_url(&repo.web_url, top_uri, top_title).await; if args.json { let carried = commits_per_member(&chain).await; crate::term::jsonout::emit(&stack_view_json( &branch, &chain, &rows, &numbers, &carried, link.numbered_url(), ))?; if let Some(note) = link.note() { crate::term::say::note!(Index, "{note}"); } if args.web { crate::term::noinput::open_in_browser(link.url()); } return Ok(()); } let carried = commits_per_member(&chain).await; let total = chain.members.len(); let target = crate::model::pull::target_branch_of(&chain.top()["value"]).unwrap_or("?"); // `size`, not `total`: the loop below numbers the rows it actually has, // but the header is a claim about the stack, and a chain that dangles // below has more of it than the listing showed. println!("stack of {} on branch {branch} -> {target}", chain.size()); for (i, member) in chain.members.iter().enumerate().rev() { let position = i + 1; let place = match position { p if p == total => "(top) ", 1 => "(bottom)", _ => " ", }; let member_uri = member["uri"].as_str().unwrap_or("?"); let number = numbers .get(member_uri) .map(|n| format!("#{n}")) .unwrap_or_else(|| "-".to_string()); let rkey = member_uri.rsplit('/').next().unwrap_or("?"); let title = crate::term::text::one_line(member["value"]["title"].as_str().unwrap_or("(untitled)")); let rounds = crate::model::pull::round_count(&member["value"]); // How many commits this member carries, which is the one thing a // stack of runs has that a stack of commits did not — and the thing // a reviewer is deciding from. `?` where the patch could not be // read: a listing that fails because one blob is missing is worse // than a listing with a gap in it. let held = match carried.get(member_uri) { Some(Some(1)) => String::new(), Some(Some(n)) => format!("{n} commits, "), Some(None) => "? commits, ".to_string(), None => String::new(), }; println!( " {position}/{total} {place} {} {} {title} ({rkey}, {held}{rounds} round{})", crate::term::column::pad_to(state_of(member), 7), crate::term::column::pad_start(&number, 4), if rounds == 1 { "" } else { "s" }, ); } if let Some(missing) = &chain.missing_below { println!( " ...continues below {missing}, which this listing does not contain \ (Bobbin lag, a listing cap, or a record deleted from under the chain)" ); } println!(); println!("view: {}", crate::term::hyperlink::url(link.url())); if let Some(note) = link.note() { crate::term::say::note!(Index, "{note}"); } if args.web { crate::term::noinput::open_in_browser(link.url()); } Ok(()) } #[cfg(test)] mod tests { use super::{Chain, stack_view_json}; use crate::cmd::pr::read::StackRow; use serde_json::{Value, json}; use std::collections::HashMap; fn member(rkey: &str, title: &str, rounds: usize) -> Value { json!({ "uri": format!("at://did:plc:me/sh.tangled.repo.pull/{rkey}"), "value": { "title": title, "target": {"branch": "main"}, "rounds": vec![json!({"createdAt": "2026-08-09T00:00:00Z"}); rounds], }, }) } fn rows(states: &[(&str, &str)]) -> Vec { states .iter() .map(|(rkey, state)| StackRow { item: json!({"uri": format!("at://did:plc:me/sh.tangled.repo.pull/{rkey}")}), state: (*state).to_string(), }) .collect() } /// The array is top first while `position` counts from the bottom — /// the same pair the text view prints, and the one thing about this /// shape a caller can get backwards. #[test] fn members_are_listed_top_first_and_numbered_from_the_bottom() { let (bottom, top) = (member("3aaa", "bottom", 1), member("3bbb", "top", 2)); let chain = Chain { members: vec![&bottom, &top], missing_below: None, }; let numbers = HashMap::from([( "at://did:plc:me/sh.tangled.repo.pull/3bbb".to_string(), 12u32, )]); // One member's commit count is known and the other's is not, which // is the state a patch that could not be read leaves behind: `null` // in the object rather than a listing that failed. let carried = HashMap::from([( "at://did:plc:me/sh.tangled.repo.pull/3bbb".to_string(), Some(3usize), )]); let view = stack_view_json( "claude/feature", &chain, &rows(&[("3aaa", "merged"), ("3bbb", "open")]), &numbers, &carried, Some("https://tangled.org/x/y/pulls/12"), ); assert_eq!( view.members[0].commits, Some(3), "the top member holds three" ); assert_eq!(view.members[1].commits, None, "an unread patch is null"); assert_eq!(view.total, 2); assert_eq!(view.branch, "claude/feature"); assert_eq!(view.target.as_deref(), Some("main")); assert_eq!(view.members[0].title, "top"); assert_eq!(view.members[0].position, 2); assert_eq!(view.members[0].rkey, "3bbb"); assert_eq!(view.members[0].number, Some(12)); assert_eq!(view.members[0].rounds, 2); assert_eq!(view.members[1].title, "bottom"); assert_eq!(view.members[1].position, 1); // Not swept by the appview: `null`, while the rkey beside it is the // identifier that always exists. assert_eq!(view.members[1].number, None); } /// A member no listing row answers for reads `null`, not the `?` the /// text column prints; a chain the listing could not see the bottom of /// says so rather than pretending it starts where the rows do. #[test] fn unknown_state_is_null_and_a_short_chain_names_what_is_missing() { let only = member("3aaa", "orphaned middle", 1); let chain = Chain { members: vec![&only], missing_below: Some("at://did:plc:me/sh.tangled.repo.pull/3gone".to_string()), }; let view = stack_view_json( "claude/feature", &chain, &rows(&[]), &HashMap::new(), &HashMap::new(), None, ); assert_eq!(view.members[0].state, None); assert_eq!( view.missing_below.as_deref(), Some("at://did:plc:me/sh.tangled.repo.pull/3gone") ); // A top the appview cannot number leaves `url` null rather than // publishing the repo's listing as if it were the stack's page. assert_eq!(view.url, None); } /// The exact key set a `jq` pipeline sees, pinned the way the `pr` /// builders' shapes are. #[test] fn serializes_with_the_documented_field_names() { let only = member("3aaa", "one", 1); let chain = Chain { members: vec![&only], missing_below: None, }; let value = serde_json::to_value(stack_view_json( "b", &chain, &rows(&[("3aaa", "open")]), &HashMap::new(), &HashMap::new(), Some("https://x/pulls/1"), )) .unwrap(); let keys = |v: &Value| { let mut k: Vec = v.as_object().unwrap().keys().cloned().collect(); k.sort_unstable(); k }; assert_eq!( keys(&value), [ "branch", "members", "missing_below", "target", "total", "url" ] ); assert_eq!( keys(&value["members"][0]), [ "commits", "number", "position", "rkey", "rounds", "state", "title", "uri" ] ); } } #[derive(clap::Args, Debug)] pub(crate) struct MarkArgs { /// Where the mark goes: any revision inside the range, e.g. HEAD~3. /// A name may be given first: `atgc stack mark part1 HEAD~3` pub first: Option, /// Where it goes, when a name was given pub rev: Option, /// Forget this mark, leaving its branch alone #[arg(long, value_name = "NAME", conflicts_with_all = ["first", "rev"])] pub forget: Option, /// Git remote pointing at the repo #[arg(long, default_value = "origin")] pub remote: String, /// Target branch on the destination repo (defaults to the remote's default branch) #[arg(long)] pub target: Option, /// Print one JSON object instead of the summary lines #[arg(long)] pub json: bool, } /// One mark, as `stack mark --json` reports it. #[derive(serde::Serialize, Debug, PartialEq)] pub(in crate::cmd) struct MarkJson { pub name: String, /// Where the branch points, or `null` when the branch is gone. pub sha: Option, /// Its 1-based position from the bottom of the range, or `null` when it /// sits outside the range — a mark left behind by a rebase that did not /// carry it, and the one thing this listing exists to make visible. pub position: Option, pub subject: Option, } #[derive(serde::Serialize, Debug, PartialEq)] pub(in crate::cmd) struct MarksJson { pub branch: String, pub base: String, pub commits: usize, /// Bottom first, in the order they cut the range. pub marks: Vec, } /// How many commits each member carries, by at-uri. /// /// Read off the latest round's patch, which is the only place it is written: /// a pull record says nothing about how many commits its patch holds. That /// is one blob per member, so it is done at the same bounded concurrency /// `stack resubmit` reads members at, and a member whose patch cannot be /// read comes back `None` rather than failing the listing. async fn commits_per_member(chain: &Chain<'_>) -> std::collections::HashMap> { use futures_util::stream::{self, StreamExt}; let reads = chain.members.iter().map(|member| async move { let uri = member["uri"].as_str().unwrap_or_default().to_string(); let did = crate::model::record::authority_of(&uri) .unwrap_or_default() .to_string(); let Some(pds) = crate::clients::atproto::did::pds_from_did_doc(&did).await else { return (uri, None); }; let held = super::write::latest_round_patch(&pds, &did, &member["value"], &uri) .await .ok() .map(|patch| { crate::clients::git::patch::message_offsets(&patch) .len() .max(1) }); (uri, held) }); stream::iter(reads).buffered(4).collect().await } /// A branch name for a mark on `rev`, from the subject of the commit it /// lands on. /// /// `feat(stack): cut the range at marks` becomes `cut-the-range-at-marks`: /// the conventional-commit prefix is dropped because every commit in a repo /// that uses them would otherwise start the same way, and what distinguishes /// one cut from another is the rest. Trimmed to something a listing can show /// whole, and suffixed if that name is taken, because a mark that silently /// moved an existing branch would be a re-cut nobody asked for. fn name_for(rev: &str) -> Result { let subject = git::git_in( std::path::Path::new("."), &["log", "-1", "--format=%s", rev], )?; let subject = subject .split_once(": ") .map(|(_, rest)| rest) .unwrap_or(&subject); let slug: String = subject .chars() .map(|c| match c.is_ascii_alphanumeric() { true => c.to_ascii_lowercase(), false => '-', }) .collect::() .split('-') .filter(|part| !part.is_empty()) .take(5) .collect::>() .join("-"); let slug = match slug.is_empty() { // A subject of nothing but punctuation, which git allows. true => "mark".to_string(), false => slug.chars().take(40).collect(), }; let taken = |name: &str| { git::git_in( std::path::Path::new("."), &[ "rev-parse", "--verify", "--quiet", &format!("refs/heads/{name}"), ], ) .map(|out| !out.trim().is_empty()) .unwrap_or(false) }; if !taken(&slug) { return Ok(slug); } for n in 2..100 { let candidate = format!("{slug}-{n}"); if !taken(&candidate) { return Ok(candidate); } } bail!("could not find a free branch name from {slug}; name the mark yourself") } /// `atgc stack mark`: place one, forget one, or list them. /// /// Reads no records: a mark is a fact about this checkout, not about /// anything published, and nothing here is sent anywhere. The one thing it /// needs from outside is the range the marks sit in, so it fetches the /// target *only* when this checkout has never seen it — and says so plainly /// when that fetch is the thing that failed. pub(crate) fn mark(args: MarkArgs) -> Result<()> { crate::term::jsonout::init(args.json); let branch = git::current_branch()?; if let Some(name) = &args.forget { super::marks::forget(&branch, name)?; if !args.json { println!("forgot mark {name} (its branch is untouched)"); } return Ok(()); } let target = match &args.target { Some(t) => t.clone(), None => git::remote_default_branch(&args.remote).unwrap_or_else(|| "main".to_string()), }; let base = format!("{}/{}", args.remote, target); if !git::ref_exists(&base) && let Err(e) = git::fetch(&args.remote, &target) { // Marks are a fact about this checkout, and a listing of them should // not depend on a host being up. What it does need is the range they // sit in, so this only fails when the target is nowhere to be found // locally either — and says that, rather than handing over git's // sentence about a repository that does not appear to be one. crate::logging::debug::dump_err("stack mark: fetch failed", &e); bail!( "{base} is not in this checkout and {} could not be fetched, so there is no \ range to place marks in\n\ fetch it when the remote is reachable, or name another target with --target", args.remote, ); } let commits = crate::clients::git::patch::commits_since(&base)?; if let Some(first) = &args.first { // One argument is the revision, and the name comes off the commit // it lands on: naming a cut is a chore, and the subject of the // commit that ends it is the name somebody would have typed anyway. // Two arguments are the name and the revision, in that order. let (name, rev) = match &args.rev { Some(rev) => (first.clone(), rev.clone()), None => (name_for(first)?, first.clone()), }; let placed = super::marks::place(&branch, &name, &rev, &base)?; if !args.json { let said = match placed { super::marks::Placed::Created => format!("marked {rev} as the end of {name}"), super::marks::Placed::Moved { from } => format!( "moved {name} to {rev} (was {}), which re-cuts the stack", &from[..7.min(from.len())] ), super::marks::Placed::Kept => format!("{name} already points at {rev}"), }; println!("{said}"); } } // The listing, which is also what a bare `atgc stack mark` is for: every // recorded mark, where it sits, and — the part worth printing — which of // them no longer sit in the range at all. let placed = super::marks::positions(&branch, &commits); let mut marks = Vec::new(); for name in super::marks::recorded(&branch) { let at = placed.iter().find(|(_, m)| *m == name).map(|(i, _)| *i); let sha = git::git_in( std::path::Path::new("."), &[ "rev-parse", "--verify", "--quiet", &format!("refs/heads/{name}^{{commit}}"), ], ) .ok() .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()); let subject = at.map(|i| commits[i].clone()).and_then(|sha| { git::git_in( std::path::Path::new("."), &["log", "-1", "--format=%s", &sha], ) .ok() }); marks.push(MarkJson { name, sha, position: at.map(|i| i + 1), subject, }); } if args.json { return crate::term::jsonout::emit(&MarksJson { branch, base, commits: commits.len(), marks, }); } if marks.is_empty() { println!( "no marks on {branch}: {} commit(s) over {base}, so a stack here would be one \ pull request per commit", commits.len() ); println!("`atgc stack mark HEAD~3` cuts it, named after that commit"); return Ok(()); } println!("branch: {branch} ({} commit(s) over {base})", commits.len()); for m in &marks { match (m.position, &m.sha) { (Some(at), Some(sha)) => println!( " {at}/{} {} {} {}", commits.len(), &sha[..7.min(sha.len())], m.name, m.subject.as_deref().unwrap_or(""), ), // The failure this listing is for. A rebase without --update-refs // leaves the branch on a commit that is no longer in the range, // so the cut it described is gone and nothing else would say so. (None, Some(sha)) => println!( " -- {} {} (outside the range: `atgc stack rebase` moves marks, a plain \ rebase does not)", &sha[..7.min(sha.len())], m.name, ), (_, None) => println!(" -- {} (branch is gone)", m.name), } } Ok(()) }