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    NavOutput, 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/// Marks a branch as a stack floor: the branch a stack sits on when it is
36/// rooted somewhere other than the trunk - a release line, say. git-stk does
37/// not manage a floor. It is never submitted, pushed, rebased, merged, or
38/// re-parented, so a shared branch cannot be pulled into a stack and rewritten.
39/// Recorded when a stack is rooted off-trunk, because the shape alone stops
40/// being visible once the branches above it land.
41const FLOOR_KEY: &str = "stkFloor";
42/// Records that git-stk created this branch's worktree, and where. Only these
43/// are ours to remove; a worktree the user made by hand stays theirs.
44const WORKTREE_KEY: &str = "stkWorktree";
45
46pub fn create_branch(branch: &str, dry_run: bool) -> Result<()> {
47    let parent = git::current_branch()?;
48    // `new` creates the branch; an existing one is an adopt, not a create.
49    if git::local_branches()?
50        .iter()
51        .any(|existing| existing == branch)
52    {
53        bail!(
54            "branch {branch} already exists - adopt it onto {parent} \
55             with `git stk adopt {branch} --parent {parent}`"
56        );
57    }
58    if !dry_run {
59        git::create_branch(branch)?;
60        set_parent(branch, &parent)?;
61        record_base(branch, &parent);
62    }
63    anstream::println!(
64        "{} {} with parent {}",
65        if dry_run { "would create" } else { "created" },
66        style::branch(branch),
67        style::branch(&parent)
68    );
69    mark_floor_if_rooting(&parent, dry_run)?;
70    Ok(())
71}
72
73/// Create `branch` in a new worktree of its own instead of checking it out here,
74/// leaving the current worktree on the branch it was already on.
75///
76/// The directory is derived from the branch name under [`settings::worktree_dir`],
77/// nested so `feat/a` keeps a basename matching the branch tail - the way git's
78/// own path-to-branch guessing would read it.
79pub fn create_branch_in_worktree(branch: &str, dry_run: bool) -> Result<()> {
80    let parent = git::current_branch()?;
81    ensure_absent(branch)?;
82
83    let path = settings::worktree_path_for(branch)?;
84    if path.exists() {
85        bail!(
86            "{} already exists; remove it or pick another branch name",
87            path.display()
88        );
89    }
90
91    if !dry_run {
92        git::worktree_add_new_branch(&path, branch, &parent)?;
93        // Provenance first: this worktree is ours, so cleanup may remove it
94        // later. Recorded before the metadata below so a failure there cannot
95        // leave a worktree on disk that nothing claims.
96        set_owned_worktree(branch, &path)?;
97        set_parent(branch, &parent)?;
98        record_base(branch, &parent);
99    }
100    anstream::println!(
101        "{} {} with parent {} in the worktree at {}",
102        if dry_run { "would create" } else { "created" },
103        style::branch(branch),
104        style::branch(&parent),
105        git::display_path(&path)
106    );
107    if !dry_run {
108        anstream::println!(
109            "{}",
110            style::dim(&format!("cd {}", git::display_path(&path)))
111        );
112    }
113    mark_floor_if_rooting(&parent, dry_run)?;
114    Ok(())
115}
116
117/// Whether the trunk cannot be fetched because another worktree has it checked
118/// out, reporting the skip when so.
119///
120/// Git refuses `fetch <remote> <trunk>:<trunk>` while another worktree holds the
121/// trunk - the normal state of a worktree-per-branch layout. Skipping beats the
122/// alternatives: failing outright would make `sync` and `merge` unusable from a
123/// worktree, and fetching only the remote-tracking ref would leave the local
124/// trunk quietly stale for the rebase that follows.
125pub fn trunk_held_elsewhere(trunk: &str) -> Result<bool> {
126    let Some(path) = git::worktree_holding(trunk)? else {
127        return Ok(false);
128    };
129    anstream::println!(
130        "{}",
131        style::warn(&format!(
132            "skipped fetching {trunk}: it is checked out in the worktree at {}",
133            git::display_path(&path)
134        ))
135    );
136    anstream::println!(
137        "{}",
138        style::dim(&format!(
139            "using the local {trunk}; fast-forward it there to pick up the remote"
140        ))
141    );
142    Ok(true)
143}
144
145/// The worktree git-stk created for `branch`, if it created one and it is still
146/// there. Only these are ours to remove.
147pub fn owned_worktree(branch: &str) -> Option<std::path::PathBuf> {
148    recorded_worktree(branch).filter(|path| path.exists())
149}
150
151/// What the marker says, whether or not the directory still exists. `repair`
152/// needs the raw value to spot a marker pointing at nothing.
153pub fn recorded_worktree(branch: &str) -> Option<std::path::PathBuf> {
154    git::config_get(&format!("branch.{branch}.{WORKTREE_KEY}"))
155        .ok()
156        .flatten()
157        .map(std::path::PathBuf::from)
158}
159
160/// Record that git-stk owns `branch`'s worktree at `path`.
161pub fn set_owned_worktree(branch: &str, path: &std::path::Path) -> Result<()> {
162    git::config_set(
163        &format!("branch.{branch}.{WORKTREE_KEY}"),
164        &path.to_string_lossy(),
165    )
166}
167
168/// Forget that git-stk owns a worktree for `branch`.
169pub fn unset_owned_worktree(branch: &str) -> Result<()> {
170    git::config_unset(&format!("branch.{branch}.{WORKTREE_KEY}"))
171}
172
173/// Insert a new empty branch directly above the current one, moving the
174/// current branch's children onto it. The new branch shares the current tip,
175/// so descendants stay correctly based; commit to it, then `restack` to
176/// replay them. Any uncommitted changes ride onto the new branch, like `new`.
177pub fn insert_branch(branch: &str, dry_run: bool) -> Result<()> {
178    ensure_absent(branch)?;
179    let current = git::current_branch()?;
180    let children = children_of(&current)?;
181
182    if !dry_run {
183        snapshot::take("new --insert");
184        git::create_branch(branch)?; // off current; leaves us on the new branch
185        set_parent(branch, &current)?;
186        record_base(branch, &current);
187        for child in &children {
188            set_parent(child, branch)?;
189            record_base(child, branch);
190        }
191    }
192
193    anstream::println!(
194        "{} {} above {}",
195        if dry_run { "would insert" } else { "inserted" },
196        style::branch(branch),
197        style::branch(&current)
198    );
199    for child in &children {
200        anstream::println!(
201            "{} {} -> {}",
202            if dry_run {
203                "would retarget"
204            } else {
205                "retargeted"
206            },
207            style::branch(child),
208            style::branch(branch)
209        );
210    }
211    mark_floor_if_rooting(&current, dry_run)?;
212    Ok(())
213}
214
215/// Insert a new empty branch directly below the current one, moving the
216/// current branch onto it. Branches from the current branch's parent, so it
217/// requires a clean worktree. Commit to it, then `restack`.
218pub fn prepend_branch(branch: &str, dry_run: bool) -> Result<()> {
219    ensure_absent(branch)?;
220    let current = git::current_branch()?;
221    let parent = stacked_parent_of(&current)?
222        .context("current branch has no stack parent to prepend below")?;
223    if !git::worktree_is_clean()? {
224        bail!(
225            "working tree has uncommitted changes; commit or stash before `git stk new --prepend`"
226        );
227    }
228
229    if !dry_run {
230        snapshot::take("new --prepend");
231        git::checkout(&parent)?;
232        git::create_branch(branch)?; // off the parent; leaves us on the new branch
233        set_parent(branch, &parent)?;
234        record_base(branch, &parent);
235        set_parent(&current, branch)?;
236        record_base(&current, branch);
237    }
238
239    anstream::println!(
240        "{} {} between {} and {}",
241        if dry_run { "would insert" } else { "inserted" },
242        style::branch(branch),
243        style::branch(&parent),
244        style::branch(&current)
245    );
246    anstream::println!(
247        "{} {} -> {}",
248        if dry_run {
249            "would retarget"
250        } else {
251            "retargeted"
252        },
253        style::branch(&current),
254        style::branch(branch)
255    );
256    mark_floor_if_rooting(&parent, dry_run)?;
257    Ok(())
258}
259
260fn ensure_absent(branch: &str) -> Result<()> {
261    if git::local_branches()?
262        .iter()
263        .any(|existing| existing == branch)
264    {
265        bail!("branch {branch} already exists");
266    }
267    Ok(())
268}
269
270/// The trunk branch: the remote's default branch when known locally,
271/// otherwise a conventional name that exists.
272pub fn trunk_branch(branches: &[String]) -> Option<String> {
273    let remote = settings::remote().unwrap_or_else(|_| settings::DEFAULT_REMOTE.to_owned());
274    if let Some(default) = git::remote_default_branch(&remote) {
275        return Some(default);
276    }
277
278    ["main", "master"]
279        .iter()
280        .find(|name| branches.iter().any(|branch| branch == *name))
281        .map(|name| (*name).to_owned())
282}
283
284pub fn adopt_branch(branch: &str, parent: &str, dry_run: bool) -> Result<()> {
285    if branch == parent {
286        bail!("a branch cannot be its own stack parent");
287    }
288
289    let branches: BTreeSet<_> = git::local_branches()?.into_iter().collect();
290    if !branches.contains(branch) {
291        bail!("branch {branch} does not exist");
292    }
293    if !branches.contains(parent) {
294        bail!("parent branch {parent} does not exist");
295    }
296    if branch_and_descendants(branch)?
297        .iter()
298        .any(|descendant| descendant == parent)
299    {
300        bail!("{parent} is already below {branch} in the stack; that would form a cycle");
301    }
302
303    if !dry_run {
304        set_parent(branch, parent)?;
305        record_base(branch, parent);
306    }
307    anstream::println!(
308        "{} {} to {}",
309        if dry_run { "would attach" } else { "attached" },
310        style::branch(branch),
311        style::branch(parent)
312    );
313    // Adopting a branch onto a parent says it is a layer, so it is no longer a
314    // base - otherwise it stays out of `submit`/`merge` while `restack` treats
315    // it as ordinary. Announced like the recording, and on a dry run too: it
316    // removes protection, which is the direction that most wants saying.
317    if is_floor(branch)? {
318        if !dry_run {
319            clear_floor(branch)?;
320        }
321        anstream::println!(
322            "{}",
323            style::dim(&format!(
324                "{} {branch} is no longer a stack base",
325                if dry_run {
326                    "would record that"
327                } else {
328                    "recorded that"
329                }
330            ))
331        );
332    }
333    mark_floor_if_rooting(parent, dry_run)?;
334    Ok(())
335}
336
337pub fn detach_branch(branch: Option<&str>) -> Result<()> {
338    let branch = branch
339        .map(str::to_owned)
340        .map_or_else(git::current_branch, Ok)?;
341    unset_parent(&branch)?;
342    unset_base(&branch)?;
343    // Also the way to say "stop treating this as a stack base" - and the
344    // escape every base hint names, so it confirms what it cleared.
345    let was_floor = is_floor(&branch)?;
346    clear_floor(&branch)?;
347    anstream::println!("detached {}", style::branch(&branch));
348    if was_floor {
349        anstream::println!(
350            "{}",
351            style::dim(&format!("{branch} is no longer a stack base"))
352        );
353    }
354    Ok(())
355}
356
357/// Rename a branch and keep the stack intact. Git moves the branch's own
358/// metadata with the rename; children pointing at the old name are
359/// retargeted here.
360pub fn rename_branch(old: &str, new: &str, dry_run: bool) -> Result<()> {
361    let children = children_of(old)?;
362
363    if !dry_run {
364        snapshot::take("rename");
365        git::rename_branch(old, new)?;
366    }
367    anstream::println!(
368        "{} {} -> {}",
369        if dry_run { "would rename" } else { "renamed" },
370        style::branch(old),
371        style::branch(new)
372    );
373
374    for child in &children {
375        if !dry_run {
376            set_parent(child, new)?;
377        }
378        anstream::println!(
379            "{} {} -> {}",
380            if dry_run {
381                "would retarget"
382            } else {
383                "retargeted"
384            },
385            style::branch(child),
386            style::branch(new)
387        );
388    }
389    Ok(())
390}
391
392/// Record that `branch` is the rename of `old`, whose open review the next
393/// submit should replace and close.
394pub fn set_renamed_from(branch: &str, old: &str) -> Result<()> {
395    git::config_set(&renamed_from_key(branch), old)
396}
397
398/// The branch `branch` was renamed from, if a replaced review is still pending.
399pub fn renamed_from(branch: &str) -> Result<Option<String>> {
400    git::config_get(&renamed_from_key(branch))
401}
402
403/// Drop the rename marker once its review has been handled.
404pub fn clear_renamed_from(branch: &str) -> Result<()> {
405    git::config_unset(&renamed_from_key(branch))
406}
407
408/// Record the fork point between a branch and its parent (best effort; e.g.
409/// unrelated histories have no merge base, which is not an error here).
410pub fn record_base(branch: &str, parent: &str) {
411    if let Ok(base) = git::merge_base(parent, branch) {
412        let _ = git::config_set(&base_key(branch), &base);
413    }
414}
415
416/// The commit to replay `branch` from when rebasing onto `parent`: the tighter
417/// of its recorded fork point and the live `merge_base(parent, branch)`. The
418/// recorded base is trusted only when it is a descendant of (or equal to) the
419/// live merge base - a fork point at least as recent, as after the parent is
420/// rewritten and its old commits leave the branch's history. A recorded base
421/// that is a proper *ancestor* of the live merge base is stale: the true fork
422/// point has moved past it (e.g. the branch was rebased onto a newer trunk out
423/// of band), so the merge base wins and only the branch's own commits replay.
424/// With neither available there is nothing to anchor on.
425pub(crate) fn fork_point(branch: &str, parent: &str) -> Result<Option<String>> {
426    let recorded = base_of(branch)?.filter(|base| git::is_ancestor(base, branch).unwrap_or(false));
427    let merge_base = git::merge_base(parent, branch).ok();
428    Ok(match (recorded, merge_base) {
429        (Some(recorded), Some(merge_base)) => Some(
430            if git::is_ancestor(&merge_base, &recorded).unwrap_or(false) {
431                recorded
432            } else {
433                merge_base
434            },
435        ),
436        (recorded, merge_base) => recorded.or(merge_base),
437    })
438}
439
440/// Whether `branch`'s recorded fork point is still current: present, an
441/// ancestor of the branch, and not stale (not a proper ancestor of the live
442/// `merge_base(parent, branch)`). A stale one must be re-recorded.
443pub(crate) fn base_is_current(branch: &str, parent: &str) -> Result<bool> {
444    let Some(base) = base_of(branch)? else {
445        return Ok(false);
446    };
447    if !git::is_ancestor(&base, branch).unwrap_or(false) {
448        return Ok(false);
449    }
450    Ok(match git::merge_base(parent, branch) {
451        Ok(merge_base) => git::is_ancestor(&merge_base, &base).unwrap_or(false),
452        Err(_) => true,
453    })
454}
455
456/// The root of the stack containing `branch` (the base everything sits on).
457pub fn stack_root(branch: &str) -> Result<String> {
458    let parents = parent_map()?;
459    Ok(root_for(branch, &parents))
460}
461
462pub fn branch_and_descendants(branch: &str) -> Result<Vec<String>> {
463    let parents = parent_map()?;
464    let children = children_map(&parents);
465    let mut branches = vec![branch.to_owned()];
466    let mut visited = BTreeSet::from([branch.to_owned()]);
467    collect_descendants(branch, &children, &mut branches, &mut visited);
468    Ok(branches)
469}
470
471/// Every branch in the stack containing `branch`, parent-first: the line from
472/// the stack bottom up through `branch`, plus everything above it. Sibling
473/// stacks that share only the trunk are left out - they branch off the trunk
474/// separately, not through `branch`. The trunk itself is excluded; an
475/// unanchored root stays in (`path_from_root` keeps it).
476pub fn stack_line(branch: &str) -> Result<Vec<String>> {
477    // The trunk is not part of any stack, so standing on it your line is empty
478    // - its descendants are sibling stacks, each left for its own submit.
479    // Without this, `branch_and_descendants(trunk)` would pull in every stack.
480    let trunk = trunk_branch(&git::local_branches()?);
481    if Some(branch) == trunk.as_deref() {
482        return Ok(Vec::new());
483    }
484
485    let mut line = path_from_root(branch)?; // [bottom ..= branch]
486    let above = branch_and_descendants(branch)?; // [branch, ..descendants]
487    line.extend(above.into_iter().skip(1)); // append above-branch, dropping the duplicate
488
489    // `path_from_root` keeps its starting branch even when that is the trunk
490    // (you are standing on it); a trunk is never part of a stack.
491    line.retain(|candidate| Some(candidate) != trunk.as_ref());
492    Ok(line)
493}
494
495/// The base of `branch`'s own line: its topmost non-trunk ancestor (the branch
496/// just above the trunk), or `branch` itself when it has no parent. This is the
497/// anchor for "the current stack" - the subtree under it includes genuine fork
498/// siblings but excludes stacks that merely share the trunk - so `restack`,
499/// `list`, `sync`, and `run` all agree on scope. Unlike [`stack_root`], which
500/// collapses a trunk-anchored line all the way to the trunk and so sweeps in
501/// every sibling stack.
502pub(crate) fn line_base(branch: &str) -> Result<String> {
503    Ok(path_from_root(branch)?
504        .into_iter()
505        .next()
506        .unwrap_or_else(|| branch.to_owned()))
507}
508
509/// Every branch in the stack `branch` belongs to, trunk excluded: the whole
510/// subtree under the stack's base (so fork siblings are included too), unlike
511/// [`stack_line`] which is only `branch`'s own line. The base is the bottom of
512/// `branch`'s line - its topmost non-trunk ancestor - so sibling stacks that
513/// merely share the trunk are left out, exactly as they are for [`stack_line`].
514/// For an unanchored stack the base is its real root branch, itself stacked,
515/// so it stays in.
516pub fn current_stack_branches(branch: &str) -> Result<Vec<String>> {
517    let base = line_base(branch)?;
518    let trunk = trunk_branch(&git::local_branches()?);
519    Ok(branch_and_descendants(&base)?
520        .into_iter()
521        .filter(|candidate| Some(candidate) != trunk.as_ref())
522        .collect())
523}
524
525/// The branches `list` may annotate with review info: the current stack, or -
526/// with `all` - every stacked branch. A superset of what the tree actually
527/// draws is fine here; its only job is to bound which branches get a per-branch
528/// review lookup, so `list` never queries every open PR in the repo.
529pub fn listed_branches(all: bool) -> Result<BTreeSet<String>> {
530    if all {
531        Ok(parent_map()?
532            .into_iter()
533            .flat_map(|(child, parent)| [child, parent])
534            .collect())
535    } else {
536        let current = git::current_branch()?;
537        Ok(current_stack_branches(&current)?.into_iter().collect())
538    }
539}
540
541/// Publish the current stack's parent map to the shared metadata ref so
542/// another clone can rebuild it. Best effort: a failure warns but never aborts
543/// the push that triggered it.
544pub fn publish_metadata(remote: &str) {
545    if let Err(error) = try_publish_metadata(remote) {
546        anstream::eprintln!(
547            "{}",
548            style::warn(&format!("could not publish stack metadata: {error:#}"))
549        );
550    }
551}
552
553fn try_publish_metadata(remote: &str) -> Result<()> {
554    let current = git::current_branch()?;
555    let trunk = trunk_branch(&git::local_branches()?);
556
557    let mut parents = serde_json::Map::new();
558    let mut floors = Vec::new();
559    for branch in current_stack_branches(&current)? {
560        // Marker first, matching every reader: a base that picked up a stray
561        // parent must still publish as a base. Published as a layer it would
562        // arrive with `floors: []` and revoke the marker on the other clone -
563        // the protected machine exporting the damage.
564        if is_floor(&branch)? {
565            floors.push(Value::String(branch));
566        } else if let Some(parent) = parent_of(&branch)? {
567            parents.insert(branch, Value::String(parent));
568        }
569    }
570    if parents.is_empty() {
571        return Ok(());
572    }
573
574    let document = json!({ "trunk": trunk, "parents": parents, "floors": floors });
575    git::write_blob_ref(METADATA_REF, METADATA_FILE, &document.to_string())?;
576    git::push_ref(remote, METADATA_REF)
577}
578
579/// Rebuild local stack metadata from the shared ref, fetching any listed
580/// branch that is not present locally. Returns how many branches it attached.
581pub fn apply_remote_metadata(remote: &str) -> Result<usize> {
582    git::fetch_ref(remote, METADATA_REF)
583        .context("no stack metadata on the remote - push it from the other machine first")?;
584    let Some(content) = git::read_ref_file(METADATA_REF, METADATA_FILE)? else {
585        bail!("the remote stack metadata is empty");
586    };
587
588    let document: Value =
589        serde_json::from_str(&content).context("failed to parse remote stack metadata")?;
590    let parents = document
591        .get("parents")
592        .and_then(Value::as_object)
593        .context("remote stack metadata is malformed")?;
594
595    // The metadata comes from a remote, so the names are untrusted. Drop any
596    // that aren't safe to hand to git as an argument before they reach
597    // `fetch`/`rebase`: a name like `--upload-pack=...` would be read as a git
598    // option, not a branch.
599    let mut pairs = Vec::new();
600    for (branch, parent) in parents {
601        let Some(parent) = parent.as_str() else {
602            continue;
603        };
604        if !is_safe_ref_name(branch) || !is_safe_ref_name(parent) {
605            anstream::eprintln!(
606                "{}",
607                style::warn(&format!(
608                    "skipping unsafe stack metadata entry: {branch:?} -> {parent:?}"
609                ))
610            );
611            continue;
612        }
613        pairs.push((branch.clone(), parent.to_owned()));
614    }
615
616    // The branch each stack sits on. It has no parent, so it is absent from the
617    // map above - and without it this machine cannot tell a stack's base from a
618    // branch whose metadata is missing. Untrusted names, same as the parents.
619    // A document written before bases were recorded has no `floors` key at all.
620    // That is not "no bases" - it is "this writer did not know about them" - so
621    // the revocation below must not run, or an un-upgraded clone (whose `sync`
622    // is the one that adopts the base) would clear the marker here and hand the
623    // release line back to `restack`.
624    let publishes_floors = document.get("floors").is_some();
625    let floors: Vec<String> = document
626        .get("floors")
627        .and_then(Value::as_array)
628        .map(|floors| {
629            floors
630                .iter()
631                .filter_map(Value::as_str)
632                .filter(|floor| is_safe_ref_name(floor))
633                .map(str::to_owned)
634                .collect()
635        })
636        .unwrap_or_default();
637
638    // Fetch every listed branch first, so each parent resolves locally before
639    // we record it. Bases included: a fresh clone has only the trunk, and the
640    // stack is unusable without the branch it sits on.
641    let local: BTreeSet<String> = git::local_branches()?.into_iter().collect();
642    for branch in pairs.iter().map(|(branch, _)| branch).chain(floors.iter()) {
643        if !local.contains(branch) {
644            git::fetch_branch(remote, branch)
645                .with_context(|| format!("failed to fetch {branch} from {remote}"))?;
646        }
647    }
648
649    for floor in &floors {
650        if is_floor(floor)? {
651            continue;
652        }
653        mark_floor(floor);
654        // Recording a base another machine chose changes what this clone will
655        // rebase, so it is not something to do in silence - the revocation
656        // below announces the same membership change.
657        anstream::println!("{} is now a stack base", style::branch(floor));
658    }
659    // A branch the other machine lists with a parent is a layer there, so any
660    // floor recorded here is stale. Without this the ref can only ever add
661    // floors, and the two clones quietly disagree about what is in the stack.
662    for (branch, _) in pairs
663        .iter()
664        .filter(|(branch, _)| publishes_floors && !floors.contains(branch))
665    {
666        if is_floor(branch)? {
667            clear_floor(branch)?;
668            anstream::println!("{} is no longer a stack base", style::branch(branch));
669        }
670    }
671
672    let mut attached = 0;
673    for (branch, parent) in &pairs {
674        set_parent(branch, parent)?;
675        record_base(branch, parent);
676        attached += 1;
677        anstream::println!(
678            "attached {} to {}",
679            style::branch(branch),
680            style::branch(parent)
681        );
682    }
683    Ok(attached)
684}
685
686/// Whether a branch name from untrusted remote metadata is safe to hand to git
687/// as an argument: non-empty, not an option (`-...`), and free of whitespace
688/// and control characters. git rejects other invalid refs itself; this guards
689/// the one thing it would not - a name it would parse as a flag.
690pub(crate) fn is_safe_ref_name(name: &str) -> bool {
691    !name.is_empty()
692        && !name.starts_with('-')
693        && !name.chars().any(|c| c.is_whitespace() || c.is_control())
694}
695
696/// The stack path from the bottom up to (and including) `branch`,
697/// parent-first; descendants above it are left out.
698pub fn path_from_root(branch: &str) -> Result<Vec<String>> {
699    let trunk = trunk_branch(&git::local_branches()?);
700    let mut path = vec![branch.to_owned()];
701    let mut seen = BTreeSet::from([branch.to_owned()]);
702
703    let mut cursor = branch.to_owned();
704    while let Some(parent) = stacked_parent_of(&cursor)? {
705        if Some(&parent) == trunk.as_ref() || !seen.insert(parent.clone()) {
706            break;
707        }
708        path.push(parent.clone());
709        // A floor is where a line starts, like the trunk: keep it, as the base
710        // the branch above targets, but never walk past it.
711        if is_floor(&parent)? {
712            break;
713        }
714        cursor = parent;
715    }
716
717    path.reverse();
718    Ok(path)
719}
720
721/// The branches in `line` that actually stack on something - those with a
722/// recorded parent. A line rooted off the trunk keeps its parentless root
723/// (see [`path_from_root`]), and that root is the base the branch above it
724/// targets, not a layer of the stack: nothing submits, pushes, or merges it.
725/// `restack` and `absorb` already skip it; this is how the rest agree.
726///
727/// Only a line's first branch can be parentless - `path_from_root` stops
728/// where the parents run out, and descendants are found through theirs - so
729/// an empty result means the line is a single unstacked branch, which callers
730/// report rather than silently skip.
731pub fn stacked_layers(line: &[String]) -> Result<Vec<String>> {
732    Ok(branch_parents(line)?
733        .into_iter()
734        .map(|(branch, _)| branch)
735        .collect())
736}
737
738/// The base `branches` sits on, when that is a branch rather than the trunk:
739/// the parentless root of a line rooted off-trunk, with layers stacked on it.
740/// It is not part of the stack - nothing submits, pushes, merges, or
741/// re-parents it - so callers hold it out of whatever they are about to do.
742///
743/// A single unstacked branch is not a base: nothing is stacked on it, and it
744/// is usually a branch whose metadata is simply missing. That returns `None`,
745/// so callers treat it as an ordinary branch (an error to submit, something
746/// `sync` may still adopt) rather than silently skipping it.
747pub fn unanchored_base(branches: &[String]) -> Result<Option<String>> {
748    let layers = stacked_layers(branches)?;
749    if layers.len() == branches.len() {
750        return Ok(None);
751    }
752    if layers.is_empty() {
753        // Nothing is stacked here, so the shape says nothing: only a recorded
754        // floor is a base. This is what keeps a base a base after the branches
755        // above it land, and still lets `sync` adopt a lone branch whose
756        // metadata is simply missing.
757        let [lone] = branches else {
758            return Ok(None);
759        };
760        return Ok(is_floor(lone)?.then(|| lone.clone()));
761    }
762    Ok(branches
763        .iter()
764        .find(|branch| !layers.contains(branch))
765        .cloned())
766}
767
768/// (branch, parent) pairs for the branches that stack on something. A branch
769/// with no recorded parent is skipped, and so is a floor - it is the base the
770/// stack sits on, whatever parent it may have picked up, and callers use this
771/// to decide what to write to (review bodies, the metadata ref).
772pub fn branch_parents(branches: &[String]) -> Result<Vec<(String, String)>> {
773    let mut pairs = Vec::new();
774    for branch in branches {
775        if let Some(parent) = stacked_parent_of(branch)? {
776            pairs.push((branch.clone(), parent));
777        }
778    }
779    Ok(pairs)
780}
781
782fn parent_map() -> Result<BTreeMap<String, String>> {
783    let mut parents = BTreeMap::new();
784    for branch in git::local_branches()? {
785        if let Some(parent) = stacked_parent_of(&branch)? {
786            parents.insert(branch, parent);
787        }
788    }
789    Ok(parents)
790}
791
792fn collect_descendants(
793    branch: &str,
794    children: &BTreeMap<String, Vec<String>>,
795    branches: &mut Vec<String>,
796    visited: &mut BTreeSet<String>,
797) {
798    if let Some(branch_children) = children.get(branch) {
799        for child in branch_children {
800            if !visited.insert(child.to_owned()) {
801                continue; // cyclic metadata; mirror the guard in path_from_root/root_for
802            }
803            branches.push(child.to_owned());
804            collect_descendants(child, children, branches, visited);
805        }
806    }
807}
808
809/// Whether the repo has any stacked branch at all. Not the same question as
810/// "does the trunk have children": a stack rooted off the trunk leaves the
811/// trunk childless, so that proxy answers no for a repo that plainly has one.
812pub(crate) fn has_stacked_branches() -> Result<bool> {
813    Ok(!parent_map()?.is_empty())
814}
815
816pub(crate) fn children_of(parent: &str) -> Result<Vec<String>> {
817    Ok(parent_map()?
818        .into_iter()
819        .filter_map(|(branch, branch_parent)| (branch_parent == parent).then_some(branch))
820        .collect())
821}
822
823fn children_map(parents: &BTreeMap<String, String>) -> BTreeMap<String, Vec<String>> {
824    let mut children: BTreeMap<String, Vec<String>> = BTreeMap::new();
825    for (branch, parent) in parents {
826        children
827            .entry(parent.to_owned())
828            .or_default()
829            .push(branch.to_owned());
830    }
831    children
832}
833
834fn root_for(branch: &str, parents: &BTreeMap<String, String>) -> String {
835    let mut root = branch.to_owned();
836    let mut seen = BTreeSet::new();
837
838    while let Some(parent) = parents.get(&root) {
839        if !seen.insert(root.clone()) {
840            break;
841        }
842        root = parent.to_owned();
843    }
844
845    root
846}
847
848/// Record `parent` as a stack floor when a stack is being rooted on it: it is
849/// not the trunk, and has no stack parent of its own. Called only where the
850/// user says so - `new`, `adopt`, `insert`, `prepend` - never from `sync` or
851/// `repair`, where a parentless parent is far more likely to be a branch whose
852/// metadata has not been rebuilt yet than a base.
853fn mark_floor_if_rooting(parent: &str, dry_run: bool) -> Result<()> {
854    let trunk = trunk_branch(&git::local_branches()?);
855    if Some(parent) == trunk.as_deref() || stacked_parent_of(parent)?.is_some() || is_floor(parent)?
856    {
857        return Ok(());
858    }
859
860    // Stacking on an unadopted branch is ambiguous - a release line and a
861    // stack branch nobody has adopted yet look identical, and only the person
862    // typing knows which this is. Record the reading that makes the branch
863    // safe, but say so and name the way back, because the alternative reading
864    // means the branch is frozen out of its own restacks until someone does.
865    // Announced on a dry run too: this writes metadata to a branch the command
866    // does not name, which is the last thing to leave to a surprise.
867    if !dry_run {
868        mark_floor(parent);
869    }
870    anstream::println!(
871        "{}",
872        style::dim(&format!(
873            "{} {parent} as this stack's base; \
874             if it is a stacked branch, run `git stk detach {parent}`",
875            if dry_run { "would record" } else { "recorded" }
876        ))
877    );
878    Ok(())
879}
880
881/// Whether `branch` is a stack floor - see [`FLOOR_KEY`].
882pub fn is_floor(branch: &str) -> Result<bool> {
883    Ok(git::config_get(&floor_key(branch))?.is_some())
884}
885
886/// Record `branch` as a stack floor. Best effort: a floor that fails to record
887/// is still derived from the shape while branches sit on it, so a failure here
888/// costs persistence, not protection.
889pub fn mark_floor(branch: &str) {
890    let _ = git::config_set(&floor_key(branch), "true");
891}
892
893pub fn clear_floor(branch: &str) -> Result<()> {
894    git::config_unset(&floor_key(branch))
895}
896
897pub(crate) fn parent_of(branch: &str) -> Result<Option<String>> {
898    git::config_get(&parent_key(branch))
899}
900
901/// The branch's stack parent for any purpose that walks or rewrites the stack:
902/// `None` for a floor, whatever `stkParent` it may have picked up, because the
903/// base a stack sits on is not ours to move. [`parent_of`] is the raw read,
904/// kept for `repair` - which exists to fix such metadata - and for snapshots,
905/// which record state exactly as it was.
906pub(crate) fn stacked_parent_of(branch: &str) -> Result<Option<String>> {
907    if is_floor(branch)? {
908        return Ok(None);
909    }
910    parent_of(branch)
911}
912
913pub(crate) fn base_of(branch: &str) -> Result<Option<String>> {
914    git::config_get(&base_key(branch))
915}
916
917pub(crate) fn set_parent(branch: &str, parent: &str) -> Result<()> {
918    git::config_set(&parent_key(branch), parent)
919}
920
921pub(crate) fn unset_parent(branch: &str) -> Result<()> {
922    git::config_unset(&parent_key(branch))
923}
924
925pub(crate) fn set_base(branch: &str, base: &str) -> Result<()> {
926    git::config_set(&base_key(branch), base)
927}
928
929pub(crate) fn unset_base(branch: &str) -> Result<()> {
930    git::config_unset(&base_key(branch))
931}
932
933fn floor_key(branch: &str) -> String {
934    format!("branch.{branch}.{FLOOR_KEY}")
935}
936
937fn parent_key(branch: &str) -> String {
938    format!("branch.{branch}.{PARENT_KEY}")
939}
940
941fn base_key(branch: &str) -> String {
942    format!("branch.{branch}.{BASE_KEY}")
943}
944
945fn renamed_from_key(branch: &str) -> String {
946    format!("branch.{branch}.{RENAMED_FROM_KEY}")
947}
948
949#[cfg(test)]
950mod tests {
951    use super::is_safe_ref_name;
952
953    #[test]
954    fn safe_ref_names_pass() {
955        assert!(is_safe_ref_name("main"));
956        assert!(is_safe_ref_name("feature/a"));
957        assert!(is_safe_ref_name("user/fix-123"));
958    }
959
960    #[test]
961    fn unsafe_ref_names_are_rejected() {
962        // The injection vector: a name git would parse as an option.
963        assert!(!is_safe_ref_name("--upload-pack=touch /tmp/pwned"));
964        assert!(!is_safe_ref_name("-x"));
965        // Whitespace / control chars (newline-bearing refspecs, etc.).
966        assert!(!is_safe_ref_name("a branch"));
967        assert!(!is_safe_ref_name("a\nb"));
968        assert!(!is_safe_ref_name("a\tb"));
969        assert!(!is_safe_ref_name(""));
970    }
971}