use std::fs;
use std::path::{Path, PathBuf};
use regex::Regex;
use crate::paths::absolute_normalized;
pub fn current_branch(repo_root: &Path) -> Option<String> {
let head = head_path(repo_root)?;
let content = fs::read_to_string(head).ok()?;
parse_head(&content)
}
pub fn current_issue_key(repo_root: &Path, pattern: &Regex) -> Option<String> {
let branch = current_branch(repo_root)?;
let captures = pattern.captures(&branch)?;
captures
.get(1)
.or_else(|| captures.get(0))
.map(|m| m.as_str().to_string())
}
fn head_path(repo_root: &Path) -> Option<PathBuf> {
let dot_git = repo_root.join(".git");
let metadata = fs::metadata(&dot_git).ok()?;
if metadata.is_dir() {
return Some(dot_git.join("HEAD"));
}
let pointer = fs::read_to_string(&dot_git).ok()?;
let pointer = pointer.trim().strip_prefix("gitdir:")?.trim();
if pointer.is_empty() {
return None;
}
Some(absolute_normalized(repo_root, Path::new(pointer)).join("HEAD"))
}
fn parse_head(content: &str) -> Option<String> {
let reference = content.trim().strip_prefix("ref:")?.trim();
let branch = reference.strip_prefix("refs/heads/")?;
if branch.is_empty() {
None
} else {
Some(branch.to_string())
}
}
#[cfg(test)]
mod tests {
use std::fs;
use tempfile::TempDir;
use super::*;
fn repo_with_head(contents: &str) -> TempDir {
let temp = TempDir::new().unwrap();
let git_dir = temp.path().join(".git");
fs::create_dir_all(&git_dir).unwrap();
fs::write(git_dir.join("HEAD"), contents).unwrap();
temp
}
#[test]
fn reads_the_branch_name_from_a_plain_repository() {
let repo = repo_with_head("ref: refs/heads/master\n");
assert_eq!(current_branch(repo.path()).as_deref(), Some("master"));
}
#[test]
fn a_branch_name_keeps_its_slashes() {
let repo = repo_with_head("ref: refs/heads/feat/foo/bar\n");
assert_eq!(current_branch(repo.path()).as_deref(), Some("feat/foo/bar"));
}
#[test]
fn trailing_whitespace_is_trimmed() {
let repo = repo_with_head("ref: refs/heads/topic \n\n");
assert_eq!(current_branch(repo.path()).as_deref(), Some("topic"));
}
#[test]
fn a_detached_head_has_no_branch() {
let repo = repo_with_head("1077b28f9a4c5d6e7f8091a2b3c4d5e6f7089a1b\n");
assert_eq!(current_branch(repo.path()), None);
}
#[test]
fn a_non_branch_reference_has_no_branch() {
let repo = repo_with_head("ref: refs/tags/v3.7.0\n");
assert_eq!(current_branch(repo.path()), None);
}
#[test]
fn unrecognised_head_contents_yield_none() {
for contents in ["", " \n", "garbage", "ref:\n", "ref: refs/heads/\n"] {
let repo = repo_with_head(contents);
assert_eq!(current_branch(repo.path()), None, "contents {contents:?}");
}
}
#[test]
fn a_missing_or_unreadable_git_entry_yields_none() {
let temp = TempDir::new().unwrap();
assert_eq!(current_branch(temp.path()), None);
fs::create_dir_all(temp.path().join(".git")).unwrap();
assert_eq!(current_branch(temp.path()), None);
}
#[test]
fn a_worktree_git_file_with_an_absolute_pointer_is_followed() {
let temp = TempDir::new().unwrap();
let worktree_git_dir = temp.path().join("main/.git/worktrees/wt");
fs::create_dir_all(&worktree_git_dir).unwrap();
fs::write(worktree_git_dir.join("HEAD"), "ref: refs/heads/feat/linked\n").unwrap();
let worktree = temp.path().join("wt");
fs::create_dir_all(&worktree).unwrap();
fs::write(
worktree.join(".git"),
format!("gitdir: {}\n", worktree_git_dir.display()),
)
.unwrap();
assert_eq!(current_branch(&worktree).as_deref(), Some("feat/linked"));
}
#[test]
fn a_worktree_git_file_with_a_relative_pointer_resolves_against_the_worktree() {
let temp = TempDir::new().unwrap();
let worktree_git_dir = temp.path().join("main/.git/worktrees/wt");
fs::create_dir_all(&worktree_git_dir).unwrap();
fs::write(worktree_git_dir.join("HEAD"), "ref: refs/heads/relative/pointer\n").unwrap();
let worktree = temp.path().join("wt");
fs::create_dir_all(&worktree).unwrap();
fs::write(worktree.join(".git"), "gitdir: ../main/.git/worktrees/wt\n").unwrap();
assert_eq!(current_branch(&worktree).as_deref(), Some("relative/pointer"));
}
#[test]
fn a_dangling_or_malformed_git_file_yields_none() {
let temp = TempDir::new().unwrap();
let worktree = temp.path().join("wt");
fs::create_dir_all(&worktree).unwrap();
for contents in ["gitdir: /nowhere/at/all\n", "gitdir:\n", "not a pointer\n"] {
fs::write(worktree.join(".git"), contents).unwrap();
assert_eq!(current_branch(&worktree), None, "contents {contents:?}");
}
}
#[test]
fn the_issue_key_is_the_first_capture_of_the_pattern() {
let repo = repo_with_head("ref: refs/heads/naaman.AMVP-160815.ai-rulez-migration\n");
let pattern = Regex::new(r"([A-Z][A-Z0-9]+-\d+)").unwrap();
assert_eq!(current_issue_key(repo.path(), &pattern).as_deref(), Some("AMVP-160815"));
}
#[test]
fn a_pattern_without_a_capture_group_falls_back_to_the_whole_match() {
let repo = repo_with_head("ref: refs/heads/feat/PPSC-42-comment-inventory\n");
let pattern = Regex::new(r"[A-Z][A-Z0-9]+-\d+").unwrap();
assert_eq!(current_issue_key(repo.path(), &pattern).as_deref(), Some("PPSC-42"));
}
#[test]
fn a_branch_without_an_issue_key_has_no_key() {
let repo = repo_with_head("ref: refs/heads/master\n");
let pattern = Regex::new(r"([A-Z][A-Z0-9]+-\d+)").unwrap();
assert_eq!(current_issue_key(repo.path(), &pattern), None);
let detached = repo_with_head("1077b28f9a4c5d6e7f8091a2b3c4d5e6f7089a1b\n");
assert_eq!(current_issue_key(detached.path(), &pattern), None);
}
}