use std::path::Path;
use seshat_core::BranchId;
use seshat_storage::BranchRepository;
pub fn get_head_commit(path: &Path) -> Option<String> {
let repo = gix::open(path).ok()?;
let head = repo.head_commit().ok()?;
Some(head.id().to_string())
}
pub fn record_branch_scan_complete<R: BranchRepository>(
branch_repo: &R,
root: &Path,
branch_id: &BranchId,
) {
match get_head_commit(root) {
Some(head) => {
if let Err(e) = branch_repo.set_last_scanned_commit(branch_id, &head) {
tracing::warn!(
error = %e,
branch = %branch_id.0,
"failed to record last_scanned_commit; freshness check may be stale"
);
} else {
tracing::debug!(
branch = %branch_id.0,
head = %head,
"recorded last_scanned_commit"
);
}
}
None => {
tracing::debug!(
root = %root.display(),
branch = %branch_id.0,
"git unavailable; skipping last_scanned_commit update"
);
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FreshnessCheck {
Stale {
old_commit: Option<String>,
new_commit: String,
},
UpToDate,
GitUnavailable,
}
pub fn check_branch_freshness<R: BranchRepository>(
branch_repo: &R,
root: &Path,
branch_id: &BranchId,
) -> FreshnessCheck {
let new_commit = match get_head_commit(root) {
Some(c) => c,
None => return FreshnessCheck::GitUnavailable,
};
let old_commit = match branch_repo.get_last_scanned_commit(branch_id) {
Ok(c) => c,
Err(e) => {
tracing::warn!(
error = %e,
branch = %branch_id.0,
"failed to read last_scanned_commit; treating as never-scanned"
);
None
}
};
match &old_commit {
Some(prev) if *prev == new_commit => FreshnessCheck::UpToDate,
_ => FreshnessCheck::Stale {
old_commit,
new_commit,
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::process::{Command, Stdio};
use tempfile::tempdir;
use seshat_storage::{BranchRepository, Database, SqliteBranchRepository};
fn init_git_repo_with_commit(path: &Path) -> String {
Command::new("git")
.args(["init", "-b", "main"])
.current_dir(path)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.expect("git init");
Command::new("git")
.args(["config", "user.email", "test@seshat.dev"])
.current_dir(path)
.stdout(Stdio::null())
.status()
.expect("git config email");
Command::new("git")
.args(["config", "user.name", "Seshat Test"])
.current_dir(path)
.stdout(Stdio::null())
.status()
.expect("git config name");
fs::write(path.join("README.md"), "# fixture").expect("write readme");
Command::new("git")
.args(["add", "."])
.current_dir(path)
.stdout(Stdio::null())
.status()
.expect("git add");
Command::new("git")
.args(["commit", "-m", "initial commit"])
.current_dir(path)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.expect("git commit");
let out = Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(path)
.output()
.expect("git rev-parse HEAD");
String::from_utf8(out.stdout)
.expect("rev-parse output utf8")
.trim()
.to_owned()
}
#[test]
fn returns_none_for_non_git_directory() {
let dir = tempdir().expect("create temp dir");
assert!(get_head_commit(dir.path()).is_none());
assert!(get_head_commit(dir.path()).is_none());
}
#[test]
fn returns_none_for_nonexistent_path() {
assert!(get_head_commit(Path::new("/tmp/does-not-exist-seshat-test")).is_none());
assert!(get_head_commit(Path::new("/tmp/does-not-exist-seshat-test")).is_none());
}
#[test]
fn returns_none_for_empty_git_repo() {
let dir = tempdir().expect("create temp dir");
fs::create_dir(dir.path().join(".git")).expect("create .git");
assert!(get_head_commit(dir.path()).is_none());
assert!(get_head_commit(dir.path()).is_none());
}
#[test]
fn get_head_commit_returns_hash_for_real_git_repo() {
let dir = tempdir().expect("create temp dir");
let expected = init_git_repo_with_commit(dir.path());
let hash = get_head_commit(dir.path()).expect("HEAD commit hash");
assert_eq!(hash, expected, "gix HEAD should match git rev-parse HEAD");
assert_eq!(hash.len(), 40, "SHA-1 hash should be 40 hex chars");
assert!(
hash.chars().all(|c| c.is_ascii_hexdigit()),
"hash should be hex: {hash}"
);
}
#[test]
fn record_branch_scan_complete_writes_head_to_branches_table() {
let dir = tempdir().expect("create temp dir");
let expected_head = init_git_repo_with_commit(dir.path());
let db = Database::open(":memory:").expect("open DB");
let branch_repo = SqliteBranchRepository::new(db.connection().clone());
let branch = BranchId::from("main");
branch_repo
.ensure_branch_exists(&branch)
.expect("ensure branch exists");
record_branch_scan_complete(&branch_repo, dir.path(), &branch);
let stored = branch_repo
.get_last_scanned_commit(&branch)
.expect("get last_scanned_commit");
assert_eq!(
stored,
Some(expected_head),
"branches.last_scanned_commit must match git rev-parse HEAD"
);
}
#[test]
fn record_branch_scan_complete_is_silent_noop_when_git_unavailable() {
let dir = tempdir().expect("create temp dir");
let db = Database::open(":memory:").expect("open DB");
let branch_repo = SqliteBranchRepository::new(db.connection().clone());
let branch = BranchId::from("main");
branch_repo
.ensure_branch_exists(&branch)
.expect("ensure branch exists");
record_branch_scan_complete(&branch_repo, dir.path(), &branch);
let stored = branch_repo
.get_last_scanned_commit(&branch)
.expect("get last_scanned_commit");
assert_eq!(
stored, None,
"branches.last_scanned_commit must stay NULL when git is unavailable"
);
}
fn init_git_repo_with_two_commits(path: &Path) -> (String, String) {
let head1 = init_git_repo_with_commit(path);
fs::write(path.join("CHANGES.md"), "# changes").expect("write CHANGES.md");
Command::new("git")
.args(["add", "."])
.current_dir(path)
.stdout(Stdio::null())
.status()
.expect("git add second");
Command::new("git")
.args(["commit", "-m", "follow-up commit"])
.current_dir(path)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.expect("git commit second");
let out = Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(path)
.output()
.expect("git rev-parse HEAD second");
let head2 = String::from_utf8(out.stdout)
.expect("rev-parse output utf8 second")
.trim()
.to_owned();
assert_ne!(head1, head2, "two commits must have distinct SHAs");
(head1, head2)
}
#[test]
fn check_branch_freshness_returns_up_to_date_when_sentinel_matches_head() {
let dir = tempdir().expect("create temp dir");
let head = init_git_repo_with_commit(dir.path());
let db = Database::open(":memory:").expect("open DB");
let branch_repo = SqliteBranchRepository::new(db.connection().clone());
let branch = BranchId::from("main");
branch_repo
.set_last_scanned_commit(&branch, &head)
.expect("set sentinel");
let result = check_branch_freshness(&branch_repo, dir.path(), &branch);
assert_eq!(result, FreshnessCheck::UpToDate);
}
#[test]
fn check_branch_freshness_returns_stale_when_head_advances() {
let dir = tempdir().expect("create temp dir");
let (head1, head2) = init_git_repo_with_two_commits(dir.path());
let db = Database::open(":memory:").expect("open DB");
let branch_repo = SqliteBranchRepository::new(db.connection().clone());
let branch = BranchId::from("main");
branch_repo
.set_last_scanned_commit(&branch, &head1)
.expect("set sentinel at head1");
let result = check_branch_freshness(&branch_repo, dir.path(), &branch);
assert_eq!(
result,
FreshnessCheck::Stale {
old_commit: Some(head1),
new_commit: head2,
},
"sentinel at head1 with HEAD at head2 must be Stale"
);
}
#[test]
fn check_branch_freshness_returns_stale_with_none_old_commit_when_never_scanned() {
let dir = tempdir().expect("create temp dir");
let head = init_git_repo_with_commit(dir.path());
let db = Database::open(":memory:").expect("open DB");
let branch_repo = SqliteBranchRepository::new(db.connection().clone());
let branch = BranchId::from("main");
let result = check_branch_freshness(&branch_repo, dir.path(), &branch);
assert_eq!(
result,
FreshnessCheck::Stale {
old_commit: None,
new_commit: head,
},
"no recorded sentinel + reachable HEAD must be Stale with old_commit=None"
);
}
#[test]
fn check_branch_freshness_returns_git_unavailable_for_non_git_directory() {
let dir = tempdir().expect("create temp dir");
let db = Database::open(":memory:").expect("open DB");
let branch_repo = SqliteBranchRepository::new(db.connection().clone());
let branch = BranchId::from("main");
branch_repo
.set_last_scanned_commit(&branch, "deadbeefcafebabedeadbeefcafebabedeadbeef")
.expect("set sentinel");
let result = check_branch_freshness(&branch_repo, dir.path(), &branch);
assert_eq!(result, FreshnessCheck::GitUnavailable);
}
}