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::UpdateRefsMode;
10use crate::git;
11
12const PARENT_KEY: &str = "stackParent";
13const BASE_KEY: &str = "stackBase";
14const STATE_FILE: &str = "stack-state";
15
16pub fn create_branch(branch: &str) -> Result<()> {
17    let parent = git::current_branch()?;
18    git::create_branch(branch)?;
19    set_parent(branch, &parent)?;
20    record_base(branch, &parent);
21    println!("created {branch} with parent {parent}");
22    Ok(())
23}
24
25pub fn print_parent(branch: Option<&str>) -> Result<()> {
26    let branch = branch
27        .map(str::to_owned)
28        .map_or_else(git::current_branch, Ok)?;
29    match parent_of(&branch)? {
30        Some(parent) => println!("{parent}"),
31        None => bail!("{branch} has no stack parent"),
32    }
33    Ok(())
34}
35
36pub fn print_children(branch: Option<&str>) -> Result<()> {
37    let branch = branch
38        .map(str::to_owned)
39        .map_or_else(git::current_branch, Ok)?;
40    for child in children_of(&branch)? {
41        println!("{child}");
42    }
43    Ok(())
44}
45
46pub fn checkout_parent() -> Result<()> {
47    let current = git::current_branch()?;
48    let Some(parent) = parent_of(&current)? else {
49        bail!("{current} has no stack parent");
50    };
51
52    git::checkout(&parent)
53}
54
55pub fn checkout_child(branch: Option<&str>) -> Result<()> {
56    let current = git::current_branch()?;
57    let children = children_of(&current)?;
58    let child = match (branch, children.as_slice()) {
59        (Some(branch), _) => {
60            if children.iter().any(|child| child == branch) {
61                branch.to_owned()
62            } else {
63                bail!("{branch} is not a stack child of {current}");
64            }
65        }
66        (None, [child]) => child.to_owned(),
67        (None, []) => bail!("{current} has no stack children"),
68        (None, _) => {
69            eprintln!("{current} has multiple stack children:");
70            for child in children {
71                eprintln!("  {child}");
72            }
73            bail!("choose one with `git stk down <branch>`");
74        }
75    };
76
77    git::checkout(&child)
78}
79
80pub fn print_stack() -> Result<()> {
81    let current = git::current_branch()?;
82    let parents = parent_map()?;
83    let root = root_for(&current, &parents);
84    let children = children_map(&parents);
85    print_tree(&root, &current, &children, 0, &mut BTreeSet::new());
86    Ok(())
87}
88
89pub fn adopt_branch(branch: &str, parent: &str) -> Result<()> {
90    if branch == parent {
91        bail!("a branch cannot be its own stack parent");
92    }
93
94    let branches: BTreeSet<_> = git::local_branches()?.into_iter().collect();
95    if !branches.contains(branch) {
96        bail!("branch {branch} does not exist");
97    }
98    if !branches.contains(parent) {
99        bail!("parent branch {parent} does not exist");
100    }
101
102    set_parent(branch, parent)?;
103    record_base(branch, parent);
104    println!("attached {branch} to {parent}");
105    Ok(())
106}
107
108pub fn detach_branch(branch: Option<&str>) -> Result<()> {
109    let branch = branch
110        .map(str::to_owned)
111        .map_or_else(git::current_branch, Ok)?;
112    unset_parent(&branch)?;
113    unset_base(&branch)?;
114    println!("detached {branch}");
115    Ok(())
116}
117
118pub fn restack(update_refs_mode: UpdateRefsMode) -> Result<()> {
119    let current = git::current_branch()?;
120    let parents = parent_map()?;
121    let branches = restack_order(&current, &parents);
122
123    if branches.is_empty() {
124        println!("nothing to restack");
125        return Ok(());
126    }
127
128    let update_refs = resolve_update_refs(update_refs_mode)?;
129
130    clear_state()?;
131    restack_branches(branches, &parents, update_refs)
132}
133
134pub fn continue_restack() -> Result<()> {
135    let Some(state) = RestackState::read()? else {
136        bail!("no interrupted restack found");
137    };
138
139    if let Err(error) = git::rebase_continue() {
140        eprintln!("restack still has conflicts");
141        eprintln!("resolve conflicts, then run `git stk continue`");
142        eprintln!("or run `git stk abort`");
143        return Err(error);
144    }
145
146    record_base(&state.branch, &state.parent);
147
148    if state.remaining.is_empty() {
149        clear_state()?;
150        println!("restack complete");
151        return Ok(());
152    }
153
154    let parents = parent_map()?;
155    restack_branches(state.remaining, &parents, state.update_refs)
156}
157
158pub fn abort_restack() -> Result<()> {
159    git::rebase_abort()?;
160    clear_state()?;
161    println!("restack aborted");
162    Ok(())
163}
164
165pub fn parent_for_branch(branch: &str) -> Result<Option<String>> {
166    parent_of(branch)
167}
168
169pub fn children_for_branch(branch: &str) -> Result<Vec<String>> {
170    children_of(branch)
171}
172
173pub fn set_parent_for_branch(branch: &str, parent: &str) -> Result<()> {
174    set_parent(branch, parent)
175}
176
177pub fn unset_parent_for_branch(branch: &str) -> Result<()> {
178    unset_parent(branch)
179}
180
181pub fn set_base_for_branch(branch: &str, base: &str) -> Result<()> {
182    git::config_set(&base_key(branch), base)
183}
184
185pub fn unset_base_for_branch(branch: &str) -> Result<()> {
186    unset_base(branch)
187}
188
189/// Record the fork point between a branch and its parent (best effort; e.g.
190/// unrelated histories have no merge base, which is not an error here).
191pub fn record_base(branch: &str, parent: &str) {
192    if let Ok(base) = git::merge_base(parent, branch) {
193        let _ = git::config_set(&base_key(branch), &base);
194    }
195}
196
197pub fn branch_and_descendants(branch: &str) -> Result<Vec<String>> {
198    let parents = parent_map()?;
199    let children = children_map(&parents);
200    let mut branches = vec![branch.to_owned()];
201    collect_descendants(branch, &children, &mut branches);
202    Ok(branches)
203}
204
205fn parent_map() -> Result<BTreeMap<String, String>> {
206    let mut parents = BTreeMap::new();
207    for branch in git::local_branches()? {
208        if let Some(parent) = parent_of(&branch)? {
209            parents.insert(branch, parent);
210        }
211    }
212    Ok(parents)
213}
214
215fn restack_order(current: &str, parents: &BTreeMap<String, String>) -> Vec<String> {
216    let children = children_map(parents);
217    let mut branches = Vec::new();
218
219    if parents.contains_key(current) {
220        branches.push(current.to_owned());
221    }
222
223    collect_descendants(current, &children, &mut branches);
224    branches
225}
226
227fn collect_descendants(
228    branch: &str,
229    children: &BTreeMap<String, Vec<String>>,
230    branches: &mut Vec<String>,
231) {
232    if let Some(branch_children) = children.get(branch) {
233        for child in branch_children {
234            branches.push(child.to_owned());
235            collect_descendants(child, children, branches);
236        }
237    }
238}
239
240fn restack_branches(
241    branches: Vec<String>,
242    parents: &BTreeMap<String, String>,
243    update_refs: bool,
244) -> Result<()> {
245    for (index, branch) in branches.iter().enumerate() {
246        let Some(parent) = parents.get(branch) else {
247            bail!("{branch} has no stack parent");
248        };
249
250        if update_refs {
251            println!("rebasing {branch} onto {parent} with --update-refs");
252        } else {
253            println!("rebasing {branch} onto {parent}");
254        }
255
256        // Replay only the commits after the recorded fork point so commits
257        // that landed upstream via squash or rebase merges are not repeated.
258        // A base that is no longer an ancestor (stale or garbage) falls back
259        // to a plain rebase.
260        let base = match base_of(branch)? {
261            Some(base) if git::is_ancestor(&base, branch).unwrap_or(false) => Some(base),
262            _ => None,
263        };
264        let rebase_result = match &base {
265            Some(base) => git::rebase_onto(parent, base, branch, update_refs),
266            None => git::rebase(parent, branch, update_refs),
267        };
268
269        if let Err(error) = rebase_result {
270            let remaining = branches[index + 1..].to_vec();
271            RestackState {
272                branch: branch.to_owned(),
273                parent: parent.to_owned(),
274                remaining,
275                update_refs,
276            }
277            .write()?;
278
279            eprintln!("conflict while rebasing {branch} onto {parent}");
280            eprintln!("resolve conflicts, then run `git stk continue`");
281            eprintln!("or run `git stk abort`");
282            return Err(error);
283        }
284
285        record_base(branch, parent);
286    }
287
288    clear_state()?;
289    println!("restack complete");
290    Ok(())
291}
292
293fn resolve_update_refs(mode: UpdateRefsMode) -> Result<bool> {
294    match mode {
295        UpdateRefsMode::Config => {
296            let configured = git::config_get_bool("rebase.updateRefs")?.unwrap_or(false);
297            if configured && !git::supports_rebase_update_refs()? {
298                eprintln!("rebase.updateRefs is true, but this Git does not support --update-refs");
299                return Ok(false);
300            }
301            Ok(configured)
302        }
303        UpdateRefsMode::Enabled => {
304            if !git::supports_rebase_update_refs()? {
305                bail!("--update-refs was requested, but this Git does not support it");
306            }
307            Ok(true)
308        }
309        UpdateRefsMode::Disabled => Ok(false),
310    }
311}
312
313fn children_of(parent: &str) -> Result<Vec<String>> {
314    Ok(parent_map()?
315        .into_iter()
316        .filter_map(|(branch, branch_parent)| (branch_parent == parent).then_some(branch))
317        .collect())
318}
319
320fn children_map(parents: &BTreeMap<String, String>) -> BTreeMap<String, Vec<String>> {
321    let mut children: BTreeMap<String, Vec<String>> = BTreeMap::new();
322    for (branch, parent) in parents {
323        children
324            .entry(parent.to_owned())
325            .or_default()
326            .push(branch.to_owned());
327    }
328    children
329}
330
331fn root_for(branch: &str, parents: &BTreeMap<String, String>) -> String {
332    let mut root = branch.to_owned();
333    let mut seen = BTreeSet::new();
334
335    while let Some(parent) = parents.get(&root) {
336        if !seen.insert(root.clone()) {
337            break;
338        }
339        root = parent.to_owned();
340    }
341
342    root
343}
344
345fn print_tree(
346    branch: &str,
347    current: &str,
348    children: &BTreeMap<String, Vec<String>>,
349    depth: usize,
350    seen: &mut BTreeSet<String>,
351) {
352    let marker = if branch == current { " *" } else { "" };
353    println!("{}{}{}", "  ".repeat(depth), branch, marker);
354
355    if !seen.insert(branch.to_owned()) {
356        println!("{}<cycle detected>", "  ".repeat(depth + 1));
357        return;
358    }
359
360    if let Some(branch_children) = children.get(branch) {
361        for child in branch_children {
362            print_tree(child, current, children, depth + 1, seen);
363        }
364    }
365}
366
367fn parent_of(branch: &str) -> Result<Option<String>> {
368    git::config_get(&parent_key(branch))
369}
370
371fn base_of(branch: &str) -> Result<Option<String>> {
372    git::config_get(&base_key(branch))
373}
374
375fn set_parent(branch: &str, parent: &str) -> Result<()> {
376    git::config_set(&parent_key(branch), parent)
377}
378
379fn unset_parent(branch: &str) -> Result<()> {
380    git::config_unset(&parent_key(branch))
381}
382
383fn unset_base(branch: &str) -> Result<()> {
384    git::config_unset(&base_key(branch))
385}
386
387fn parent_key(branch: &str) -> String {
388    format!("branch.{branch}.{PARENT_KEY}")
389}
390
391fn base_key(branch: &str) -> String {
392    format!("branch.{branch}.{BASE_KEY}")
393}
394
395#[derive(Debug, Eq, PartialEq)]
396struct RestackState {
397    branch: String,
398    parent: String,
399    remaining: Vec<String>,
400    update_refs: bool,
401}
402
403impl RestackState {
404    fn read() -> Result<Option<Self>> {
405        let path = state_path()?;
406        if !path.exists() {
407            return Ok(None);
408        }
409
410        let contents = fs::read_to_string(&path)
411            .with_context(|| format!("failed to read {}", path.display()))?;
412        let mut branch = None;
413        let mut parent = None;
414        let mut remaining = Vec::new();
415        let mut update_refs = false;
416
417        for line in contents.lines() {
418            if let Some(value) = line.strip_prefix("branch=") {
419                branch = Some(value.to_owned());
420            } else if let Some(value) = line.strip_prefix("parent=") {
421                parent = Some(value.to_owned());
422            } else if let Some(value) = line.strip_prefix("updateRefs=") {
423                update_refs = value == "true";
424            } else if let Some(value) = line.strip_prefix("remaining=") {
425                remaining = value
426                    .split('\t')
427                    .filter(|branch| !branch.is_empty())
428                    .map(str::to_owned)
429                    .collect();
430            }
431        }
432
433        let Some(branch) = branch else {
434            bail!("restack state is missing current branch");
435        };
436        let Some(parent) = parent else {
437            bail!("restack state is missing parent branch");
438        };
439
440        Ok(Some(Self {
441            branch,
442            parent,
443            remaining,
444            update_refs,
445        }))
446    }
447
448    fn write(&self) -> Result<()> {
449        let path = state_path()?;
450        let contents = format!(
451            "branch={}\nparent={}\nupdateRefs={}\nremaining={}\n",
452            self.branch,
453            self.parent,
454            self.update_refs,
455            self.remaining.join("\t")
456        );
457        fs::write(&path, contents).with_context(|| format!("failed to write {}", path.display()))
458    }
459}
460
461fn clear_state() -> Result<()> {
462    let path = state_path()?;
463    if path.exists() {
464        fs::remove_file(&path).with_context(|| format!("failed to remove {}", path.display()))?;
465    }
466    Ok(())
467}
468
469fn state_path() -> Result<PathBuf> {
470    Ok(PathBuf::from(git::git_path(STATE_FILE)?))
471}