1use std::collections::{BTreeMap, BTreeSet};
6
7use anyhow::{Context, Result, bail};
8use serde_json::{Value, json};
9
10use crate::git;
11use crate::settings;
12use crate::style;
13
14const METADATA_REF: &str = "refs/stk/metadata";
17const METADATA_FILE: &str = "stack.json";
18
19mod nav;
20mod restack;
21mod snapshot;
22
23pub use nav::{
24 behind_parent_hint, checkout_bottom, checkout_child, checkout_parent, checkout_top,
25 print_all_stacks, print_children, print_parent, print_stack,
26};
27pub use restack::{abort_restack, continue_restack, restack};
28pub use snapshot::{take as snapshot, undo};
29
30const PARENT_KEY: &str = "stkParent";
31const BASE_KEY: &str = "stkBase";
32const RENAMED_FROM_KEY: &str = "stkRenamedFrom";
35
36pub fn create_branch(branch: &str, dry_run: bool) -> Result<()> {
37 let parent = git::current_branch()?;
38 if git::local_branches()?
40 .iter()
41 .any(|existing| existing == branch)
42 {
43 bail!(
44 "branch {branch} already exists - adopt it onto {parent} \
45 with `git stk adopt {branch} --parent {parent}`"
46 );
47 }
48 if !dry_run {
49 git::create_branch(branch)?;
50 set_parent(branch, &parent)?;
51 record_base(branch, &parent);
52 }
53 anstream::println!(
54 "{} {} with parent {}",
55 if dry_run { "would create" } else { "created" },
56 style::branch(branch),
57 style::branch(&parent)
58 );
59 Ok(())
60}
61
62pub fn insert_branch(branch: &str, dry_run: bool) -> Result<()> {
67 ensure_absent(branch)?;
68 let current = git::current_branch()?;
69 let children = children_of(¤t)?;
70
71 if !dry_run {
72 snapshot::take("new --insert");
73 git::create_branch(branch)?; set_parent(branch, ¤t)?;
75 record_base(branch, ¤t);
76 for child in &children {
77 set_parent(child, branch)?;
78 record_base(child, branch);
79 }
80 }
81
82 anstream::println!(
83 "{} {} above {}",
84 if dry_run { "would insert" } else { "inserted" },
85 style::branch(branch),
86 style::branch(¤t)
87 );
88 for child in &children {
89 anstream::println!(
90 "{} {} -> {}",
91 if dry_run {
92 "would retarget"
93 } else {
94 "retargeted"
95 },
96 style::branch(child),
97 style::branch(branch)
98 );
99 }
100 Ok(())
101}
102
103pub fn prepend_branch(branch: &str, dry_run: bool) -> Result<()> {
107 ensure_absent(branch)?;
108 let current = git::current_branch()?;
109 let parent =
110 parent_of(¤t)?.context("current branch has no stack parent to prepend below")?;
111 if !git::worktree_is_clean()? {
112 bail!(
113 "working tree has uncommitted changes; commit or stash before `git stk new --prepend`"
114 );
115 }
116
117 if !dry_run {
118 snapshot::take("new --prepend");
119 git::checkout(&parent)?;
120 git::create_branch(branch)?; set_parent(branch, &parent)?;
122 record_base(branch, &parent);
123 set_parent(¤t, branch)?;
124 record_base(¤t, branch);
125 }
126
127 anstream::println!(
128 "{} {} between {} and {}",
129 if dry_run { "would insert" } else { "inserted" },
130 style::branch(branch),
131 style::branch(&parent),
132 style::branch(¤t)
133 );
134 anstream::println!(
135 "{} {} -> {}",
136 if dry_run {
137 "would retarget"
138 } else {
139 "retargeted"
140 },
141 style::branch(¤t),
142 style::branch(branch)
143 );
144 Ok(())
145}
146
147fn ensure_absent(branch: &str) -> Result<()> {
148 if git::local_branches()?
149 .iter()
150 .any(|existing| existing == branch)
151 {
152 bail!("branch {branch} already exists");
153 }
154 Ok(())
155}
156
157pub fn trunk_branch(branches: &[String]) -> Option<String> {
160 let remote = settings::remote().unwrap_or_else(|_| settings::DEFAULT_REMOTE.to_owned());
161 if let Some(default) = git::remote_default_branch(&remote) {
162 return Some(default);
163 }
164
165 ["main", "master"]
166 .iter()
167 .find(|name| branches.iter().any(|branch| branch == *name))
168 .map(|name| (*name).to_owned())
169}
170
171pub fn adopt_branch(branch: &str, parent: &str, dry_run: bool) -> Result<()> {
172 if branch == parent {
173 bail!("a branch cannot be its own stack parent");
174 }
175
176 let branches: BTreeSet<_> = git::local_branches()?.into_iter().collect();
177 if !branches.contains(branch) {
178 bail!("branch {branch} does not exist");
179 }
180 if !branches.contains(parent) {
181 bail!("parent branch {parent} does not exist");
182 }
183 if branch_and_descendants(branch)?
184 .iter()
185 .any(|descendant| descendant == parent)
186 {
187 bail!("{parent} is already below {branch} in the stack; that would form a cycle");
188 }
189
190 if !dry_run {
191 set_parent(branch, parent)?;
192 record_base(branch, parent);
193 }
194 anstream::println!(
195 "{} {} to {}",
196 if dry_run { "would attach" } else { "attached" },
197 style::branch(branch),
198 style::branch(parent)
199 );
200 Ok(())
201}
202
203pub fn detach_branch(branch: Option<&str>) -> Result<()> {
204 let branch = branch
205 .map(str::to_owned)
206 .map_or_else(git::current_branch, Ok)?;
207 unset_parent(&branch)?;
208 unset_base(&branch)?;
209 anstream::println!("detached {}", style::branch(&branch));
210 Ok(())
211}
212
213pub fn rename_branch(old: &str, new: &str, dry_run: bool) -> Result<()> {
217 let children = children_of(old)?;
218
219 if !dry_run {
220 snapshot::take("rename");
221 git::rename_branch(old, new)?;
222 }
223 anstream::println!(
224 "{} {} -> {}",
225 if dry_run { "would rename" } else { "renamed" },
226 style::branch(old),
227 style::branch(new)
228 );
229
230 for child in &children {
231 if !dry_run {
232 set_parent(child, new)?;
233 }
234 anstream::println!(
235 "{} {} -> {}",
236 if dry_run {
237 "would retarget"
238 } else {
239 "retargeted"
240 },
241 style::branch(child),
242 style::branch(new)
243 );
244 }
245 Ok(())
246}
247
248pub fn set_renamed_from(branch: &str, old: &str) -> Result<()> {
251 git::config_set(&renamed_from_key(branch), old)
252}
253
254pub fn renamed_from(branch: &str) -> Result<Option<String>> {
256 git::config_get(&renamed_from_key(branch))
257}
258
259pub fn clear_renamed_from(branch: &str) -> Result<()> {
261 git::config_unset(&renamed_from_key(branch))
262}
263
264pub fn record_base(branch: &str, parent: &str) {
267 if let Ok(base) = git::merge_base(parent, branch) {
268 let _ = git::config_set(&base_key(branch), &base);
269 }
270}
271
272pub(crate) fn fork_point(branch: &str, parent: &str) -> Result<Option<String>> {
282 let recorded = base_of(branch)?.filter(|base| git::is_ancestor(base, branch).unwrap_or(false));
283 let merge_base = git::merge_base(parent, branch).ok();
284 Ok(match (recorded, merge_base) {
285 (Some(recorded), Some(merge_base)) => Some(
286 if git::is_ancestor(&merge_base, &recorded).unwrap_or(false) {
287 recorded
288 } else {
289 merge_base
290 },
291 ),
292 (recorded, merge_base) => recorded.or(merge_base),
293 })
294}
295
296pub(crate) fn base_is_current(branch: &str, parent: &str) -> Result<bool> {
300 let Some(base) = base_of(branch)? else {
301 return Ok(false);
302 };
303 if !git::is_ancestor(&base, branch).unwrap_or(false) {
304 return Ok(false);
305 }
306 Ok(match git::merge_base(parent, branch) {
307 Ok(merge_base) => git::is_ancestor(&merge_base, &base).unwrap_or(false),
308 Err(_) => true,
309 })
310}
311
312pub fn stack_root(branch: &str) -> Result<String> {
314 let parents = parent_map()?;
315 Ok(root_for(branch, &parents))
316}
317
318pub fn branch_and_descendants(branch: &str) -> Result<Vec<String>> {
319 let parents = parent_map()?;
320 let children = children_map(&parents);
321 let mut branches = vec![branch.to_owned()];
322 let mut visited = BTreeSet::from([branch.to_owned()]);
323 collect_descendants(branch, &children, &mut branches, &mut visited);
324 Ok(branches)
325}
326
327pub fn stack_line(branch: &str) -> Result<Vec<String>> {
333 let trunk = trunk_branch(&git::local_branches()?);
337 if Some(branch) == trunk.as_deref() {
338 return Ok(Vec::new());
339 }
340
341 let mut line = path_from_root(branch)?; let above = branch_and_descendants(branch)?; line.extend(above.into_iter().skip(1)); line.retain(|candidate| Some(candidate) != trunk.as_ref());
348 Ok(line)
349}
350
351pub(crate) fn line_base(branch: &str) -> Result<String> {
359 Ok(path_from_root(branch)?
360 .into_iter()
361 .next()
362 .unwrap_or_else(|| branch.to_owned()))
363}
364
365pub fn current_stack_branches(branch: &str) -> Result<Vec<String>> {
373 let base = line_base(branch)?;
374 let trunk = trunk_branch(&git::local_branches()?);
375 Ok(branch_and_descendants(&base)?
376 .into_iter()
377 .filter(|candidate| Some(candidate) != trunk.as_ref())
378 .collect())
379}
380
381pub fn listed_branches(all: bool) -> Result<BTreeSet<String>> {
386 if all {
387 Ok(parent_map()?
388 .into_iter()
389 .flat_map(|(child, parent)| [child, parent])
390 .collect())
391 } else {
392 let current = git::current_branch()?;
393 Ok(current_stack_branches(¤t)?.into_iter().collect())
394 }
395}
396
397pub fn publish_metadata(remote: &str) {
401 if let Err(error) = try_publish_metadata(remote) {
402 anstream::eprintln!(
403 "{}",
404 style::warn(&format!("could not publish stack metadata: {error:#}"))
405 );
406 }
407}
408
409fn try_publish_metadata(remote: &str) -> Result<()> {
410 let current = git::current_branch()?;
411 let trunk = trunk_branch(&git::local_branches()?);
412
413 let mut parents = serde_json::Map::new();
414 for branch in current_stack_branches(¤t)? {
415 if let Some(parent) = parent_of(&branch)? {
416 parents.insert(branch, Value::String(parent));
417 }
418 }
419 if parents.is_empty() {
420 return Ok(());
421 }
422
423 let document = json!({ "trunk": trunk, "parents": parents });
424 git::write_blob_ref(METADATA_REF, METADATA_FILE, &document.to_string())?;
425 git::push_ref(remote, METADATA_REF)
426}
427
428pub fn apply_remote_metadata(remote: &str) -> Result<usize> {
431 git::fetch_ref(remote, METADATA_REF)
432 .context("no stack metadata on the remote - push it from the other machine first")?;
433 let Some(content) = git::read_ref_file(METADATA_REF, METADATA_FILE)? else {
434 bail!("the remote stack metadata is empty");
435 };
436
437 let document: Value =
438 serde_json::from_str(&content).context("failed to parse remote stack metadata")?;
439 let parents = document
440 .get("parents")
441 .and_then(Value::as_object)
442 .context("remote stack metadata is malformed")?;
443
444 let mut pairs = Vec::new();
449 for (branch, parent) in parents {
450 let Some(parent) = parent.as_str() else {
451 continue;
452 };
453 if !is_safe_ref_name(branch) || !is_safe_ref_name(parent) {
454 anstream::eprintln!(
455 "{}",
456 style::warn(&format!(
457 "skipping unsafe stack metadata entry: {branch:?} -> {parent:?}"
458 ))
459 );
460 continue;
461 }
462 pairs.push((branch.clone(), parent.to_owned()));
463 }
464
465 let local: BTreeSet<String> = git::local_branches()?.into_iter().collect();
468 for (branch, _) in &pairs {
469 if !local.contains(branch) {
470 git::fetch_branch(remote, branch)
471 .with_context(|| format!("failed to fetch {branch} from {remote}"))?;
472 }
473 }
474
475 let mut attached = 0;
476 for (branch, parent) in &pairs {
477 set_parent(branch, parent)?;
478 record_base(branch, parent);
479 attached += 1;
480 anstream::println!(
481 "attached {} to {}",
482 style::branch(branch),
483 style::branch(parent)
484 );
485 }
486 Ok(attached)
487}
488
489pub(crate) fn is_safe_ref_name(name: &str) -> bool {
494 !name.is_empty()
495 && !name.starts_with('-')
496 && !name.chars().any(|c| c.is_whitespace() || c.is_control())
497}
498
499pub fn path_from_root(branch: &str) -> Result<Vec<String>> {
502 let trunk = trunk_branch(&git::local_branches()?);
503 let mut path = vec![branch.to_owned()];
504 let mut seen = BTreeSet::from([branch.to_owned()]);
505
506 let mut cursor = branch.to_owned();
507 while let Some(parent) = parent_of(&cursor)? {
508 if Some(&parent) == trunk.as_ref() || !seen.insert(parent.clone()) {
509 break;
510 }
511 path.push(parent.clone());
512 cursor = parent;
513 }
514
515 path.reverse();
516 Ok(path)
517}
518
519pub fn branch_parents(branches: &[String]) -> Result<Vec<(String, String)>> {
522 let mut pairs = Vec::new();
523 for branch in branches {
524 if let Some(parent) = parent_of(branch)? {
525 pairs.push((branch.clone(), parent));
526 }
527 }
528 Ok(pairs)
529}
530
531fn parent_map() -> Result<BTreeMap<String, String>> {
532 let mut parents = BTreeMap::new();
533 for branch in git::local_branches()? {
534 if let Some(parent) = parent_of(&branch)? {
535 parents.insert(branch, parent);
536 }
537 }
538 Ok(parents)
539}
540
541fn collect_descendants(
542 branch: &str,
543 children: &BTreeMap<String, Vec<String>>,
544 branches: &mut Vec<String>,
545 visited: &mut BTreeSet<String>,
546) {
547 if let Some(branch_children) = children.get(branch) {
548 for child in branch_children {
549 if !visited.insert(child.to_owned()) {
550 continue; }
552 branches.push(child.to_owned());
553 collect_descendants(child, children, branches, visited);
554 }
555 }
556}
557
558pub(crate) fn children_of(parent: &str) -> Result<Vec<String>> {
559 Ok(parent_map()?
560 .into_iter()
561 .filter_map(|(branch, branch_parent)| (branch_parent == parent).then_some(branch))
562 .collect())
563}
564
565fn children_map(parents: &BTreeMap<String, String>) -> BTreeMap<String, Vec<String>> {
566 let mut children: BTreeMap<String, Vec<String>> = BTreeMap::new();
567 for (branch, parent) in parents {
568 children
569 .entry(parent.to_owned())
570 .or_default()
571 .push(branch.to_owned());
572 }
573 children
574}
575
576fn root_for(branch: &str, parents: &BTreeMap<String, String>) -> String {
577 let mut root = branch.to_owned();
578 let mut seen = BTreeSet::new();
579
580 while let Some(parent) = parents.get(&root) {
581 if !seen.insert(root.clone()) {
582 break;
583 }
584 root = parent.to_owned();
585 }
586
587 root
588}
589
590pub(crate) fn parent_of(branch: &str) -> Result<Option<String>> {
591 git::config_get(&parent_key(branch))
592}
593
594pub(crate) fn base_of(branch: &str) -> Result<Option<String>> {
595 git::config_get(&base_key(branch))
596}
597
598pub(crate) fn set_parent(branch: &str, parent: &str) -> Result<()> {
599 git::config_set(&parent_key(branch), parent)
600}
601
602pub(crate) fn unset_parent(branch: &str) -> Result<()> {
603 git::config_unset(&parent_key(branch))
604}
605
606pub(crate) fn set_base(branch: &str, base: &str) -> Result<()> {
607 git::config_set(&base_key(branch), base)
608}
609
610pub(crate) fn unset_base(branch: &str) -> Result<()> {
611 git::config_unset(&base_key(branch))
612}
613
614fn parent_key(branch: &str) -> String {
615 format!("branch.{branch}.{PARENT_KEY}")
616}
617
618fn base_key(branch: &str) -> String {
619 format!("branch.{branch}.{BASE_KEY}")
620}
621
622fn renamed_from_key(branch: &str) -> String {
623 format!("branch.{branch}.{RENAMED_FROM_KEY}")
624}
625
626#[cfg(test)]
627mod tests {
628 use super::is_safe_ref_name;
629
630 #[test]
631 fn safe_ref_names_pass() {
632 assert!(is_safe_ref_name("main"));
633 assert!(is_safe_ref_name("feature/a"));
634 assert!(is_safe_ref_name("user/fix-123"));
635 }
636
637 #[test]
638 fn unsafe_ref_names_are_rejected() {
639 assert!(!is_safe_ref_name("--upload-pack=touch /tmp/pwned"));
641 assert!(!is_safe_ref_name("-x"));
642 assert!(!is_safe_ref_name("a branch"));
644 assert!(!is_safe_ref_name("a\nb"));
645 assert!(!is_safe_ref_name("a\tb"));
646 assert!(!is_safe_ref_name(""));
647 }
648}