//! 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, /// 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, /// 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, /// 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, } /// The branches ending each member of a stack, bottom first, and where the /// checkout is standing in them. struct Layers { branches: Vec, /// 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 { 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 = 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 { 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> { 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::() { 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(()) }