use std::collections::{BTreeMap, BTreeSet};
use anyhow::{Context, Result, bail};
use serde_json::{Value, json};
use crate::git;
use crate::settings;
use crate::style;
const METADATA_REF: &str = "refs/stk/metadata";
const METADATA_FILE: &str = "stack.json";
mod nav;
mod restack;
mod snapshot;
pub use nav::{
NavOutput, behind_parent_hint, checkout_bottom, checkout_child, checkout_parent, checkout_top,
print_all_stacks, print_children, print_parent, print_stack,
};
pub use restack::{abort_restack, continue_restack, restack};
pub use snapshot::{take as snapshot, undo};
const PARENT_KEY: &str = "stkParent";
const BASE_KEY: &str = "stkBase";
const RENAMED_FROM_KEY: &str = "stkRenamedFrom";
const FLOOR_KEY: &str = "stkFloor";
const WORKTREE_KEY: &str = "stkWorktree";
pub fn create_branch(branch: &str, dry_run: bool) -> Result<()> {
let parent = git::current_branch()?;
if git::local_branches()?
.iter()
.any(|existing| existing == branch)
{
bail!(
"branch {branch} already exists - adopt it onto {parent} \
with `git stk adopt {branch} --parent {parent}`"
);
}
if !dry_run {
git::create_branch(branch)?;
set_parent(branch, &parent)?;
record_base(branch, &parent);
}
anstream::println!(
"{} {} with parent {}",
if dry_run { "would create" } else { "created" },
style::branch(branch),
style::branch(&parent)
);
mark_floor_if_rooting(&parent, dry_run)?;
Ok(())
}
pub fn create_branch_in_worktree(branch: &str, dry_run: bool) -> Result<()> {
let parent = git::current_branch()?;
ensure_absent(branch)?;
let path = settings::worktree_path_for(branch)?;
if path.exists() {
bail!(
"{} already exists; remove it or pick another branch name",
path.display()
);
}
if !dry_run {
git::worktree_add_new_branch(&path, branch, &parent)?;
set_owned_worktree(branch, &path)?;
set_parent(branch, &parent)?;
record_base(branch, &parent);
}
anstream::println!(
"{} {} with parent {} in the worktree at {}",
if dry_run { "would create" } else { "created" },
style::branch(branch),
style::branch(&parent),
git::display_path(&path)
);
if !dry_run {
anstream::println!(
"{}",
style::dim(&format!("cd {}", git::display_path(&path)))
);
}
mark_floor_if_rooting(&parent, dry_run)?;
Ok(())
}
pub fn trunk_held_elsewhere(trunk: &str) -> Result<bool> {
let Some(path) = git::worktree_holding(trunk)? else {
return Ok(false);
};
anstream::println!(
"{}",
style::warn(&format!(
"skipped fetching {trunk}: it is checked out in the worktree at {}",
git::display_path(&path)
))
);
anstream::println!(
"{}",
style::dim(&format!(
"using the local {trunk}; fast-forward it there to pick up the remote"
))
);
Ok(true)
}
pub fn owned_worktree(branch: &str) -> Option<std::path::PathBuf> {
recorded_worktree(branch).filter(|path| path.exists())
}
pub fn recorded_worktree(branch: &str) -> Option<std::path::PathBuf> {
git::config_get(&format!("branch.{branch}.{WORKTREE_KEY}"))
.ok()
.flatten()
.map(std::path::PathBuf::from)
}
pub fn set_owned_worktree(branch: &str, path: &std::path::Path) -> Result<()> {
git::config_set(
&format!("branch.{branch}.{WORKTREE_KEY}"),
&path.to_string_lossy(),
)
}
pub fn unset_owned_worktree(branch: &str) -> Result<()> {
git::config_unset(&format!("branch.{branch}.{WORKTREE_KEY}"))
}
pub fn insert_branch(branch: &str, dry_run: bool) -> Result<()> {
ensure_absent(branch)?;
let current = git::current_branch()?;
let children = children_of(¤t)?;
if !dry_run {
snapshot::take("new --insert");
git::create_branch(branch)?; set_parent(branch, ¤t)?;
record_base(branch, ¤t);
for child in &children {
set_parent(child, branch)?;
record_base(child, branch);
}
}
anstream::println!(
"{} {} above {}",
if dry_run { "would insert" } else { "inserted" },
style::branch(branch),
style::branch(¤t)
);
for child in &children {
anstream::println!(
"{} {} -> {}",
if dry_run {
"would retarget"
} else {
"retargeted"
},
style::branch(child),
style::branch(branch)
);
}
mark_floor_if_rooting(¤t, dry_run)?;
Ok(())
}
pub fn prepend_branch(branch: &str, dry_run: bool) -> Result<()> {
ensure_absent(branch)?;
let current = git::current_branch()?;
let parent = stacked_parent_of(¤t)?
.context("current branch has no stack parent to prepend below")?;
if !git::worktree_is_clean()? {
bail!(
"working tree has uncommitted changes; commit or stash before `git stk new --prepend`"
);
}
if !dry_run {
snapshot::take("new --prepend");
git::checkout(&parent)?;
git::create_branch(branch)?; set_parent(branch, &parent)?;
record_base(branch, &parent);
set_parent(¤t, branch)?;
record_base(¤t, branch);
}
anstream::println!(
"{} {} between {} and {}",
if dry_run { "would insert" } else { "inserted" },
style::branch(branch),
style::branch(&parent),
style::branch(¤t)
);
anstream::println!(
"{} {} -> {}",
if dry_run {
"would retarget"
} else {
"retargeted"
},
style::branch(¤t),
style::branch(branch)
);
mark_floor_if_rooting(&parent, dry_run)?;
Ok(())
}
fn ensure_absent(branch: &str) -> Result<()> {
if git::local_branches()?
.iter()
.any(|existing| existing == branch)
{
bail!("branch {branch} already exists");
}
Ok(())
}
pub fn trunk_branch(branches: &[String]) -> Option<String> {
let remote = settings::remote().unwrap_or_else(|_| settings::DEFAULT_REMOTE.to_owned());
if let Some(default) = git::remote_default_branch(&remote) {
return Some(default);
}
["main", "master"]
.iter()
.find(|name| branches.iter().any(|branch| branch == *name))
.map(|name| (*name).to_owned())
}
pub fn adopt_branch(branch: &str, parent: &str, dry_run: bool) -> Result<()> {
if branch == parent {
bail!("a branch cannot be its own stack parent");
}
let branches: BTreeSet<_> = git::local_branches()?.into_iter().collect();
if !branches.contains(branch) {
bail!("branch {branch} does not exist");
}
if !branches.contains(parent) {
bail!("parent branch {parent} does not exist");
}
if branch_and_descendants(branch)?
.iter()
.any(|descendant| descendant == parent)
{
bail!("{parent} is already below {branch} in the stack; that would form a cycle");
}
if !dry_run {
set_parent(branch, parent)?;
record_base(branch, parent);
}
anstream::println!(
"{} {} to {}",
if dry_run { "would attach" } else { "attached" },
style::branch(branch),
style::branch(parent)
);
if is_floor(branch)? {
if !dry_run {
clear_floor(branch)?;
}
anstream::println!(
"{}",
style::dim(&format!(
"{} {branch} is no longer a stack base",
if dry_run {
"would record that"
} else {
"recorded that"
}
))
);
}
mark_floor_if_rooting(parent, dry_run)?;
Ok(())
}
pub fn detach_branch(branch: Option<&str>) -> Result<()> {
let branch = branch
.map(str::to_owned)
.map_or_else(git::current_branch, Ok)?;
unset_parent(&branch)?;
unset_base(&branch)?;
let was_floor = is_floor(&branch)?;
clear_floor(&branch)?;
anstream::println!("detached {}", style::branch(&branch));
if was_floor {
anstream::println!(
"{}",
style::dim(&format!("{branch} is no longer a stack base"))
);
}
Ok(())
}
pub fn rename_branch(old: &str, new: &str, dry_run: bool) -> Result<()> {
let children = children_of(old)?;
if !dry_run {
snapshot::take("rename");
git::rename_branch(old, new)?;
}
anstream::println!(
"{} {} -> {}",
if dry_run { "would rename" } else { "renamed" },
style::branch(old),
style::branch(new)
);
for child in &children {
if !dry_run {
set_parent(child, new)?;
}
anstream::println!(
"{} {} -> {}",
if dry_run {
"would retarget"
} else {
"retargeted"
},
style::branch(child),
style::branch(new)
);
}
Ok(())
}
pub fn set_renamed_from(branch: &str, old: &str) -> Result<()> {
git::config_set(&renamed_from_key(branch), old)
}
pub fn renamed_from(branch: &str) -> Result<Option<String>> {
git::config_get(&renamed_from_key(branch))
}
pub fn clear_renamed_from(branch: &str) -> Result<()> {
git::config_unset(&renamed_from_key(branch))
}
pub fn record_base(branch: &str, parent: &str) {
if let Ok(base) = git::merge_base(parent, branch) {
let _ = git::config_set(&base_key(branch), &base);
}
}
pub(crate) fn fork_point(branch: &str, parent: &str) -> Result<Option<String>> {
let recorded = base_of(branch)?.filter(|base| git::is_ancestor(base, branch).unwrap_or(false));
let merge_base = git::merge_base(parent, branch).ok();
Ok(match (recorded, merge_base) {
(Some(recorded), Some(merge_base)) => Some(
if git::is_ancestor(&merge_base, &recorded).unwrap_or(false) {
recorded
} else {
merge_base
},
),
(recorded, merge_base) => recorded.or(merge_base),
})
}
pub(crate) fn base_is_current(branch: &str, parent: &str) -> Result<bool> {
let Some(base) = base_of(branch)? else {
return Ok(false);
};
if !git::is_ancestor(&base, branch).unwrap_or(false) {
return Ok(false);
}
Ok(match git::merge_base(parent, branch) {
Ok(merge_base) => git::is_ancestor(&merge_base, &base).unwrap_or(false),
Err(_) => true,
})
}
pub fn stack_root(branch: &str) -> Result<String> {
let parents = parent_map()?;
Ok(root_for(branch, &parents))
}
pub fn branch_and_descendants(branch: &str) -> Result<Vec<String>> {
let parents = parent_map()?;
let children = children_map(&parents);
let mut branches = vec![branch.to_owned()];
let mut visited = BTreeSet::from([branch.to_owned()]);
collect_descendants(branch, &children, &mut branches, &mut visited);
Ok(branches)
}
pub fn stack_line(branch: &str) -> Result<Vec<String>> {
let trunk = trunk_branch(&git::local_branches()?);
if Some(branch) == trunk.as_deref() {
return Ok(Vec::new());
}
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());
Ok(line)
}
pub(crate) fn line_base(branch: &str) -> Result<String> {
Ok(path_from_root(branch)?
.into_iter()
.next()
.unwrap_or_else(|| branch.to_owned()))
}
pub fn current_stack_branches(branch: &str) -> Result<Vec<String>> {
let base = line_base(branch)?;
let trunk = trunk_branch(&git::local_branches()?);
Ok(branch_and_descendants(&base)?
.into_iter()
.filter(|candidate| Some(candidate) != trunk.as_ref())
.collect())
}
pub fn listed_branches(all: bool) -> Result<BTreeSet<String>> {
if all {
Ok(parent_map()?
.into_iter()
.flat_map(|(child, parent)| [child, parent])
.collect())
} else {
let current = git::current_branch()?;
Ok(current_stack_branches(¤t)?.into_iter().collect())
}
}
pub fn publish_metadata(remote: &str) {
if let Err(error) = try_publish_metadata(remote) {
anstream::eprintln!(
"{}",
style::warn(&format!("could not publish stack metadata: {error:#}"))
);
}
}
fn try_publish_metadata(remote: &str) -> Result<()> {
let current = git::current_branch()?;
let trunk = trunk_branch(&git::local_branches()?);
let mut parents = serde_json::Map::new();
let mut floors = Vec::new();
for branch in current_stack_branches(¤t)? {
if is_floor(&branch)? {
floors.push(Value::String(branch));
} else if let Some(parent) = parent_of(&branch)? {
parents.insert(branch, Value::String(parent));
}
}
if parents.is_empty() {
return Ok(());
}
let document = json!({ "trunk": trunk, "parents": parents, "floors": floors });
git::write_blob_ref(METADATA_REF, METADATA_FILE, &document.to_string())?;
git::push_ref(remote, METADATA_REF)
}
pub fn apply_remote_metadata(remote: &str) -> Result<usize> {
git::fetch_ref(remote, METADATA_REF)
.context("no stack metadata on the remote - push it from the other machine first")?;
let Some(content) = git::read_ref_file(METADATA_REF, METADATA_FILE)? else {
bail!("the remote stack metadata is empty");
};
let document: Value =
serde_json::from_str(&content).context("failed to parse remote stack metadata")?;
let parents = document
.get("parents")
.and_then(Value::as_object)
.context("remote stack metadata is malformed")?;
let mut pairs = Vec::new();
for (branch, parent) in parents {
let Some(parent) = parent.as_str() else {
continue;
};
if !is_safe_ref_name(branch) || !is_safe_ref_name(parent) {
anstream::eprintln!(
"{}",
style::warn(&format!(
"skipping unsafe stack metadata entry: {branch:?} -> {parent:?}"
))
);
continue;
}
pairs.push((branch.clone(), parent.to_owned()));
}
let publishes_floors = document.get("floors").is_some();
let floors: Vec<String> = document
.get("floors")
.and_then(Value::as_array)
.map(|floors| {
floors
.iter()
.filter_map(Value::as_str)
.filter(|floor| is_safe_ref_name(floor))
.map(str::to_owned)
.collect()
})
.unwrap_or_default();
let local: BTreeSet<String> = git::local_branches()?.into_iter().collect();
for branch in pairs.iter().map(|(branch, _)| branch).chain(floors.iter()) {
if !local.contains(branch) {
git::fetch_branch(remote, branch)
.with_context(|| format!("failed to fetch {branch} from {remote}"))?;
}
}
for floor in &floors {
if is_floor(floor)? {
continue;
}
mark_floor(floor);
anstream::println!("{} is now a stack base", style::branch(floor));
}
for (branch, _) in pairs
.iter()
.filter(|(branch, _)| publishes_floors && !floors.contains(branch))
{
if is_floor(branch)? {
clear_floor(branch)?;
anstream::println!("{} is no longer a stack base", style::branch(branch));
}
}
let mut attached = 0;
for (branch, parent) in &pairs {
set_parent(branch, parent)?;
record_base(branch, parent);
attached += 1;
anstream::println!(
"attached {} to {}",
style::branch(branch),
style::branch(parent)
);
}
Ok(attached)
}
pub(crate) fn is_safe_ref_name(name: &str) -> bool {
!name.is_empty()
&& !name.starts_with('-')
&& !name.chars().any(|c| c.is_whitespace() || c.is_control())
}
pub fn path_from_root(branch: &str) -> Result<Vec<String>> {
let trunk = trunk_branch(&git::local_branches()?);
let mut path = vec![branch.to_owned()];
let mut seen = BTreeSet::from([branch.to_owned()]);
let mut cursor = branch.to_owned();
while let Some(parent) = stacked_parent_of(&cursor)? {
if Some(&parent) == trunk.as_ref() || !seen.insert(parent.clone()) {
break;
}
path.push(parent.clone());
if is_floor(&parent)? {
break;
}
cursor = parent;
}
path.reverse();
Ok(path)
}
pub fn stacked_layers(line: &[String]) -> Result<Vec<String>> {
Ok(branch_parents(line)?
.into_iter()
.map(|(branch, _)| branch)
.collect())
}
pub fn unanchored_base(branches: &[String]) -> Result<Option<String>> {
let layers = stacked_layers(branches)?;
if layers.len() == branches.len() {
return Ok(None);
}
if layers.is_empty() {
let [lone] = branches else {
return Ok(None);
};
return Ok(is_floor(lone)?.then(|| lone.clone()));
}
Ok(branches
.iter()
.find(|branch| !layers.contains(branch))
.cloned())
}
pub fn branch_parents(branches: &[String]) -> Result<Vec<(String, String)>> {
let mut pairs = Vec::new();
for branch in branches {
if let Some(parent) = stacked_parent_of(branch)? {
pairs.push((branch.clone(), parent));
}
}
Ok(pairs)
}
fn parent_map() -> Result<BTreeMap<String, String>> {
let mut parents = BTreeMap::new();
for branch in git::local_branches()? {
if let Some(parent) = stacked_parent_of(&branch)? {
parents.insert(branch, parent);
}
}
Ok(parents)
}
fn collect_descendants(
branch: &str,
children: &BTreeMap<String, Vec<String>>,
branches: &mut Vec<String>,
visited: &mut BTreeSet<String>,
) {
if let Some(branch_children) = children.get(branch) {
for child in branch_children {
if !visited.insert(child.to_owned()) {
continue; }
branches.push(child.to_owned());
collect_descendants(child, children, branches, visited);
}
}
}
pub(crate) fn has_stacked_branches() -> Result<bool> {
Ok(!parent_map()?.is_empty())
}
pub(crate) fn children_of(parent: &str) -> Result<Vec<String>> {
Ok(parent_map()?
.into_iter()
.filter_map(|(branch, branch_parent)| (branch_parent == parent).then_some(branch))
.collect())
}
fn children_map(parents: &BTreeMap<String, String>) -> BTreeMap<String, Vec<String>> {
let mut children: BTreeMap<String, Vec<String>> = BTreeMap::new();
for (branch, parent) in parents {
children
.entry(parent.to_owned())
.or_default()
.push(branch.to_owned());
}
children
}
fn root_for(branch: &str, parents: &BTreeMap<String, String>) -> String {
let mut root = branch.to_owned();
let mut seen = BTreeSet::new();
while let Some(parent) = parents.get(&root) {
if !seen.insert(root.clone()) {
break;
}
root = parent.to_owned();
}
root
}
fn mark_floor_if_rooting(parent: &str, dry_run: bool) -> Result<()> {
let trunk = trunk_branch(&git::local_branches()?);
if Some(parent) == trunk.as_deref() || stacked_parent_of(parent)?.is_some() || is_floor(parent)?
{
return Ok(());
}
if !dry_run {
mark_floor(parent);
}
anstream::println!(
"{}",
style::dim(&format!(
"{} {parent} as this stack's base; \
if it is a stacked branch, run `git stk detach {parent}`",
if dry_run { "would record" } else { "recorded" }
))
);
Ok(())
}
pub fn is_floor(branch: &str) -> Result<bool> {
Ok(git::config_get(&floor_key(branch))?.is_some())
}
pub fn mark_floor(branch: &str) {
let _ = git::config_set(&floor_key(branch), "true");
}
pub fn clear_floor(branch: &str) -> Result<()> {
git::config_unset(&floor_key(branch))
}
pub(crate) fn parent_of(branch: &str) -> Result<Option<String>> {
git::config_get(&parent_key(branch))
}
pub(crate) fn stacked_parent_of(branch: &str) -> Result<Option<String>> {
if is_floor(branch)? {
return Ok(None);
}
parent_of(branch)
}
pub(crate) fn base_of(branch: &str) -> Result<Option<String>> {
git::config_get(&base_key(branch))
}
pub(crate) fn set_parent(branch: &str, parent: &str) -> Result<()> {
git::config_set(&parent_key(branch), parent)
}
pub(crate) fn unset_parent(branch: &str) -> Result<()> {
git::config_unset(&parent_key(branch))
}
pub(crate) fn set_base(branch: &str, base: &str) -> Result<()> {
git::config_set(&base_key(branch), base)
}
pub(crate) fn unset_base(branch: &str) -> Result<()> {
git::config_unset(&base_key(branch))
}
fn floor_key(branch: &str) -> String {
format!("branch.{branch}.{FLOOR_KEY}")
}
fn parent_key(branch: &str) -> String {
format!("branch.{branch}.{PARENT_KEY}")
}
fn base_key(branch: &str) -> String {
format!("branch.{branch}.{BASE_KEY}")
}
fn renamed_from_key(branch: &str) -> String {
format!("branch.{branch}.{RENAMED_FROM_KEY}")
}
#[cfg(test)]
mod tests {
use super::is_safe_ref_name;
#[test]
fn safe_ref_names_pass() {
assert!(is_safe_ref_name("main"));
assert!(is_safe_ref_name("feature/a"));
assert!(is_safe_ref_name("user/fix-123"));
}
#[test]
fn unsafe_ref_names_are_rejected() {
assert!(!is_safe_ref_name("--upload-pack=touch /tmp/pwned"));
assert!(!is_safe_ref_name("-x"));
assert!(!is_safe_ref_name("a branch"));
assert!(!is_safe_ref_name("a\nb"));
assert!(!is_safe_ref_name("a\tb"));
assert!(!is_safe_ref_name(""));
}
}