//! Stacked pull requests: the `atgc stack` commands. //! //! A Tangled stack is a chain of ordinary pull records, each `dependentOn` //! the pull beneath it: see the stacking section of //! [`crate::docs::architecture`]. There is no stack record and no stack //! query anywhere in the protocol, so everything here is assembled from the //! same merged pull listing the `pr` read commands use, by following the //! chain. //! //! Nothing ties a member to one commit: a pull request, stacked or not, //! carries whatever patch it carries. Where the members end is set by //! *marks* — local branches recorded by [`marks`] — each ending one member. //! An unmarked range is one member per commit, the jj-shaped workflow these //! commands grew out of. A member's patch is a mailbox with a `Change-Id:` header per //! commit it holds, which is how a reconcile finds the member again — and //! how it re-derives the grouping without being told it a second time. //! //! A branch that is one change from bottom to top is not a stack at all: it //! is one pull request, and `atgc pr create` is the command for it. //! //! The command split is deliberate and documented in TODO.md: `stack` //! commands are for stacked branches and *bail with a pointer* when the //! situation is not stacked, while the `pr` commands keep working on stack //! members and warn where a stack command would serve better. `stack view` //! bailing on an unstacked pull is not a limitation; it is the contract. //! //! What a chain *is* — the walk, what damages one, and what a batch of //! writes would leave behind — is [`crate::model::chain`], and every rule //! about it lives there rather than in any verb here. What is left in this //! module is the part that is about a command: reading a listing, deciding //! whose stack a branch's is, choosing an exit status, and saying it. use anyhow::{Result, bail}; pub(crate) mod marks; pub(crate) mod nav; pub(crate) mod read; pub(crate) mod write; pub(in crate::cmd) use write::{MergePlan, latest_round_patch, repo_facts, run_merge}; /// The chain rules themselves live in [`crate::model::chain`] — what a chain /// is, what damages one, and what a batch of writes would leave behind. They /// are re-exported here because every caller is a stack verb, and because /// they were written here before there was a model layer to put them in. pub(in crate::cmd) use crate::model::chain::{ Chain, chain_containing, closed_uris, refuse_new_damage, state_of, }; /// A repo's rows, refusing a listing that is not the whole of it: the /// shared front door for every command that draws chain conclusions. The /// message names what was about to run, because "refusing" is only useful /// next to what was refused. pub(in crate::cmd) async fn complete_rows( source: crate::cmd::pr::read::Source, repo_did: &str, doing: &str, ) -> Result> { let listing = crate::cmd::pr::read::repo_rows(source, repo_did).await?; if !listing.complete() { bail!( "the pull listing hit its page cap, so the chain cannot be seen whole\n\ refusing: {doing} against part of a stack would act on the wrong members" ); } Ok(listing.rows) } /// The pull to enter a branch's stack through. /// /// [`crate::cmd::pr::read::for_branch`] answers "this branch's pull", newest /// first, which is the whole answer for a flat branch. For a stack it can /// land on a member somebody retired: unlinking one leaves a record that /// still names the branch and depends on nothing, so on a stack whose *top* /// was retired the newest match is a pull that is not in the chain any more, /// and the branch reads back as unstacked. /// /// Preferring a member that is still open costs one filter, and falls back /// to the whole set — a stack every member of which is closed is somebody's /// abandoned work, and it still has to be viewable. pub(in crate::cmd) fn entry_for_branch<'a>( items: &[&'a serde_json::Value], branch: &str, closed: &std::collections::HashSet<&str>, ) -> Option<&'a serde_json::Value> { let live = items .iter() .copied() .filter(|i| !closed.contains(i["uri"].as_str().unwrap_or_default())); crate::cmd::pr::read::for_branch(live, branch) .or_else(|| crate::cmd::pr::read::for_branch(items.iter().copied(), branch)) } /// The acting account's chain for `branch`, with the refusals every stack /// write shares: the entry match scoped to own pulls (a contributor's /// same-named branch must not seed the walk), a dangling `missing_below` /// refused (a reconcile that cannot see the whole chain re-mints or orphans /// members), and every member owned (the writes go to the author's PDS and /// nowhere else). Three commands hand-rolled this preamble; the drift the /// safety batch had to patch in triplicate lived exactly there. /// What a "this is not stacked" answer has to admit it did not look at. /// /// **A pull is stacked when something depends on it, and that something is a /// record in somebody else's repository.** So "not stacked" is a claim about /// records the acting account does not hold, and off an index it is not a /// claim atgc is in a position to make: a contributor who stacks on a /// maintainer's pull is invisible to the maintainer, who is then told their /// own pull stands alone. /// /// With an index it is a fair answer and still worth a word, because Bobbin's /// ingest stalls and a recent dependent is exactly what it would be missing. pub(in crate::cmd) fn unstacked_caveat(source: crate::cmd::pr::read::Source) -> &'static str { match source.indexed() { false => { "\nonly your own records were read, so a member somebody else stacked on top \ of this one would not be visible from here: `--source bobbin` asks the index" } true => { "\nthe index was read too, and its ingest stalls, so a dependent written in the \ last while can be missing from it" } } } /// Whose chain a verb is entitled to act on. /// /// **The one thing `own_chain` and `any_chain` ever differed by**, and they /// differed by it twice: which pulls the branch is looked up among, and /// whether every member has to be the acting account's. Everything else — /// the entry, the walk, the "not stacked" refusal, the dangling-link /// refusal — was written out twice, four blocks verbatim in each. #[derive(Clone, Copy, PartialEq, Eq)] pub(in crate::cmd) enum Whose { /// Only the acting account's, and every member must be theirs. /// /// For the verbs that write *member* records — `resubmit`, `link`, /// `unlink` — where a member somebody else authored is a write that /// cannot land. Mine, /// Any one account's, with no member ownership required. /// /// For `merge`, which writes no member: it asks the knot to land the /// patch and records the outcome in the acting account's own repository, /// which is why the repo owner may merge a contributor's stack. Own /// pulls still win the entry — a branch name is not unique across /// accounts, and landing a stranger's stack because it shares a name /// with yours would merge the wrong work. Anyones, } /// The chain on `branch`, entered and constrained by `whose`. #[allow(clippy::too_many_arguments)] pub(in crate::cmd) fn chain_for_branch<'a>( items: &[&'a serde_json::Value], branch: &str, me: &str, closed: &std::collections::HashSet<&str>, source: crate::cmd::pr::read::Source, whose: Whose, none_advice: &str, flat_advice: &str, ) -> Result> { let own: Vec<&serde_json::Value> = items .iter() .copied() .filter(|i| crate::model::record::is_authored_by(i["uri"].as_str().unwrap_or_default(), me)) .collect(); let mine = match (entry_for_branch(&own, branch, closed), whose) { // Own pulls win the entry under either policy. (Some(entry), _) => entry, (None, Whose::Mine) => { return Err(crate::exit::fail( crate::exit::Exit::NotFound, format!("no pull request of yours found for branch {branch}\n{none_advice}"), )); } (None, Whose::Anyones) => { // Whose stack this is has to be unambiguous before it is landed. let authors: std::collections::BTreeSet<&str> = items .iter() .filter(|i| { crate::cmd::pr::read::for_branch(std::iter::once(**i), branch).is_some() }) .filter_map(|i| i["uri"].as_str()) .filter_map(crate::model::record::authority_of) .collect(); if authors.len() > 1 { let named: Vec<&str> = authors.into_iter().collect(); bail!( "{} accounts have a pull request on branch {branch}: {}\n\ a merge lands one stack and cannot tell which of these you mean; \ `atgc pr merge ` names one outright", named.len(), named.join(", "), ); } let Some(entry) = entry_for_branch(items, branch, closed) else { return Err(crate::exit::fail( crate::exit::Exit::NotFound, format!("no pull request found for branch {branch}\n{none_advice}"), )); }; entry } }; let start_uri = mine["uri"].as_str().unwrap_or_default(); let Some(chain) = chain_containing(items, start_uri, closed)? else { return Err(crate::exit::fail( crate::exit::Exit::Usage, format!( "branch {branch}'s pull request is not stacked\n{flat_advice}{}", unstacked_caveat(source) ), )); }; if let Some(missing) = &chain.missing_below { bail!( "the chain continues below {missing}, which no listing row answers to\n\ a record was probably deleted from under the stack; repair the chain first \ (Tangled's web resubmit relinks it)" ); } if whose == Whose::Mine { for member in &chain.members { let uri = member["uri"].as_str().unwrap_or_default(); if !crate::model::record::is_authored_by(uri, me) { // What `pr` already answers for a round, an edit or a close // against somebody else's record: there is a session and it // is the wrong one. return Err(crate::exit::fail( crate::exit::Exit::Denied, format!("{uri} is not {me}'s record; only a stack's author can write it"), )); } } } Ok(chain) } #[cfg(test)] mod tests { use serde_json::{Value, json}; use std::collections::HashSet; fn nothing_closed() -> HashSet<&'static str> { HashSet::new() } fn item(uri: &str, dependent_on: Option<&str>, title: &str) -> Value { let mut value = json!({ "title": title, "source": {"branch": "claude/stack"}, "target": {"branch": "main"}, "createdAt": "2026-08-09T00:00:00Z", }); if let Some(dep) = dependent_on { value["dependentOn"] = json!(dep); } json!({"uri": uri, "value": value}) } /// `own_chain`'s three refusals are three problems, and each now carries /// the status that names its next move. All three exited the /// unclassified `1`, which is what `stack` answered on nearly every /// refusal it had — and the first of them disagreed with `pr view`, which /// has answered `5` for a branch with no pull since the `pr` family was /// classified. /// /// A branch with no pull of yours is `5`, an identifier that resolved to /// nothing. A pull that exists and is not stacked is `2`: it was found, /// and the message names the command that reads it. A chain reaching a /// record somebody else wrote is `4`, which is what `pr` already answers /// for a round or an edit against another account's record. #[test] fn own_chains_three_refusals_carry_three_statuses() { let a = item("at://me/p/a", None, "bottom"); let b = item("at://me/p/b", Some("at://me/p/a"), "top"); let theirs = item("at://you/p/c", Some("at://me/p/b"), "not mine"); let refuse = |items: &[&Value], branch: &str| { super::chain_for_branch( items, branch, "me", ¬hing_closed(), crate::cmd::pr::read::Source::PDS, super::Whose::Mine, "none", "flat", ) .map(|_| ()) .expect_err("a refusal") }; let no_pull = refuse(&[&a, &b], "claude/no-such-branch"); assert_eq!(crate::exit::classify(&no_pull), crate::exit::Exit::NotFound); let not_stacked = refuse(&[&a], "claude/stack"); assert_eq!( crate::exit::classify(¬_stacked), crate::exit::Exit::Usage ); let somebody_else = refuse(&[&a, &b, &theirs], "claude/stack"); assert_eq!( crate::exit::classify(&somebody_else), crate::exit::Exit::Denied ); } } /// The `atgc stack` verbs. #[derive(clap::Subcommand, Debug)] pub(crate) enum Command { /// Split a branch of separate changes into a chain of dependent pulls /// /// For a branch whose commits are each a reviewable change of their own, /// to be reviewed apart and landed in order. A branch that is one change, /// however many commits it took, is one pull request: `atgc pr create` /// is the command for it, and this one is the wrong tool. /// /// Where the pull requests end is set by this branch's marks: `atgc /// stack mark part1 HEAD~3` records a cut, so everything up to it is one /// pull request and the branch being stacked ends the top one. An /// unmarked branch is one pull request per commit, which is also what /// `--per-commit` forces when marks exist — though past three commits an /// unmarked branch is asked about first, since a pull request per commit /// is rarely what somebody meant by eight of them. /// `git config stack.askWhenUnmarked false` stops it asking. All records are written in /// one atomic batch. /// /// `atgc stack rebase` replays the branch onto its target and carries /// the marks; a plain `git rebase` strands them unless it is given /// --update-refs. /// /// Every commit must carry a change-id (jj writes one with /// write-change-id-header; --add-change-ids rewrites the branch to add /// Change-Id trailers for plain git); a member's patch carries one per /// commit it holds. Refuses a single-commit range (`atgc pr create` is /// for that) and a branch that is already stacked (`atgc stack /// resubmit` reconciles that one). /// /// A branch that has one ordinary pull request is not refused: that pull /// becomes the stack's bottom member, keeping its number, its comments /// and its rounds, and gains a round holding the bottom cut alone. This /// is how a stack usually starts — as `atgc pr create`, with the second /// feature arriving on top of it later. /// /// A member is titled and described by its bottom commit; `atgc pr edit` /// changes either without touching git history. /// /// The branch is pushed to `--remote` first. Every member records it as /// the source, and that is what `stack view`, `stack resubmit` and /// `stack merge` match a checkout back to its chain by, so there is no /// --patch-only here: a push that fails refuses the create and points /// at `atgc pr create --patch-only`. /// /// Examples: /// atgc stack create /// atgc stack mark part1 HEAD~3 && atgc stack create --dry-run /// atgc stack create --per-commit /// atgc stack create --add-change-ids /// atgc stack create --target develop --dry-run /// atgc stack create --dry-run # on a branch that already has a pull #[command(verbatim_doc_comment)] Create(write::CreateArgs), /// Mark where a pull request ends, or list the marks already set /// /// A stack is cut at *marks*: ordinary local branches pointing at commits /// inside `/..HEAD`, each ending one pull request, with /// the branch you are on ending the top one. `atgc stack mark HEAD~3` /// points a branch there and records it as a cut of this branch, named /// after the commit it lands on; `atgc stack mark part1 HEAD~3` names it /// yourself. With no arguments it lists what is recorded and where each /// one sits. /// /// Recorded, not guessed: a branch that merely happens to point into the /// range — `backup` before a rebase, an old worktree's branch — is not a /// mark and does not reshape anything. Moving a mark re-cuts the stack, /// which the next `atgc stack resubmit` reconciles. /// /// `--forget` drops a mark without deleting its branch. A branch nothing /// marks means one pull request per commit, which is what an unmarked /// stack has always been. /// /// Examples: /// atgc stack mark /// atgc stack mark HEAD~3 /// atgc stack mark part1 HEAD~3 /// atgc stack mark --forget part1 #[command(verbatim_doc_comment)] Mark(read::MarkArgs), /// Stand on the member above the current one /// /// A stack's members end at marks, and a mark is a local branch, so /// moving between them is a checkout — the hard part is knowing which /// branch is next, which is what these four verbs answer. Nothing is /// read from or written to any host. /// /// Takes a number of layers, and stops at the top rather than refusing /// to go that far. /// /// Examples: /// atgc stack up /// atgc stack up 2 #[command(verbatim_doc_comment)] Up(nav::NavArgs), /// Stand on the member below the current one /// /// The other half of `atgc stack up`; stops at the bottom. /// /// Examples: /// atgc stack down /// atgc stack down 2 #[command(verbatim_doc_comment)] Down(nav::NavArgs), /// Stand on the top member: the stacked branch itself #[command(verbatim_doc_comment)] Top(nav::EndArgs), /// Stand on the bottom member: the one that merges first #[command(verbatim_doc_comment)] Bottom(nav::EndArgs), /// Stand on one member by position from the bottom, or by mark name /// /// Examples: /// atgc stack checkout 2 /// atgc stack checkout part1 #[command(verbatim_doc_comment)] Checkout(nav::CheckoutArgs), /// Chain pull requests that already exist into a stack /// /// `atgc stack link 12 15 17` makes 15 depend on 12 and 17 on 15: bottom /// first, the order they are reviewed and merged in. Every other stack /// verb builds a chain from a branch; this one says that pulls which /// already exist *are* one, without reopening them, so their numbers, /// rounds and comments survive the ordering. /// /// All of them must be yours, aim at the same repo and branch, and none /// may be merged or already depended on by a pull outside the list. Where /// both source branches are in this checkout the order is checked against /// git — a stack lands bottom-up, so each member has to sit on top of the /// one below — and where they are not, it is taken on trust and said so. /// /// Each pull's patch must hold its own commits and nobody else's, since /// a merge applies the whole series at once. Two pulls opened by `atgc /// pr create` from branches that contain one another do *not* satisfy /// that — each records `..` — so this is the /// command for pulls that are already separate, not the way to stack a /// branch on top of an existing pull. For that, add the commits to the /// branch that pull came from and run `atgc stack create`, which adopts /// it as the bottom member. /// /// Written as one `applyWrites`: a chain built a record at a time passes /// through a state the appview refuses at ingest. /// /// Examples: /// atgc stack link 12 15 17 --dry-run /// atgc stack link 12 15 17 #[command(verbatim_doc_comment)] Link(write::LinkArgs), /// Take pull requests out of a chain, closing the gap behind them Unlink(write::UnlinkArgs), /// Catch up with the target and reconcile the stack: rebase, then resubmit /// /// The verb for a review cycle. `atgc stack rebase` replays the branch /// onto its target carrying the marks, and `atgc stack resubmit` /// reconciles the records with what the branch now is; this runs both, /// in that order, and stops after the first if the rebase stops. /// /// The halves are still there for when only one is wanted: an amend that /// needs no rebase needs no fetch, and a rebase that hits a conflict /// leaves work to do before any reconcile means anything. /// /// Examples: /// atgc stack sync --dry-run /// atgc stack sync /// atgc stack sync --prune #[command(verbatim_doc_comment)] Sync(write::SyncArgs), /// Rebase the stack onto its target branch, marks and all /// /// Fetches the target and replays `base..HEAD` onto it with /// `--update-refs`, so every mark travels with the commit it marks /// instead of being left behind on a sha the branch no longer has. That /// is the whole reason this exists rather than a line of advice: a plain /// `git rebase` silently un-cuts a stack. /// /// A target that cannot be fetched is a warning rather than a stop: the /// copy already in the checkout is replayed onto, which may be behind. /// A dirty working tree, or a rebase already in progress, is refused /// outright, and a conflict stops exactly where git stops, leaving the /// rebase in progress for `git rebase --continue` or `--abort`. /// /// Nothing is sent. `atgc stack resubmit` reconciles the records /// afterwards, and `atgc stack sync` is the two of them in one command, /// which is usually what you want. /// /// Examples: /// atgc stack rebase --dry-run /// atgc stack rebase /// atgc stack rebase && atgc stack resubmit #[command(verbatim_doc_comment)] Rebase(write::RebaseArgs), /// Merge the current branch's stack into its target branch /// /// Lands the stack bottom-up as one combined patch: the same merge /// Tangled's web button performs: after the knot confirms it applies /// cleanly, then marks every landed pull merged. `--through ` stops at /// position n counted from the bottom, landing only the pulls at or /// below it. Already-merged members contribute nothing and are skipped. /// The knot call is authorized by a service-auth token minted by your /// PDS, and the knot takes it from anyone it lets push: the repo's /// owner, or a collaborator landing their own stack. /// /// Examples: /// atgc stack merge --dry-run /// atgc stack merge --through 1 /// atgc stack merge #[command(verbatim_doc_comment)] Merge(write::MergeArgs), /// Reconcile the stack's records with the rewritten branch /// /// The branch is republished to `--remote` first, leased against the /// head the last round recorded: every member records `source: /// {branch}`, so a reconcile that left the branch behind would write /// rounds describing commits the knot does not have. A branch something /// else moved refuses instead of being overwritten. /// /// After a rebase, amend, reorder or drop, this matches each pull to /// its commits by change-id: matched pulls whose patch changed get a new /// round, new commits get new pulls, vanished commits delete their /// pulls (only with --prune), and the whole chain is relinked to the /// new order: in one atomic batch, the same reconcile Tangled's web /// resubmit performs. Merged pulls are never touched. Rerunning against /// an unchanged branch is a no-op. /// /// The cuts are this branch's marks, as on create, so `atgc stack mark /// part1 ` re-cuts a stack and a reconcile follows it; a pull left /// with no commits by the re-cut is a drop like any other, so it needs /// --prune. With no marks the records themselves say where the cuts are /// — a pull holding several commits keeps them, and a commit written /// into the middle of one joins it — so a grouped stack still reconciles /// in a checkout that has lost the marks. /// /// Examples: /// atgc stack resubmit --dry-run /// atgc stack resubmit /// atgc stack mark part1 HEAD~2 && atgc stack resubmit --prune /// atgc stack resubmit --per-commit --prune #[command(verbatim_doc_comment)] Resubmit(write::ResubmitArgs), /// View the current branch's stack, top to bottom /// /// One line per pull: position, state, number, title, record key, how /// many commits it holds when that is more than one, and round count, /// with the top of the stack: the pull nothing depends on: first. The /// commit count is read off each member's latest patch, which is the /// only place it is written. Refuses when the branch's pull is not stacked, because /// `atgc pr view` is the single-pull command. /// /// `--json` prints one object for the whole chain: the branch, its /// target, and a `members` array top first, each member carrying its /// at-uri, record key, appview number, state, title, round count and /// how many commits it holds (`null` where the patch could not be read). /// Richer than `pr view --json`'s `stack.members`, which is the same /// chain seen from beside a single pull. Same stability rule as every /// other --json; see docs/output.md. /// /// Examples: /// atgc stack view /// atgc stack view --source pds /// atgc stack view --json | jq -r '.members[] | "\(.position) \(.rkey)"' #[command(verbatim_doc_comment)] View(read::ViewArgs), } /// Run whichever `stack` verb was parsed. pub(crate) async fn run(command: Command) -> Result<()> { match command { Command::Create(args) => write::create(args).await, Command::Mark(args) => read::mark(args), Command::Link(args) => write::link(args).await, Command::Unlink(args) => write::unlink(args).await, Command::Up(args) => nav::up(args), Command::Down(args) => nav::down(args), Command::Top(args) => nav::top(args), Command::Bottom(args) => nav::bottom(args), Command::Checkout(args) => nav::checkout(args), Command::Rebase(args) => write::rebase(args).await, Command::Sync(args) => write::sync(args).await, Command::Merge(args) => write::merge(args).await, Command::Resubmit(args) => write::resubmit(args).await, Command::View(args) => read::view(args).await, } }