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(¤t)? 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(¤t)?;
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(¤t, &parents);
84 let children = children_map(&parents);
85 print_tree(&root, ¤t, &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(¤t, &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
189pub 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 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}