Something went wrong. Try again.
atproto git client
Something went wrong. Try again.
Rust
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306//! Moving between the members of a stack: `up`, `down`, `top`, `bottom`.//!//! A member of a stack ends at a mark, and a mark is a local branch, so//! moving between members is `git checkout` and the only hard part is//! knowing which branch is next. That knowledge is in two places — the marks//! recorded against the stacked branch, and the order the commits sit in —//! and neither is visible from the branch you are standing on. Doing it by//! hand means `atgc stack mark` in one window and `git checkout` in the//! other, and getting it wrong is silent: you amend on the layer above the//! one you meant.//!//! Everything here is local. Nothing is read from a PDS and nothing is//! written anywhere: these verbs move `HEAD` and say where it landed.//!//! The layers, bottom first, are the recorded marks that sit in the range//! followed by the stacked branch itself, which ends the top member. A mark//! placed on the tip commit is left out — it would name a member holding no//! commits, which `stack create` refuses to open — so navigation and the//! stack that would be published agree about how many members there are.
use crate::clients::git::run as git;use anyhow::{Result, bail};use std::path::Path;
#[derive(clap::Args, Debug)]pub(crate) struct NavArgs { /// How many layers to move (default 1) #[arg(default_value_t = 1)] pub steps: usize, /// Git remote pointing at the repo #[arg(long, default_value = "origin")] pub remote: String, /// The branch the stack lands on, when it is not the remote's default #[arg(long)] pub target: Option<String>, /// Print one JSON object describing the move instead of the summary line #[arg(long)] pub json: bool,}
#[derive(clap::Args, Debug)]pub(crate) struct EndArgs { /// Git remote pointing at the repo #[arg(long, default_value = "origin")] pub remote: String, /// The branch the stack lands on, when it is not the remote's default #[arg(long)] pub target: Option<String>, /// Print one JSON object describing the move instead of the summary line #[arg(long)] pub json: bool,}
#[derive(clap::Args, Debug)]pub(crate) struct CheckoutArgs { /// Which member to stand on: a position from the bottom, or a mark name pub which: String, /// Git remote pointing at the repo #[arg(long, default_value = "origin")] pub remote: String, /// The branch the stack lands on, when it is not the remote's default #[arg(long)] pub target: Option<String>, /// Print one JSON object describing the move instead of the summary line #[arg(long)] pub json: bool,}
/// What a navigation verb did.#[derive(serde::Serialize, Debug, PartialEq)]pub(in crate::cmd) struct MovedJson { /// The branch that was checked out before, which equals `to` when the /// move was already where it was going. pub from: String, pub to: String, /// 1-based from the bottom of the stack. pub position: usize, pub total: usize, /// `false` when nothing was checked out, because the end was already /// underfoot. Not an error: rerunning `up` at the top is how a script /// finds out it is at the top. pub moved: bool, /// Every layer, bottom first. pub layers: Vec<String>,}
/// The branches ending each member of a stack, bottom first, and where the/// checkout is standing in them.struct Layers { branches: Vec<String>, /// Index into `branches` of the branch currently checked out. at: usize,}
/// Work out the stack the checkout is standing in.////// The branch you are on is either the stacked branch — it has marks/// recorded against it — or one of its marks, in which case the stacked/// branch is whichever branch recorded it. That second lookup is why marks/// are stored in git config rather than inferred: it is a reverse index, and/// there is nothing else to search.fn layers(remote: &str, target: Option<&str>) -> Result<Layers> { let here = git::current_branch()?; let stacked = stacked_branch(&here)?;
let target = match target { Some(t) => t.to_string(), None => git::remote_default_branch(remote).unwrap_or_else(|| "main".to_string()), }; let base = format!("{remote}/{target}"); if !git::ref_exists(&base) { bail!( "{base} is not in this checkout, so there is no range to find the stack's \ members in\n\ fetch it, or name another target with --target" ); } // The range is the stacked branch's, not HEAD's: standing on a lower // member, `HEAD` stops partway up and the layers above it would vanish // from the listing exactly when `up` needs them. let commits = commits_between(&base, &stacked)?; let placed = super::marks::positions(&stacked, &commits);
let mut branches: Vec<String> = placed .into_iter() .filter(|(at, _)| *at + 1 < commits.len()) .map(|(_, name)| name) .collect(); branches.push(stacked.clone());
let Some(at) = branches.iter().position(|b| *b == here) else { // Reachable when the mark you are standing on has fallen out of the // range — the state a plain `git rebase` leaves behind, which // `atgc stack mark` lists and this cannot navigate. bail!( "{here} is a mark of {stacked} but no longer sits in {base}..{stacked}\n\ `atgc stack mark` lists the marks that are stranded; `atgc stack rebase` \ carries them" ); }; Ok(Layers { branches, at })}
/// The stacked branch `here` belongs to: itself when it has marks, and/// otherwise the branch that recorded it as one.fn stacked_branch(here: &str) -> Result<String> { if !super::marks::recorded(here).is_empty() { return Ok(here.to_string()); } let owners = super::marks::owners_of(here); match owners.len() { 1 => Ok(owners.into_iter().next().expect("one owner")), // `Usage`, the same status `stack view` answers this same condition // with. Nothing here is retryable and nothing is missing on a // server: the command line named a branch that is not a stack, and // what has to change is the command line. Reaching one status by two // roads is what `exit_status.rs` exists to stop, and this was the // second road. 0 => Err(crate::exit::fail( crate::exit::Exit::Usage, format!( "{here} is not part of a stack: it has no marks, and no branch records it \ as one\n\ `atgc stack mark HEAD~3` cuts a branch into members; `atgc pr view` is the \ command for a flat pull request" ), )), // Two branches marking the same name is a repository somebody has // reshaped by hand. Naming them is the whole fix, and guessing which // stack was meant would move HEAD somewhere unasked for. _ => Err(crate::exit::fail( crate::exit::Exit::Usage, format!( "{here} is recorded as a mark of more than one branch ({})\n\ `atgc stack mark --forget {here}` on the one it no longer cuts", owners.join(", "), ), )), }}
/// The commits of `base..branch`, oldest first.////// [`crate::clients::git::patch::commits_since`] answers about `HEAD`, which/// is the wrong end when the point is to look at layers above where you are/// standing.fn commits_between(base: &str, branch: &str) -> Result<Vec<String>> { let out = git::git_in( Path::new("."), &["rev-list", "--reverse", &format!("{base}..{branch}")], )?; Ok(out.lines().map(str::to_string).collect())}
pub(crate) fn up(args: NavArgs) -> Result<()> { step(args, 1)}
pub(crate) fn down(args: NavArgs) -> Result<()> { step(args, -1)}
/// Move `steps` layers in `direction`, stopping at the end rather than/// refusing: "as far as it goes" is what somebody asking for three steps in/// a two-layer stack means, and an error there would only be re-run with a/// smaller number.fn step(args: NavArgs, direction: isize) -> Result<()> { crate::term::jsonout::init(args.json); let layers = layers(&args.remote, args.target.as_deref())?; // Clamped before the cast, not after. `steps` is a positional, so clap // accepts anything up to `usize::MAX`, and `as isize` on a value past // `isize::MAX` yields a negative — which sends `up` to the *bottom* of the // stack, silently and in every profile. Larger still and the multiply // overflows: a panic where overflow checks are on, and a wrap where they // are not, which release builds are. Nobody can mean more steps than the // stack has layers, so saturating there costs nothing and removes both. let steps = args.steps.min(layers.branches.len()) as isize; let wanted = layers.at as isize + direction * steps; let target = wanted.clamp(0, layers.branches.len() as isize - 1) as usize; go(layers, target, args.json)}
pub(crate) fn top(args: EndArgs) -> Result<()> { crate::term::jsonout::init(args.json); let layers = layers(&args.remote, args.target.as_deref())?; let last = layers.branches.len() - 1; go(layers, last, args.json)}
pub(crate) fn bottom(args: EndArgs) -> Result<()> { crate::term::jsonout::init(args.json); let layers = layers(&args.remote, args.target.as_deref())?; go(layers, 0, args.json)}
/// Stand on a member named by position or by mark.pub(crate) fn checkout(args: CheckoutArgs) -> Result<()> { crate::term::jsonout::init(args.json); let layers = layers(&args.remote, args.target.as_deref())?; let total = layers.branches.len(); // A position first, because the listings print one and a branch named // "2" is a branch somebody will have to rename anyway. let target = match args.which.parse::<usize>() { Ok(n) if (1..=total).contains(&n) => n - 1, // Both are `Usage` and not `NotFound`: the stack resolved, and what // did not is the word typed after `atgc stack checkout`. Retrying it // unchanged fails identically, and every value that would work is // listed in the message. Ok(n) => { return Err(crate::exit::fail( crate::exit::Exit::Usage, format!("there is no member {n}: this stack has {total}"), )); } Err(_) => layers .branches .iter() .position(|b| *b == args.which) .ok_or_else(|| { crate::exit::fail( crate::exit::Exit::Usage, format!( "{} is not a member of this stack ({})", args.which, layers.branches.join(", "), ), ) })?, }; go(layers, target, args.json)}
/// Check out layer `target` and say where that is, or say that it is already/// underfoot.fn go(layers: Layers, target: usize, json: bool) -> Result<()> { let from = layers.branches[layers.at].clone(); let to = layers.branches[target].clone(); let moved = target != layers.at; if moved { // git's own refusal is the right one for a dirty tree: it names the // files, which is what somebody has to act on. git::git_in(Path::new("."), &["checkout", &to])?; } let report = MovedJson { from, to: to.clone(), position: target + 1, total: layers.branches.len(), moved, layers: layers.branches, }; if json { return crate::term::jsonout::emit(&report); } let subject = git::git_in(Path::new("."), &["log", "-1", "--format=%s", &to]) .map(|s| s.trim().to_string()) .unwrap_or_default(); let where_ = format!("{}/{}", report.position, report.total); if moved { println!("{to} ({where_}) {subject}"); } else { println!("already on {to} ({where_}) {subject}"); } Ok(())}