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(¤t)? 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(¤t)?;
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(¤t, &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 ¤t,
95 trunk.as_deref(),
96 &children,
97 0,
98 &mut BTreeSet::new(),
99 &mut lines,
100 );
101
102 for line in lines.iter().rev() {
105 println!("{line}");
106 }
107 Ok(())
108}
109
110pub 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(¤t, &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
239pub 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 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 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
352fn 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 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}