use anyhow::{Context, Result};
use regex::Regex;
use std::path::Path;
use std::process::Command;
use std::sync::OnceLock;
static JIRA_RE: OnceLock<Regex> = OnceLock::new();
static GITHUB_RE: OnceLock<Regex> = OnceLock::new();
fn jira_re() -> &'static Regex {
JIRA_RE.get_or_init(|| Regex::new(r"([A-Z]+-\d+)").unwrap())
}
fn github_re() -> &'static Regex {
GITHUB_RE.get_or_init(|| Regex::new(r"(?:fixes|closes|fixed|closed)?\s*#(\d+)").unwrap())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitContext {
pub head_sha: String,
pub parent_shas: Vec<String>,
pub timestamp: i64,
pub branch: Option<String>,
pub is_detached: bool,
pub message: Option<String>,
pub author: Option<String>,
pub is_fix_commit: Option<bool>,
pub is_revert_commit: Option<bool>,
pub ticket_ids: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileChurn {
pub file: String,
pub lines_added: usize,
pub lines_deleted: usize,
}
#[derive(Debug, Clone)]
pub struct BatchedTouchMetrics {
pub touch_count_30d: std::collections::HashMap<String, usize>,
pub days_since_last_change: std::collections::HashMap<String, u32>,
}
fn git(args: &[&str]) -> Result<String> {
let output = Command::new("git")
.args(args)
.output()
.context("failed to invoke git")?;
if !output.status.success() {
anyhow::bail!(
"git {:?} failed: {}",
args,
String::from_utf8_lossy(&output.stderr)
);
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
fn git_at(repo_path: &Path, args: &[&str]) -> Result<String> {
let output = Command::new("git")
.current_dir(repo_path)
.args(args)
.output()
.context("failed to invoke git")?;
if !output.status.success() {
anyhow::bail!(
"git {:?} failed: {}",
args,
String::from_utf8_lossy(&output.stderr)
);
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub fn extract_git_context() -> Result<GitContext> {
if git(&["rev-parse", "--git-dir"]).is_err() {
anyhow::bail!("not in a git repository");
}
let head_sha = git(&["rev-parse", "HEAD"]).context("failed to extract HEAD SHA")?;
let parents_raw = git(&["rev-list", "--parents", "-n", "1", "HEAD"])
.context("failed to extract parent SHAs")?;
let mut parts = parents_raw.split_whitespace();
let _ = parts.next(); let parent_shas = parts.map(|s| s.to_string()).collect::<Vec<_>>();
if parent_shas.is_empty() && head_sha != "4b825dc642cb6eb9a060e54bf8d69288fbee4904" {
}
let timestamp = git(&["show", "-s", "--format=%ct", "HEAD"])
.context("failed to extract commit timestamp")?
.parse::<i64>()
.context("failed to parse commit timestamp")?;
let branch = git(&["symbolic-ref", "--short", "HEAD"]).ok();
let message = git(&["log", "-1", "--format=%B", "HEAD"]).ok();
let author = git(&["log", "-1", "--format=%an", "HEAD"]).ok();
let is_fix_commit = message.as_ref().map(|m| detect_fix_commit(m));
let is_revert_commit = message.as_ref().map(|m| detect_revert_commit(m));
let ticket_ids = message
.as_ref()
.map(|m| extract_ticket_ids(m, branch.as_deref()))
.unwrap_or_default();
Ok(GitContext {
head_sha,
parent_shas,
timestamp,
is_detached: branch.is_none(),
branch,
message,
author,
is_fix_commit,
is_revert_commit,
ticket_ids,
})
}
pub fn extract_git_context_at(repo_path: &Path) -> Result<GitContext> {
if git_at(repo_path, &["rev-parse", "--git-dir"]).is_err() {
anyhow::bail!("not in a git repository at {}", repo_path.display());
}
let head_sha =
git_at(repo_path, &["rev-parse", "HEAD"]).context("failed to extract HEAD SHA")?;
let parents_raw = git_at(repo_path, &["rev-list", "--parents", "-n", "1", "HEAD"])
.context("failed to extract parent SHAs")?;
let mut parts = parents_raw.split_whitespace();
let _ = parts.next(); let parent_shas = parts.map(|s| s.to_string()).collect::<Vec<_>>();
let timestamp = git_at(repo_path, &["show", "-s", "--format=%ct", "HEAD"])
.context("failed to extract commit timestamp")?
.parse::<i64>()
.context("failed to parse commit timestamp")?;
let branch = git_at(repo_path, &["symbolic-ref", "--short", "HEAD"]).ok();
let message = git_at(repo_path, &["log", "-1", "--format=%B", "HEAD"]).ok();
let author = git_at(repo_path, &["log", "-1", "--format=%an", "HEAD"]).ok();
let is_fix_commit = message.as_ref().map(|m| detect_fix_commit(m));
let is_revert_commit = message.as_ref().map(|m| detect_revert_commit(m));
let ticket_ids = message
.as_ref()
.map(|m| extract_ticket_ids(m, branch.as_deref()))
.unwrap_or_default();
Ok(GitContext {
head_sha,
parent_shas,
timestamp,
is_detached: branch.is_none(),
branch,
message,
author,
is_fix_commit,
is_revert_commit,
ticket_ids,
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PrContext {
pub is_pr: bool,
pub merge_base: Option<String>,
}
pub fn detect_pr_context() -> PrContext {
let github_event_name = std::env::var("GITHUB_EVENT_NAME").ok();
let github_ref = std::env::var("GITHUB_REF").ok();
let is_pr = match (&github_event_name, &github_ref) {
(Some(event), Some(ref_name)) => {
event == "pull_request" || ref_name.starts_with("refs/pull/")
}
_ => false,
};
PrContext {
is_pr,
merge_base: None, }
}
pub fn resolve_merge_base(target_branch: &str) -> Result<Option<String>> {
match git(&["merge-base", "HEAD", target_branch]) {
Ok(sha) => Ok(Some(sha)),
Err(_) => {
Ok(None)
}
}
}
pub fn resolve_merge_base_auto() -> Option<String> {
let common_branches = ["main", "master", "develop", "trunk"];
for branch in &common_branches {
if let Ok(Some(sha)) = resolve_merge_base(branch) {
return Some(sha);
}
}
None
}
pub fn resolve_ref_to_sha(repo_root: &Path, git_ref: &str) -> Result<String> {
git_at(repo_root, &["rev-parse", "--verify", git_ref])
.with_context(|| format!("failed to resolve git ref '{git_ref}'"))
}
pub struct TempWorktree {
pub path: std::path::PathBuf,
repo_root: std::path::PathBuf,
}
impl Drop for TempWorktree {
fn drop(&mut self) {
let result = git_at(
&self.repo_root,
&[
"worktree",
"remove",
"--force",
&self.path.to_string_lossy(),
],
);
if let Err(e) = result {
eprintln!(
"warning: failed to remove temporary worktree {}: {e}",
self.path.display()
);
}
}
}
pub fn create_worktree(repo_root: &Path, sha: &str) -> Result<TempWorktree> {
let dir = std::env::temp_dir().join(format!("hotspots-worktree-{sha}-{}", std::process::id()));
if let Err(e) = git_at(
repo_root,
&["worktree", "remove", "--force", &dir.to_string_lossy()],
) {
eprintln!(
"warning: failed to remove existing git worktree at {}: {e}",
dir.display()
);
}
if dir.exists() {
std::fs::remove_dir_all(&dir).with_context(|| {
format!(
"failed to remove existing worktree directory {}; \
try removing it manually and re-run",
dir.display()
)
})?;
}
git_at(
repo_root,
&["worktree", "add", "--detach", &dir.to_string_lossy(), sha],
)
.with_context(|| format!("failed to create worktree for {sha} at {}", dir.display()))?;
Ok(TempWorktree {
path: dir,
repo_root: repo_root.to_path_buf(),
})
}
pub fn find_merge_base(repo_root: &Path) -> Option<(String, i64)> {
for branch in &["main", "master", "origin/HEAD"] {
if let Ok(sha) = git_at(repo_root, &["merge-base", "HEAD", branch]) {
let sha = sha.trim().to_string();
if sha.is_empty() {
continue;
}
if let Ok(head) = git_at(repo_root, &["rev-parse", "HEAD"]) {
if head.trim() == sha {
return None; }
}
if let Ok(ts_str) = git_at(repo_root, &["log", "-1", "--format=%ct", &sha]) {
if let Ok(ts) = ts_str.trim().parse::<i64>() {
return Some((sha, ts));
}
}
}
}
None
}
pub fn days_since_last_change_at_sha(
repo_root: &Path,
file: &str,
before_sha: &str,
as_of_timestamp: i64,
) -> Option<u32> {
git_at(
repo_root,
&["log", "-1", "--format=%ct", before_sha, "--", file],
)
.ok()
.and_then(|out| out.trim().parse::<i64>().ok())
.map(|ts| ((as_of_timestamp - ts).max(0) / 86400) as u32)
}
pub fn extract_commit_churn(sha: &str) -> Result<Vec<FileChurn>> {
let output = match git(&["show", "--numstat", "--format=", sha]) {
Ok(out) => out,
Err(_) => {
return Ok(Vec::new());
}
};
let mut churns = Vec::new();
for line in output.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let parts: Vec<&str> = line.split('\t').collect();
if parts.len() < 3 {
continue; }
let added_str = parts[0];
let deleted_str = parts[1];
let file = parts[2].to_string();
if added_str == "-" || deleted_str == "-" {
continue;
}
let lines_added = added_str
.parse::<usize>()
.with_context(|| format!("Failed to parse lines added: {}", added_str))?;
let lines_deleted = deleted_str
.parse::<usize>()
.with_context(|| format!("Failed to parse lines deleted: {}", deleted_str))?;
churns.push(FileChurn {
file,
lines_added,
lines_deleted,
});
}
Ok(churns)
}
pub fn extract_commit_churn_at(repo_path: &Path, sha: &str) -> Result<Vec<FileChurn>> {
let output = match git_at(repo_path, &["show", "--numstat", "--format=", sha]) {
Ok(out) => out,
Err(_) => {
return Ok(Vec::new());
}
};
let mut churns = Vec::new();
for line in output.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let parts: Vec<&str> = line.split('\t').collect();
if parts.len() < 3 {
continue;
}
let added_str = parts[0];
let deleted_str = parts[1];
let file = parts[2].to_string();
if added_str == "-" || deleted_str == "-" {
continue;
}
let lines_added = added_str
.parse::<usize>()
.with_context(|| format!("Failed to parse lines added: {}", added_str))?;
let lines_deleted = deleted_str
.parse::<usize>()
.with_context(|| format!("Failed to parse lines deleted: {}", deleted_str))?;
churns.push(FileChurn {
file,
lines_added,
lines_deleted,
});
}
Ok(churns)
}
pub fn batch_touch_metrics_at(
repo_root: &Path,
as_of_timestamp: i64,
) -> Result<BatchedTouchMetrics> {
use std::collections::HashMap;
let thirty_days_ago = as_of_timestamp - (30 * 24 * 60 * 60);
let since_arg = format!("--since={}", thirty_days_ago);
let until_arg = format!("--until={}", as_of_timestamp);
let window_output = git_at(
repo_root,
&[
"log",
"--format=COMMIT %ct",
"--name-only",
&since_arg,
&until_arg,
],
)
.unwrap_or_default();
let mut touch_count: HashMap<String, usize> = HashMap::new();
let mut last_touch_ts: HashMap<String, i64> = HashMap::new();
let mut current_ts: i64 = 0;
for line in window_output.lines() {
if let Some(ts_str) = line.strip_prefix("COMMIT ") {
current_ts = ts_str.trim().parse().unwrap_or(0);
} else if !line.trim().is_empty() {
let file = line.trim().to_string();
*touch_count.entry(file.clone()).or_insert(0) += 1;
last_touch_ts.entry(file).or_insert(current_ts);
}
}
let days_since: HashMap<String, u32> = last_touch_ts
.iter()
.map(|(file, &ts)| {
let days = ((as_of_timestamp - ts).max(0) / (24 * 60 * 60)) as u32;
(file.clone(), days)
})
.collect();
Ok(BatchedTouchMetrics {
touch_count_30d: touch_count,
days_since_last_change: days_since,
})
}
pub fn batch_last_touch_for_files(
repo_path: &Path,
stale_files: &std::collections::HashSet<&str>,
as_of_timestamp: i64,
) -> std::collections::HashMap<String, u32> {
use std::collections::HashMap;
if stale_files.is_empty() {
return HashMap::new();
}
let until_arg = format!("--until={}", as_of_timestamp);
let files: Vec<&str> = stale_files.iter().copied().collect();
let mut result: HashMap<String, u32> = HashMap::new();
for chunk in files.chunks(500) {
let mut args: Vec<&str> = vec![
"log",
"--format=COMMIT %ct",
"--name-only",
&until_arg,
"--",
];
args.extend_from_slice(chunk);
let output = match git_at(repo_path, &args) {
Ok(o) => o,
Err(_) => continue,
};
let mut current_ts: i64 = 0;
for line in output.lines() {
if let Some(ts_str) = line.strip_prefix("COMMIT ") {
current_ts = ts_str.trim().parse().unwrap_or(0);
} else if current_ts > 0 && !line.trim().is_empty() {
let file = line.trim();
if stale_files.contains(file) {
result.entry(file.to_string()).or_insert_with(|| {
((as_of_timestamp - current_ts).max(0) / (24 * 60 * 60)) as u32
});
}
}
}
}
result
}
pub fn function_touch_metrics_at(
repo_path: &Path,
file: &str,
start_line: u32,
end_line: u32,
as_of_timestamp: i64,
) -> Result<(usize, Option<u32>)> {
let thirty_days_ago = as_of_timestamp - (30 * 24 * 60 * 60);
let since_arg = format!("--since={}", thirty_days_ago);
let until_arg = format!("--until={}", as_of_timestamp);
let range_arg = format!("-L{},{}:{}", start_line, end_line, file);
let window_output = git_at(
repo_path,
&[
"log",
&range_arg,
"--format=COMMIT %ct",
&since_arg,
&until_arg,
],
)
.unwrap_or_default();
let window_timestamps: Vec<i64> = window_output
.lines()
.filter_map(|l| l.strip_prefix("COMMIT "))
.filter_map(|ts| ts.trim().parse::<i64>().ok())
.collect();
let touch_count = window_timestamps.len();
let days_since = if let Some(&ts) = window_timestamps.first() {
Some(((as_of_timestamp - ts).max(0) / (24 * 60 * 60)) as u32)
} else {
let recent_until = format!("--until={}", as_of_timestamp);
let recent_output = git_at(
repo_path,
&[
"log",
&range_arg,
"--format=COMMIT %ct",
"-1",
&recent_until,
],
)
.unwrap_or_default();
recent_output
.lines()
.filter_map(|l| l.strip_prefix("COMMIT "))
.filter_map(|ts| ts.trim().parse::<i64>().ok())
.next()
.map(|ts| ((as_of_timestamp - ts).max(0) / (24 * 60 * 60)) as u32)
};
Ok((touch_count, days_since))
}
pub fn count_file_touches_30d(file: &str, as_of_timestamp: i64) -> Result<usize> {
let thirty_days_ago = as_of_timestamp - (30 * 24 * 60 * 60);
let since_arg = format!("--since={}", thirty_days_ago);
let until_arg = format!("--until={}", as_of_timestamp);
let output = match git(&["log", &since_arg, &until_arg, "--oneline", "--", file]) {
Ok(out) => out,
Err(_) => {
return Ok(0);
}
};
let count = output
.lines()
.filter(|line| !line.trim().is_empty())
.count();
Ok(count)
}
pub fn count_file_touches_30d_at(
repo_path: &Path,
file: &str,
as_of_timestamp: i64,
) -> Result<usize> {
let thirty_days_ago = as_of_timestamp - (30 * 24 * 60 * 60);
let since_arg = format!("--since={}", thirty_days_ago);
let until_arg = format!("--until={}", as_of_timestamp);
let output = match git_at(
repo_path,
&["log", &since_arg, &until_arg, "--oneline", "--", file],
) {
Ok(out) => out,
Err(_) => {
return Ok(0);
}
};
let count = output
.lines()
.filter(|line| !line.trim().is_empty())
.count();
Ok(count)
}
pub fn days_since_last_change(file: &str, as_of_timestamp: i64) -> Result<u32> {
let output = match git(&["log", "-1", "--format=%ct", "--", file]) {
Ok(out) => out,
Err(_) => {
return Ok(0);
}
};
let last_change_timestamp = output
.trim()
.parse::<i64>()
.context("failed to parse last change timestamp")?;
let seconds_diff = as_of_timestamp - last_change_timestamp;
let days = (seconds_diff / (24 * 60 * 60)).max(0) as u32;
Ok(days)
}
pub fn days_since_last_change_at(
repo_path: &Path,
file: &str,
as_of_timestamp: i64,
) -> Result<u32> {
let output = match git_at(repo_path, &["log", "-1", "--format=%ct", "--", file]) {
Ok(out) => out,
Err(_) => {
return Ok(0);
}
};
let last_change_timestamp = output
.trim()
.parse::<i64>()
.context("failed to parse last change timestamp")?;
let seconds_diff = as_of_timestamp - last_change_timestamp;
let days = (seconds_diff / (24 * 60 * 60)).max(0) as u32;
Ok(days)
}
pub fn detect_fix_commit(message: &str) -> bool {
let lower = message.to_lowercase();
lower.contains("fix")
|| lower.contains("bug")
|| lower.contains("hotfix")
|| lower.contains("bugfix")
}
pub fn detect_revert_commit(message: &str) -> bool {
let lower = message.to_lowercase();
lower.contains("revert")
}
pub fn extract_ticket_ids(message: &str, branch: Option<&str>) -> Vec<String> {
let mut tickets = Vec::new();
for cap in jira_re().captures_iter(message) {
if let Some(ticket) = cap.get(1) {
let ticket_str = ticket.as_str().to_string();
if !tickets.contains(&ticket_str) {
tickets.push(ticket_str);
}
}
}
for cap in github_re().captures_iter(message) {
if let Some(number) = cap.get(1) {
let ticket_str = format!("#{}", number.as_str());
if !tickets.contains(&ticket_str) {
tickets.push(ticket_str);
}
}
}
if let Some(branch_name) = branch {
for cap in jira_re().captures_iter(branch_name) {
if let Some(ticket) = cap.get(1) {
let ticket_str = ticket.as_str().to_string();
if !tickets.contains(&ticket_str) {
tickets.push(ticket_str);
}
}
}
for cap in github_re().captures_iter(branch_name) {
if let Some(number) = cap.get(1) {
let ticket_str = format!("#{}", number.as_str());
if !tickets.contains(&ticket_str) {
tickets.push(ticket_str);
}
}
}
}
tickets
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub struct CoChangePair {
pub file_a: String,
pub file_b: String,
pub co_change_count: usize,
pub coupling_ratio: f64,
pub risk: String,
pub has_static_dep: bool,
}
fn is_trivial_pair(file_a: &str, file_b: &str) -> bool {
let dir_a = std::path::Path::new(file_a)
.parent()
.unwrap_or(std::path::Path::new(""));
let dir_b = std::path::Path::new(file_b)
.parent()
.unwrap_or(std::path::Path::new(""));
let stem_a = std::path::Path::new(file_a)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("");
let stem_b = std::path::Path::new(file_b)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("");
if dir_a == dir_b {
if stem_b == format!("{}_test", stem_a) || stem_b == format!("{}_tests", stem_a) {
return true;
}
if stem_a == format!("{}_test", stem_b) || stem_a == format!("{}_tests", stem_b) {
return true;
}
if stem_a == "mod" || stem_b == "mod" {
return true;
}
}
false
}
pub fn extract_co_change_pairs(
repo_root: &Path,
window_days: u64,
min_count: usize,
) -> Result<Vec<CoChangePair>> {
let since = format!("{} days ago", window_days);
let output = git_at(
repo_root,
&[
"log",
"--name-only",
"--format=COMMIT:%H",
&format!("--since={}", since),
"--diff-filter=AM",
],
)
.unwrap_or_default();
let mut commit_files: Vec<Vec<String>> = Vec::new();
let mut current: Vec<String> = Vec::new();
for line in output.lines() {
if line.starts_with("COMMIT:") {
if !current.is_empty() {
commit_files.push(std::mem::take(&mut current));
}
} else if !line.trim().is_empty() {
current.push(line.trim().to_string());
}
}
if !current.is_empty() {
commit_files.push(current);
}
let mut file_counts: std::collections::HashMap<String, usize> =
std::collections::HashMap::new();
let mut pair_counts: std::collections::HashMap<(String, String), usize> =
std::collections::HashMap::new();
const MAX_FILES_PER_COMMIT: usize = 200;
for files in &commit_files {
for f in files {
*file_counts.entry(f.clone()).or_insert(0) += 1;
}
if files.len() > MAX_FILES_PER_COMMIT {
continue;
}
let mut sorted = files.clone();
sorted.sort();
sorted.dedup();
for i in 0..sorted.len() {
for j in (i + 1)..sorted.len() {
let key = (sorted[i].clone(), sorted[j].clone());
*pair_counts.entry(key).or_insert(0) += 1;
}
}
}
let mut pairs: Vec<CoChangePair> = pair_counts
.into_iter()
.filter(|(_, count)| *count >= min_count)
.filter(|((file_a, file_b), _)| {
repo_root.join(file_a).exists() && repo_root.join(file_b).exists()
})
.filter(|((file_a, file_b), _)| !is_trivial_pair(file_a, file_b))
.map(|((file_a, file_b), co_change_count)| {
let count_a = file_counts.get(&file_a).copied().unwrap_or(1);
let count_b = file_counts.get(&file_b).copied().unwrap_or(1);
let coupling_ratio = co_change_count as f64 / count_a.min(count_b) as f64;
let risk = if coupling_ratio > 0.5 {
"high".to_string()
} else if coupling_ratio > 0.25 {
"moderate".to_string()
} else {
"low".to_string()
};
CoChangePair {
file_a,
file_b,
co_change_count,
coupling_ratio: (coupling_ratio * 1000.0).round() / 1000.0,
risk,
has_static_dep: false, }
})
.collect();
pairs.sort_by(|a, b| {
b.coupling_ratio
.partial_cmp(&a.coupling_ratio)
.unwrap_or(std::cmp::Ordering::Equal)
.then(a.file_a.cmp(&b.file_a))
.then(a.file_b.cmp(&b.file_b))
});
Ok(pairs)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_git_context() {
if git(&["rev-parse", "--git-dir"]).is_err() {
eprintln!("Skipping test: not in a git repository");
return;
}
let context = extract_git_context().expect("should extract git context");
assert!(!context.head_sha.is_empty(), "HEAD SHA should not be empty");
assert!(
context.timestamp > 1112832000,
"timestamp should be after 2005"
);
assert!(
context.timestamp < 4102444800,
"timestamp should be before 2100"
);
assert_eq!(
context.is_detached,
context.branch.is_none(),
"is_detached should match branch presence"
);
}
#[test]
fn test_extract_commit_churn() {
if git(&["rev-parse", "--git-dir"]).is_err() {
eprintln!("Skipping test: not in a git repository");
return;
}
let context = match extract_git_context() {
Ok(ctx) => ctx,
Err(_) => {
eprintln!("Skipping test: could not extract git context");
return;
}
};
if context.parent_shas.is_empty() {
eprintln!("Skipping test: initial commit has no churn");
return;
}
let churns = extract_commit_churn(&context.head_sha).expect("should extract churn");
println!("Extracted {} file churns for HEAD", churns.len());
for churn in &churns {
assert!(!churn.file.is_empty(), "file path should not be empty");
}
}
#[test]
fn test_resolve_ref_to_sha_head() {
let cwd = std::env::current_dir().expect("failed to get cwd");
if git_at(&cwd, &["rev-parse", "--git-dir"]).is_err() {
eprintln!("Skipping test: not in a git repository");
return;
}
let sha = resolve_ref_to_sha(&cwd, "HEAD").expect("should resolve HEAD");
assert_eq!(sha.len(), 40, "resolved SHA should be 40 characters");
assert!(
sha.chars().all(|c| c.is_ascii_hexdigit()),
"resolved SHA should be hex"
);
}
#[test]
fn test_resolve_ref_to_sha_abbreviated() {
let cwd = std::env::current_dir().expect("failed to get cwd");
if git_at(&cwd, &["rev-parse", "--git-dir"]).is_err() {
eprintln!("Skipping test: not in a git repository");
return;
}
let full_sha = resolve_ref_to_sha(&cwd, "HEAD").expect("should resolve HEAD");
let abbrev = &full_sha[..8];
let resolved = resolve_ref_to_sha(&cwd, abbrev).expect("should resolve abbreviated SHA");
assert_eq!(
resolved, full_sha,
"abbreviated SHA should resolve to the same full SHA as HEAD"
);
}
#[test]
fn test_resolve_ref_to_sha_invalid_ref() {
let cwd = std::env::current_dir().expect("failed to get cwd");
if git_at(&cwd, &["rev-parse", "--git-dir"]).is_err() {
eprintln!("Skipping test: not in a git repository");
return;
}
let result = resolve_ref_to_sha(&cwd, "refs/heads/this-branch-definitely-does-not-exist");
assert!(result.is_err(), "invalid ref should return an error");
}
}