use std::collections::{HashMap, HashSet};
use git2::{BranchType, Oid, Repository, StatusOptions};
use crate::error::{ChangesetError, Result};
use crate::stack::{graphite, StackModel};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChangesetSource {
Committed { base: Oid, head: Oid },
Uncommitted,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Changeset {
pub name: String,
pub source: ChangesetSource,
pub title: Option<String>,
pub current: bool,
pub needs_restack: bool,
}
pub fn assemble_changesets(
repo: &Repository,
head_branch: &str,
model: StackModel,
) -> Result<Vec<Changeset>> {
match model {
StackModel::None => Ok(vec![]),
StackModel::Git => assemble_git(repo, head_branch),
StackModel::Graphite => assemble_graphite(repo, head_branch),
}
}
fn assemble_graphite(repo: &Repository, head_branch: &str) -> Result<Vec<Changeset>> {
let metadata = graphite::read_branch_metadata(repo)?;
let trunks: HashSet<String> = graphite::read_trunks(repo).into_iter().collect();
if trunks.contains(head_branch) || !metadata.contains_key(head_branch) {
return assemble_git(repo, head_branch);
}
if repo.find_branch(head_branch, BranchType::Local).is_err() {
return Err(ChangesetError::UnresolvableBranch {
branch: head_branch.to_string(),
}
.into());
}
let mut ancestors_desc: Vec<String> = Vec::new();
{
let mut walk = head_branch.to_string();
let mut seen: HashSet<String> = HashSet::new();
seen.insert(walk.clone());
loop {
if trunks.contains(&walk) {
break;
}
let Some(entry) = metadata.get(&walk) else {
break;
};
let parent = entry.parent.clone();
if trunks.contains(&parent) {
break;
}
if !metadata.contains_key(&parent) {
break;
}
if !seen.insert(parent.clone()) {
break; }
ancestors_desc.push(parent.clone());
walk = parent;
}
}
ancestors_desc.reverse();
let mut reverse_map: HashMap<String, Vec<String>> = HashMap::new();
for (branch, entry) in &metadata {
reverse_map
.entry(entry.parent.clone())
.or_default()
.push(branch.clone());
}
for children in reverse_map.values_mut() {
children.sort();
}
fn visit_descendants(
branch: &str,
reverse_map: &HashMap<String, Vec<String>>,
visited: &mut HashSet<String>,
out: &mut Vec<String>,
) {
if let Some(children) = reverse_map.get(branch) {
for child in children {
if visited.insert(child.clone()) {
out.push(child.clone());
visit_descendants(child, reverse_map, visited, out);
}
}
}
}
let mut descendants: Vec<String> = Vec::new();
let mut visited: HashSet<String> = HashSet::new();
visited.insert(head_branch.to_string());
visit_descendants(head_branch, &reverse_map, &mut visited, &mut descendants);
let titles = graphite::read_pr_titles(repo);
let ordered_names: Vec<String> = ancestors_desc
.into_iter()
.chain(std::iter::once(head_branch.to_string()))
.chain(descendants)
.collect();
let mut changesets: Vec<Changeset> = Vec::new();
let mut current_index: Option<usize> = None;
for name in ordered_names {
if !crate::resolve::branch_exists(repo, &name) {
continue;
}
let branch_ref = repo.find_branch(&name, BranchType::Local).map_err(|_| {
ChangesetError::UnresolvableBranch {
branch: name.clone(),
}
})?;
let head_oid =
branch_ref
.get()
.target()
.ok_or_else(|| ChangesetError::UnresolvableBranch {
branch: name.clone(),
})?;
let entry = &metadata[&name];
let (base_oid, needs_restack) = resolve_graphite_base(
repo,
&metadata,
&trunks,
&entry.parent,
entry.parent_revision.as_deref(),
head_oid,
&name,
)?;
let is_current = name == head_branch;
if is_current {
current_index = Some(changesets.len());
}
changesets.push(Changeset {
title: titles.get(&name).cloned(),
source: ChangesetSource::Committed {
base: base_oid,
head: head_oid,
},
current: is_current,
needs_restack,
name,
});
}
insert_uncommitted_layer(repo, head_branch, current_index, &mut changesets)?;
Ok(changesets)
}
fn resolve_graphite_base(
repo: &Repository,
metadata: &HashMap<String, graphite::BranchMetadata>,
trunks: &HashSet<String>,
parent: &str,
parent_revision: Option<&str>,
head: Oid,
branch: &str,
) -> Result<(Oid, bool)> {
match parent_revision {
Some(rev) => {
let oid = Oid::from_str(rev)
.ok()
.filter(|oid| repo.find_commit(*oid).is_ok())
.ok_or_else(|| ChangesetError::InvalidParentRevision {
branch: branch.to_string(),
revision: rev.to_string(),
})?;
let parent_live_tip = repo
.find_branch(parent, BranchType::Local)
.ok()
.and_then(|b| b.get().target());
let needs_restack = match parent_live_tip {
Some(tip) => oid != tip,
None => false,
};
Ok((oid, needs_restack))
}
None => {
let tip =
resolve_live_ancestor_tip(repo, metadata, trunks, parent).ok_or_else(|| {
ChangesetError::UnresolvableBranch {
branch: parent.to_string(),
}
})?;
let base = repo.merge_base(tip, head)?;
Ok((base, false))
}
}
}
fn resolve_live_ancestor_tip(
repo: &Repository,
metadata: &HashMap<String, graphite::BranchMetadata>,
trunks: &HashSet<String>,
start: &str,
) -> Option<Oid> {
let mut walk = start.to_string();
let mut seen: HashSet<String> = HashSet::new();
seen.insert(walk.clone());
loop {
if let Some(tip) = repo
.find_branch(&walk, BranchType::Local)
.ok()
.and_then(|b| b.get().target())
{
return Some(tip);
}
if trunks.contains(&walk) {
return None;
}
match metadata.get(&walk) {
Some(entry) if seen.insert(entry.parent.clone()) => walk = entry.parent.clone(),
_ => return None, }
}
}
fn assemble_git(repo: &Repository, head_branch: &str) -> Result<Vec<Changeset>> {
let branch = repo.find_branch(head_branch, BranchType::Local)?;
let upstream = branch.upstream().map_err(|_| ChangesetError::NoUpstream {
branch: head_branch.to_string(),
})?;
let head_oid = branch
.get()
.target()
.ok_or_else(|| ChangesetError::NoUpstream {
branch: head_branch.to_string(),
})?;
let upstream_oid = upstream
.get()
.target()
.ok_or_else(|| ChangesetError::NoUpstream {
branch: head_branch.to_string(),
})?;
let mut revwalk = repo.revwalk()?;
revwalk.push(head_oid)?;
revwalk.hide(upstream_oid)?;
revwalk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::REVERSE)?;
revwalk.simplify_first_parent()?;
let mut changesets: Vec<Changeset> = Vec::new();
for oid in revwalk {
let oid = oid?;
let commit = repo.find_commit(oid)?;
let base = commit.parent_id(0).unwrap_or(oid);
changesets.push(Changeset {
name: short_id(oid),
source: ChangesetSource::Committed { base, head: oid },
title: commit.summary()?.map(str::to_string),
current: false,
needs_restack: false,
});
}
let current_index = if changesets.is_empty() {
None
} else {
let last = changesets.len() - 1;
changesets[last].current = true;
Some(last)
};
insert_uncommitted_layer(repo, head_branch, current_index, &mut changesets)?;
Ok(changesets)
}
fn short_id(oid: Oid) -> String {
oid.to_string()[..8].to_string()
}
fn insert_uncommitted_layer(
repo: &Repository,
current_branch: &str,
current_index: Option<usize>,
changesets: &mut Vec<Changeset>,
) -> Result<()> {
let mut opts = StatusOptions::new();
opts.include_untracked(true);
opts.include_ignored(false);
let statuses = repo.statuses(Some(&mut opts))?;
if statuses.is_empty() {
return Ok(());
}
if let Some(idx) = current_index {
changesets[idx].current = false;
}
let insert_at = current_index.map_or(changesets.len(), |i| i + 1);
changesets.insert(
insert_at,
Changeset {
name: current_branch.to_string(),
source: ChangesetSource::Uncommitted,
title: None,
current: true,
needs_restack: false,
},
);
Ok(())
}