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, dry_run: bool) -> 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    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
62/// Insert a new empty branch directly above the current one, moving the
63/// current branch's children onto it. The new branch shares the current tip,
64/// so descendants stay correctly based; commit to it, then `restack` to
65/// replay them. Any uncommitted changes ride onto the new branch, like `new`.
66pub fn insert_branch(branch: &str, dry_run: bool) -> Result<()> {
67    ensure_absent(branch)?;
68    let current = git::current_branch()?;
69    let children = children_of(&current)?;
70
71    if !dry_run {
72        snapshot::take("new --insert");
73        git::create_branch(branch)?; // off current; leaves us on the new branch
74        set_parent(branch, &current)?;
75        record_base(branch, &current);
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(&current)
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
103/// Insert a new empty branch directly below the current one, moving the
104/// current branch onto it. Branches from the current branch's parent, so it
105/// requires a clean worktree. Commit to it, then `restack`.
106pub 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(&current)?.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)?; // off the parent; leaves us on the new branch
121        set_parent(branch, &parent)?;
122        record_base(branch, &parent);
123        set_parent(&current, branch)?;
124        record_base(&current, 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(&current)
133    );
134    anstream::println!(
135        "{} {} -> {}",
136        if dry_run {
137            "would retarget"
138        } else {
139            "retargeted"
140        },
141        style::branch(&current),
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
157/// The trunk branch: the remote's default branch when known locally,
158/// otherwise a conventional name that exists.
159pub 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
213/// Rename a branch and keep the stack intact. Git moves the branch's own
214/// metadata with the rename; children pointing at the old name are
215/// retargeted here.
216pub 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
248/// Record that `branch` is the rename of `old`, whose open review the next
249/// submit should replace and close.
250pub fn set_renamed_from(branch: &str, old: &str) -> Result<()> {
251    git::config_set(&renamed_from_key(branch), old)
252}
253
254/// The branch `branch` was renamed from, if a replaced review is still pending.
255pub fn renamed_from(branch: &str) -> Result<Option<String>> {
256    git::config_get(&renamed_from_key(branch))
257}
258
259/// Drop the rename marker once its review has been handled.
260pub fn clear_renamed_from(branch: &str) -> Result<()> {
261    git::config_unset(&renamed_from_key(branch))
262}
263
264/// Record the fork point between a branch and its parent (best effort; e.g.
265/// unrelated histories have no merge base, which is not an error here).
266pub 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
272/// The root of the stack containing `branch` (the base everything sits on).
273pub fn stack_root(branch: &str) -> Result<String> {
274    let parents = parent_map()?;
275    Ok(root_for(branch, &parents))
276}
277
278pub fn branch_and_descendants(branch: &str) -> Result<Vec<String>> {
279    let parents = parent_map()?;
280    let children = children_map(&parents);
281    let mut branches = vec![branch.to_owned()];
282    let mut visited = BTreeSet::from([branch.to_owned()]);
283    collect_descendants(branch, &children, &mut branches, &mut visited);
284    Ok(branches)
285}
286
287/// Every branch in the stack containing `branch`, parent-first: the line from
288/// the stack bottom up through `branch`, plus everything above it. Sibling
289/// stacks that share only the trunk are left out - they branch off the trunk
290/// separately, not through `branch`. The trunk itself is excluded; an
291/// unanchored root stays in (`path_from_root` keeps it).
292pub fn stack_line(branch: &str) -> Result<Vec<String>> {
293    // The trunk is not part of any stack, so standing on it your line is empty
294    // - its descendants are sibling stacks, each left for its own submit.
295    // Without this, `branch_and_descendants(trunk)` would pull in every stack.
296    let trunk = trunk_branch(&git::local_branches()?);
297    if Some(branch) == trunk.as_deref() {
298        return Ok(Vec::new());
299    }
300
301    let mut line = path_from_root(branch)?; // [bottom ..= branch]
302    let above = branch_and_descendants(branch)?; // [branch, ..descendants]
303    line.extend(above.into_iter().skip(1)); // append above-branch, dropping the duplicate
304
305    // `path_from_root` keeps its starting branch even when that is the trunk
306    // (you are standing on it); a trunk is never part of a stack.
307    line.retain(|candidate| Some(candidate) != trunk.as_ref());
308    Ok(line)
309}
310
311/// Every branch in the stack `branch` belongs to, trunk excluded: the whole
312/// subtree under the stack's root (so fork siblings are included too), unlike
313/// [`stack_line`] which is only `branch`'s own line. The root is the trunk for
314/// an anchored stack - dropped here - or an unanchored base branch, which is a
315/// real stacked branch and stays in.
316pub fn current_stack_branches(branch: &str) -> Result<Vec<String>> {
317    let root = stack_root(branch)?;
318    let trunk = trunk_branch(&git::local_branches()?);
319    Ok(branch_and_descendants(&root)?
320        .into_iter()
321        .filter(|candidate| Some(candidate) != trunk.as_ref())
322        .collect())
323}
324
325/// Publish the current stack's parent map to the shared metadata ref so
326/// another clone can rebuild it. Best effort: a failure warns but never aborts
327/// the push that triggered it.
328pub fn publish_metadata(remote: &str) {
329    if let Err(error) = try_publish_metadata(remote) {
330        anstream::eprintln!(
331            "{}",
332            style::warn(&format!("could not publish stack metadata: {error:#}"))
333        );
334    }
335}
336
337fn try_publish_metadata(remote: &str) -> Result<()> {
338    let current = git::current_branch()?;
339    let trunk = trunk_branch(&git::local_branches()?);
340
341    let mut parents = serde_json::Map::new();
342    for branch in current_stack_branches(&current)? {
343        if let Some(parent) = parent_of(&branch)? {
344            parents.insert(branch, Value::String(parent));
345        }
346    }
347    if parents.is_empty() {
348        return Ok(());
349    }
350
351    let document = json!({ "trunk": trunk, "parents": parents });
352    git::write_blob_ref(METADATA_REF, METADATA_FILE, &document.to_string())?;
353    git::push_ref(remote, METADATA_REF)
354}
355
356/// Rebuild local stack metadata from the shared ref, fetching any listed
357/// branch that is not present locally. Returns how many branches it attached.
358pub fn apply_remote_metadata(remote: &str) -> Result<usize> {
359    git::fetch_ref(remote, METADATA_REF)
360        .context("no stack metadata on the remote - push it from the other machine first")?;
361    let Some(content) = git::read_ref_file(METADATA_REF, METADATA_FILE)? else {
362        bail!("the remote stack metadata is empty");
363    };
364
365    let document: Value =
366        serde_json::from_str(&content).context("failed to parse remote stack metadata")?;
367    let parents = document
368        .get("parents")
369        .and_then(Value::as_object)
370        .context("remote stack metadata is malformed")?;
371
372    // Fetch every listed branch first, so each parent resolves locally before
373    // we record it.
374    let local: BTreeSet<String> = git::local_branches()?.into_iter().collect();
375    for branch in parents.keys() {
376        if !local.contains(branch) {
377            git::fetch_branch(remote, branch)
378                .with_context(|| format!("failed to fetch {branch} from {remote}"))?;
379        }
380    }
381
382    let mut attached = 0;
383    for (branch, parent) in parents {
384        let Some(parent) = parent.as_str() else {
385            continue;
386        };
387        set_parent(branch, parent)?;
388        record_base(branch, parent);
389        attached += 1;
390        anstream::println!(
391            "attached {} to {}",
392            style::branch(branch),
393            style::branch(parent)
394        );
395    }
396    Ok(attached)
397}
398
399/// The stack path from the bottom up to (and including) `branch`,
400/// parent-first; descendants above it are left out.
401pub fn path_from_root(branch: &str) -> Result<Vec<String>> {
402    let trunk = trunk_branch(&git::local_branches()?);
403    let mut path = vec![branch.to_owned()];
404    let mut seen = BTreeSet::from([branch.to_owned()]);
405
406    let mut cursor = branch.to_owned();
407    while let Some(parent) = parent_of(&cursor)? {
408        if Some(&parent) == trunk.as_ref() || !seen.insert(parent.clone()) {
409            break;
410        }
411        path.push(parent.clone());
412        cursor = parent;
413    }
414
415    path.reverse();
416    Ok(path)
417}
418
419/// (branch, parent) pairs for the branches that have a stack parent;
420/// branches without one are skipped.
421pub fn branch_parents(branches: &[String]) -> Result<Vec<(String, String)>> {
422    let mut pairs = Vec::new();
423    for branch in branches {
424        if let Some(parent) = parent_of(branch)? {
425            pairs.push((branch.clone(), parent));
426        }
427    }
428    Ok(pairs)
429}
430
431fn parent_map() -> Result<BTreeMap<String, String>> {
432    let mut parents = BTreeMap::new();
433    for branch in git::local_branches()? {
434        if let Some(parent) = parent_of(&branch)? {
435            parents.insert(branch, parent);
436        }
437    }
438    Ok(parents)
439}
440
441fn collect_descendants(
442    branch: &str,
443    children: &BTreeMap<String, Vec<String>>,
444    branches: &mut Vec<String>,
445    visited: &mut BTreeSet<String>,
446) {
447    if let Some(branch_children) = children.get(branch) {
448        for child in branch_children {
449            if !visited.insert(child.to_owned()) {
450                continue; // cyclic metadata; mirror the guard in path_from_root/root_for
451            }
452            branches.push(child.to_owned());
453            collect_descendants(child, children, branches, visited);
454        }
455    }
456}
457
458pub(crate) fn children_of(parent: &str) -> Result<Vec<String>> {
459    Ok(parent_map()?
460        .into_iter()
461        .filter_map(|(branch, branch_parent)| (branch_parent == parent).then_some(branch))
462        .collect())
463}
464
465fn children_map(parents: &BTreeMap<String, String>) -> BTreeMap<String, Vec<String>> {
466    let mut children: BTreeMap<String, Vec<String>> = BTreeMap::new();
467    for (branch, parent) in parents {
468        children
469            .entry(parent.to_owned())
470            .or_default()
471            .push(branch.to_owned());
472    }
473    children
474}
475
476fn root_for(branch: &str, parents: &BTreeMap<String, String>) -> String {
477    let mut root = branch.to_owned();
478    let mut seen = BTreeSet::new();
479
480    while let Some(parent) = parents.get(&root) {
481        if !seen.insert(root.clone()) {
482            break;
483        }
484        root = parent.to_owned();
485    }
486
487    root
488}
489
490pub(crate) fn parent_of(branch: &str) -> Result<Option<String>> {
491    git::config_get(&parent_key(branch))
492}
493
494pub(crate) fn base_of(branch: &str) -> Result<Option<String>> {
495    git::config_get(&base_key(branch))
496}
497
498pub(crate) fn set_parent(branch: &str, parent: &str) -> Result<()> {
499    git::config_set(&parent_key(branch), parent)
500}
501
502pub(crate) fn unset_parent(branch: &str) -> Result<()> {
503    git::config_unset(&parent_key(branch))
504}
505
506pub(crate) fn set_base(branch: &str, base: &str) -> Result<()> {
507    git::config_set(&base_key(branch), base)
508}
509
510pub(crate) fn unset_base(branch: &str) -> Result<()> {
511    git::config_unset(&base_key(branch))
512}
513
514fn parent_key(branch: &str) -> String {
515    format!("branch.{branch}.{PARENT_KEY}")
516}
517
518fn base_key(branch: &str) -> String {
519    format!("branch.{branch}.{BASE_KEY}")
520}
521
522fn renamed_from_key(branch: &str) -> String {
523    format!("branch.{branch}.{RENAMED_FROM_KEY}")
524}