//! The marks a stack is cut at: where one pull request ends and the next //! begins. //! //! A mark is an ordinary local branch pointing at a commit inside //! `base..HEAD`, and it is *recorded* — `atgc stack mark part1 HEAD~3` writes //! the name into the checkout's git config, and only recorded names are read //! back. That indirection is the whole point of this module. //! //! Recorded rather than inferred, because inferring it is wrong in a working //! repository: `git branch backup` before a risky rebase lands in the range //! too, and a stack that silently re-cut itself around it would say nothing. //! `gh stack` reaches the same shape from the other direction — a stack is //! `init`ed and layers `add`ed, never guessed at. //! //! The record is per-branch and multivalued: `branch..atgcMark`, in //! the same namespace git keeps `branch..description` in. It lives in //! `.git/config`, so it is shared with every linked worktree of the repo, //! which is the same rule the identity written by `repo configure` follows. use crate::clients::git::run as git; use anyhow::{Result, bail}; use std::path::Path; /// The config key holding one stacked branch's marks. fn key(branch: &str) -> String { format!("branch.{branch}.atgcMark") } /// Every mark recorded for `branch`, in the order they were recorded. Names /// only: whether each still points into the range is the caller's question, /// and a stale one is not an error here. pub(crate) fn recorded(branch: &str) -> Vec { git::git_in( Path::new("."), &["config", "--local", "--get-all", &key(branch)], ) .map(|out| { out.lines() .map(str::trim) .filter(|l| !l.is_empty()) .map(str::to_string) .collect() }) .unwrap_or_default() } /// Record `mark` as a cut of `branch`, if it is not recorded already. pub(crate) fn record(branch: &str, mark: &str) -> Result<()> { if recorded(branch).iter().any(|m| m == mark) { return Ok(()); } git::git_in( Path::new("."), &["config", "--local", "--add", &key(branch), mark], )?; Ok(()) } /// Forget `mark`, leaving the branch itself alone. Deleting the branch is /// git's to do and somebody else's decision: a mark that is forgotten but /// still exists is exactly the state somebody wants when they are reshaping a /// stack and want the sha kept. pub(crate) fn forget(branch: &str, mark: &str) -> Result<()> { if !recorded(branch).iter().any(|m| m == mark) { bail!("{mark} is not a mark of {branch}; `atgc stack mark` lists the ones that are"); } git::git_in( Path::new("."), &[ "config", "--local", "--unset-all", &key(branch), &format!("^{}$", regex_quote(mark)), ], )?; Ok(()) } /// git's `--unset-all` takes a value *pattern*, so a mark whose name contains /// regex punctuation would unset the wrong thing — or nothing. Only a few /// characters are legal in a branch name to begin with; escaping them all is /// still cheaper than reasoning about which. fn regex_quote(value: &str) -> String { value .chars() .flat_map(|c| { let escape = matches!( c, '.' | '^' | '$' | '*' | '+' | '?' | '(' | ')' | '[' | ']' | '{' | '}' | '|' | '\\' ); escape.then_some('\\').into_iter().chain(std::iter::once(c)) }) .collect() } /// The recorded marks that actually sit in `commits`, as (index, name), /// bottom first — the cut, ready to be turned into members. /// /// A recorded mark whose branch is gone, or whose tip is outside the range, /// is skipped rather than refused: that is the ordinary state after the /// bottom of a stack merges, and a reconcile that failed there would fail /// exactly when it is most needed. pub(crate) fn positions(branch: &str, commits: &[String]) -> Vec<(usize, String)> { positions_and_stranded(branch, commits).0 } /// The same, and the marks that cut nothing. /// /// **A mark outside the range is silently not a cut**, and silence there /// changes what a command does without saying so: a stack recorded as two /// grouped members reconciles as one pull request per commit, because the /// mark that grouped them is sitting on a commit the branch no longer has. /// `stack mark` reports it when asked; the commands that *act* on the cut /// said nothing, so the shape somebody asked for quietly became a different /// one. A plain `git rebase` without `--update-refs` is the usual cause, /// which is exactly when nobody is thinking about marks. pub(crate) fn positions_and_stranded( branch: &str, commits: &[String], ) -> (Vec<(usize, String)>, Vec) { let mut stranded = Vec::new(); for mark in recorded(branch) { let sha = git::git_in( Path::new("."), &[ "rev-parse", "--verify", "--quiet", &format!("refs/heads/{mark}^{{commit}}"), ], ) .ok() .map(|sha| sha.trim().to_string()) .unwrap_or_default(); if sha.is_empty() || !commits.contains(&sha) { stranded.push(mark); } } (positions_inner(branch, commits), stranded) } fn positions_inner(branch: &str, commits: &[String]) -> Vec<(usize, String)> { let mut found: Vec<(usize, String)> = recorded(branch) .into_iter() .filter_map(|mark| { let sha = git::git_in( Path::new("."), &[ "rev-parse", "--verify", "--quiet", &format!("refs/heads/{mark}^{{commit}}"), ], ) .ok()?; let sha = sha.trim().to_string(); let at = commits.iter().position(|c| *c == sha)?; Some((at, mark)) }) .collect(); found.sort(); found.dedup_by(|a, b| a.0 == b.0); found } /// Point `mark` at `rev`, creating the branch or moving it, and record it. /// /// Moving an existing branch is deliberate and is what re-cutting a stack /// looks like — the equivalent of `gh stack modify`'s fold and reorder — so /// it is allowed, and reported, rather than refused. pub(crate) fn place(branch: &str, mark: &str, rev: &str, base: &str) -> Result { // The branch being stacked already ends the top member, and git refuses // to force-update a checked-out branch anyway — but it refuses with a // sentence about worktrees that says nothing about stacks, and the // recovery is not obvious from it. if mark == branch { bail!( "{branch} is the branch you are on, and it already ends the top pull request\n\ mark a cut below it with another name: `atgc stack mark part1 HEAD~1`" ); } let target = git::git_in( Path::new("."), &["rev-parse", "--verify", &format!("{rev}^{{commit}}")], )?; let target = target.trim().to_string(); // **A mark cuts the range, so it has to be in it.** `stack mark x // origin/main` was accepted and recorded, and then cut nothing: the cut // is decided by where a mark's commit sits among `base..HEAD`, and a // commit that is not among them has no position. The stack that came out // was the unmarked one, silently, with a branch left behind claiming // otherwise. // // The base is the mark's own branch's upstream — the same `/` // `stack create` cuts against — so this is checked against the commits // that will actually be planned rather than against `HEAD` alone. if let Ok(range) = git::git_in(Path::new("."), &["rev-list", &format!("{base}..{branch}")]) && !range.lines().any(|sha| sha.trim() == target) { return Err(crate::exit::fail( crate::exit::Exit::Usage, format!( "{rev} is not among the commits {branch} has over {base}, so a mark there \ would cut nothing\n\ a mark takes a revision inside the range being stacked; \ `git log --oneline {base}..{branch}` lists them" ), )); } // Two marks on one commit end two pull requests at the same place, which // makes one of them empty. Named rather than left to produce a member // with no commits in it. for other in recorded(branch) { if other == mark { continue; } if let Ok(sha) = git::git_in( Path::new("."), &[ "rev-parse", "--verify", "--quiet", &format!("refs/heads/{other}^{{commit}}"), ], ) && sha.trim() == target { return Err(crate::exit::fail( crate::exit::Exit::Usage, format!( "{other} already marks that commit, and two cuts there would leave one \ pull request with no commits in it\n\ `atgc stack mark --forget {other}` drops it, or mark a different commit" ), )); } } let existing = git::git_in( Path::new("."), &[ "rev-parse", "--verify", "--quiet", &format!("refs/heads/{mark}^{{commit}}"), ], ) .ok() .map(|sha| sha.trim().to_string()) .filter(|sha| !sha.is_empty()); let placed = match &existing { Some(sha) if *sha == target => Placed::Kept, Some(sha) => { git::git_in(Path::new("."), &["branch", "--force", mark, &target])?; Placed::Moved { from: sha.clone() } } None => { git::git_in(Path::new("."), &["branch", mark, &target])?; Placed::Created } }; record(branch, mark)?; Ok(placed) } /// What [`place`] did to the branch behind a mark. #[derive(Debug, PartialEq, Eq)] pub(crate) enum Placed { Created, Moved { from: String }, Kept, } /// Every branch that records `mark` as one of its cuts. /// /// The reverse of [`recorded`], and the lookup the navigation verbs need: /// standing on a mark, the stacked branch it belongs to is not derivable /// from the commits — two branches can hold the same commit — so it is read /// back out of the config that recorded it. pub(crate) fn owners_of(mark: &str) -> Vec { git::git_in( Path::new("."), // Lowercase: git normalises the variable half of a key, so this is // the spelling `--get-regexp` both matches and prints, whatever // `record` wrote. &[ "config", "--local", "--get-regexp", r"^branch\..*\.atgcmark$", ], ) .map(|out| { out.lines() .filter_map(|line| line.split_once(' ')) .filter(|(_, value)| value.trim() == mark) .filter_map(|(key, _)| { key.strip_prefix("branch.") .and_then(|rest| rest.strip_suffix(".atgcmark")) }) .map(str::to_string) .collect::>() }) .unwrap_or_default() }