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;
11
12const PARENT_KEY: &str = "stkParent";
13const BASE_KEY: &str = "stkBase";
14const STATE_FILE: &str = "stack-state";
15const PUSH_ON_RESTACK_KEY: &str = "stk.pushOnRestack";
16const UPDATE_REFS_KEY: &str = "stk.updateRefs";
17const REMOTE_KEY: &str = "stk.remote";
18const DEFAULT_REMOTE: &str = "origin";
19
20pub fn create_branch(branch: &str) -> Result<()> {
21    let parent = git::current_branch()?;
22    git::create_branch(branch)?;
23    set_parent(branch, &parent)?;
24    record_base(branch, &parent);
25    println!("created {branch} with parent {parent}");
26    Ok(())
27}
28
29pub fn print_parent(branch: Option<&str>) -> Result<()> {
30    let branch = branch
31        .map(str::to_owned)
32        .map_or_else(git::current_branch, Ok)?;
33    match parent_of(&branch)? {
34        Some(parent) => println!("{parent}"),
35        None => bail!("{branch} has no stack parent"),
36    }
37    Ok(())
38}
39
40pub fn print_children(branch: Option<&str>) -> Result<()> {
41    let branch = branch
42        .map(str::to_owned)
43        .map_or_else(git::current_branch, Ok)?;
44    for child in children_of(&branch)? {
45        println!("{child}");
46    }
47    Ok(())
48}
49
50pub fn checkout_parent() -> Result<()> {
51    let current = git::current_branch()?;
52    let Some(parent) = parent_of(&current)? else {
53        bail!("{current} has no stack parent");
54    };
55
56    git::checkout(&parent)
57}
58
59pub fn checkout_child(branch: Option<&str>) -> Result<()> {
60    let current = git::current_branch()?;
61    let children = children_of(&current)?;
62    let child = match (branch, children.as_slice()) {
63        (Some(branch), _) => {
64            if children.iter().any(|child| child == branch) {
65                branch.to_owned()
66            } else {
67                bail!("{branch} is not a stack child of {current}");
68            }
69        }
70        (None, [child]) => child.to_owned(),
71        (None, []) => bail!("{current} has no stack children"),
72        (None, _) => {
73            eprintln!("{current} has multiple stack children:");
74            for child in children {
75                eprintln!("  {child}");
76            }
77            bail!("choose one with `git stk up <branch>`");
78        }
79    };
80
81    git::checkout(&child)
82}
83
84pub fn print_stack() -> Result<()> {
85    let current = git::current_branch()?;
86    let parents = parent_map()?;
87    let root = root_for(&current, &parents);
88    let children = children_map(&parents);
89    let trunk = trunk_branch(&git::local_branches()?);
90
91    let mut lines = Vec::new();
92    collect_tree_lines(
93        &root,
94        &current,
95        trunk.as_deref(),
96        &children,
97        0,
98        &mut BTreeSet::new(),
99        &mut lines,
100    );
101
102    // Leaf-first, trunk last: the stack reads like a pile sitting on its
103    // base, matching the up/down direction of navigation.
104    for line in lines.iter().rev() {
105        println!("{line}");
106    }
107    Ok(())
108}
109
110/// The trunk branch: the remote's default branch when known locally,
111/// otherwise a conventional name that exists.
112pub fn trunk_branch(branches: &[String]) -> Option<String> {
113    let remote = git::config_get(REMOTE_KEY)
114        .ok()
115        .flatten()
116        .unwrap_or_else(|| DEFAULT_REMOTE.to_owned());
117    if let Some(default) = git::remote_default_branch(&remote) {
118        return Some(default);
119    }
120
121    ["main", "master"]
122        .iter()
123        .find(|name| branches.iter().any(|branch| branch == *name))
124        .map(|name| (*name).to_owned())
125}
126
127pub fn adopt_branch(branch: &str, parent: &str) -> Result<()> {
128    if branch == parent {
129        bail!("a branch cannot be its own stack parent");
130    }
131
132    let branches: BTreeSet<_> = git::local_branches()?.into_iter().collect();
133    if !branches.contains(branch) {
134        bail!("branch {branch} does not exist");
135    }
136    if !branches.contains(parent) {
137        bail!("parent branch {parent} does not exist");
138    }
139
140    set_parent(branch, parent)?;
141    record_base(branch, parent);
142    println!("attached {branch} to {parent}");
143    Ok(())
144}
145
146pub fn detach_branch(branch: Option<&str>) -> Result<()> {
147    let branch = branch
148        .map(str::to_owned)
149        .map_or_else(git::current_branch, Ok)?;
150    unset_parent(&branch)?;
151    unset_base(&branch)?;
152    println!("detached {branch}");
153    Ok(())
154}
155
156pub fn restack(update_refs_mode: UpdateRefsMode, push_mode: PushMode) -> Result<()> {
157    let current = git::current_branch()?;
158    let parents = parent_map()?;
159    let branches = restack_order(&current, &parents);
160
161    if branches.is_empty() {
162        println!("nothing to restack");
163        return Ok(());
164    }
165
166    let update_refs = resolve_update_refs(update_refs_mode)?;
167    let push = resolve_push(push_mode)?;
168
169    clear_state()?;
170    let all = branches.clone();
171    restack_branches(branches, &parents, update_refs, push, &all)
172}
173
174pub fn continue_restack() -> Result<()> {
175    let Some(state) = RestackState::read()? else {
176        bail!("no interrupted restack found");
177    };
178
179    if let Err(error) = git::rebase_continue() {
180        eprintln!("restack still has conflicts");
181        eprintln!("resolve conflicts, then run `git stk continue`");
182        eprintln!("or run `git stk abort`");
183        return Err(error);
184    }
185
186    record_base(&state.branch, &state.parent);
187
188    if state.remaining.is_empty() {
189        clear_state()?;
190        finish_restack(&state.all, state.push)?;
191        return Ok(());
192    }
193
194    let parents = parent_map()?;
195    restack_branches(
196        state.remaining,
197        &parents,
198        state.update_refs,
199        state.push,
200        &state.all,
201    )
202}
203
204pub fn abort_restack() -> Result<()> {
205    git::rebase_abort()?;
206    clear_state()?;
207    println!("restack aborted");
208    Ok(())
209}
210
211pub fn parent_for_branch(branch: &str) -> Result<Option<String>> {
212    parent_of(branch)
213}
214
215pub fn children_for_branch(branch: &str) -> Result<Vec<String>> {
216    children_of(branch)
217}
218
219pub fn set_parent_for_branch(branch: &str, parent: &str) -> Result<()> {
220    set_parent(branch, parent)
221}
222
223pub fn unset_parent_for_branch(branch: &str) -> Result<()> {
224    unset_parent(branch)
225}
226
227pub fn base_for_branch(branch: &str) -> Result<Option<String>> {
228    base_of(branch)
229}
230
231pub fn set_base_for_branch(branch: &str, base: &str) -> Result<()> {
232    git::config_set(&base_key(branch), base)
233}
234
235pub fn unset_base_for_branch(branch: &str) -> Result<()> {
236    unset_base(branch)
237}
238
239/// Record the fork point between a branch and its parent (best effort; e.g.
240/// unrelated histories have no merge base, which is not an error here).
241pub fn record_base(branch: &str, parent: &str) {
242    if let Ok(base) = git::merge_base(parent, branch) {
243        let _ = git::config_set(&base_key(branch), &base);
244    }
245}
246
247/// The root of the stack containing `branch` (the base everything sits on).
248pub fn stack_root(branch: &str) -> Result<String> {
249    let parents = parent_map()?;
250    Ok(root_for(branch, &parents))
251}
252
253pub fn branch_and_descendants(branch: &str) -> Result<Vec<String>> {
254    let parents = parent_map()?;
255    let children = children_map(&parents);
256    let mut branches = vec![branch.to_owned()];
257    collect_descendants(branch, &children, &mut branches);
258    Ok(branches)
259}
260
261fn parent_map() -> Result<BTreeMap<String, String>> {
262    let mut parents = BTreeMap::new();
263    for branch in git::local_branches()? {
264        if let Some(parent) = parent_of(&branch)? {
265            parents.insert(branch, parent);
266        }
267    }
268    Ok(parents)
269}
270
271fn restack_order(current: &str, parents: &BTreeMap<String, String>) -> Vec<String> {
272    let children = children_map(parents);
273    let mut branches = Vec::new();
274
275    if parents.contains_key(current) {
276        branches.push(current.to_owned());
277    }
278
279    collect_descendants(current, &children, &mut branches);
280    branches
281}
282
283fn collect_descendants(
284    branch: &str,
285    children: &BTreeMap<String, Vec<String>>,
286    branches: &mut Vec<String>,
287) {
288    if let Some(branch_children) = children.get(branch) {
289        for child in branch_children {
290            branches.push(child.to_owned());
291            collect_descendants(child, children, branches);
292        }
293    }
294}
295
296fn restack_branches(
297    branches: Vec<String>,
298    parents: &BTreeMap<String, String>,
299    update_refs: bool,
300    push: bool,
301    all: &[String],
302) -> Result<()> {
303    for (index, branch) in branches.iter().enumerate() {
304        let Some(parent) = parents.get(branch) else {
305            bail!("{branch} has no stack parent");
306        };
307
308        if update_refs {
309            println!("rebasing {branch} onto {parent} with --update-refs");
310        } else {
311            println!("rebasing {branch} onto {parent}");
312        }
313
314        // Replay only the commits after the recorded fork point so commits
315        // that landed upstream via squash or rebase merges are not repeated.
316        // A base that is no longer an ancestor (stale or garbage) falls back
317        // to a plain rebase.
318        let base = match base_of(branch)? {
319            Some(base) if git::is_ancestor(&base, branch).unwrap_or(false) => Some(base),
320            _ => None,
321        };
322        let rebase_result = match &base {
323            Some(base) => git::rebase_onto(parent, base, branch, update_refs),
324            None => git::rebase(parent, branch, update_refs),
325        };
326
327        if let Err(error) = rebase_result {
328            let remaining = branches[index + 1..].to_vec();
329            RestackState {
330                branch: branch.to_owned(),
331                parent: parent.to_owned(),
332                remaining,
333                update_refs,
334                push,
335                all: all.to_vec(),
336            }
337            .write()?;
338
339            eprintln!("conflict while rebasing {branch} onto {parent}");
340            eprintln!("resolve conflicts, then run `git stk continue`");
341            eprintln!("or run `git stk abort`");
342            return Err(error);
343        }
344
345        record_base(branch, parent);
346    }
347
348    clear_state()?;
349    finish_restack(all, push)
350}
351
352/// After every branch has been rebased: push the rewritten branches, or print
353/// the exact command so stale remote PR diffs are a copy-paste away from fixed.
354fn finish_restack(branches: &[String], push: bool) -> Result<()> {
355    println!("restack complete");
356
357    let remote = git::config_get(REMOTE_KEY)?.unwrap_or_else(|| DEFAULT_REMOTE.to_owned());
358    if push {
359        git::push_force_with_lease(&remote, branches)?;
360        println!("pushed {} to {remote}", branches.join(" "));
361    } else {
362        println!("remote branches may be stale; push them with:");
363        println!(
364            "  git push --force-with-lease {remote} {}",
365            branches.join(" ")
366        );
367    }
368    Ok(())
369}
370
371fn resolve_push(mode: PushMode) -> Result<bool> {
372    match mode {
373        PushMode::Config => Ok(git::config_get_bool(PUSH_ON_RESTACK_KEY)?.unwrap_or(false)),
374        PushMode::Enabled => Ok(true),
375        PushMode::Disabled => Ok(false),
376    }
377}
378
379fn resolve_update_refs(mode: UpdateRefsMode) -> Result<bool> {
380    match mode {
381        UpdateRefsMode::Config => {
382            let configured = git::config_get_bool(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}