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