use std::collections::HashMap;
use std::path::Path;
use git2::Repository;
use rusqlite::OpenFlags;
use serde_json::Value;
use super::metadata::{self, BranchMetadata, StackMetadata};
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)
}
pub(crate) 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()]
}
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)
}
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(crate) fn read_metadata(repo: &Repository) -> Result<StackMetadata, StackError> {
let parents = read_branch_metadata(repo)?;
Ok(StackMetadata {
trunks: read_trunks(repo),
parents,
pr_titles: read_pr_titles(repo),
stack_numbers: HashMap::new(),
})
}
pub fn enumerate_stacks(repo: &Repository) -> Result<Vec<Stack>, StackError> {
Ok(metadata::enumerate(repo, &read_metadata(repo)?))
}
pub fn current_stack(repo: &Repository, head_branch: &str) -> Result<Option<Stack>, StackError> {
Ok(metadata::current(&read_metadata(repo)?, head_branch))
}
#[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());
}
}