use std::collections::HashMap;
use std::path::Path;
use git2::{Repository, Sort};
use rusqlite::{params, Connection};
use tracing::{debug, info, warn};
use crate::collect::errors::{CollectError, Result};
use crate::core::config::ReachabilityConfig;
#[derive(Debug, Default, Clone)]
pub struct ReachabilityStats {
pub rows_upserted: usize,
pub tagged_commits: usize,
pub release_branch_commits: usize,
pub default_branch_commits: usize,
}
pub fn scan_and_persist(
repo_path: &Path,
conn: &Connection,
config: &ReachabilityConfig,
repo_name: Option<&str>,
) -> Result<ReachabilityStats> {
let repo = Repository::open(repo_path).map_err(CollectError::Git)?;
let all_shas: Vec<String> = if let Some(name) = repo_name {
let mut stmt = conn
.prepare("SELECT sha FROM commits WHERE repository = ?1")
.map_err(crate::core::TgaError::from)?;
let rows = stmt
.query_map(params![name], |row| row.get::<_, String>(0))
.map_err(crate::core::TgaError::from)?;
let mut out = Vec::new();
for r in rows {
out.push(r.map_err(crate::core::TgaError::from)?);
}
out
} else {
let mut stmt = conn
.prepare("SELECT sha FROM commits")
.map_err(crate::core::TgaError::from)?;
let rows = stmt
.query_map([], |row| row.get::<_, String>(0))
.map_err(crate::core::TgaError::from)?;
let mut out = Vec::new();
for r in rows {
out.push(r.map_err(crate::core::TgaError::from)?);
}
out
};
if all_shas.is_empty() {
debug!("no commits in DB; skipping reachability scan");
return Ok(ReachabilityStats::default());
}
let sha_set: std::collections::HashSet<String> = all_shas.iter().cloned().collect();
let default_branch_set = detect_default_branch_set(&repo, &sha_set, repo_path);
let tag_map = if config.track_tags {
build_tag_map(&repo, &sha_set)?
} else {
HashMap::new()
};
let branch_map = if config.track_release_branches && !config.release_branch_patterns.is_empty()
{
build_branch_map(&repo, &config.release_branch_patterns, &sha_set)?
} else {
HashMap::new()
};
let mut stats = ReachabilityStats::default();
let tx = conn
.unchecked_transaction()
.map_err(crate::core::TgaError::from)?;
for sha in &all_shas {
let tags = tag_map.get(sha).cloned().unwrap_or_default();
let branches = branch_map.get(sha).cloned().unwrap_or_default();
let on_any_tag = !tags.is_empty();
let on_release_branch = !branches.is_empty();
let on_default_branch = default_branch_set.contains(sha.as_str());
let tags_json = serde_json::to_string(&tags).unwrap_or_else(|_| "[]".to_string());
let branches_json = serde_json::to_string(&branches).unwrap_or_else(|_| "[]".to_string());
tx.execute(
"INSERT INTO fact_commit_reachability \
(commit_sha, on_default_branch, on_any_tag, reachable_from_tags, \
on_release_branch, release_branches) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6) \
ON CONFLICT(commit_sha) DO UPDATE SET \
on_default_branch = excluded.on_default_branch, \
on_any_tag = excluded.on_any_tag, \
reachable_from_tags = excluded.reachable_from_tags, \
on_release_branch = excluded.on_release_branch, \
release_branches = excluded.release_branches",
params![
sha,
on_default_branch as i64,
on_any_tag as i64,
tags_json,
on_release_branch as i64,
branches_json
],
)
.map_err(crate::core::TgaError::from)?;
stats.rows_upserted += 1;
if on_default_branch {
stats.default_branch_commits += 1;
}
if on_any_tag {
stats.tagged_commits += 1;
}
if on_release_branch {
stats.release_branch_commits += 1;
}
}
tx.commit().map_err(crate::core::TgaError::from)?;
info!(
rows = stats.rows_upserted,
default_branch = stats.default_branch_commits,
tagged = stats.tagged_commits,
release_branch = stats.release_branch_commits,
"reachability scan complete"
);
Ok(stats)
}
pub fn detect_default_branch_set(
repo: &Repository,
known_shas: &std::collections::HashSet<String>,
repo_path: &Path,
) -> std::collections::HashSet<String> {
let tip_oid = 'resolve: {
if let Ok(head_ref) = repo.find_reference("refs/remotes/origin/HEAD") {
if let Ok(resolved) = head_ref.resolve() {
if let Some(oid) = resolved.target() {
break 'resolve Some(oid);
}
}
}
for candidate in [
"refs/heads/main",
"refs/heads/master",
"refs/remotes/origin/main",
"refs/remotes/origin/master",
] {
if let Ok(r) = repo.find_reference(candidate) {
if let Some(oid) = r.target() {
debug!(candidate, "default branch detected via fallback");
break 'resolve Some(oid);
}
}
}
None
};
match tip_oid {
None => {
warn!(
repo = %repo_path.display(),
"could not detect default branch (tried origin/HEAD, main, master); \
on_default_branch will be 0 for this repo"
);
std::collections::HashSet::new()
}
Some(oid) => {
let mut set = std::collections::HashSet::new();
let mut revwalk = match repo.revwalk() {
Ok(w) => w,
Err(e) => {
warn!(error = %e, "revwalk init failed for default-branch detection");
return set;
}
};
if let Err(e) = revwalk.set_sorting(git2::Sort::TIME) {
warn!(error = %e, "revwalk sort failed");
return set;
}
if let Err(e) = revwalk.push(oid) {
warn!(error = %e, "revwalk push failed");
return set;
}
for oid_res in revwalk {
match oid_res {
Ok(o) => {
let sha = o.to_string();
if known_shas.contains(&sha) {
set.insert(sha);
}
}
Err(e) => {
warn!(error = %e, "revwalk error during default-branch detection; stopping");
break;
}
}
}
debug!(
default_branch_commits = set.len(),
"default-branch SHA set built"
);
set
}
}
}
pub fn build_tag_map(
repo: &Repository,
known_shas: &std::collections::HashSet<String>,
) -> Result<HashMap<String, Vec<String>>> {
let mut map: HashMap<String, Vec<String>> = HashMap::new();
let tag_names = repo.tag_names(None).map_err(CollectError::Git)?;
let tag_count = tag_names.len();
debug!(tags = tag_count, "scanning tags for reachability");
for name_opt in tag_names.iter() {
let name = match name_opt {
Some(n) => n,
None => {
warn!("tag with non-UTF-8 name skipped");
continue;
}
};
let refname = format!("refs/tags/{name}");
let tip_oid = match resolve_ref_to_commit(repo, &refname) {
Some(oid) => oid,
None => {
debug!(tag = %name, "could not resolve tag to a commit; skipping");
continue;
}
};
let before = map.len();
walk_ancestors(repo, tip_oid, known_shas, name, &mut map)?;
let added = map.len() - before;
debug!(tag = %name, tip = %tip_oid, new_commits = added, "tag walked");
}
debug!(map_size = map.len(), "tag reachability map built");
Ok(map)
}
pub fn build_branch_map(
repo: &Repository,
patterns: &[String],
known_shas: &std::collections::HashSet<String>,
) -> Result<HashMap<String, Vec<String>>> {
let mut map: HashMap<String, Vec<String>> = HashMap::new();
for branch_type in [git2::BranchType::Local, git2::BranchType::Remote] {
let branches = repo
.branches(Some(branch_type))
.map_err(CollectError::Git)?;
for entry in branches {
let (branch, _) = entry.map_err(CollectError::Git)?;
let short_name = match branch.name() {
Ok(Some(n)) => n.to_string(),
_ => {
warn!("branch with non-UTF-8 or missing name skipped");
continue;
}
};
let stripped = short_name
.strip_prefix("origin/")
.unwrap_or(short_name.as_str());
if !patterns.iter().any(|p| glob_matches(p, stripped)) {
continue;
}
let tip_oid = match branch.get().target() {
Some(oid) => oid,
None => {
debug!(branch = %short_name, "symbolic ref without target; skipping");
continue;
}
};
walk_ancestors(repo, tip_oid, known_shas, stripped, &mut map)?;
}
}
debug!(
map_size = map.len(),
"release-branch reachability map built"
);
Ok(map)
}
fn walk_ancestors(
repo: &Repository,
tip_oid: git2::Oid,
known_shas: &std::collections::HashSet<String>,
label: &str,
map: &mut HashMap<String, Vec<String>>,
) -> Result<()> {
let mut revwalk = repo.revwalk().map_err(CollectError::Git)?;
revwalk.set_sorting(Sort::TIME).map_err(CollectError::Git)?;
revwalk.push(tip_oid).map_err(CollectError::Git)?;
for oid_res in revwalk {
let oid = match oid_res {
Ok(o) => o,
Err(e) => {
warn!(error = %e, "revwalk error while walking {label}; stopping");
break;
}
};
let sha = oid.to_string();
if known_shas.contains(&sha) {
map.entry(sha).or_default().push(label.to_string());
}
}
Ok(())
}
fn resolve_ref_to_commit(repo: &Repository, refname: &str) -> Option<git2::Oid> {
let reference = repo.find_reference(refname).ok()?;
let commit = reference.peel_to_commit().ok()?;
Some(commit.id())
}
pub fn glob_matches(pattern: &str, text: &str) -> bool {
match pattern.find('*') {
None => pattern == text,
Some(star_pos) => {
let prefix = &pattern[..star_pos];
let suffix = &pattern[star_pos + 1..];
let effective_suffix = if suffix.contains('*') {
""
} else {
suffix
};
if !text.starts_with(prefix) {
return false;
}
let rest = &text[prefix.len()..];
if effective_suffix.is_empty() {
true
} else {
rest.ends_with(effective_suffix) && rest.len() >= effective_suffix.len()
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use git2::{Repository, Signature, Time};
use rusqlite::Connection;
#[test]
fn glob_exact_match() {
assert!(glob_matches("main", "main"));
assert!(!glob_matches("main", "master"));
}
#[test]
fn glob_star_prefix() {
assert!(glob_matches("release/*", "release/v1.0"));
assert!(glob_matches("release/*", "release/2024-01-15"));
assert!(!glob_matches("release/*", "releaze/v1.0"));
assert!(!glob_matches("release/*", "hotfix/v1.0"));
}
#[test]
fn glob_star_suffix_prefix() {
assert!(glob_matches("v*", "v1.0"));
assert!(glob_matches("v*", "v2.3.4-rc"));
assert!(glob_matches("v*", "v1"));
assert!(glob_matches("v*", "version-1"));
assert!(!glob_matches("v*", "1.0"));
assert!(!glob_matches("v*", "release/v1.0"));
}
#[test]
fn glob_hotfix_pattern() {
assert!(glob_matches("hotfix/*", "hotfix/PROJ-123"));
assert!(glob_matches("hotfix/*", "hotfix/security-patch"));
assert!(!glob_matches("hotfix/*", "feature/my-feature"));
}
#[test]
fn glob_chore_release_pattern() {
assert!(glob_matches("chore/release-*", "chore/release-v2.0"));
assert!(glob_matches("chore/release-*", "chore/release-2024"));
assert!(!glob_matches("chore/release-*", "chore/releases"));
}
#[test]
fn glob_no_wildcard() {
assert!(glob_matches("main", "main"));
assert!(!glob_matches("main", "main-branch"));
}
#[test]
fn glob_double_star_treated_as_prefix_only() {
assert!(glob_matches("release/**", "release/foo/bar"));
}
#[test]
fn glob_star_only() {
assert!(glob_matches("*", "anything"));
assert!(glob_matches("*", "release/v1.0"));
assert!(glob_matches("*", ""));
}
struct TempRepo {
path: PathBuf,
}
impl Drop for TempRepo {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
fn unique_dir(label: &str) -> PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static N: AtomicU64 = AtomicU64::new(0);
let n = N.fetch_add(1, Ordering::Relaxed);
let mut p = std::env::temp_dir();
p.push(format!("tga-reach-{}-{}-{label}", std::process::id(), n));
p
}
fn init_repo(label: &str) -> (TempRepo, Repository) {
let path = unique_dir(label);
std::fs::create_dir_all(&path).expect("mkdir");
let repo = Repository::init(&path).expect("git init");
let mut cfg = repo.config().expect("repo config");
cfg.set_str("user.name", "Test").expect("set user.name");
cfg.set_str("user.email", "t@example.com")
.expect("set email");
(TempRepo { path }, repo)
}
fn make_commit(repo: &Repository, repo_path: &Path, msg: &str, ts: i64) -> git2::Oid {
use std::sync::atomic::{AtomicU64, Ordering};
static F: AtomicU64 = AtomicU64::new(0);
let n = F.fetch_add(1, Ordering::Relaxed);
let fname = format!("f{n}.txt");
std::fs::write(repo_path.join(&fname), msg).expect("write");
let mut idx = repo.index().expect("index");
idx.add_path(std::path::Path::new(&fname)).expect("add");
idx.write().expect("idx write");
let tree_oid = idx.write_tree().expect("write_tree");
let tree = repo.find_tree(tree_oid).expect("find_tree");
let sig = Signature::new("Test", "t@example.com", &Time::new(ts, 0)).expect("sig");
let parents: Vec<git2::Commit<'_>> = match repo.head() {
Ok(h) => vec![h.peel_to_commit().expect("peel")],
Err(_) => vec![],
};
let parent_refs: Vec<&git2::Commit<'_>> = parents.iter().collect();
repo.commit(Some("HEAD"), &sig, &sig, msg, &tree, &parent_refs)
.expect("commit")
}
fn tag_commit(repo: &Repository, oid: git2::Oid, name: &str) {
repo.tag_lightweight(name, &repo.find_object(oid, None).expect("obj"), false)
.expect("tag");
}
fn branch_at(repo: &Repository, oid: git2::Oid, name: &str) {
let commit = repo.find_commit(oid).expect("find_commit");
repo.branch(name, &commit, false).expect("branch");
}
fn open_in_memory_db() -> Connection {
let conn = Connection::open_in_memory().expect("in-memory");
conn.execute_batch(
"PRAGMA journal_mode=WAL; \
PRAGMA foreign_keys=OFF;", )
.expect("pragmas");
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS commits (
id INTEGER PRIMARY KEY,
sha TEXT NOT NULL UNIQUE,
author_name TEXT NOT NULL DEFAULT '',
author_email TEXT NOT NULL DEFAULT '',
timestamp TEXT NOT NULL DEFAULT '',
message TEXT NOT NULL DEFAULT '',
repository TEXT NOT NULL DEFAULT '',
files_changed INTEGER NOT NULL DEFAULT 0,
insertions INTEGER NOT NULL DEFAULT 0,
deletions INTEGER NOT NULL DEFAULT 0,
is_merge INTEGER NOT NULL DEFAULT 0,
ticketed INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS fact_commit_reachability (
commit_sha TEXT PRIMARY KEY,
on_default_branch INTEGER NOT NULL DEFAULT 0,
on_any_tag INTEGER NOT NULL DEFAULT 0,
reachable_from_tags TEXT NOT NULL DEFAULT '[]',
on_release_branch INTEGER NOT NULL DEFAULT 0,
release_branches TEXT NOT NULL DEFAULT '[]'
);",
)
.expect("create tables");
conn
}
fn insert_sha(conn: &Connection, sha: &str) {
conn.execute(
"INSERT OR IGNORE INTO commits (sha) VALUES (?1)",
params![sha],
)
.expect("insert sha");
}
#[test]
fn build_tag_map_basic() {
let (tr, repo) = init_repo("tag-map");
let sha1 = make_commit(&repo, &tr.path, "first", 1000).to_string();
let sha2 = make_commit(&repo, &tr.path, "second", 2000).to_string();
let sha3 = make_commit(&repo, &tr.path, "third", 3000).to_string();
let oid2 = git2::Oid::from_str(&sha2).expect("oid2");
let oid3 = git2::Oid::from_str(&sha3).expect("oid3");
tag_commit(&repo, oid2, "v1.0");
tag_commit(&repo, oid3, "v2.0");
let known: std::collections::HashSet<String> =
[sha1.clone(), sha2.clone(), sha3.clone()].into();
let map = build_tag_map(&repo, &known).expect("build_tag_map");
assert!(
map.get(&sha1)
.map(|v| v.contains(&"v1.0".to_string()))
.unwrap_or(false),
"sha1 should be reachable from v1.0"
);
assert!(
map.get(&sha2)
.map(|v| v.contains(&"v1.0".to_string()))
.unwrap_or(false),
"sha2 should be reachable from v1.0"
);
assert!(
map.get(&sha3)
.map(|v| v.contains(&"v2.0".to_string()))
.unwrap_or(false),
"sha3 should be reachable from v2.0"
);
assert!(
map.get(&sha2)
.map(|v| v.contains(&"v2.0".to_string()))
.unwrap_or(false),
"sha2 should also be reachable from v2.0"
);
}
#[test]
fn build_tag_map_empty_known_shas() {
let (tr, repo) = init_repo("tag-map-empty");
let sha = make_commit(&repo, &tr.path, "c1", 1000).to_string();
let oid = git2::Oid::from_str(&sha).expect("oid");
tag_commit(&repo, oid, "v1.0");
let known = std::collections::HashSet::new();
let map = build_tag_map(&repo, &known).expect("build_tag_map");
assert!(map.is_empty(), "empty known_shas → empty map");
}
#[test]
fn build_branch_map_basic() {
let (tr, repo) = init_repo("branch-map");
let sha1 = make_commit(&repo, &tr.path, "base", 1000).to_string();
let sha2 = make_commit(&repo, &tr.path, "release commit", 2000).to_string();
let oid2 = git2::Oid::from_str(&sha2).expect("oid2");
branch_at(&repo, oid2, "release/v1.0");
let known: std::collections::HashSet<String> = [sha1.clone(), sha2.clone()].into();
let patterns = vec!["release/*".to_string()];
let map = build_branch_map(&repo, &patterns, &known).expect("build_branch_map");
assert!(
map.get(&sha2)
.map(|v| v.contains(&"release/v1.0".to_string()))
.unwrap_or(false),
"sha2 should be on release/v1.0"
);
assert!(
map.get(&sha1)
.map(|v| v.contains(&"release/v1.0".to_string()))
.unwrap_or(false),
"sha1 (ancestor) should also be on release/v1.0"
);
}
#[test]
fn build_branch_map_non_matching_excluded() {
let (tr, repo) = init_repo("branch-map-non-match");
let sha1 = make_commit(&repo, &tr.path, "base", 1000).to_string();
let sha2 = make_commit(&repo, &tr.path, "feature", 2000).to_string();
let oid2 = git2::Oid::from_str(&sha2).expect("oid2");
branch_at(&repo, oid2, "feature/my-work");
let known: std::collections::HashSet<String> = [sha1.clone(), sha2.clone()].into();
let patterns = vec!["release/*".to_string()];
let map = build_branch_map(&repo, &patterns, &known).expect("build_branch_map");
assert!(
map.is_empty(),
"feature/* branches must not be matched by release/* pattern"
);
}
#[test]
fn scan_full_lifecycle() {
let (tr, repo) = init_repo("scan-lifecycle");
let sha1 = make_commit(&repo, &tr.path, "initial", 1000).to_string();
let sha2 = make_commit(&repo, &tr.path, "feature", 2000).to_string();
let sha3 = make_commit(&repo, &tr.path, "hotfix", 3000).to_string();
let oid2 = git2::Oid::from_str(&sha2).expect("oid2");
tag_commit(&repo, oid2, "v1.0");
let oid3 = git2::Oid::from_str(&sha3).expect("oid3");
branch_at(&repo, oid3, "release/v2.0");
let conn = open_in_memory_db();
for sha in [&sha1, &sha2, &sha3] {
insert_sha(&conn, sha);
}
let cfg = ReachabilityConfig {
track_tags: true,
track_release_branches: true,
release_branch_patterns: vec!["release/*".to_string()],
};
let stats = scan_and_persist(&tr.path, &conn, &cfg, None).expect("scan_and_persist");
assert_eq!(stats.rows_upserted, 3, "one row per commit");
let (on_tag, on_rel): (i64, i64) = conn
.query_row(
"SELECT on_any_tag, on_release_branch FROM fact_commit_reachability WHERE commit_sha = ?1",
params![sha2],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.expect("query sha2");
assert_eq!(on_tag, 1, "sha2 should be on a tag");
let (s3_on_tag, s3_on_rel): (i64, i64) = conn
.query_row(
"SELECT on_any_tag, on_release_branch FROM fact_commit_reachability WHERE commit_sha = ?1",
params![sha3],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.expect("query sha3");
assert_eq!(s3_on_rel, 1, "sha3 should be on release/v2.0");
let _ = (on_rel, s3_on_tag);
let s1_on_tag: i64 = conn
.query_row(
"SELECT on_any_tag FROM fact_commit_reachability WHERE commit_sha = ?1",
params![sha1],
|r| r.get(0),
)
.expect("query sha1");
assert_eq!(s1_on_tag, 1, "sha1 (ancestor of v1.0) should be tagged");
}
#[test]
fn scan_skip_tags_when_disabled() {
let (tr, repo) = init_repo("scan-skip-tags");
let sha1 = make_commit(&repo, &tr.path, "c1", 1000).to_string();
let oid1 = git2::Oid::from_str(&sha1).expect("oid1");
tag_commit(&repo, oid1, "v1.0");
let conn = open_in_memory_db();
insert_sha(&conn, &sha1);
let cfg = ReachabilityConfig {
track_tags: false,
track_release_branches: false,
release_branch_patterns: vec![],
};
let stats = scan_and_persist(&tr.path, &conn, &cfg, None).expect("scan");
assert_eq!(stats.rows_upserted, 1);
assert_eq!(
stats.tagged_commits, 0,
"tag tracking disabled — no tagged_commits"
);
let on_tag: i64 = conn
.query_row(
"SELECT on_any_tag FROM fact_commit_reachability WHERE commit_sha = ?1",
params![sha1],
|r| r.get(0),
)
.expect("query");
assert_eq!(on_tag, 0, "on_any_tag must be 0 when track_tags=false");
}
#[test]
fn scan_reachable_from_tags_json() {
let (tr, repo) = init_repo("scan-tags-json");
let sha = make_commit(&repo, &tr.path, "c1", 1000).to_string();
let oid = git2::Oid::from_str(&sha).expect("oid");
tag_commit(&repo, oid, "tga-v1.1.0");
let conn = open_in_memory_db();
insert_sha(&conn, &sha);
let cfg = ReachabilityConfig {
track_tags: true,
track_release_branches: false,
release_branch_patterns: vec![],
};
scan_and_persist(&tr.path, &conn, &cfg, None).expect("scan");
let tags_json: String = conn
.query_row(
"SELECT reachable_from_tags FROM fact_commit_reachability WHERE commit_sha = ?1",
params![sha],
|r| r.get(0),
)
.expect("query");
let tags: Vec<String> = serde_json::from_str(&tags_json).expect("parse json");
assert!(
tags.contains(&"tga-v1.1.0".to_string()),
"must include tga-v1.1.0 in JSON array"
);
}
#[test]
fn scan_default_branch_main() {
let (tr, repo) = init_repo("default-main");
let sha1 = make_commit(&repo, &tr.path, "c1", 1000).to_string();
let sha2 = make_commit(&repo, &tr.path, "c2", 2000).to_string();
let sha3 = make_commit(&repo, &tr.path, "c3", 3000).to_string();
let oid2 = git2::Oid::from_str(&sha2).expect("oid2");
branch_at(&repo, oid2, "feature/stray");
let oid3 = git2::Oid::from_str(&sha3).expect("oid3");
let commit3 = repo.find_commit(oid3).expect("commit3");
repo.branch("main", &commit3, false).expect("main branch");
repo.set_head("refs/heads/main").expect("set HEAD to main");
let conn = open_in_memory_db();
for sha in [&sha1, &sha2, &sha3] {
insert_sha(&conn, sha);
}
let cfg = ReachabilityConfig {
track_tags: false,
track_release_branches: false,
release_branch_patterns: vec![],
};
let stats = scan_and_persist(&tr.path, &conn, &cfg, None).expect("scan");
assert_eq!(
stats.default_branch_commits, 3,
"all three commits are on main"
);
let s3: i64 = conn
.query_row(
"SELECT on_default_branch FROM fact_commit_reachability WHERE commit_sha = ?1",
params![sha3],
|r| r.get(0),
)
.expect("sha3");
assert_eq!(s3, 1, "sha3 is on main");
let s1: i64 = conn
.query_row(
"SELECT on_default_branch FROM fact_commit_reachability WHERE commit_sha = ?1",
params![sha1],
|r| r.get(0),
)
.expect("sha1");
assert_eq!(s1, 1, "sha1 ancestor of main → on_default_branch");
}
#[test]
fn scan_default_branch_via_origin_head() {
let (tr, repo) = init_repo("origin-head");
let sha1 = make_commit(&repo, &tr.path, "c1", 1000).to_string();
let sha2 = make_commit(&repo, &tr.path, "c2", 2000).to_string();
let oid2 = git2::Oid::from_str(&sha2).expect("oid2");
repo.reference("refs/remotes/origin/master", oid2, true, "origin master")
.expect("create origin/master ref");
repo.reference_symbolic(
"refs/remotes/origin/HEAD",
"refs/remotes/origin/master",
true,
"origin HEAD",
)
.expect("create origin/HEAD symref");
let conn = open_in_memory_db();
for sha in [&sha1, &sha2] {
insert_sha(&conn, sha);
}
let cfg = ReachabilityConfig {
track_tags: false,
track_release_branches: false,
release_branch_patterns: vec![],
};
let stats = scan_and_persist(&tr.path, &conn, &cfg, None).expect("scan");
assert_eq!(stats.default_branch_commits, 2, "both commits on master");
let s2: i64 = conn
.query_row(
"SELECT on_default_branch FROM fact_commit_reachability WHERE commit_sha = ?1",
params![sha2],
|r| r.get(0),
)
.expect("sha2");
assert_eq!(s2, 1, "sha2 reachable from origin/master");
}
#[test]
fn scan_default_branch_missing_graceful() {
let (tr, repo) = init_repo("no-default");
let sha = make_commit(&repo, &tr.path, "c1", 1000).to_string();
let oid = git2::Oid::from_str(&sha).expect("oid");
let commit = repo.find_commit(oid).expect("commit");
repo.branch("develop", &commit, false).expect("develop");
repo.set_head_detached(oid).expect("detach HEAD");
for bname in ["master", "main"] {
if let Ok(mut b) = repo.find_branch(bname, git2::BranchType::Local) {
let _ = b.delete();
}
}
let known: std::collections::HashSet<String> = [sha.clone()].into();
let set = detect_default_branch_set(&repo, &known, &tr.path);
assert!(
set.is_empty(),
"no detectable default branch → empty set, on_default_branch stays 0"
);
}
#[test]
fn scan_gitflow_develop_tag_marks_non_default_branch_commits() {
let (tr, repo) = init_repo("gitflow-develop-tag");
let sha_a = make_commit(&repo, &tr.path, "initial", 1000).to_string();
let oid_a = git2::Oid::from_str(&sha_a).expect("oid_a");
branch_at(&repo, oid_a, "develop");
repo.set_head("refs/heads/develop")
.expect("set HEAD to develop");
let sha_b = make_commit(&repo, &tr.path, "develop work B", 2000).to_string();
let sha_c = make_commit(&repo, &tr.path, "develop work C", 3000).to_string();
let oid_c = git2::Oid::from_str(&sha_c).expect("oid_c");
tag_commit(&repo, oid_c, "v1.0.0");
repo.set_head("refs/heads/master")
.expect("set HEAD to master");
let sha_d = make_commit(&repo, &tr.path, "main-only D", 4000).to_string();
tag_commit(&repo, oid_a, "v0.5.0");
let conn = open_in_memory_db();
for sha in [&sha_a, &sha_b, &sha_c, &sha_d] {
insert_sha(&conn, sha);
}
let cfg = ReachabilityConfig {
track_tags: true,
track_release_branches: false,
release_branch_patterns: vec![],
};
let stats = scan_and_persist(&tr.path, &conn, &cfg, None).expect("scan_and_persist");
assert_eq!(stats.rows_upserted, 4, "one row per commit");
let (a_on_tag, a_tags_json): (i64, String) = conn
.query_row(
"SELECT on_any_tag, reachable_from_tags \
FROM fact_commit_reachability WHERE commit_sha = ?1",
params![sha_a],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.expect("query A");
assert_eq!(
a_on_tag, 1,
"A is an ancestor of v1.0.0 (and tagged v0.5.0)"
);
let a_tags: Vec<String> = serde_json::from_str(&a_tags_json).expect("parse json");
assert!(
a_tags.contains(&"v1.0.0".to_string()),
"A must appear in reachable_from_tags for v1.0.0; got {a_tags_json}"
);
assert!(
a_tags.contains(&"v0.5.0".to_string()),
"A must appear in reachable_from_tags for v0.5.0; got {a_tags_json}"
);
let (b_on_tag, b_tags_json): (i64, String) = conn
.query_row(
"SELECT on_any_tag, reachable_from_tags \
FROM fact_commit_reachability WHERE commit_sha = ?1",
params![sha_b],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.expect("query B");
assert_eq!(
b_on_tag, 1,
"B is on develop and ancestor of v1.0.0 — must be on_any_tag=1 (issue #303)"
);
let b_tags: Vec<String> = serde_json::from_str(&b_tags_json).expect("parse json");
assert!(
b_tags.contains(&"v1.0.0".to_string()),
"B must be in reachable_from_tags=[\"v1.0.0\"]; got {b_tags_json}"
);
let (c_on_tag, c_tags_json): (i64, String) = conn
.query_row(
"SELECT on_any_tag, reachable_from_tags \
FROM fact_commit_reachability WHERE commit_sha = ?1",
params![sha_c],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.expect("query C");
assert_eq!(c_on_tag, 1, "C is the tip of v1.0.0");
let c_tags: Vec<String> = serde_json::from_str(&c_tags_json).expect("parse json");
assert!(
c_tags.contains(&"v1.0.0".to_string()),
"C must be in reachable_from_tags=[\"v1.0.0\"]; got {c_tags_json}"
);
let d_on_tag: i64 = conn
.query_row(
"SELECT on_any_tag FROM fact_commit_reachability WHERE commit_sha = ?1",
params![sha_d],
|r| r.get(0),
)
.expect("query D");
assert_eq!(
d_on_tag, 0,
"D is only on main and not reachable from any tag"
);
}
#[test]
fn scan_stray_branch_excluded_from_default() {
let (tr, repo) = init_repo("stray-excl");
let sha1 = make_commit(&repo, &tr.path, "base", 1000).to_string();
let sha2 = make_commit(&repo, &tr.path, "main-commit", 2000).to_string();
let oid1 = git2::Oid::from_str(&sha1).expect("oid1");
let commit1 = repo.find_commit(oid1).expect("commit1");
repo.branch("stray", &commit1, false).expect("stray");
let oid2 = git2::Oid::from_str(&sha2).expect("oid2");
let commit2 = repo.find_commit(oid2).expect("commit2");
repo.branch("main", &commit2, false).expect("main");
repo.set_head("refs/heads/main").expect("set HEAD");
repo.set_head("refs/heads/stray").expect("checkout stray");
let sha_stray = make_commit(&repo, &tr.path, "stray-exclusive", 3000).to_string();
repo.set_head("refs/heads/main").expect("back to main");
let conn = open_in_memory_db();
for sha in [&sha1, &sha2, &sha_stray] {
insert_sha(&conn, sha);
}
let cfg = ReachabilityConfig {
track_tags: false,
track_release_branches: false,
release_branch_patterns: vec![],
};
scan_and_persist(&tr.path, &conn, &cfg, None).expect("scan");
let stray_flag: i64 = conn
.query_row(
"SELECT on_default_branch FROM fact_commit_reachability WHERE commit_sha = ?1",
params![sha_stray],
|r| r.get(0),
)
.expect("stray query");
assert_eq!(
stray_flag, 0,
"stray-exclusive commit must have on_default_branch=0"
);
let main_flag: i64 = conn
.query_row(
"SELECT on_default_branch FROM fact_commit_reachability WHERE commit_sha = ?1",
params![sha2],
|r| r.get(0),
)
.expect("main query");
assert_eq!(main_flag, 1, "sha2 is on main → on_default_branch=1");
}
#[test]
fn scan_multi_repo_no_cross_contamination() {
let (tr_a, repo_a) = init_repo("multi-repo-A");
let sha_a = make_commit(&repo_a, &tr_a.path, "repo-A commit", 1000).to_string();
let oid_a = git2::Oid::from_str(&sha_a).expect("oid_a");
tag_commit(&repo_a, oid_a, "v1.0");
let (tr_b, repo_b) = init_repo("multi-repo-B");
let sha_b = make_commit(&repo_b, &tr_b.path, "repo-B commit", 2000).to_string();
let conn = open_in_memory_db();
conn.execute(
"INSERT OR IGNORE INTO commits (sha, repository) VALUES (?1, ?2)",
params![sha_a, "repo-A"],
)
.expect("insert sha_a");
conn.execute(
"INSERT OR IGNORE INTO commits (sha, repository) VALUES (?1, ?2)",
params![sha_b, "repo-B"],
)
.expect("insert sha_b");
let cfg = ReachabilityConfig {
track_tags: true,
track_release_branches: false,
release_branch_patterns: vec![],
};
scan_and_persist(&tr_a.path, &conn, &cfg, Some("repo-A")).expect("scan repo-A");
let a_on_tag: i64 = conn
.query_row(
"SELECT on_any_tag FROM fact_commit_reachability WHERE commit_sha = ?1",
params![sha_a],
|r| r.get(0),
)
.expect("query sha_a after repo-A scan");
assert_eq!(
a_on_tag, 1,
"sha_a should be on_any_tag=1 after repo-A scan"
);
scan_and_persist(&tr_b.path, &conn, &cfg, Some("repo-B")).expect("scan repo-B");
let a_on_tag_after: i64 = conn
.query_row(
"SELECT on_any_tag FROM fact_commit_reachability WHERE commit_sha = ?1",
params![sha_a],
|r| r.get(0),
)
.expect("query sha_a after repo-B scan");
assert_eq!(
a_on_tag_after, 1,
"repo-B scan must not overwrite sha_a's on_any_tag=1 (issue #303)"
);
let b_on_tag: i64 = conn
.query_row(
"SELECT on_any_tag FROM fact_commit_reachability WHERE commit_sha = ?1",
params![sha_b],
|r| r.get(0),
)
.expect("query sha_b");
assert_eq!(b_on_tag, 0, "sha_b has no tag in repo-B");
}
}