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
81pub fn print_stack() -> Result<()> {
82    let current = git::current_branch()?;
83    let parents = parent_map()?;
84    let root = root_for(&current, &parents);
85    let children = children_map(&parents);
86    let trunk = trunk_branch(&git::local_branches()?);
87
88    let mut lines = Vec::new();
89    collect_tree_lines(
90        &root,
91        &current,
92        trunk.as_deref(),
93        &children,
94        0,
95        &mut BTreeSet::new(),
96        &mut lines,
97    );
98
99    // Leaf-first, trunk last: the stack reads like a pile sitting on its
100    // base, matching the up/down direction of navigation.
101    for line in lines.iter().rev() {
102        println!("{line}");
103    }
104    Ok(())
105}
106
107/// The trunk branch: the remote's default branch when known locally,
108/// otherwise a conventional name that exists.
109pub fn trunk_branch(branches: &[String]) -> Option<String> {
110    let remote = settings::remote().unwrap_or_else(|_| settings::DEFAULT_REMOTE.to_owned());
111    if let Some(default) = git::remote_default_branch(&remote) {
112        return Some(default);
113    }
114
115    ["main", "master"]
116        .iter()
117        .find(|name| branches.iter().any(|branch| branch == *name))
118        .map(|name| (*name).to_owned())
119}
120
121pub fn adopt_branch(branch: &str, parent: &str) -> Result<()> {
122    if branch == parent {
123        bail!("a branch cannot be its own stack parent");
124    }
125
126    let branches: BTreeSet<_> = git::local_branches()?.into_iter().collect();
127    if !branches.contains(branch) {
128        bail!("branch {branch} does not exist");
129    }
130    if !branches.contains(parent) {
131        bail!("parent branch {parent} does not exist");
132    }
133
134    set_parent(branch, parent)?;
135    record_base(branch, parent);
136    println!("attached {branch} to {parent}");
137    Ok(())
138}
139
140pub fn detach_branch(branch: Option<&str>) -> Result<()> {
141    let branch = branch
142        .map(str::to_owned)
143        .map_or_else(git::current_branch, Ok)?;
144    unset_parent(&branch)?;
145    unset_base(&branch)?;
146    println!("detached {branch}");
147    Ok(())
148}
149
150pub fn restack(update_refs_mode: UpdateRefsMode, push_mode: PushMode) -> Result<()> {
151    let current = git::current_branch()?;
152    let parents = parent_map()?;
153    // Restack the whole stack containing the current branch, from anywhere
154    // in it: walk to the root, then rebase its descendants parent-first.
155    let root = root_for(&current, &parents);
156    let branches = restack_order(&root, &parents);
157
158    if branches.is_empty() {
159        println!("nothing to restack");
160        return Ok(());
161    }
162
163    let update_refs = resolve_update_refs(update_refs_mode)?;
164    let push = settings::push_enabled(push_mode, settings::PUSH_ON_RESTACK_KEY)?;
165
166    clear_state()?;
167    let all = branches.clone();
168    restack_branches(branches, &parents, update_refs, push, &all)
169}
170
171pub fn continue_restack() -> Result<()> {
172    let Some(state) = RestackState::read()? else {
173        bail!("no interrupted restack found");
174    };
175
176    if let Err(error) = git::rebase_continue() {
177        eprintln!("restack still has conflicts");
178        eprintln!("resolve conflicts, then run `git stk continue`");
179        eprintln!("or run `git stk abort`");
180        return Err(error);
181    }
182
183    record_base(&state.branch, &state.parent);
184
185    if state.remaining.is_empty() {
186        clear_state()?;
187        finish_restack(&state.all, state.push)?;
188        return Ok(());
189    }
190
191    let parents = parent_map()?;
192    restack_branches(
193        state.remaining,
194        &parents,
195        state.update_refs,
196        state.push,
197        &state.all,
198    )
199}
200
201pub fn abort_restack() -> Result<()> {
202    git::rebase_abort()?;
203    clear_state()?;
204    println!("restack aborted");
205    Ok(())
206}
207
208pub fn parent_for_branch(branch: &str) -> Result<Option<String>> {
209    parent_of(branch)
210}
211
212pub fn children_for_branch(branch: &str) -> Result<Vec<String>> {
213    children_of(branch)
214}
215
216pub fn set_parent_for_branch(branch: &str, parent: &str) -> Result<()> {
217    set_parent(branch, parent)
218}
219
220pub fn unset_parent_for_branch(branch: &str) -> Result<()> {
221    unset_parent(branch)
222}
223
224pub fn base_for_branch(branch: &str) -> Result<Option<String>> {
225    base_of(branch)
226}
227
228pub fn set_base_for_branch(branch: &str, base: &str) -> Result<()> {
229    git::config_set(&base_key(branch), base)
230}
231
232pub fn unset_base_for_branch(branch: &str) -> Result<()> {
233    unset_base(branch)
234}
235
236/// Record the fork point between a branch and its parent (best effort; e.g.
237/// unrelated histories have no merge base, which is not an error here).
238pub fn record_base(branch: &str, parent: &str) {
239    if let Ok(base) = git::merge_base(parent, branch) {
240        let _ = git::config_set(&base_key(branch), &base);
241    }
242}
243
244/// The root of the stack containing `branch` (the base everything sits on).
245pub fn stack_root(branch: &str) -> Result<String> {
246    let parents = parent_map()?;
247    Ok(root_for(branch, &parents))
248}
249
250pub fn branch_and_descendants(branch: &str) -> Result<Vec<String>> {
251    let parents = parent_map()?;
252    let children = children_map(&parents);
253    let mut branches = vec![branch.to_owned()];
254    collect_descendants(branch, &children, &mut branches);
255    Ok(branches)
256}
257
258fn parent_map() -> Result<BTreeMap<String, String>> {
259    let mut parents = BTreeMap::new();
260    for branch in git::local_branches()? {
261        if let Some(parent) = parent_of(&branch)? {
262            parents.insert(branch, parent);
263        }
264    }
265    Ok(parents)
266}
267
268fn restack_order(current: &str, parents: &BTreeMap<String, String>) -> Vec<String> {
269    let children = children_map(parents);
270    let mut branches = Vec::new();
271
272    if parents.contains_key(current) {
273        branches.push(current.to_owned());
274    }
275
276    collect_descendants(current, &children, &mut branches);
277    branches
278}
279
280fn collect_descendants(
281    branch: &str,
282    children: &BTreeMap<String, Vec<String>>,
283    branches: &mut Vec<String>,
284) {
285    if let Some(branch_children) = children.get(branch) {
286        for child in branch_children {
287            branches.push(child.to_owned());
288            collect_descendants(child, children, branches);
289        }
290    }
291}
292
293fn restack_branches(
294    branches: Vec<String>,
295    parents: &BTreeMap<String, String>,
296    update_refs: bool,
297    push: bool,
298    all: &[String],
299) -> Result<()> {
300    for (index, branch) in branches.iter().enumerate() {
301        let Some(parent) = parents.get(branch) else {
302            bail!("{branch} has no stack parent");
303        };
304
305        // Replay only the commits after the recorded fork point so commits
306        // that landed upstream via squash or rebase merges are not repeated.
307        // A base that is no longer an ancestor (stale or garbage) falls back
308        // to a plain rebase.
309        let base = match base_of(branch)? {
310            Some(base) if git::is_ancestor(&base, branch).unwrap_or(false) => Some(base),
311            _ => None,
312        };
313
314        // Already sitting exactly on the parent tip with a fresh fork point:
315        // skip the rebase entirely. (git rebase --update-refs would otherwise
316        // replay and rewrite identical commits with new hashes.)
317        let parent_tip = git::rev_parse(parent)?;
318        if base.as_deref() == Some(parent_tip.as_str())
319            && git::is_ancestor(parent, branch).unwrap_or(false)
320        {
321            println!("{branch} already up to date with {parent}");
322            continue;
323        }
324
325        if update_refs {
326            println!("rebasing {branch} onto {parent} with --update-refs");
327        } else {
328            println!("rebasing {branch} onto {parent}");
329        }
330        let rebase_result = match &base {
331            Some(base) => git::rebase_onto(parent, base, branch, update_refs),
332            None => git::rebase(parent, branch, update_refs),
333        };
334
335        if let Err(error) = rebase_result {
336            let remaining = branches[index + 1..].to_vec();
337            RestackState {
338                branch: branch.to_owned(),
339                parent: parent.to_owned(),
340                remaining,
341                update_refs,
342                push,
343                all: all.to_vec(),
344            }
345            .write()?;
346
347            eprintln!("conflict while rebasing {branch} onto {parent}");
348            eprintln!("resolve conflicts, then run `git stk continue`");
349            eprintln!("or run `git stk abort`");
350            return Err(error);
351        }
352
353        record_base(branch, parent);
354    }
355
356    clear_state()?;
357    finish_restack(all, push)
358}
359
360/// After every branch has been rebased: push the rewritten branches, or print
361/// the exact command so stale remote PR diffs are a copy-paste away from fixed.
362fn finish_restack(branches: &[String], push: bool) -> Result<()> {
363    println!("restack complete");
364
365    let remote = settings::remote()?;
366    if push {
367        git::push_force_with_lease(&remote, branches)?;
368        println!("pushed {} to {remote}", branches.join(" "));
369    } else {
370        println!("remote branches may be stale; push them with:");
371        println!(
372            "  git push --force-with-lease {remote} {}",
373            branches.join(" ")
374        );
375    }
376    Ok(())
377}
378
379fn resolve_update_refs(mode: UpdateRefsMode) -> Result<bool> {
380    match mode {
381        UpdateRefsMode::Config => {
382            let configured = git::config_get_bool(settings::UPDATE_REFS_KEY)?.unwrap_or(false);
383            if configured && !git::supports_rebase_update_refs()? {
384                eprintln!("stk.updateRefs is true, but this Git does not support --update-refs");
385                return Ok(false);
386            }
387            Ok(configured)
388        }
389        UpdateRefsMode::Enabled => {
390            if !git::supports_rebase_update_refs()? {
391                bail!("--update-refs was requested, but this Git does not support it");
392            }
393            Ok(true)
394        }
395        UpdateRefsMode::Disabled => Ok(false),
396    }
397}
398
399fn children_of(parent: &str) -> Result<Vec<String>> {
400    Ok(parent_map()?
401        .into_iter()
402        .filter_map(|(branch, branch_parent)| (branch_parent == parent).then_some(branch))
403        .collect())
404}
405
406fn children_map(parents: &BTreeMap<String, String>) -> BTreeMap<String, Vec<String>> {
407    let mut children: BTreeMap<String, Vec<String>> = BTreeMap::new();
408    for (branch, parent) in parents {
409        children
410            .entry(parent.to_owned())
411            .or_default()
412            .push(branch.to_owned());
413    }
414    children
415}
416
417fn root_for(branch: &str, parents: &BTreeMap<String, String>) -> String {
418    let mut root = branch.to_owned();
419    let mut seen = BTreeSet::new();
420
421    while let Some(parent) = parents.get(&root) {
422        if !seen.insert(root.clone()) {
423            break;
424        }
425        root = parent.to_owned();
426    }
427
428    root
429}
430
431#[allow(clippy::too_many_arguments)]
432fn collect_tree_lines(
433    branch: &str,
434    current: &str,
435    trunk: Option<&str>,
436    children: &BTreeMap<String, Vec<String>>,
437    depth: usize,
438    seen: &mut BTreeSet<String>,
439    lines: &mut Vec<String>,
440) {
441    let mut line = format!("{}{}", "  ".repeat(depth), branch);
442    if Some(branch) == trunk {
443        line.push_str(" (trunk)");
444    }
445    if branch == current {
446        line.push_str(" *");
447    }
448    lines.push(line);
449
450    if !seen.insert(branch.to_owned()) {
451        lines.push(format!("{}<cycle detected>", "  ".repeat(depth + 1)));
452        return;
453    }
454
455    if let Some(branch_children) = children.get(branch) {
456        for child in branch_children {
457            collect_tree_lines(child, current, trunk, children, depth + 1, seen, lines);
458        }
459    }
460}
461
462fn parent_of(branch: &str) -> Result<Option<String>> {
463    git::config_get(&parent_key(branch))
464}
465
466fn base_of(branch: &str) -> Result<Option<String>> {
467    git::config_get(&base_key(branch))
468}
469
470fn set_parent(branch: &str, parent: &str) -> Result<()> {
471    git::config_set(&parent_key(branch), parent)
472}
473
474fn unset_parent(branch: &str) -> Result<()> {
475    git::config_unset(&parent_key(branch))
476}
477
478fn unset_base(branch: &str) -> Result<()> {
479    git::config_unset(&base_key(branch))
480}
481
482fn parent_key(branch: &str) -> String {
483    format!("branch.{branch}.{PARENT_KEY}")
484}
485
486fn base_key(branch: &str) -> String {
487    format!("branch.{branch}.{BASE_KEY}")
488}
489
490#[derive(Debug, Eq, PartialEq)]
491struct RestackState {
492    branch: String,
493    parent: String,
494    remaining: Vec<String>,
495    update_refs: bool,
496    push: bool,
497    /// Every branch in the interrupted restack, so the post-restack push (or
498    /// push hint) can cover branches rebased before the conflict too.
499    all: Vec<String>,
500}
501
502impl RestackState {
503    fn read() -> Result<Option<Self>> {
504        let path = state_path()?;
505        if !path.exists() {
506            return Ok(None);
507        }
508
509        let contents = fs::read_to_string(&path)
510            .with_context(|| format!("failed to read {}", path.display()))?;
511        let mut branch = None;
512        let mut parent = None;
513        let mut remaining = Vec::new();
514        let mut update_refs = false;
515        let mut push = false;
516        let mut all = Vec::new();
517
518        for line in contents.lines() {
519            if let Some(value) = line.strip_prefix("branch=") {
520                branch = Some(value.to_owned());
521            } else if let Some(value) = line.strip_prefix("parent=") {
522                parent = Some(value.to_owned());
523            } else if let Some(value) = line.strip_prefix("updateRefs=") {
524                update_refs = value == "true";
525            } else if let Some(value) = line.strip_prefix("push=") {
526                push = value == "true";
527            } else if let Some(value) = line.strip_prefix("remaining=") {
528                remaining = value
529                    .split('\t')
530                    .filter(|branch| !branch.is_empty())
531                    .map(str::to_owned)
532                    .collect();
533            } else if let Some(value) = line.strip_prefix("all=") {
534                all = value
535                    .split('\t')
536                    .filter(|branch| !branch.is_empty())
537                    .map(str::to_owned)
538                    .collect();
539            }
540        }
541
542        let Some(branch) = branch else {
543            bail!("restack state is missing current branch");
544        };
545        let Some(parent) = parent else {
546            bail!("restack state is missing parent branch");
547        };
548
549        Ok(Some(Self {
550            branch,
551            parent,
552            remaining,
553            update_refs,
554            push,
555            all,
556        }))
557    }
558
559    fn write(&self) -> Result<()> {
560        let path = state_path()?;
561        let contents = format!(
562            "branch={}\nparent={}\nupdateRefs={}\npush={}\nremaining={}\nall={}\n",
563            self.branch,
564            self.parent,
565            self.update_refs,
566            self.push,
567            self.remaining.join("\t"),
568            self.all.join("\t")
569        );
570        fs::write(&path, contents).with_context(|| format!("failed to write {}", path.display()))
571    }
572}
573
574fn clear_state() -> Result<()> {
575    let path = state_path()?;
576    if path.exists() {
577        fs::remove_file(&path).with_context(|| format!("failed to remove {}", path.display()))?;
578    }
579    Ok(())
580}
581
582fn state_path() -> Result<PathBuf> {
583    Ok(PathBuf::from(git::git_path(STATE_FILE)?))
584}