use std::collections::{HashMap, HashSet, VecDeque};
use std::path::Path;
use git2::Repository;
use rusqlite::OpenFlags;
use serde_json::Value;
use super::Stack;
use crate::error::StackError;
pub fn detect_gt() -> bool {
let Some(path) = std::env::var_os("PATH") else {
return false;
};
std::env::split_paths(&path).any(|dir| is_executable_file(&dir.join("gt")))
}
#[cfg(unix)]
fn is_executable_file(candidate: &Path) -> bool {
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(candidate)
.map(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
.unwrap_or(false)
}
#[cfg(not(unix))]
fn is_executable_file(candidate: &Path) -> bool {
candidate.is_file()
}
pub fn is_graphite_repo(repo: &Repository) -> bool {
let git_dir = repo.commondir();
git_dir.join(".graphite_metadata.db").exists() || git_dir.join(".graphite_repo_config").exists()
}
pub fn graphite_trunk(repo: &Repository) -> Option<String> {
let path = repo.commondir().join(".graphite_repo_config");
let content = std::fs::read_to_string(&path).ok()?;
let json = serde_json::from_str::<Value>(&content).ok()?;
if let Some(trunks) = json.get("trunks").and_then(|t| t.as_array()) {
let first = trunks
.iter()
.find_map(|t| t.get("name").and_then(|n| n.as_str()))
.map(String::from);
if first.is_some() {
return first;
}
}
json.get("trunk").and_then(|t| t.as_str()).map(String::from)
}
fn read_trunks(repo: &Repository) -> Vec<String> {
let path = repo.commondir().join(".graphite_repo_config");
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => return vec!["main".to_string()],
};
let Ok(json) = serde_json::from_str::<Value>(&content) else {
return vec!["main".to_string()];
};
if let Some(trunks) = json.get("trunks").and_then(|t| t.as_array()) {
let names: Vec<String> = trunks
.iter()
.filter_map(|t| t.get("name").and_then(|n| n.as_str()))
.map(String::from)
.collect();
if !names.is_empty() {
return names;
}
}
if let Some(trunk) = json.get("trunk").and_then(|t| t.as_str()) {
return vec![trunk.to_string()];
}
vec!["main".to_string()]
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct BranchMetadata {
pub parent: String,
pub parent_revision: Option<String>,
}
pub(crate) fn read_branch_metadata(
repo: &Repository,
) -> Result<HashMap<String, BranchMetadata>, StackError> {
let db_path = repo.commondir().join(".graphite_metadata.db");
if db_path.exists() {
read_branch_metadata_from_sqlite(&db_path)
} else {
read_branch_metadata_from_refs(repo)
}
}
fn read_branch_metadata_from_sqlite(
db_path: &Path,
) -> Result<HashMap<String, BranchMetadata>, StackError> {
let conn = rusqlite::Connection::open_with_flags(
db_path,
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
)
.map_err(|e| StackError::GtParseFailed {
message: format!("failed to open .graphite_metadata.db: {e}"),
})?;
let mut stmt = conn
.prepare(
"SELECT branch_name, parent_branch_name, parent_branch_revision \
FROM branch_metadata \
WHERE parent_branch_name IS NOT NULL AND parent_branch_name != ''",
)
.map_err(|e| StackError::GtParseFailed {
message: format!("failed to query .graphite_metadata.db: {e}"),
})?;
let mut map = HashMap::new();
let rows = stmt
.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, Option<String>>(2)?,
))
})
.map_err(|e| StackError::GtParseFailed {
message: format!("failed to read .graphite_metadata.db rows: {e}"),
})?;
for row in rows {
let (branch, parent, parent_revision) = row.map_err(|e| StackError::GtParseFailed {
message: format!("failed to read .graphite_metadata.db row: {e}"),
})?;
let parent_revision = parent_revision.filter(|r| !r.is_empty());
map.insert(
branch,
BranchMetadata {
parent,
parent_revision,
},
);
}
Ok(map)
}
fn read_branch_metadata_from_refs(
repo: &Repository,
) -> Result<HashMap<String, BranchMetadata>, StackError> {
let mut map = HashMap::new();
let references = repo
.references_glob("refs/branch-metadata/*")
.map_err(|e| StackError::GtParseFailed {
message: format!("failed to list branch-metadata refs: {e}"),
})?;
for reference in references {
let reference = reference.map_err(|e| StackError::GtParseFailed {
message: format!("failed to read branch-metadata ref: {e}"),
})?;
let Ok(refname) = reference.name() else {
continue;
};
let Some(branch) = refname.strip_prefix("refs/branch-metadata/") else {
continue;
};
if branch.is_empty() {
continue;
}
let Ok(object) = reference.peel(git2::ObjectType::Blob) else {
continue;
};
let Ok(blob) = object.into_blob() else {
continue;
};
let Ok(json) = serde_json::from_slice::<Value>(blob.content()) else {
continue;
};
if let Some(parent) = json.get("parentBranchName").and_then(|p| p.as_str()) {
let parent_revision = json
.get("parentBranchRevision")
.and_then(|r| r.as_str())
.map(String::from)
.filter(|r| !r.is_empty());
map.insert(
branch.to_string(),
BranchMetadata {
parent: parent.to_string(),
parent_revision,
},
);
}
}
Ok(map)
}
fn build_parent_map(repo: &Repository) -> Result<HashMap<String, String>, StackError> {
Ok(read_branch_metadata(repo)?
.into_iter()
.map(|(branch, metadata)| (branch, metadata.parent))
.collect())
}
#[allow(dead_code)]
pub(crate) fn read_pr_titles(repo: &Repository) -> HashMap<String, String> {
let path = repo.commondir().join(".graphite_pr_info");
let Ok(content) = std::fs::read_to_string(&path) else {
return HashMap::new();
};
let Ok(json) = serde_json::from_str::<Value>(&content) else {
return HashMap::new();
};
let Some(pr_infos) = json.get("prInfos").and_then(|p| p.as_array()) else {
return HashMap::new();
};
pr_infos
.iter()
.filter_map(|entry| {
let branch = entry.get("headRefName").and_then(|v| v.as_str())?;
let title = entry.get("title").and_then(|v| v.as_str())?;
Some((branch.to_string(), title.to_string()))
})
.collect()
}
pub fn enumerate_stacks(repo: &Repository) -> Result<Vec<Stack>, StackError> {
let mut parent_map = build_parent_map(repo)?;
if parent_map.is_empty() {
return Ok(vec![]);
}
parent_map.retain(|branch, _| crate::resolve::branch_exists(repo, branch));
if parent_map.is_empty() {
return Ok(vec![]);
}
let trunks: HashSet<String> = read_trunks(repo).into_iter().collect();
let mut reverse_map: HashMap<String, Vec<String>> = HashMap::new();
for (branch, parent) in &parent_map {
reverse_map
.entry(parent.clone())
.or_default()
.push(branch.clone());
}
let mut root_branches: Vec<String> = parent_map
.iter()
.filter(|(_, p)| trunks.contains(*p))
.map(|(b, _)| b.clone())
.collect();
root_branches.sort();
let mut stacks: Vec<Stack> = Vec::new();
let mut visited: HashSet<String> = HashSet::new();
for root in root_branches {
if visited.contains(&root) {
continue;
}
let trunk = parent_map.get(&root).cloned().unwrap_or_default();
let mut diffs: Vec<String> = Vec::new();
let mut queue: VecDeque<String> = VecDeque::new();
queue.push_back(root);
while let Some(branch) = queue.pop_front() {
if !visited.insert(branch.clone()) {
continue;
}
if trunks.contains(&branch) {
continue;
}
diffs.push(branch.clone());
if let Some(children) = reverse_map.get(&branch) {
let mut sorted = children.clone();
sorted.sort();
for child in sorted {
queue.push_back(child);
}
}
}
if !diffs.is_empty() {
let current = diffs[0].clone();
let parents: std::collections::HashMap<String, String> = diffs
.iter()
.filter_map(|b| parent_map.get(b).map(|p| (b.clone(), p.clone())))
.collect();
stacks.push(Stack {
trunk,
diffs,
current,
parents,
});
}
}
Ok(stacks)
}
pub fn current_stack(repo: &Repository, head_branch: &str) -> Result<Option<Stack>, StackError> {
let parent_map = build_parent_map(repo)?;
if !parent_map.contains_key(head_branch) {
return Ok(None);
}
let trunks: HashSet<String> = read_trunks(repo).into_iter().collect();
let mut walk = head_branch.to_string();
let mut ancestors: Vec<String> = Vec::new();
let mut upward_seen: HashSet<String> = HashSet::new();
upward_seen.insert(walk.clone());
let trunk = loop {
if trunks.contains(&walk) {
break walk.clone();
}
match parent_map.get(&walk) {
Some(parent) => {
if !upward_seen.insert(parent.clone()) {
break walk.clone();
}
ancestors.push(walk.clone());
walk = parent.clone();
}
None => break walk.clone(),
}
};
ancestors.reverse();
let stack_root = match ancestors.first() {
Some(r) => r.clone(),
None => return Ok(None), };
let mut reverse_map: HashMap<String, Vec<String>> = HashMap::new();
for (branch, parent) in &parent_map {
reverse_map
.entry(parent.clone())
.or_default()
.push(branch.clone());
}
let mut stack_branches: Vec<String> = Vec::new();
let mut queue: VecDeque<String> = VecDeque::new();
let mut visited: HashSet<String> = HashSet::new();
queue.push_back(stack_root);
while let Some(branch) = queue.pop_front() {
if !visited.insert(branch.clone()) {
continue;
}
if trunks.contains(&branch) {
continue;
}
stack_branches.push(branch.clone());
if let Some(children) = reverse_map.get(&branch) {
for child in children {
queue.push_back(child.clone());
}
}
}
let parents: std::collections::HashMap<String, String> = stack_branches
.iter()
.filter_map(|b| parent_map.get(b).map(|p| (b.clone(), p.clone())))
.collect();
Ok(Some(Stack {
trunk,
diffs: stack_branches,
current: head_branch.to_string(),
parents,
}))
}
#[cfg(test)]
mod tests {
use super::*;
use git_workon_fixture::prelude::*;
#[test]
fn read_branch_metadata_resolves_live_parent_revision_refs() {
let fixture = FixtureBuilder::new()
.branch_metadata("feat-a", "main")
.build()
.unwrap();
let repo = fixture.repo().unwrap();
let metadata = read_branch_metadata(repo).unwrap();
let entry = metadata.get("feat-a").expect("feat-a metadata present");
assert_eq!(entry.parent, "main");
let main_tip = repo
.find_branch("main", git2::BranchType::Local)
.unwrap()
.get()
.target()
.unwrap();
assert_eq!(
entry.parent_revision.as_deref(),
Some(main_tip.to_string().as_str())
);
}
#[test]
fn read_branch_metadata_resolves_live_parent_revision_sqlite() {
let fixture = FixtureBuilder::new()
.metadata_format(MetadataFormat::Sqlite)
.branch_metadata("feat-a", "main")
.build()
.unwrap();
let repo = fixture.repo().unwrap();
let metadata = read_branch_metadata(repo).unwrap();
let entry = metadata.get("feat-a").expect("feat-a metadata present");
assert_eq!(entry.parent, "main");
let main_tip = repo
.find_branch("main", git2::BranchType::Local)
.unwrap()
.get()
.target()
.unwrap();
assert_eq!(
entry.parent_revision.as_deref(),
Some(main_tip.to_string().as_str())
);
}
#[test]
fn read_branch_metadata_preserves_verbatim_stale_parent_revision_refs() {
let fixture = FixtureBuilder::new()
.branch_metadata_at(
"feat-a",
"main",
"deadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
"cafebabecafebabecafebabecafebabecafebabe",
)
.build()
.unwrap();
let repo = fixture.repo().unwrap();
let metadata = read_branch_metadata(repo).unwrap();
let entry = metadata.get("feat-a").expect("feat-a metadata present");
assert_eq!(
entry.parent_revision.as_deref(),
Some("cafebabecafebabecafebabecafebabecafebabe")
);
}
#[test]
fn read_branch_metadata_preserves_verbatim_stale_parent_revision_sqlite() {
let fixture = FixtureBuilder::new()
.metadata_format(MetadataFormat::Sqlite)
.branch_metadata_at(
"feat-a",
"main",
"deadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
"cafebabecafebabecafebabecafebabecafebabe",
)
.build()
.unwrap();
let repo = fixture.repo().unwrap();
let metadata = read_branch_metadata(repo).unwrap();
let entry = metadata.get("feat-a").expect("feat-a metadata present");
assert_eq!(
entry.parent_revision.as_deref(),
Some("cafebabecafebabecafebabecafebabecafebabe")
);
}
#[test]
fn read_branch_metadata_treats_empty_parent_revision_as_none_refs() {
let fixture = FixtureBuilder::new()
.branch_metadata_at("feat-a", "main", "", "")
.build()
.unwrap();
let repo = fixture.repo().unwrap();
let metadata = read_branch_metadata(repo).unwrap();
let entry = metadata.get("feat-a").expect("feat-a metadata present");
assert_eq!(entry.parent_revision, None);
}
#[test]
fn read_branch_metadata_treats_empty_parent_revision_as_none_sqlite() {
let fixture = FixtureBuilder::new()
.metadata_format(MetadataFormat::Sqlite)
.branch_metadata_at("feat-a", "main", "", "")
.build()
.unwrap();
let repo = fixture.repo().unwrap();
let metadata = read_branch_metadata(repo).unwrap();
let entry = metadata.get("feat-a").expect("feat-a metadata present");
assert_eq!(entry.parent_revision, None);
}
#[test]
fn read_pr_titles_maps_branch_to_title() {
let fixture = FixtureBuilder::new()
.graphite_pr_info("feat-a", 42, "Add feature A")
.build()
.unwrap();
let repo = fixture.repo().unwrap();
let titles = read_pr_titles(repo);
assert_eq!(
titles.get("feat-a").map(String::as_str),
Some("Add feature A")
);
}
#[test]
fn read_pr_titles_empty_when_file_missing() {
let fixture = FixtureBuilder::new().build().unwrap();
let repo = fixture.repo().unwrap();
assert!(read_pr_titles(repo).is_empty());
}
}