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
247pub fn branch_and_descendants(branch: &str) -> Result<Vec<String>> {
248    let parents = parent_map()?;
249    let children = children_map(&parents);
250    let mut branches = vec![branch.to_owned()];
251    collect_descendants(branch, &children, &mut branches);
252    Ok(branches)
253}
254
255fn parent_map() -> Result<BTreeMap<String, String>> {
256    let mut parents = BTreeMap::new();
257    for branch in git::local_branches()? {
258        if let Some(parent) = parent_of(&branch)? {
259            parents.insert(branch, parent);
260        }
261    }
262    Ok(parents)
263}
264
265fn restack_order(current: &str, parents: &BTreeMap<String, String>) -> Vec<String> {
266    let children = children_map(parents);
267    let mut branches = Vec::new();
268
269    if parents.contains_key(current) {
270        branches.push(current.to_owned());
271    }
272
273    collect_descendants(current, &children, &mut branches);
274    branches
275}
276
277fn collect_descendants(
278    branch: &str,
279    children: &BTreeMap<String, Vec<String>>,
280    branches: &mut Vec<String>,
281) {
282    if let Some(branch_children) = children.get(branch) {
283        for child in branch_children {
284            branches.push(child.to_owned());
285            collect_descendants(child, children, branches);
286        }
287    }
288}
289
290fn restack_branches(
291    branches: Vec<String>,
292    parents: &BTreeMap<String, String>,
293    update_refs: bool,
294    push: bool,
295    all: &[String],
296) -> Result<()> {
297    for (index, branch) in branches.iter().enumerate() {
298        let Some(parent) = parents.get(branch) else {
299            bail!("{branch} has no stack parent");
300        };
301
302        if update_refs {
303            println!("rebasing {branch} onto {parent} with --update-refs");
304        } else {
305            println!("rebasing {branch} onto {parent}");
306        }
307
308        // Replay only the commits after the recorded fork point so commits
309        // that landed upstream via squash or rebase merges are not repeated.
310        // A base that is no longer an ancestor (stale or garbage) falls back
311        // to a plain rebase.
312        let base = match base_of(branch)? {
313            Some(base) if git::is_ancestor(&base, branch).unwrap_or(false) => Some(base),
314            _ => None,
315        };
316        let rebase_result = match &base {
317            Some(base) => git::rebase_onto(parent, base, branch, update_refs),
318            None => git::rebase(parent, branch, update_refs),
319        };
320
321        if let Err(error) = rebase_result {
322            let remaining = branches[index + 1..].to_vec();
323            RestackState {
324                branch: branch.to_owned(),
325                parent: parent.to_owned(),
326                remaining,
327                update_refs,
328                push,
329                all: all.to_vec(),
330            }
331            .write()?;
332
333            eprintln!("conflict while rebasing {branch} onto {parent}");
334            eprintln!("resolve conflicts, then run `git stk continue`");
335            eprintln!("or run `git stk abort`");
336            return Err(error);
337        }
338
339        record_base(branch, parent);
340    }
341
342    clear_state()?;
343    finish_restack(all, push)
344}
345
346/// After every branch has been rebased: push the rewritten branches, or print
347/// the exact command so stale remote PR diffs are a copy-paste away from fixed.
348fn finish_restack(branches: &[String], push: bool) -> Result<()> {
349    println!("restack complete");
350
351    let remote = git::config_get(REMOTE_KEY)?.unwrap_or_else(|| DEFAULT_REMOTE.to_owned());
352    if push {
353        git::push_force_with_lease(&remote, branches)?;
354        println!("pushed {} to {remote}", branches.join(" "));
355    } else {
356        println!("remote branches may be stale; push them with:");
357        println!(
358            "  git push --force-with-lease {remote} {}",
359            branches.join(" ")
360        );
361    }
362    Ok(())
363}
364
365fn resolve_push(mode: PushMode) -> Result<bool> {
366    match mode {
367        PushMode::Config => Ok(git::config_get_bool(PUSH_ON_RESTACK_KEY)?.unwrap_or(false)),
368        PushMode::Enabled => Ok(true),
369        PushMode::Disabled => Ok(false),
370    }
371}
372
373fn resolve_update_refs(mode: UpdateRefsMode) -> Result<bool> {
374    match mode {
375        UpdateRefsMode::Config => {
376            let configured = git::config_get_bool(UPDATE_REFS_KEY)?.unwrap_or(false);
377            if configured && !git::supports_rebase_update_refs()? {
378                eprintln!("stk.updateRefs is true, but this Git does not support --update-refs");
379                return Ok(false);
380            }
381            Ok(configured)
382        }
383        UpdateRefsMode::Enabled => {
384            if !git::supports_rebase_update_refs()? {
385                bail!("--update-refs was requested, but this Git does not support it");
386            }
387            Ok(true)
388        }
389        UpdateRefsMode::Disabled => Ok(false),
390    }
391}
392
393fn children_of(parent: &str) -> Result<Vec<String>> {
394    Ok(parent_map()?
395        .into_iter()
396        .filter_map(|(branch, branch_parent)| (branch_parent == parent).then_some(branch))
397        .collect())
398}
399
400fn children_map(parents: &BTreeMap<String, String>) -> BTreeMap<String, Vec<String>> {
401    let mut children: BTreeMap<String, Vec<String>> = BTreeMap::new();
402    for (branch, parent) in parents {
403        children
404            .entry(parent.to_owned())
405            .or_default()
406            .push(branch.to_owned());
407    }
408    children
409}
410
411fn root_for(branch: &str, parents: &BTreeMap<String, String>) -> String {
412    let mut root = branch.to_owned();
413    let mut seen = BTreeSet::new();
414
415    while let Some(parent) = parents.get(&root) {
416        if !seen.insert(root.clone()) {
417            break;
418        }
419        root = parent.to_owned();
420    }
421
422    root
423}
424
425#[allow(clippy::too_many_arguments)]
426fn collect_tree_lines(
427    branch: &str,
428    current: &str,
429    trunk: Option<&str>,
430    children: &BTreeMap<String, Vec<String>>,
431    depth: usize,
432    seen: &mut BTreeSet<String>,
433    lines: &mut Vec<String>,
434) {
435    let mut line = format!("{}{}", "  ".repeat(depth), branch);
436    if Some(branch) == trunk {
437        line.push_str(" (trunk)");
438    }
439    if branch == current {
440        line.push_str(" *");
441    }
442    lines.push(line);
443
444    if !seen.insert(branch.to_owned()) {
445        lines.push(format!("{}<cycle detected>", "  ".repeat(depth + 1)));
446        return;
447    }
448
449    if let Some(branch_children) = children.get(branch) {
450        for child in branch_children {
451            collect_tree_lines(child, current, trunk, children, depth + 1, seen, lines);
452        }
453    }
454}
455
456fn parent_of(branch: &str) -> Result<Option<String>> {
457    git::config_get(&parent_key(branch))
458}
459
460fn base_of(branch: &str) -> Result<Option<String>> {
461    git::config_get(&base_key(branch))
462}
463
464fn set_parent(branch: &str, parent: &str) -> Result<()> {
465    git::config_set(&parent_key(branch), parent)
466}
467
468fn unset_parent(branch: &str) -> Result<()> {
469    git::config_unset(&parent_key(branch))
470}
471
472fn unset_base(branch: &str) -> Result<()> {
473    git::config_unset(&base_key(branch))
474}
475
476fn parent_key(branch: &str) -> String {
477    format!("branch.{branch}.{PARENT_KEY}")
478}
479
480fn base_key(branch: &str) -> String {
481    format!("branch.{branch}.{BASE_KEY}")
482}
483
484#[derive(Debug, Eq, PartialEq)]
485struct RestackState {
486    branch: String,
487    parent: String,
488    remaining: Vec<String>,
489    update_refs: bool,
490    push: bool,
491    /// Every branch in the interrupted restack, so the post-restack push (or
492    /// push hint) can cover branches rebased before the conflict too.
493    all: Vec<String>,
494}
495
496impl RestackState {
497    fn read() -> Result<Option<Self>> {
498        let path = state_path()?;
499        if !path.exists() {
500            return Ok(None);
501        }
502
503        let contents = fs::read_to_string(&path)
504            .with_context(|| format!("failed to read {}", path.display()))?;
505        let mut branch = None;
506        let mut parent = None;
507        let mut remaining = Vec::new();
508        let mut update_refs = false;
509        let mut push = false;
510        let mut all = Vec::new();
511
512        for line in contents.lines() {
513            if let Some(value) = line.strip_prefix("branch=") {
514                branch = Some(value.to_owned());
515            } else if let Some(value) = line.strip_prefix("parent=") {
516                parent = Some(value.to_owned());
517            } else if let Some(value) = line.strip_prefix("updateRefs=") {
518                update_refs = value == "true";
519            } else if let Some(value) = line.strip_prefix("push=") {
520                push = value == "true";
521            } else if let Some(value) = line.strip_prefix("remaining=") {
522                remaining = value
523                    .split('\t')
524                    .filter(|branch| !branch.is_empty())
525                    .map(str::to_owned)
526                    .collect();
527            } else if let Some(value) = line.strip_prefix("all=") {
528                all = value
529                    .split('\t')
530                    .filter(|branch| !branch.is_empty())
531                    .map(str::to_owned)
532                    .collect();
533            }
534        }
535
536        let Some(branch) = branch else {
537            bail!("restack state is missing current branch");
538        };
539        let Some(parent) = parent else {
540            bail!("restack state is missing parent branch");
541        };
542
543        Ok(Some(Self {
544            branch,
545            parent,
546            remaining,
547            update_refs,
548            push,
549            all,
550        }))
551    }
552
553    fn write(&self) -> Result<()> {
554        let path = state_path()?;
555        let contents = format!(
556            "branch={}\nparent={}\nupdateRefs={}\npush={}\nremaining={}\nall={}\n",
557            self.branch,
558            self.parent,
559            self.update_refs,
560            self.push,
561            self.remaining.join("\t"),
562            self.all.join("\t")
563        );
564        fs::write(&path, contents).with_context(|| format!("failed to write {}", path.display()))
565    }
566}
567
568fn clear_state() -> Result<()> {
569    let path = state_path()?;
570    if path.exists() {
571        fs::remove_file(&path).with_context(|| format!("failed to remove {}", path.display()))?;
572    }
573    Ok(())
574}
575
576fn state_path() -> Result<PathBuf> {
577    Ok(PathBuf::from(git::git_path(STATE_FILE)?))
578}