Skip to main content

git_stk/stack/
mod.rs

1//! Stack metadata: the `branch.<name>.stkParent`/`stkBase` annotations and
2//! the structural queries built on them. Navigation lives in [`nav`], the
3//! rebase engine in [`restack`].
4
5use 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
14/// Shared ref carrying the stack's parent map, so another clone can rebuild
15/// the metadata. Pushed/fetched explicitly; a normal fetch ignores it.
16const 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";
32/// Marks a branch as the rename of another that still has an open review, so
33/// the next submit can replace and close that review.
34const RENAMED_FROM_KEY: &str = "stkRenamedFrom";
35
36pub fn create_branch(branch: &str) -> Result<()> {
37    let parent = git::current_branch()?;
38    // `new` creates the branch; an existing one is an adopt, not a create.
39    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    git::create_branch(branch)?;
49    set_parent(branch, &parent)?;
50    record_base(branch, &parent);
51    anstream::println!(
52        "created {} with parent {}",
53        style::branch(branch),
54        style::branch(&parent)
55    );
56    Ok(())
57}
58
59/// Insert a new empty branch directly above the current one, moving the
60/// current branch's children onto it. The new branch shares the current tip,
61/// so descendants stay correctly based; commit to it, then `restack` to
62/// replay them. Any uncommitted changes ride onto the new branch, like `new`.
63pub fn insert_branch(branch: &str) -> Result<()> {
64    ensure_absent(branch)?;
65    let current = git::current_branch()?;
66    let children = children_of(&current)?;
67
68    snapshot::take("new --insert");
69    git::create_branch(branch)?; // off current; leaves us on the new branch
70    set_parent(branch, &current)?;
71    record_base(branch, &current);
72    for child in &children {
73        set_parent(child, branch)?;
74        record_base(child, branch);
75    }
76
77    anstream::println!(
78        "inserted {} above {}",
79        style::branch(branch),
80        style::branch(&current)
81    );
82    for child in &children {
83        anstream::println!(
84            "retargeted {} -> {}",
85            style::branch(child),
86            style::branch(branch)
87        );
88    }
89    Ok(())
90}
91
92/// Insert a new empty branch directly below the current one, moving the
93/// current branch onto it. Branches from the current branch's parent, so it
94/// requires a clean worktree. Commit to it, then `restack`.
95pub fn prepend_branch(branch: &str) -> Result<()> {
96    ensure_absent(branch)?;
97    let current = git::current_branch()?;
98    let parent =
99        parent_of(&current)?.context("current branch has no stack parent to prepend below")?;
100    if !git::worktree_is_clean()? {
101        bail!(
102            "working tree has uncommitted changes; commit or stash before `git stk new --prepend`"
103        );
104    }
105
106    snapshot::take("new --prepend");
107    git::checkout(&parent)?;
108    git::create_branch(branch)?; // off the parent; leaves us on the new branch
109    set_parent(branch, &parent)?;
110    record_base(branch, &parent);
111    set_parent(&current, branch)?;
112    record_base(&current, branch);
113
114    anstream::println!(
115        "inserted {} between {} and {}",
116        style::branch(branch),
117        style::branch(&parent),
118        style::branch(&current)
119    );
120    anstream::println!(
121        "retargeted {} -> {}",
122        style::branch(&current),
123        style::branch(branch)
124    );
125    Ok(())
126}
127
128fn ensure_absent(branch: &str) -> Result<()> {
129    if git::local_branches()?
130        .iter()
131        .any(|existing| existing == branch)
132    {
133        bail!("branch {branch} already exists");
134    }
135    Ok(())
136}
137
138/// The trunk branch: the remote's default branch when known locally,
139/// otherwise a conventional name that exists.
140pub fn trunk_branch(branches: &[String]) -> Option<String> {
141    let remote = settings::remote().unwrap_or_else(|_| settings::DEFAULT_REMOTE.to_owned());
142    if let Some(default) = git::remote_default_branch(&remote) {
143        return Some(default);
144    }
145
146    ["main", "master"]
147        .iter()
148        .find(|name| branches.iter().any(|branch| branch == *name))
149        .map(|name| (*name).to_owned())
150}
151
152pub fn adopt_branch(branch: &str, parent: &str) -> Result<()> {
153    if branch == parent {
154        bail!("a branch cannot be its own stack parent");
155    }
156
157    let branches: BTreeSet<_> = git::local_branches()?.into_iter().collect();
158    if !branches.contains(branch) {
159        bail!("branch {branch} does not exist");
160    }
161    if !branches.contains(parent) {
162        bail!("parent branch {parent} does not exist");
163    }
164    if branch_and_descendants(branch)?
165        .iter()
166        .any(|descendant| descendant == parent)
167    {
168        bail!("{parent} is already below {branch} in the stack; that would form a cycle");
169    }
170
171    set_parent(branch, parent)?;
172    record_base(branch, parent);
173    anstream::println!(
174        "attached {} to {}",
175        style::branch(branch),
176        style::branch(parent)
177    );
178    Ok(())
179}
180
181pub fn detach_branch(branch: Option<&str>) -> Result<()> {
182    let branch = branch
183        .map(str::to_owned)
184        .map_or_else(git::current_branch, Ok)?;
185    unset_parent(&branch)?;
186    unset_base(&branch)?;
187    anstream::println!("detached {}", style::branch(&branch));
188    Ok(())
189}
190
191/// Rename a branch and keep the stack intact. Git moves the branch's own
192/// metadata with the rename; children pointing at the old name are
193/// retargeted here.
194pub fn rename_branch(old: &str, new: &str, dry_run: bool) -> Result<()> {
195    let children = children_for_branch(old)?;
196
197    if !dry_run {
198        snapshot::take("rename");
199        git::rename_branch(old, new)?;
200    }
201    anstream::println!(
202        "{} {} -> {}",
203        if dry_run { "would rename" } else { "renamed" },
204        style::branch(old),
205        style::branch(new)
206    );
207
208    for child in &children {
209        if !dry_run {
210            set_parent_for_branch(child, new)?;
211        }
212        anstream::println!(
213            "{} {} -> {}",
214            if dry_run {
215                "would retarget"
216            } else {
217                "retargeted"
218            },
219            style::branch(child),
220            style::branch(new)
221        );
222    }
223    Ok(())
224}
225
226pub fn parent_for_branch(branch: &str) -> Result<Option<String>> {
227    parent_of(branch)
228}
229
230pub fn children_for_branch(branch: &str) -> Result<Vec<String>> {
231    children_of(branch)
232}
233
234pub fn set_parent_for_branch(branch: &str, parent: &str) -> Result<()> {
235    set_parent(branch, parent)
236}
237
238pub fn unset_parent_for_branch(branch: &str) -> Result<()> {
239    unset_parent(branch)
240}
241
242pub fn base_for_branch(branch: &str) -> Result<Option<String>> {
243    base_of(branch)
244}
245
246pub fn set_base_for_branch(branch: &str, base: &str) -> Result<()> {
247    git::config_set(&base_key(branch), base)
248}
249
250pub fn unset_base_for_branch(branch: &str) -> Result<()> {
251    unset_base(branch)
252}
253
254/// Record that `branch` is the rename of `old`, whose open review the next
255/// submit should replace and close.
256pub fn set_renamed_from(branch: &str, old: &str) -> Result<()> {
257    git::config_set(&renamed_from_key(branch), old)
258}
259
260/// The branch `branch` was renamed from, if a replaced review is still pending.
261pub fn renamed_from(branch: &str) -> Result<Option<String>> {
262    git::config_get(&renamed_from_key(branch))
263}
264
265/// Drop the rename marker once its review has been handled.
266pub fn clear_renamed_from(branch: &str) -> Result<()> {
267    git::config_unset(&renamed_from_key(branch))
268}
269
270/// Record the fork point between a branch and its parent (best effort; e.g.
271/// unrelated histories have no merge base, which is not an error here).
272pub fn record_base(branch: &str, parent: &str) {
273    if let Ok(base) = git::merge_base(parent, branch) {
274        let _ = git::config_set(&base_key(branch), &base);
275    }
276}
277
278/// The root of the stack containing `branch` (the base everything sits on).
279pub fn stack_root(branch: &str) -> Result<String> {
280    let parents = parent_map()?;
281    Ok(root_for(branch, &parents))
282}
283
284pub fn branch_and_descendants(branch: &str) -> Result<Vec<String>> {
285    let parents = parent_map()?;
286    let children = children_map(&parents);
287    let mut branches = vec![branch.to_owned()];
288    let mut visited = BTreeSet::from([branch.to_owned()]);
289    collect_descendants(branch, &children, &mut branches, &mut visited);
290    Ok(branches)
291}
292
293/// Every branch in the stack containing `branch`, parent-first: the line from
294/// the stack bottom up through `branch`, plus everything above it. Sibling
295/// stacks that share only the trunk are left out - they branch off the trunk
296/// separately, not through `branch`. The trunk itself is excluded; an
297/// unanchored root stays in (`path_from_root` keeps it).
298pub fn stack_line(branch: &str) -> Result<Vec<String>> {
299    // The trunk is not part of any stack, so standing on it your line is empty
300    // - its descendants are sibling stacks, each left for its own submit.
301    // Without this, `branch_and_descendants(trunk)` would pull in every stack.
302    let trunk = trunk_branch(&git::local_branches()?);
303    if Some(branch) == trunk.as_deref() {
304        return Ok(Vec::new());
305    }
306
307    let mut line = path_from_root(branch)?; // [bottom ..= branch]
308    let above = branch_and_descendants(branch)?; // [branch, ..descendants]
309    line.extend(above.into_iter().skip(1)); // append above-branch, dropping the duplicate
310
311    // `path_from_root` keeps its starting branch even when that is the trunk
312    // (you are standing on it); a trunk is never part of a stack.
313    line.retain(|candidate| Some(candidate) != trunk.as_ref());
314    Ok(line)
315}
316
317/// Publish the current stack's parent map to the shared metadata ref so
318/// another clone can rebuild it. Best effort: a failure warns but never aborts
319/// the push that triggered it.
320pub fn publish_metadata(remote: &str) {
321    if let Err(error) = try_publish_metadata(remote) {
322        anstream::eprintln!(
323            "{}",
324            style::warn(&format!("could not publish stack metadata: {error:#}"))
325        );
326    }
327}
328
329fn try_publish_metadata(remote: &str) -> Result<()> {
330    let current = git::current_branch()?;
331    let root = stack_root(&current)?;
332    let trunk = trunk_branch(&git::local_branches()?);
333
334    let mut parents = serde_json::Map::new();
335    for branch in branch_and_descendants(&root)? {
336        if Some(&branch) == trunk.as_ref() {
337            continue;
338        }
339        if let Some(parent) = parent_of(&branch)? {
340            parents.insert(branch, Value::String(parent));
341        }
342    }
343    if parents.is_empty() {
344        return Ok(());
345    }
346
347    let document = json!({ "trunk": trunk, "parents": parents });
348    git::write_blob_ref(METADATA_REF, METADATA_FILE, &document.to_string())?;
349    git::push_ref(remote, METADATA_REF)
350}
351
352/// Rebuild local stack metadata from the shared ref, fetching any listed
353/// branch that is not present locally. Returns how many branches it attached.
354pub fn apply_remote_metadata(remote: &str) -> Result<usize> {
355    git::fetch_ref(remote, METADATA_REF)
356        .context("no stack metadata on the remote - push it from the other machine first")?;
357    let Some(content) = git::read_ref_file(METADATA_REF, METADATA_FILE)? else {
358        bail!("the remote stack metadata is empty");
359    };
360
361    let document: Value =
362        serde_json::from_str(&content).context("failed to parse remote stack metadata")?;
363    let parents = document
364        .get("parents")
365        .and_then(Value::as_object)
366        .context("remote stack metadata is malformed")?;
367
368    // Fetch every listed branch first, so each parent resolves locally before
369    // we record it.
370    let local: BTreeSet<String> = git::local_branches()?.into_iter().collect();
371    for branch in parents.keys() {
372        if !local.contains(branch) {
373            git::fetch_branch(remote, branch)
374                .with_context(|| format!("failed to fetch {branch} from {remote}"))?;
375        }
376    }
377
378    let mut attached = 0;
379    for (branch, parent) in parents {
380        let Some(parent) = parent.as_str() else {
381            continue;
382        };
383        set_parent(branch, parent)?;
384        record_base(branch, parent);
385        attached += 1;
386        anstream::println!(
387            "attached {} to {}",
388            style::branch(branch),
389            style::branch(parent)
390        );
391    }
392    Ok(attached)
393}
394
395/// The stack path from the bottom up to (and including) `branch`,
396/// parent-first; descendants above it are left out.
397pub fn path_from_root(branch: &str) -> Result<Vec<String>> {
398    let trunk = trunk_branch(&git::local_branches()?);
399    let mut path = vec![branch.to_owned()];
400    let mut seen = BTreeSet::from([branch.to_owned()]);
401
402    let mut cursor = branch.to_owned();
403    while let Some(parent) = parent_of(&cursor)? {
404        if Some(&parent) == trunk.as_ref() || !seen.insert(parent.clone()) {
405            break;
406        }
407        path.push(parent.clone());
408        cursor = parent;
409    }
410
411    path.reverse();
412    Ok(path)
413}
414
415/// (branch, parent) pairs for the branches that have a stack parent;
416/// branches without one are skipped.
417pub fn branch_parents(branches: &[String]) -> Result<Vec<(String, String)>> {
418    let mut pairs = Vec::new();
419    for branch in branches {
420        if let Some(parent) = parent_of(branch)? {
421            pairs.push((branch.clone(), parent));
422        }
423    }
424    Ok(pairs)
425}
426
427fn parent_map() -> Result<BTreeMap<String, String>> {
428    let mut parents = BTreeMap::new();
429    for branch in git::local_branches()? {
430        if let Some(parent) = parent_of(&branch)? {
431            parents.insert(branch, parent);
432        }
433    }
434    Ok(parents)
435}
436
437fn collect_descendants(
438    branch: &str,
439    children: &BTreeMap<String, Vec<String>>,
440    branches: &mut Vec<String>,
441    visited: &mut BTreeSet<String>,
442) {
443    if let Some(branch_children) = children.get(branch) {
444        for child in branch_children {
445            if !visited.insert(child.to_owned()) {
446                continue; // cyclic metadata; mirror the guard in path_from_root/root_for
447            }
448            branches.push(child.to_owned());
449            collect_descendants(child, children, branches, visited);
450        }
451    }
452}
453
454fn children_of(parent: &str) -> Result<Vec<String>> {
455    Ok(parent_map()?
456        .into_iter()
457        .filter_map(|(branch, branch_parent)| (branch_parent == parent).then_some(branch))
458        .collect())
459}
460
461fn children_map(parents: &BTreeMap<String, String>) -> BTreeMap<String, Vec<String>> {
462    let mut children: BTreeMap<String, Vec<String>> = BTreeMap::new();
463    for (branch, parent) in parents {
464        children
465            .entry(parent.to_owned())
466            .or_default()
467            .push(branch.to_owned());
468    }
469    children
470}
471
472fn root_for(branch: &str, parents: &BTreeMap<String, String>) -> String {
473    let mut root = branch.to_owned();
474    let mut seen = BTreeSet::new();
475
476    while let Some(parent) = parents.get(&root) {
477        if !seen.insert(root.clone()) {
478            break;
479        }
480        root = parent.to_owned();
481    }
482
483    root
484}
485
486fn parent_of(branch: &str) -> Result<Option<String>> {
487    git::config_get(&parent_key(branch))
488}
489
490fn base_of(branch: &str) -> Result<Option<String>> {
491    git::config_get(&base_key(branch))
492}
493
494fn set_parent(branch: &str, parent: &str) -> Result<()> {
495    git::config_set(&parent_key(branch), parent)
496}
497
498fn unset_parent(branch: &str) -> Result<()> {
499    git::config_unset(&parent_key(branch))
500}
501
502fn unset_base(branch: &str) -> Result<()> {
503    git::config_unset(&base_key(branch))
504}
505
506fn parent_key(branch: &str) -> String {
507    format!("branch.{branch}.{PARENT_KEY}")
508}
509
510fn base_key(branch: &str) -> String {
511    format!("branch.{branch}.{BASE_KEY}")
512}
513
514fn renamed_from_key(branch: &str) -> String {
515    format!("branch.{branch}.{RENAMED_FROM_KEY}")
516}