Something went wrong. Try again.
atproto git client
Something went wrong. Try again.
Rust
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601//! 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<Vec<crate::cmd::pr::read::StackRow>> { 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<Chain<'a>> { 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 <pull>` 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 `<remote>/<target>..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 `<target>..<its own branch>` — 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 <n>` 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 <rev>` 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, }}