Skip to main content

git_stk/stack/
mod.rs

1//! Stack metadata: the `branch.<name>.stkParent`/`stkBase` annotations and
2//! the structural queries built on them. Navigation lives in [`nav`], the
3//! rebase engine in [`restack`].
4
5use std::collections::{BTreeMap, BTreeSet};
6
7use anyhow::{Context, Result, bail};
8use serde_json::{Value, json};
9
10use crate::git;
11use crate::settings;
12use crate::style;
13
14/// Shared ref carrying the stack's parent map, so another clone can rebuild
15/// the metadata. Pushed/fetched explicitly; a normal fetch ignores it.
16const METADATA_REF: &str = "refs/stk/metadata";
17const METADATA_FILE: &str = "stack.json";
18
19mod nav;
20mod restack;
21mod snapshot;
22
23pub use nav::{
24    behind_parent_hint, checkout_bottom, checkout_child, checkout_parent, checkout_top,
25    print_all_stacks, print_children, print_parent, print_stack,
26};
27pub use restack::{abort_restack, continue_restack, restack};
28pub use snapshot::{take as snapshot, undo};
29
30const PARENT_KEY: &str = "stkParent";
31const BASE_KEY: &str = "stkBase";
32/// Marks a branch as the rename of another that still has an open review, so
33/// the next submit can replace and close that review.
34const RENAMED_FROM_KEY: &str = "stkRenamedFrom";
35
36pub fn create_branch(branch: &str, dry_run: bool) -> Result<()> {
37    let parent = git::current_branch()?;
38    // `new` creates the branch; an existing one is an adopt, not a create.
39    if git::local_branches()?
40        .iter()
41        .any(|existing| existing == branch)
42    {
43        bail!(
44            "branch {branch} already exists - adopt it onto {parent} \
45             with `git stk adopt {branch} --parent {parent}`"
46        );
47    }
48    if !dry_run {
49        git::create_branch(branch)?;
50        set_parent(branch, &parent)?;
51        record_base(branch, &parent);
52    }
53    anstream::println!(
54        "{} {} with parent {}",
55        if dry_run { "would create" } else { "created" },
56        style::branch(branch),
57        style::branch(&parent)
58    );
59    Ok(())
60}
61
62/// Insert a new empty branch directly above the current one, moving the
63/// current branch's children onto it. The new branch shares the current tip,
64/// so descendants stay correctly based; commit to it, then `restack` to
65/// replay them. Any uncommitted changes ride onto the new branch, like `new`.
66pub fn insert_branch(branch: &str, dry_run: bool) -> Result<()> {
67    ensure_absent(branch)?;
68    let current = git::current_branch()?;
69    let children = children_of(&current)?;
70
71    if !dry_run {
72        snapshot::take("new --insert");
73        git::create_branch(branch)?; // off current; leaves us on the new branch
74        set_parent(branch, &current)?;
75        record_base(branch, &current);
76        for child in &children {
77            set_parent(child, branch)?;
78            record_base(child, branch);
79        }
80    }
81
82    anstream::println!(
83        "{} {} above {}",
84        if dry_run { "would insert" } else { "inserted" },
85        style::branch(branch),
86        style::branch(&current)
87    );
88    for child in &children {
89        anstream::println!(
90            "{} {} -> {}",
91            if dry_run {
92                "would retarget"
93            } else {
94                "retargeted"
95            },
96            style::branch(child),
97            style::branch(branch)
98        );
99    }
100    Ok(())
101}
102
103/// Insert a new empty branch directly below the current one, moving the
104/// current branch onto it. Branches from the current branch's parent, so it
105/// requires a clean worktree. Commit to it, then `restack`.
106pub fn prepend_branch(branch: &str, dry_run: bool) -> Result<()> {
107    ensure_absent(branch)?;
108    let current = git::current_branch()?;
109    let parent =
110        parent_of(&current)?.context("current branch has no stack parent to prepend below")?;
111    if !git::worktree_is_clean()? {
112        bail!(
113            "working tree has uncommitted changes; commit or stash before `git stk new --prepend`"
114        );
115    }
116
117    if !dry_run {
118        snapshot::take("new --prepend");
119        git::checkout(&parent)?;
120        git::create_branch(branch)?; // off the parent; leaves us on the new branch
121        set_parent(branch, &parent)?;
122        record_base(branch, &parent);
123        set_parent(&current, branch)?;
124        record_base(&current, branch);
125    }
126
127    anstream::println!(
128        "{} {} between {} and {}",
129        if dry_run { "would insert" } else { "inserted" },
130        style::branch(branch),
131        style::branch(&parent),
132        style::branch(&current)
133    );
134    anstream::println!(
135        "{} {} -> {}",
136        if dry_run {
137            "would retarget"
138        } else {
139            "retargeted"
140        },
141        style::branch(&current),
142        style::branch(branch)
143    );
144    Ok(())
145}
146
147fn ensure_absent(branch: &str) -> Result<()> {
148    if git::local_branches()?
149        .iter()
150        .any(|existing| existing == branch)
151    {
152        bail!("branch {branch} already exists");
153    }
154    Ok(())
155}
156
157/// The trunk branch: the remote's default branch when known locally,
158/// otherwise a conventional name that exists.
159pub fn trunk_branch(branches: &[String]) -> Option<String> {
160    let remote = settings::remote().unwrap_or_else(|_| settings::DEFAULT_REMOTE.to_owned());
161    if let Some(default) = git::remote_default_branch(&remote) {
162        return Some(default);
163    }
164
165    ["main", "master"]
166        .iter()
167        .find(|name| branches.iter().any(|branch| branch == *name))
168        .map(|name| (*name).to_owned())
169}
170
171pub fn adopt_branch(branch: &str, parent: &str, dry_run: bool) -> Result<()> {
172    if branch == parent {
173        bail!("a branch cannot be its own stack parent");
174    }
175
176    let branches: BTreeSet<_> = git::local_branches()?.into_iter().collect();
177    if !branches.contains(branch) {
178        bail!("branch {branch} does not exist");
179    }
180    if !branches.contains(parent) {
181        bail!("parent branch {parent} does not exist");
182    }
183    if branch_and_descendants(branch)?
184        .iter()
185        .any(|descendant| descendant == parent)
186    {
187        bail!("{parent} is already below {branch} in the stack; that would form a cycle");
188    }
189
190    if !dry_run {
191        set_parent(branch, parent)?;
192        record_base(branch, parent);
193    }
194    anstream::println!(
195        "{} {} to {}",
196        if dry_run { "would attach" } else { "attached" },
197        style::branch(branch),
198        style::branch(parent)
199    );
200    Ok(())
201}
202
203pub fn detach_branch(branch: Option<&str>) -> Result<()> {
204    let branch = branch
205        .map(str::to_owned)
206        .map_or_else(git::current_branch, Ok)?;
207    unset_parent(&branch)?;
208    unset_base(&branch)?;
209    anstream::println!("detached {}", style::branch(&branch));
210    Ok(())
211}
212
213/// Rename a branch and keep the stack intact. Git moves the branch's own
214/// metadata with the rename; children pointing at the old name are
215/// retargeted here.
216pub fn rename_branch(old: &str, new: &str, dry_run: bool) -> Result<()> {
217    let children = children_of(old)?;
218
219    if !dry_run {
220        snapshot::take("rename");
221        git::rename_branch(old, new)?;
222    }
223    anstream::println!(
224        "{} {} -> {}",
225        if dry_run { "would rename" } else { "renamed" },
226        style::branch(old),
227        style::branch(new)
228    );
229
230    for child in &children {
231        if !dry_run {
232            set_parent(child, new)?;
233        }
234        anstream::println!(
235            "{} {} -> {}",
236            if dry_run {
237                "would retarget"
238            } else {
239                "retargeted"
240            },
241            style::branch(child),
242            style::branch(new)
243        );
244    }
245    Ok(())
246}
247
248/// Record that `branch` is the rename of `old`, whose open review the next
249/// submit should replace and close.
250pub fn set_renamed_from(branch: &str, old: &str) -> Result<()> {
251    git::config_set(&renamed_from_key(branch), old)
252}
253
254/// The branch `branch` was renamed from, if a replaced review is still pending.
255pub fn renamed_from(branch: &str) -> Result<Option<String>> {
256    git::config_get(&renamed_from_key(branch))
257}
258
259/// Drop the rename marker once its review has been handled.
260pub fn clear_renamed_from(branch: &str) -> Result<()> {
261    git::config_unset(&renamed_from_key(branch))
262}
263
264/// Record the fork point between a branch and its parent (best effort; e.g.
265/// unrelated histories have no merge base, which is not an error here).
266pub fn record_base(branch: &str, parent: &str) {
267    if let Ok(base) = git::merge_base(parent, branch) {
268        let _ = git::config_set(&base_key(branch), &base);
269    }
270}
271
272/// The commit to replay `branch` from when rebasing onto `parent`: the tighter
273/// of its recorded fork point and the live `merge_base(parent, branch)`. The
274/// recorded base is trusted only when it is a descendant of (or equal to) the
275/// live merge base - a fork point at least as recent, as after the parent is
276/// rewritten and its old commits leave the branch's history. A recorded base
277/// that is a proper *ancestor* of the live merge base is stale: the true fork
278/// point has moved past it (e.g. the branch was rebased onto a newer trunk out
279/// of band), so the merge base wins and only the branch's own commits replay.
280/// With neither available there is nothing to anchor on.
281pub(crate) fn fork_point(branch: &str, parent: &str) -> Result<Option<String>> {
282    let recorded = base_of(branch)?.filter(|base| git::is_ancestor(base, branch).unwrap_or(false));
283    let merge_base = git::merge_base(parent, branch).ok();
284    Ok(match (recorded, merge_base) {
285        (Some(recorded), Some(merge_base)) => Some(
286            if git::is_ancestor(&merge_base, &recorded).unwrap_or(false) {
287                recorded
288            } else {
289                merge_base
290            },
291        ),
292        (recorded, merge_base) => recorded.or(merge_base),
293    })
294}
295
296/// Whether `branch`'s recorded fork point is still current: present, an
297/// ancestor of the branch, and not stale (not a proper ancestor of the live
298/// `merge_base(parent, branch)`). A stale one must be re-recorded.
299pub(crate) fn base_is_current(branch: &str, parent: &str) -> Result<bool> {
300    let Some(base) = base_of(branch)? else {
301        return Ok(false);
302    };
303    if !git::is_ancestor(&base, branch).unwrap_or(false) {
304        return Ok(false);
305    }
306    Ok(match git::merge_base(parent, branch) {
307        Ok(merge_base) => git::is_ancestor(&merge_base, &base).unwrap_or(false),
308        Err(_) => true,
309    })
310}
311
312/// The root of the stack containing `branch` (the base everything sits on).
313pub fn stack_root(branch: &str) -> Result<String> {
314    let parents = parent_map()?;
315    Ok(root_for(branch, &parents))
316}
317
318pub fn branch_and_descendants(branch: &str) -> Result<Vec<String>> {
319    let parents = parent_map()?;
320    let children = children_map(&parents);
321    let mut branches = vec![branch.to_owned()];
322    let mut visited = BTreeSet::from([branch.to_owned()]);
323    collect_descendants(branch, &children, &mut branches, &mut visited);
324    Ok(branches)
325}
326
327/// Every branch in the stack containing `branch`, parent-first: the line from
328/// the stack bottom up through `branch`, plus everything above it. Sibling
329/// stacks that share only the trunk are left out - they branch off the trunk
330/// separately, not through `branch`. The trunk itself is excluded; an
331/// unanchored root stays in (`path_from_root` keeps it).
332pub fn stack_line(branch: &str) -> Result<Vec<String>> {
333    // The trunk is not part of any stack, so standing on it your line is empty
334    // - its descendants are sibling stacks, each left for its own submit.
335    // Without this, `branch_and_descendants(trunk)` would pull in every stack.
336    let trunk = trunk_branch(&git::local_branches()?);
337    if Some(branch) == trunk.as_deref() {
338        return Ok(Vec::new());
339    }
340
341    let mut line = path_from_root(branch)?; // [bottom ..= branch]
342    let above = branch_and_descendants(branch)?; // [branch, ..descendants]
343    line.extend(above.into_iter().skip(1)); // append above-branch, dropping the duplicate
344
345    // `path_from_root` keeps its starting branch even when that is the trunk
346    // (you are standing on it); a trunk is never part of a stack.
347    line.retain(|candidate| Some(candidate) != trunk.as_ref());
348    Ok(line)
349}
350
351/// The base of `branch`'s own line: its topmost non-trunk ancestor (the branch
352/// just above the trunk), or `branch` itself when it has no parent. This is the
353/// anchor for "the current stack" - the subtree under it includes genuine fork
354/// siblings but excludes stacks that merely share the trunk - so `restack`,
355/// `list`, `sync`, and `run` all agree on scope. Unlike [`stack_root`], which
356/// collapses a trunk-anchored line all the way to the trunk and so sweeps in
357/// every sibling stack.
358pub(crate) fn line_base(branch: &str) -> Result<String> {
359    Ok(path_from_root(branch)?
360        .into_iter()
361        .next()
362        .unwrap_or_else(|| branch.to_owned()))
363}
364
365/// Every branch in the stack `branch` belongs to, trunk excluded: the whole
366/// subtree under the stack's base (so fork siblings are included too), unlike
367/// [`stack_line`] which is only `branch`'s own line. The base is the bottom of
368/// `branch`'s line - its topmost non-trunk ancestor - so sibling stacks that
369/// merely share the trunk are left out, exactly as they are for [`stack_line`].
370/// For an unanchored stack the base is its real root branch, itself stacked,
371/// so it stays in.
372pub fn current_stack_branches(branch: &str) -> Result<Vec<String>> {
373    let base = line_base(branch)?;
374    let trunk = trunk_branch(&git::local_branches()?);
375    Ok(branch_and_descendants(&base)?
376        .into_iter()
377        .filter(|candidate| Some(candidate) != trunk.as_ref())
378        .collect())
379}
380
381/// The branches `list` may annotate with review info: the current stack, or -
382/// with `all` - every stacked branch. A superset of what the tree actually
383/// draws is fine here; its only job is to bound which branches get a per-branch
384/// review lookup, so `list` never queries every open PR in the repo.
385pub fn listed_branches(all: bool) -> Result<BTreeSet<String>> {
386    if all {
387        Ok(parent_map()?
388            .into_iter()
389            .flat_map(|(child, parent)| [child, parent])
390            .collect())
391    } else {
392        let current = git::current_branch()?;
393        Ok(current_stack_branches(&current)?.into_iter().collect())
394    }
395}
396
397/// Publish the current stack's parent map to the shared metadata ref so
398/// another clone can rebuild it. Best effort: a failure warns but never aborts
399/// the push that triggered it.
400pub fn publish_metadata(remote: &str) {
401    if let Err(error) = try_publish_metadata(remote) {
402        anstream::eprintln!(
403            "{}",
404            style::warn(&format!("could not publish stack metadata: {error:#}"))
405        );
406    }
407}
408
409fn try_publish_metadata(remote: &str) -> Result<()> {
410    let current = git::current_branch()?;
411    let trunk = trunk_branch(&git::local_branches()?);
412
413    let mut parents = serde_json::Map::new();
414    for branch in current_stack_branches(&current)? {
415        if let Some(parent) = parent_of(&branch)? {
416            parents.insert(branch, Value::String(parent));
417        }
418    }
419    if parents.is_empty() {
420        return Ok(());
421    }
422
423    let document = json!({ "trunk": trunk, "parents": parents });
424    git::write_blob_ref(METADATA_REF, METADATA_FILE, &document.to_string())?;
425    git::push_ref(remote, METADATA_REF)
426}
427
428/// Rebuild local stack metadata from the shared ref, fetching any listed
429/// branch that is not present locally. Returns how many branches it attached.
430pub fn apply_remote_metadata(remote: &str) -> Result<usize> {
431    git::fetch_ref(remote, METADATA_REF)
432        .context("no stack metadata on the remote - push it from the other machine first")?;
433    let Some(content) = git::read_ref_file(METADATA_REF, METADATA_FILE)? else {
434        bail!("the remote stack metadata is empty");
435    };
436
437    let document: Value =
438        serde_json::from_str(&content).context("failed to parse remote stack metadata")?;
439    let parents = document
440        .get("parents")
441        .and_then(Value::as_object)
442        .context("remote stack metadata is malformed")?;
443
444    // The metadata comes from a remote, so the names are untrusted. Drop any
445    // that aren't safe to hand to git as an argument before they reach
446    // `fetch`/`rebase`: a name like `--upload-pack=...` would be read as a git
447    // option, not a branch.
448    let mut pairs = Vec::new();
449    for (branch, parent) in parents {
450        let Some(parent) = parent.as_str() else {
451            continue;
452        };
453        if !is_safe_ref_name(branch) || !is_safe_ref_name(parent) {
454            anstream::eprintln!(
455                "{}",
456                style::warn(&format!(
457                    "skipping unsafe stack metadata entry: {branch:?} -> {parent:?}"
458                ))
459            );
460            continue;
461        }
462        pairs.push((branch.clone(), parent.to_owned()));
463    }
464
465    // Fetch every listed branch first, so each parent resolves locally before
466    // we record it.
467    let local: BTreeSet<String> = git::local_branches()?.into_iter().collect();
468    for (branch, _) in &pairs {
469        if !local.contains(branch) {
470            git::fetch_branch(remote, branch)
471                .with_context(|| format!("failed to fetch {branch} from {remote}"))?;
472        }
473    }
474
475    let mut attached = 0;
476    for (branch, parent) in &pairs {
477        set_parent(branch, parent)?;
478        record_base(branch, parent);
479        attached += 1;
480        anstream::println!(
481            "attached {} to {}",
482            style::branch(branch),
483            style::branch(parent)
484        );
485    }
486    Ok(attached)
487}
488
489/// Whether a branch name from untrusted remote metadata is safe to hand to git
490/// as an argument: non-empty, not an option (`-...`), and free of whitespace
491/// and control characters. git rejects other invalid refs itself; this guards
492/// the one thing it would not - a name it would parse as a flag.
493pub(crate) fn is_safe_ref_name(name: &str) -> bool {
494    !name.is_empty()
495        && !name.starts_with('-')
496        && !name.chars().any(|c| c.is_whitespace() || c.is_control())
497}
498
499/// The stack path from the bottom up to (and including) `branch`,
500/// parent-first; descendants above it are left out.
501pub fn path_from_root(branch: &str) -> Result<Vec<String>> {
502    let trunk = trunk_branch(&git::local_branches()?);
503    let mut path = vec![branch.to_owned()];
504    let mut seen = BTreeSet::from([branch.to_owned()]);
505
506    let mut cursor = branch.to_owned();
507    while let Some(parent) = parent_of(&cursor)? {
508        if Some(&parent) == trunk.as_ref() || !seen.insert(parent.clone()) {
509            break;
510        }
511        path.push(parent.clone());
512        cursor = parent;
513    }
514
515    path.reverse();
516    Ok(path)
517}
518
519/// (branch, parent) pairs for the branches that have a stack parent;
520/// branches without one are skipped.
521pub fn branch_parents(branches: &[String]) -> Result<Vec<(String, String)>> {
522    let mut pairs = Vec::new();
523    for branch in branches {
524        if let Some(parent) = parent_of(branch)? {
525            pairs.push((branch.clone(), parent));
526        }
527    }
528    Ok(pairs)
529}
530
531fn parent_map() -> Result<BTreeMap<String, String>> {
532    let mut parents = BTreeMap::new();
533    for branch in git::local_branches()? {
534        if let Some(parent) = parent_of(&branch)? {
535            parents.insert(branch, parent);
536        }
537    }
538    Ok(parents)
539}
540
541fn collect_descendants(
542    branch: &str,
543    children: &BTreeMap<String, Vec<String>>,
544    branches: &mut Vec<String>,
545    visited: &mut BTreeSet<String>,
546) {
547    if let Some(branch_children) = children.get(branch) {
548        for child in branch_children {
549            if !visited.insert(child.to_owned()) {
550                continue; // cyclic metadata; mirror the guard in path_from_root/root_for
551            }
552            branches.push(child.to_owned());
553            collect_descendants(child, children, branches, visited);
554        }
555    }
556}
557
558pub(crate) fn children_of(parent: &str) -> Result<Vec<String>> {
559    Ok(parent_map()?
560        .into_iter()
561        .filter_map(|(branch, branch_parent)| (branch_parent == parent).then_some(branch))
562        .collect())
563}
564
565fn children_map(parents: &BTreeMap<String, String>) -> BTreeMap<String, Vec<String>> {
566    let mut children: BTreeMap<String, Vec<String>> = BTreeMap::new();
567    for (branch, parent) in parents {
568        children
569            .entry(parent.to_owned())
570            .or_default()
571            .push(branch.to_owned());
572    }
573    children
574}
575
576fn root_for(branch: &str, parents: &BTreeMap<String, String>) -> String {
577    let mut root = branch.to_owned();
578    let mut seen = BTreeSet::new();
579
580    while let Some(parent) = parents.get(&root) {
581        if !seen.insert(root.clone()) {
582            break;
583        }
584        root = parent.to_owned();
585    }
586
587    root
588}
589
590pub(crate) fn parent_of(branch: &str) -> Result<Option<String>> {
591    git::config_get(&parent_key(branch))
592}
593
594pub(crate) fn base_of(branch: &str) -> Result<Option<String>> {
595    git::config_get(&base_key(branch))
596}
597
598pub(crate) fn set_parent(branch: &str, parent: &str) -> Result<()> {
599    git::config_set(&parent_key(branch), parent)
600}
601
602pub(crate) fn unset_parent(branch: &str) -> Result<()> {
603    git::config_unset(&parent_key(branch))
604}
605
606pub(crate) fn set_base(branch: &str, base: &str) -> Result<()> {
607    git::config_set(&base_key(branch), base)
608}
609
610pub(crate) fn unset_base(branch: &str) -> Result<()> {
611    git::config_unset(&base_key(branch))
612}
613
614fn parent_key(branch: &str) -> String {
615    format!("branch.{branch}.{PARENT_KEY}")
616}
617
618fn base_key(branch: &str) -> String {
619    format!("branch.{branch}.{BASE_KEY}")
620}
621
622fn renamed_from_key(branch: &str) -> String {
623    format!("branch.{branch}.{RENAMED_FROM_KEY}")
624}
625
626#[cfg(test)]
627mod tests {
628    use super::is_safe_ref_name;
629
630    #[test]
631    fn safe_ref_names_pass() {
632        assert!(is_safe_ref_name("main"));
633        assert!(is_safe_ref_name("feature/a"));
634        assert!(is_safe_ref_name("user/fix-123"));
635    }
636
637    #[test]
638    fn unsafe_ref_names_are_rejected() {
639        // The injection vector: a name git would parse as an option.
640        assert!(!is_safe_ref_name("--upload-pack=touch /tmp/pwned"));
641        assert!(!is_safe_ref_name("-x"));
642        // Whitespace / control chars (newline-bearing refspecs, etc.).
643        assert!(!is_safe_ref_name("a branch"));
644        assert!(!is_safe_ref_name("a\nb"));
645        assert!(!is_safe_ref_name("a\tb"));
646        assert!(!is_safe_ref_name(""));
647    }
648}