Skip to main content

git_stk/
stack.rs

1use std::{
2    collections::{BTreeMap, BTreeSet},
3    fs,
4    path::PathBuf,
5};
6
7use anyhow::{Context, Result, bail};
8
9use crate::cli::{PushMode, UpdateRefsMode};
10use crate::git;
11use crate::settings;
12
13const PARENT_KEY: &str = "stkParent";
14const BASE_KEY: &str = "stkBase";
15const STATE_FILE: &str = "stack-state";
16
17pub fn create_branch(branch: &str) -> Result<()> {
18    let parent = git::current_branch()?;
19    git::create_branch(branch)?;
20    set_parent(branch, &parent)?;
21    record_base(branch, &parent);
22    println!("created {branch} with parent {parent}");
23    Ok(())
24}
25
26pub fn print_parent(branch: Option<&str>) -> Result<()> {
27    let branch = branch
28        .map(str::to_owned)
29        .map_or_else(git::current_branch, Ok)?;
30    match parent_of(&branch)? {
31        Some(parent) => println!("{parent}"),
32        None => bail!("{branch} has no stack parent"),
33    }
34    Ok(())
35}
36
37pub fn print_children(branch: Option<&str>) -> Result<()> {
38    let branch = branch
39        .map(str::to_owned)
40        .map_or_else(git::current_branch, Ok)?;
41    for child in children_of(&branch)? {
42        println!("{child}");
43    }
44    Ok(())
45}
46
47pub fn checkout_parent() -> Result<()> {
48    let current = git::current_branch()?;
49    let Some(parent) = parent_of(&current)? else {
50        bail!("{current} has no stack parent");
51    };
52
53    git::checkout(&parent)
54}
55
56pub fn checkout_child(branch: Option<&str>) -> Result<()> {
57    let current = git::current_branch()?;
58    let children = children_of(&current)?;
59    let child = match (branch, children.as_slice()) {
60        (Some(branch), _) => {
61            if children.iter().any(|child| child == branch) {
62                branch.to_owned()
63            } else {
64                bail!("{branch} is not a stack child of {current}");
65            }
66        }
67        (None, [child]) => child.to_owned(),
68        (None, []) => bail!("{current} has no stack children"),
69        (None, _) => {
70            eprintln!("{current} has multiple stack children:");
71            for child in children {
72                eprintln!("  {child}");
73            }
74            bail!("choose one with `git stk up <branch>`");
75        }
76    };
77
78    git::checkout(&child)
79}
80
81/// Check out the leaf of the current stack, following single children. A
82/// fork is ambiguous, like `up` without a branch.
83pub fn checkout_top() -> Result<()> {
84    let current = git::current_branch()?;
85    let mut top = current.clone();
86    loop {
87        let children = children_of(&top)?;
88        match children.as_slice() {
89            [] => break,
90            [child] => top = child.clone(),
91            _ => {
92                eprintln!("{top} has multiple stack children:");
93                for child in children {
94                    eprintln!("  {child}");
95                }
96                bail!("walk up from {top} with `git stk up <branch>`");
97            }
98        }
99    }
100
101    if top == current {
102        if children_of(&current)?.is_empty() && parent_of(&current)?.is_none() {
103            bail!("{current} is not in a stack");
104        }
105        println!("{current} is already at the top of the stack");
106        return Ok(());
107    }
108    git::checkout(&top)
109}
110
111/// Check out the bottom of the current stack: the branch just above the
112/// trunk. From the trunk itself, a single stacked child is unambiguous.
113pub fn checkout_bottom() -> Result<()> {
114    let current = git::current_branch()?;
115    let trunk = trunk_branch(&git::local_branches()?);
116
117    let bottom = if Some(&current) == trunk.as_ref() {
118        let children = children_of(&current)?;
119        match children.as_slice() {
120            [child] => child.clone(),
121            [] => bail!("{current} has no stacked branches"),
122            _ => {
123                eprintln!("{current} has multiple stack children:");
124                for child in children {
125                    eprintln!("  {child}");
126                }
127                bail!("choose one with `git stk up <branch>`");
128            }
129        }
130    } else {
131        let mut bottom = current.clone();
132        while let Some(parent) = parent_of(&bottom)? {
133            if Some(&parent) == trunk.as_ref() {
134                break;
135            }
136            bottom = parent;
137        }
138        bottom
139    };
140
141    if bottom == current {
142        if parent_of(&current)?.is_none() && children_of(&current)?.is_empty() {
143            bail!("{current} is not in a stack");
144        }
145        println!("{current} is already at the bottom of the stack");
146        return Ok(());
147    }
148    git::checkout(&bottom)
149}
150
151pub fn print_stack() -> Result<()> {
152    let current = git::current_branch()?;
153    let parents = parent_map()?;
154    let root = root_for(&current, &parents);
155    let children = children_map(&parents);
156    let trunk = trunk_branch(&git::local_branches()?);
157
158    let mut lines = Vec::new();
159    collect_tree_lines(
160        &root,
161        &current,
162        trunk.as_deref(),
163        &children,
164        0,
165        &mut BTreeSet::new(),
166        &mut lines,
167    );
168
169    // Leaf-first, trunk last: the stack reads like a pile sitting on its
170    // base, matching the up/down direction of navigation.
171    for line in lines.iter().rev() {
172        println!("{line}");
173    }
174
175    for branch in branch_and_descendants(&root)? {
176        if let Some(parent) = parents.get(&branch)
177            && let Some(hint) = behind_parent_hint(&branch, parent)
178        {
179            println!("hint: {hint}");
180        }
181    }
182    Ok(())
183}
184
185/// A restack nudge when `branch` is missing commits from its parent's tip.
186/// Local-only; a missing parent yields nothing.
187pub fn behind_parent_hint(branch: &str, parent: &str) -> Option<String> {
188    let behind = git::commits_behind(branch, parent)
189        .ok()
190        .filter(|count| *count > 0)?;
191    Some(format!(
192        "{branch} is {behind} commit{} behind {parent} - run `git stk restack`",
193        if behind == 1 { "" } else { "s" }
194    ))
195}
196
197/// The trunk branch: the remote's default branch when known locally,
198/// otherwise a conventional name that exists.
199pub fn trunk_branch(branches: &[String]) -> Option<String> {
200    let remote = settings::remote().unwrap_or_else(|_| settings::DEFAULT_REMOTE.to_owned());
201    if let Some(default) = git::remote_default_branch(&remote) {
202        return Some(default);
203    }
204
205    ["main", "master"]
206        .iter()
207        .find(|name| branches.iter().any(|branch| branch == *name))
208        .map(|name| (*name).to_owned())
209}
210
211pub fn adopt_branch(branch: &str, parent: &str) -> Result<()> {
212    if branch == parent {
213        bail!("a branch cannot be its own stack parent");
214    }
215
216    let branches: BTreeSet<_> = git::local_branches()?.into_iter().collect();
217    if !branches.contains(branch) {
218        bail!("branch {branch} does not exist");
219    }
220    if !branches.contains(parent) {
221        bail!("parent branch {parent} does not exist");
222    }
223
224    set_parent(branch, parent)?;
225    record_base(branch, parent);
226    println!("attached {branch} to {parent}");
227    Ok(())
228}
229
230pub fn detach_branch(branch: Option<&str>) -> Result<()> {
231    let branch = branch
232        .map(str::to_owned)
233        .map_or_else(git::current_branch, Ok)?;
234    unset_parent(&branch)?;
235    unset_base(&branch)?;
236    println!("detached {branch}");
237    Ok(())
238}
239
240/// Rename a branch and keep the stack intact. Git moves the branch's own
241/// metadata with the rename; children pointing at the old name are
242/// retargeted here.
243pub fn rename_branch(old: &str, new: &str) -> Result<()> {
244    let children = children_for_branch(old)?;
245    git::rename_branch(old, new)?;
246    println!("renamed {old} -> {new}");
247
248    for child in &children {
249        set_parent_for_branch(child, new)?;
250        println!("retargeted {child} -> {new}");
251    }
252    Ok(())
253}
254
255pub fn restack(update_refs_mode: UpdateRefsMode, push_mode: PushMode) -> Result<()> {
256    let current = git::current_branch()?;
257    let parents = parent_map()?;
258    // Restack the whole stack containing the current branch, from anywhere
259    // in it: walk to the root, then rebase its descendants parent-first.
260    let root = root_for(&current, &parents);
261    let branches = restack_order(&root, &parents);
262
263    if branches.is_empty() {
264        println!("nothing to restack");
265        return Ok(());
266    }
267
268    let update_refs = resolve_update_refs(update_refs_mode)?;
269    let push = settings::push_enabled(push_mode, settings::PUSH_ON_RESTACK_KEY)?;
270
271    clear_state()?;
272    let all = branches.clone();
273    restack_branches(branches, &parents, update_refs, push, &all)
274}
275
276pub fn continue_restack() -> Result<()> {
277    let Some(state) = RestackState::read()? else {
278        bail!("no interrupted restack found");
279    };
280
281    if let Err(error) = git::rebase_continue() {
282        eprintln!("restack still has conflicts");
283        eprintln!("resolve conflicts, then run `git stk continue`");
284        eprintln!("or run `git stk abort`");
285        return Err(error);
286    }
287
288    record_base(&state.branch, &state.parent);
289
290    if state.remaining.is_empty() {
291        clear_state()?;
292        finish_restack(&state.all, state.push)?;
293        return Ok(());
294    }
295
296    let parents = parent_map()?;
297    restack_branches(
298        state.remaining,
299        &parents,
300        state.update_refs,
301        state.push,
302        &state.all,
303    )
304}
305
306pub fn abort_restack() -> Result<()> {
307    git::rebase_abort()?;
308    clear_state()?;
309    println!("restack aborted");
310    Ok(())
311}
312
313pub fn parent_for_branch(branch: &str) -> Result<Option<String>> {
314    parent_of(branch)
315}
316
317pub fn children_for_branch(branch: &str) -> Result<Vec<String>> {
318    children_of(branch)
319}
320
321pub fn set_parent_for_branch(branch: &str, parent: &str) -> Result<()> {
322    set_parent(branch, parent)
323}
324
325pub fn unset_parent_for_branch(branch: &str) -> Result<()> {
326    unset_parent(branch)
327}
328
329pub fn base_for_branch(branch: &str) -> Result<Option<String>> {
330    base_of(branch)
331}
332
333pub fn set_base_for_branch(branch: &str, base: &str) -> Result<()> {
334    git::config_set(&base_key(branch), base)
335}
336
337pub fn unset_base_for_branch(branch: &str) -> Result<()> {
338    unset_base(branch)
339}
340
341/// Record the fork point between a branch and its parent (best effort; e.g.
342/// unrelated histories have no merge base, which is not an error here).
343pub fn record_base(branch: &str, parent: &str) {
344    if let Ok(base) = git::merge_base(parent, branch) {
345        let _ = git::config_set(&base_key(branch), &base);
346    }
347}
348
349/// The root of the stack containing `branch` (the base everything sits on).
350pub fn stack_root(branch: &str) -> Result<String> {
351    let parents = parent_map()?;
352    Ok(root_for(branch, &parents))
353}
354
355pub fn branch_and_descendants(branch: &str) -> Result<Vec<String>> {
356    let parents = parent_map()?;
357    let children = children_map(&parents);
358    let mut branches = vec![branch.to_owned()];
359    collect_descendants(branch, &children, &mut branches);
360    Ok(branches)
361}
362
363fn parent_map() -> Result<BTreeMap<String, String>> {
364    let mut parents = BTreeMap::new();
365    for branch in git::local_branches()? {
366        if let Some(parent) = parent_of(&branch)? {
367            parents.insert(branch, parent);
368        }
369    }
370    Ok(parents)
371}
372
373fn restack_order(current: &str, parents: &BTreeMap<String, String>) -> Vec<String> {
374    let children = children_map(parents);
375    let mut branches = Vec::new();
376
377    if parents.contains_key(current) {
378        branches.push(current.to_owned());
379    }
380
381    collect_descendants(current, &children, &mut branches);
382    branches
383}
384
385fn collect_descendants(
386    branch: &str,
387    children: &BTreeMap<String, Vec<String>>,
388    branches: &mut Vec<String>,
389) {
390    if let Some(branch_children) = children.get(branch) {
391        for child in branch_children {
392            branches.push(child.to_owned());
393            collect_descendants(child, children, branches);
394        }
395    }
396}
397
398fn restack_branches(
399    branches: Vec<String>,
400    parents: &BTreeMap<String, String>,
401    update_refs: bool,
402    push: bool,
403    all: &[String],
404) -> Result<()> {
405    for (index, branch) in branches.iter().enumerate() {
406        let Some(parent) = parents.get(branch) else {
407            bail!("{branch} has no stack parent");
408        };
409
410        // Replay only the commits after the recorded fork point so commits
411        // that landed upstream via squash or rebase merges are not repeated.
412        // A base that is no longer an ancestor (stale or garbage) falls back
413        // to a plain rebase.
414        let base = match base_of(branch)? {
415            Some(base) if git::is_ancestor(&base, branch).unwrap_or(false) => Some(base),
416            _ => None,
417        };
418
419        // Already sitting exactly on the parent tip with a fresh fork point:
420        // skip the rebase entirely. (git rebase --update-refs would otherwise
421        // replay and rewrite identical commits with new hashes.)
422        let parent_tip = git::rev_parse(parent)?;
423        if base.as_deref() == Some(parent_tip.as_str())
424            && git::is_ancestor(parent, branch).unwrap_or(false)
425        {
426            println!("{branch} already up to date with {parent}");
427            continue;
428        }
429
430        if update_refs {
431            println!("rebasing {branch} onto {parent} with --update-refs");
432        } else {
433            println!("rebasing {branch} onto {parent}");
434        }
435        let rebase_result = match &base {
436            Some(base) => git::rebase_onto(parent, base, branch, update_refs),
437            None => git::rebase(parent, branch, update_refs),
438        };
439
440        if let Err(error) = rebase_result {
441            let remaining = branches[index + 1..].to_vec();
442            RestackState {
443                branch: branch.to_owned(),
444                parent: parent.to_owned(),
445                remaining,
446                update_refs,
447                push,
448                all: all.to_vec(),
449            }
450            .write()?;
451
452            eprintln!("conflict while rebasing {branch} onto {parent}");
453            eprintln!("resolve conflicts, then run `git stk continue`");
454            eprintln!("or run `git stk abort`");
455            return Err(error);
456        }
457
458        record_base(branch, parent);
459    }
460
461    clear_state()?;
462    finish_restack(all, push)
463}
464
465/// After every branch has been rebased: push the rewritten branches, or print
466/// the exact command so stale remote PR diffs are a copy-paste away from fixed.
467fn finish_restack(branches: &[String], push: bool) -> Result<()> {
468    println!("restack complete");
469
470    let remote = settings::remote()?;
471    if push {
472        git::push_force_with_lease(&remote, branches)?;
473        println!("pushed {} to {remote}", branches.join(" "));
474    } else {
475        println!("remote branches may be stale; push them with:");
476        println!(
477            "  git push --force-with-lease {remote} {}",
478            branches.join(" ")
479        );
480    }
481    Ok(())
482}
483
484fn resolve_update_refs(mode: UpdateRefsMode) -> Result<bool> {
485    match mode {
486        UpdateRefsMode::Config => {
487            let configured = git::config_get_bool(settings::UPDATE_REFS_KEY)?.unwrap_or(false);
488            if configured && !git::supports_rebase_update_refs()? {
489                eprintln!("stk.updateRefs is true, but this Git does not support --update-refs");
490                return Ok(false);
491            }
492            Ok(configured)
493        }
494        UpdateRefsMode::Enabled => {
495            if !git::supports_rebase_update_refs()? {
496                bail!("--update-refs was requested, but this Git does not support it");
497            }
498            Ok(true)
499        }
500        UpdateRefsMode::Disabled => Ok(false),
501    }
502}
503
504fn children_of(parent: &str) -> Result<Vec<String>> {
505    Ok(parent_map()?
506        .into_iter()
507        .filter_map(|(branch, branch_parent)| (branch_parent == parent).then_some(branch))
508        .collect())
509}
510
511fn children_map(parents: &BTreeMap<String, String>) -> BTreeMap<String, Vec<String>> {
512    let mut children: BTreeMap<String, Vec<String>> = BTreeMap::new();
513    for (branch, parent) in parents {
514        children
515            .entry(parent.to_owned())
516            .or_default()
517            .push(branch.to_owned());
518    }
519    children
520}
521
522fn root_for(branch: &str, parents: &BTreeMap<String, String>) -> String {
523    let mut root = branch.to_owned();
524    let mut seen = BTreeSet::new();
525
526    while let Some(parent) = parents.get(&root) {
527        if !seen.insert(root.clone()) {
528            break;
529        }
530        root = parent.to_owned();
531    }
532
533    root
534}
535
536#[allow(clippy::too_many_arguments)]
537fn collect_tree_lines(
538    branch: &str,
539    current: &str,
540    trunk: Option<&str>,
541    children: &BTreeMap<String, Vec<String>>,
542    depth: usize,
543    seen: &mut BTreeSet<String>,
544    lines: &mut Vec<String>,
545) {
546    let mut line = format!("{}{}", "  ".repeat(depth), branch);
547    if Some(branch) == trunk {
548        line.push_str(" (trunk)");
549    }
550    if branch == current {
551        line.push_str(" *");
552    }
553    lines.push(line);
554
555    if !seen.insert(branch.to_owned()) {
556        lines.push(format!("{}<cycle detected>", "  ".repeat(depth + 1)));
557        return;
558    }
559
560    if let Some(branch_children) = children.get(branch) {
561        for child in branch_children {
562            collect_tree_lines(child, current, trunk, children, depth + 1, seen, lines);
563        }
564    }
565}
566
567fn parent_of(branch: &str) -> Result<Option<String>> {
568    git::config_get(&parent_key(branch))
569}
570
571fn base_of(branch: &str) -> Result<Option<String>> {
572    git::config_get(&base_key(branch))
573}
574
575fn set_parent(branch: &str, parent: &str) -> Result<()> {
576    git::config_set(&parent_key(branch), parent)
577}
578
579fn unset_parent(branch: &str) -> Result<()> {
580    git::config_unset(&parent_key(branch))
581}
582
583fn unset_base(branch: &str) -> Result<()> {
584    git::config_unset(&base_key(branch))
585}
586
587fn parent_key(branch: &str) -> String {
588    format!("branch.{branch}.{PARENT_KEY}")
589}
590
591fn base_key(branch: &str) -> String {
592    format!("branch.{branch}.{BASE_KEY}")
593}
594
595#[derive(Debug, Eq, PartialEq)]
596struct RestackState {
597    branch: String,
598    parent: String,
599    remaining: Vec<String>,
600    update_refs: bool,
601    push: bool,
602    /// Every branch in the interrupted restack, so the post-restack push (or
603    /// push hint) can cover branches rebased before the conflict too.
604    all: Vec<String>,
605}
606
607impl RestackState {
608    fn read() -> Result<Option<Self>> {
609        let path = state_path()?;
610        if !path.exists() {
611            return Ok(None);
612        }
613
614        let contents = fs::read_to_string(&path)
615            .with_context(|| format!("failed to read {}", path.display()))?;
616        let mut branch = None;
617        let mut parent = None;
618        let mut remaining = Vec::new();
619        let mut update_refs = false;
620        let mut push = false;
621        let mut all = Vec::new();
622
623        for line in contents.lines() {
624            if let Some(value) = line.strip_prefix("branch=") {
625                branch = Some(value.to_owned());
626            } else if let Some(value) = line.strip_prefix("parent=") {
627                parent = Some(value.to_owned());
628            } else if let Some(value) = line.strip_prefix("updateRefs=") {
629                update_refs = value == "true";
630            } else if let Some(value) = line.strip_prefix("push=") {
631                push = value == "true";
632            } else if let Some(value) = line.strip_prefix("remaining=") {
633                remaining = value
634                    .split('\t')
635                    .filter(|branch| !branch.is_empty())
636                    .map(str::to_owned)
637                    .collect();
638            } else if let Some(value) = line.strip_prefix("all=") {
639                all = value
640                    .split('\t')
641                    .filter(|branch| !branch.is_empty())
642                    .map(str::to_owned)
643                    .collect();
644            }
645        }
646
647        let Some(branch) = branch else {
648            bail!("restack state is missing current branch");
649        };
650        let Some(parent) = parent else {
651            bail!("restack state is missing parent branch");
652        };
653
654        Ok(Some(Self {
655            branch,
656            parent,
657            remaining,
658            update_refs,
659            push,
660            all,
661        }))
662    }
663
664    fn write(&self) -> Result<()> {
665        let path = state_path()?;
666        let contents = format!(
667            "branch={}\nparent={}\nupdateRefs={}\npush={}\nremaining={}\nall={}\n",
668            self.branch,
669            self.parent,
670            self.update_refs,
671            self.push,
672            self.remaining.join("\t"),
673            self.all.join("\t")
674        );
675        fs::write(&path, contents).with_context(|| format!("failed to write {}", path.display()))
676    }
677}
678
679fn clear_state() -> Result<()> {
680    let path = state_path()?;
681    if path.exists() {
682        fs::remove_file(&path).with_context(|| format!("failed to remove {}", path.display()))?;
683    }
684    Ok(())
685}
686
687fn state_path() -> Result<PathBuf> {
688    Ok(PathBuf::from(git::git_path(STATE_FILE)?))
689}