pub(crate) mod gh_stack;
pub(crate) mod graphite;
pub(crate) mod metadata;
use std::collections::{BTreeMap, HashMap};
use git2::Repository;
use crate::error::Result;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StackModel {
None,
Graphite,
GhStack,
Git,
}
impl StackModel {
pub fn detect(repo: &Repository) -> Self {
if graphite::is_graphite_repo(repo) {
Self::Graphite
} else if gh_stack::is_gh_stack_repo(repo) {
Self::GhStack
} else {
Self::None
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Granularity {
Stack,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Stack {
pub trunk: String,
pub diffs: Vec<String>,
pub current: String,
pub parents: HashMap<String, String>,
pub number: Option<u64>,
}
pub fn enumerate_stacks(repo: &Repository, model: StackModel) -> Result<Vec<Stack>> {
match model {
StackModel::None => Ok(vec![]),
StackModel::Graphite => graphite::enumerate_stacks(repo).map_err(Into::into),
StackModel::GhStack => gh_stack::enumerate_stacks(repo).map_err(Into::into),
StackModel::Git => Ok(vec![]),
}
}
pub fn current_stack(
repo: &Repository,
head_branch: &str,
model: StackModel,
) -> Result<Option<Stack>> {
match model {
StackModel::None => Ok(None),
StackModel::Graphite => graphite::current_stack(repo, head_branch).map_err(Into::into),
StackModel::GhStack => gh_stack::current_stack(repo, head_branch).map_err(Into::into),
StackModel::Git => Ok(None),
}
}
pub fn is_graphite_active(repo: &Repository) -> bool {
graphite::detect_gt() && graphite::is_graphite_repo(repo)
}
pub fn graphite_trunk(repo: &Repository) -> Option<String> {
graphite::graphite_trunk(repo)
}
pub fn link_worktree(repo: &Repository, worktree_name: &str) -> Result<()> {
gh_stack::link_worktree(repo, worktree_name).map_err(Into::into)
}
pub fn register_branch(repo: &Repository, branch: &str, base_branch: &str) -> Result<()> {
gh_stack::register_branch(repo, branch, base_branch).map_err(Into::into)
}
pub fn migrate_worktree(repo: &Repository, worktree_name: &str) -> Result<()> {
gh_stack::migrate_worktree(repo, worktree_name).map_err(Into::into)
}
pub fn is_graphite_repo(repo: &Repository) -> bool {
graphite::is_graphite_repo(repo)
}
pub fn is_gh_stack_repo(repo: &Repository) -> bool {
gh_stack::is_gh_stack_repo(repo)
}
pub use gh_stack::LinkStatus as GhStackLinkStatus;
pub fn gh_stack_worktree_link_status(repo: &Repository, worktree_name: &str) -> GhStackLinkStatus {
gh_stack::worktree_link_status(repo, worktree_name)
}
pub fn gh_stack_readability_errors(repo: &Repository) -> Vec<(std::path::PathBuf, String)> {
gh_stack::readability_errors(repo)
.into_iter()
.map(|(path, err)| (path, err.to_string()))
.collect()
}
pub fn gh_stack_divergent_stack_numbers(repo: &Repository) -> Vec<u64> {
gh_stack::divergent_stack_numbers(repo)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StackGroup {
pub stack: Stack,
pub members: Vec<usize>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StackGrouping {
pub groups: Vec<StackGroup>,
pub ungrouped: Vec<usize>,
}
pub fn group_by_stack(stacks: &[Option<Stack>]) -> StackGrouping {
let mut map: BTreeMap<(String, Vec<String>), (Stack, Vec<usize>)> = BTreeMap::new();
let mut ungrouped: Vec<usize> = Vec::new();
for (i, opt) in stacks.iter().enumerate() {
match opt {
None => ungrouped.push(i),
Some(stack) => {
let mut key_branches = stack.diffs.clone();
key_branches.sort();
let key = (stack.trunk.clone(), key_branches);
let entry = map
.entry(key)
.or_insert_with(|| (stack.clone(), Vec::new()));
entry.1.push(i);
}
}
}
let groups = map
.into_values()
.map(|(rep_stack, mut members)| {
members.sort_by_key(|&idx| {
let branch = stacks[idx]
.as_ref()
.map(|s| s.current.as_str())
.unwrap_or("");
rep_stack
.diffs
.iter()
.position(|b| b == branch)
.unwrap_or(usize::MAX)
});
StackGroup {
stack: rep_stack,
members,
}
})
.collect();
StackGrouping { groups, ungrouped }
}
#[cfg(test)]
mod tests {
use super::*;
use git_workon_fixture::prelude::*;
#[test]
fn detect_resolves_graphite_from_repo_metadata_without_requiring_the_gt_binary() {
let fixture = FixtureBuilder::new()
.graphite_config(&["main"])
.branch_metadata("a", "main")
.build()
.unwrap();
let repo = fixture.repo().unwrap();
assert_eq!(
StackModel::detect(repo),
StackModel::Graphite,
"a gt-initialized repo is a Graphite stack regardless of PATH"
);
}
#[test]
fn detect_resolves_gh_stack_from_canonical_file() {
let fixture = FixtureBuilder::new()
.gh_stack(None, 1, "main", &["feat-a"])
.build()
.unwrap();
let repo = fixture.repo().unwrap();
assert_eq!(StackModel::detect(repo), StackModel::GhStack);
}
#[test]
fn detect_prefers_graphite_when_both_tools_artifacts_are_present() {
let fixture = FixtureBuilder::new()
.graphite_config(&["main"])
.branch_metadata("a", "main")
.gh_stack(None, 1, "main", &["feat-a"])
.build()
.unwrap();
let repo = fixture.repo().unwrap();
assert_eq!(
StackModel::detect(repo),
StackModel::Graphite,
"Graphite's repo-wide gt init must win over a gh-stack file appearing alongside it"
);
}
fn stack(trunk: &str, diffs: &[&str], current: &str) -> Stack {
Stack {
trunk: trunk.to_string(),
diffs: diffs.iter().map(|s| s.to_string()).collect(),
current: current.to_string(),
parents: HashMap::new(),
number: None,
}
}
#[test]
fn empty_input_yields_empty_grouping() {
let result = group_by_stack(&[]);
assert!(result.groups.is_empty());
assert!(result.ungrouped.is_empty());
}
#[test]
fn all_none_yields_all_ungrouped() {
let stacks: Vec<Option<Stack>> = vec![None, None, None];
let result = group_by_stack(&stacks);
assert!(result.groups.is_empty());
assert_eq!(result.ungrouped, vec![0, 1, 2]);
}
#[test]
fn single_stacked_worktree_forms_one_group() {
let stacks = vec![Some(stack("main", &["feat-a"], "feat-a"))];
let result = group_by_stack(&stacks);
assert!(result.ungrouped.is_empty());
assert_eq!(result.groups.len(), 1);
assert_eq!(result.groups[0].members, vec![0]);
assert_eq!(result.groups[0].stack.trunk, "main");
}
#[test]
fn two_worktrees_same_stack_collapse_into_one_group_ordered_bottom_to_top() {
let stacks = vec![
Some(stack("main", &["feat-b", "feat-a"], "feat-a")), Some(stack("main", &["feat-b", "feat-a"], "feat-b")), ];
let result = group_by_stack(&stacks);
assert!(result.ungrouped.is_empty());
assert_eq!(result.groups.len(), 1);
assert_eq!(result.groups[0].members, vec![1, 0]);
}
#[test]
fn same_stack_different_branches_vector_order_still_collapses() {
let stacks = vec![
Some(stack("main", &["feat-a", "feat-b"], "feat-a")),
Some(stack("main", &["feat-b", "feat-a"], "feat-b")),
];
let result = group_by_stack(&stacks);
assert_eq!(
result.groups.len(),
1,
"different branch vector order must still collapse"
);
assert!(result.ungrouped.is_empty());
}
#[test]
fn two_distinct_stacks_on_same_trunk_stay_separate() {
let stacks = vec![
Some(stack("main", &["stack1-a"], "stack1-a")),
Some(stack("main", &["stack2-a"], "stack2-a")),
];
let result = group_by_stack(&stacks);
assert_eq!(result.groups.len(), 2);
assert!(result.ungrouped.is_empty());
}
#[test]
fn mixed_stacked_and_ungrouped() {
let stacks = vec![
None, Some(stack("main", &["feat-a", "feat-b"], "feat-a")),
None, Some(stack("main", &["feat-a", "feat-b"], "feat-b")),
];
let result = group_by_stack(&stacks);
assert_eq!(result.ungrouped, vec![0, 2]);
assert_eq!(result.groups.len(), 1);
assert_eq!(result.groups[0].members, vec![1, 3]);
}
#[test]
fn groups_ordered_deterministically_by_trunk_then_branch_set() {
let stacks = vec![
Some(stack("main", &["z-feat"], "z-feat")),
Some(stack("dev", &["d-feat"], "d-feat")),
Some(stack("main", &["a-feat"], "a-feat")),
];
let result = group_by_stack(&stacks);
assert_eq!(result.groups.len(), 3);
assert_eq!(result.groups[0].stack.trunk, "dev");
assert_eq!(result.groups[1].stack.diffs, vec!["a-feat"]);
assert_eq!(result.groups[2].stack.diffs, vec!["z-feat"]);
}
}