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 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 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
346fn 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 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}