pub(crate) mod gh_stack;
pub(crate) mod graphite;
pub(crate) mod metadata;
use std::collections::{BTreeMap, HashMap, HashSet};
use git2::Repository;
use crate::error::Result;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StackProvider {
Graphite,
GhStack,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StackModel {
None,
Graphite,
GhStack,
Mixed { primary: StackProvider },
Git,
}
impl StackModel {
pub fn detect(repo: &Repository) -> Self {
let graphite_artifacts = graphite::is_graphite_repo(repo);
let gh_artifacts = gh_stack::is_gh_stack_repo(repo);
match (graphite_artifacts, gh_artifacts) {
(false, false) => Self::None,
(true, false) => Self::Graphite,
(false, true) => Self::GhStack,
(true, true) => {
let graphite_live = graphite::has_live_branches(repo);
let gh_stack_live = gh_stack::has_live_branches(repo);
match (graphite_live, gh_stack_live) {
(true, true) => Self::Mixed {
primary: StackProvider::GhStack,
},
(true, false) => Self::Graphite,
(false, true) => Self::GhStack,
(false, false) => Self::GhStack,
}
}
}
}
pub fn providers(self) -> impl Iterator<Item = StackProvider> {
let (first, second) = match self {
Self::Graphite => (Some(StackProvider::Graphite), None),
Self::GhStack => (Some(StackProvider::GhStack), None),
Self::Mixed { primary } => {
let other = match primary {
StackProvider::Graphite => StackProvider::GhStack,
StackProvider::GhStack => StackProvider::Graphite,
};
(Some(primary), Some(other))
}
Self::None | Self::Git => (None, None),
};
first.into_iter().chain(second)
}
}
#[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 merged: HashSet<String>,
}
fn provider_enumerate_stacks(repo: &Repository, provider: StackProvider) -> Result<Vec<Stack>> {
match provider {
StackProvider::Graphite => graphite::enumerate_stacks(repo).map_err(Into::into),
StackProvider::GhStack => gh_stack::enumerate_stacks(repo).map_err(Into::into),
}
}
fn provider_current_stack(
repo: &Repository,
head_branch: &str,
provider: StackProvider,
) -> Result<Option<Stack>> {
match provider {
StackProvider::Graphite => graphite::current_stack(repo, head_branch).map_err(Into::into),
StackProvider::GhStack => gh_stack::current_stack(repo, head_branch).map_err(Into::into),
}
}
pub fn enumerate_stacks(repo: &Repository, model: StackModel) -> Result<Vec<Stack>> {
match model {
StackModel::None => Ok(vec![]),
StackModel::Git => Ok(vec![]),
StackModel::Graphite => provider_enumerate_stacks(repo, StackProvider::Graphite),
StackModel::GhStack => provider_enumerate_stacks(repo, StackProvider::GhStack),
StackModel::Mixed { .. } => {
let mut providers = model.providers();
let primary = providers.next().expect("Mixed always has a primary");
let secondary = providers.next().expect("Mixed always has a secondary");
let primary_stacks = provider_enumerate_stacks(repo, primary)?;
let claimed: std::collections::HashSet<String> = primary_stacks
.iter()
.flat_map(|s| s.diffs.iter().cloned())
.collect();
let secondary_stacks = provider_enumerate_stacks(repo, secondary)?
.into_iter()
.filter(|s| !s.diffs.iter().any(|b| claimed.contains(b)));
Ok(primary_stacks.into_iter().chain(secondary_stacks).collect())
}
}
}
pub fn current_stack(
repo: &Repository,
head_branch: &str,
model: StackModel,
) -> Result<Option<Stack>> {
match model {
StackModel::None => Ok(None),
StackModel::Git => Ok(None),
_ => {
for provider in model.providers() {
if let Some(stack) = provider_current_stack(repo, head_branch, provider)? {
return Ok(Some(stack));
}
}
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 register_branch(repo: &Repository, branch: &str, base_branch: &str) -> Result<()> {
gh_stack::register_branch(repo, branch, base_branch).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 fn provider_has_live_branches(repo: &Repository, provider: StackProvider) -> bool {
match provider {
StackProvider::Graphite => graphite::has_live_branches(repo),
StackProvider::GhStack => gh_stack::has_live_branches(repo),
}
}
pub use gh_stack::WorktreeGhStackState as GhStackWorktreeState;
pub fn gh_stack_worktree_state(repo: &Repository, worktree_name: &str) -> GhStackWorktreeState {
gh_stack::worktree_state(repo, worktree_name)
}
pub fn unlink_gh_stack_worktree(
repo: &Repository,
worktree_name: &str,
) -> Result<Vec<&'static str>> {
gh_stack::unlink_worktree(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_resolves_mixed_when_both_tools_have_live_branches() {
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::Mixed {
primary: StackProvider::GhStack
},
"both tools have a ref-backed tracked branch, so neither is hidden"
);
}
#[test]
fn detect_resolves_gh_stack_when_only_gh_stack_has_live_branches() {
let fixture = FixtureBuilder::new()
.graphite_config(&["main"])
.ghost_branch_metadata("a", "main")
.gh_stack(None, 1, "main", &["feat-a"])
.build()
.unwrap();
let repo = fixture.repo().unwrap();
assert_eq!(
StackModel::detect(repo),
StackModel::GhStack,
"Graphite's only row is a ghost; gh-stack's tracked branch is live"
);
}
#[test]
fn detect_resolves_graphite_when_gh_stack_has_only_ghost_branches() {
let fixture = FixtureBuilder::new()
.graphite_config(&["main"])
.branch_metadata("a", "main")
.gh_stack(None, 1, "main", &[])
.gh_stack_ghost_branch(None, 1, "feat-a")
.build()
.unwrap();
let repo = fixture.repo().unwrap();
assert_eq!(
StackModel::detect(repo),
StackModel::Graphite,
"gh-stack's only row is a ghost; Graphite's tracked branch is live"
);
}
#[test]
fn detect_resolves_graphite_when_gh_stack_has_only_merged_branches() {
let fixture = FixtureBuilder::new()
.graphite_config(&["main"])
.branch_metadata("a", "main")
.gh_stack(None, 1, "main", &["feat-a"])
.gh_stack_merged_branch(None, 1, "feat-a")
.build()
.unwrap();
let repo = fixture.repo().unwrap();
assert_eq!(
StackModel::detect(repo),
StackModel::Graphite,
"gh-stack's only row is merged; Graphite's tracked branch is live"
);
}
#[test]
fn detect_resolves_graphite_when_both_tools_have_only_ghost_branches() {
let fixture = FixtureBuilder::new()
.graphite_config(&["main"])
.ghost_branch_metadata("a", "main")
.gh_stack(None, 1, "main", &[])
.gh_stack_ghost_branch(None, 1, "feat-a")
.build()
.unwrap();
let repo = fixture.repo().unwrap();
assert_eq!(
StackModel::detect(repo),
StackModel::GhStack,
"neither tool has a live branch; gh-stack is the default, doctor explains the ambiguity"
);
}
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,
merged: HashSet::new(),
}
}
#[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"]);
}
}