use anyhow::{Context, Result};
use notify::{Event, RecommendedWatcher, RecursiveMode, Watcher as NotifyWatcher};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::mpsc::{Receiver, TryRecvError, channel};
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct LastIndexedGitState {
pub head_ref: Option<String>,
pub head_commit_oid: Option<String>,
pub head_tree_oid: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GitChangeClass {
BranchSwitch,
TreeDiverged,
LocalCommit,
Noise,
}
impl GitChangeClass {
#[must_use]
pub fn requires_full_rebuild(self) -> bool {
matches!(self, Self::BranchSwitch | Self::TreeDiverged)
}
}
pub struct GitStateWatcher {
_watcher: RecommendedWatcher,
receiver: Receiver<Result<Event, notify::Error>>,
repo_root: PathBuf,
gitdir: PathBuf,
}
impl GitStateWatcher {
pub fn new(repo_root: &Path) -> Result<Self> {
let repo_root = repo_root.to_path_buf();
let gitdir = resolve_gitdir(&repo_root)
.with_context(|| format!("Failed to resolve gitdir at {}", repo_root.display()))?;
let (tx, rx) = channel();
let mut watcher = notify::recommended_watcher(move |res| {
let _ = tx.send(res);
})
.context("Failed to create git-state watcher")?;
watcher
.watch(&gitdir, RecursiveMode::Recursive)
.with_context(|| format!("Failed to watch gitdir: {}", gitdir.display()))?;
log::info!(
"Git-state watcher started for repo {} (gitdir {})",
repo_root.display(),
gitdir.display(),
);
Ok(Self {
_watcher: watcher,
receiver: rx,
repo_root,
gitdir,
})
}
#[must_use]
pub fn gitdir(&self) -> &Path {
&self.gitdir
}
#[must_use]
pub fn repo_root(&self) -> &Path {
&self.repo_root
}
#[must_use]
pub fn poll_changed(&self) -> bool {
let mut observed = false;
loop {
match self.receiver.try_recv() {
Ok(Ok(_event)) => {
observed = true;
}
Ok(Err(e)) => {
log::warn!("Git-state watcher error: {e}");
observed = true;
}
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => {
log::error!("Git-state watcher channel disconnected");
break;
}
}
}
observed
}
#[must_use]
pub fn current_state(&self) -> LastIndexedGitState {
let head_ref = read_head_ref_from_file(&self.gitdir);
let (head_commit_oid, head_tree_oid) = read_commit_and_tree(&self.repo_root);
LastIndexedGitState {
head_ref,
head_commit_oid,
head_tree_oid,
}
}
#[must_use]
pub fn classify(&self, last: &LastIndexedGitState) -> GitChangeClass {
let current = self.current_state();
let (Some(current_ref), Some(current_commit), Some(current_tree)) = (
current.head_ref.as_deref(),
current.head_commit_oid.as_deref(),
current.head_tree_oid.as_deref(),
) else {
return GitChangeClass::BranchSwitch;
};
let (Some(last_ref), Some(last_commit), Some(last_tree)) = (
last.head_ref.as_deref(),
last.head_commit_oid.as_deref(),
last.head_tree_oid.as_deref(),
) else {
return GitChangeClass::BranchSwitch;
};
if current_ref != last_ref {
return GitChangeClass::BranchSwitch;
}
if current_tree != last_tree {
return GitChangeClass::TreeDiverged;
}
if current_commit != last_commit {
return GitChangeClass::LocalCommit;
}
GitChangeClass::Noise
}
}
fn resolve_gitdir(repo_root: &Path) -> Result<PathBuf> {
let dot_git = repo_root.join(".git");
let metadata = std::fs::metadata(&dot_git)
.with_context(|| format!("No .git entry at {}", dot_git.display()))?;
if metadata.is_dir() {
return Ok(dot_git);
}
let contents = std::fs::read_to_string(&dot_git)
.with_context(|| format!("Failed to read .git file at {}", dot_git.display()))?;
for line in contents.lines() {
if let Some(rest) = line.strip_prefix("gitdir:") {
let raw = rest.trim();
let candidate = PathBuf::from(raw);
let resolved = if candidate.is_absolute() {
candidate
} else {
repo_root.join(candidate)
};
let canonical = std::fs::canonicalize(&resolved).with_context(|| {
format!(
"Failed to canonicalize worktree gitdir {}",
resolved.display()
)
})?;
return Ok(canonical);
}
}
anyhow::bail!(
"Malformed .git file at {}: missing `gitdir:` line",
dot_git.display()
)
}
fn read_head_ref_from_file(gitdir: &Path) -> Option<String> {
let head_path = gitdir.join("HEAD");
let contents = match std::fs::read_to_string(&head_path) {
Ok(c) => c,
Err(e) => {
log::error!(
"failed to read git HEAD file at {}: {e}",
head_path.display()
);
return None;
}
};
let trimmed = contents.trim();
trimmed
.strip_prefix("ref: ")
.map(|refname| refname.to_owned())
}
fn read_commit_and_tree(repo_root: &Path) -> (Option<String>, Option<String>) {
let output = match Command::new("git")
.arg("-C")
.arg(repo_root)
.args(["rev-parse", "HEAD", "HEAD^{tree}"])
.output()
{
Ok(out) => out,
Err(e) => {
log::error!(
"failed to spawn `git rev-parse` at {}: {e} \
— is `git` on the daemon's PATH?",
repo_root.display()
);
return (None, None);
}
};
if !output.status.success() {
log::warn!(
"`git rev-parse HEAD HEAD^{{tree}}` returned {} at {} (stderr: {})",
output.status,
repo_root.display(),
String::from_utf8_lossy(&output.stderr).trim(),
);
return (None, None);
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut lines = stdout.lines();
let commit = lines.next().map(|s| s.trim().to_owned());
let tree = lines.next().map(|s| s.trim().to_owned());
(commit, tree)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::process::Command;
use tempfile::TempDir;
fn init_repo(dir: &Path) {
run_git(dir, &["init", "-q", "-b", "main"]);
run_git(dir, &["config", "user.email", "test@sqry.dev"]);
run_git(dir, &["config", "user.name", "Sqry Test"]);
run_git(dir, &["config", "commit.gpgsign", "false"]);
fs::write(dir.join("a.txt"), b"alpha\n").unwrap();
run_git(dir, &["add", "a.txt"]);
run_git(dir, &["commit", "-q", "-m", "initial"]);
}
fn run_git(dir: &Path, args: &[&str]) {
let status = Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.status()
.expect("git command failed to launch");
assert!(status.success(), "git {args:?} failed in {}", dir.display());
}
#[test]
fn gitdir_resolves_conventional_directory_layout() {
let tmp = TempDir::new().unwrap();
init_repo(tmp.path());
let gitdir = resolve_gitdir(tmp.path()).unwrap();
assert_eq!(gitdir, tmp.path().join(".git"));
}
#[test]
fn gitdir_resolves_worktree_dot_git_file_layout() {
let primary = TempDir::new().unwrap();
init_repo(primary.path());
let work = TempDir::new().unwrap();
let work_path = work.path().join("wt");
run_git(
primary.path(),
&[
"worktree",
"add",
"-b",
"feature",
work_path.to_str().unwrap(),
],
);
let dot_git = work_path.join(".git");
let md = fs::metadata(&dot_git).unwrap();
assert!(md.is_file(), ".git should be a file in the worktree");
let resolved = resolve_gitdir(&work_path).unwrap();
assert!(
resolved.is_dir(),
"resolved gitdir should be a directory: {}",
resolved.display()
);
let primary_gitdir = primary.path().join(".git").join("worktrees").join("wt");
let primary_canon = fs::canonicalize(&primary_gitdir).unwrap();
assert_eq!(resolved, primary_canon);
}
#[test]
fn watcher_attaches_to_conventional_repo() {
let tmp = TempDir::new().unwrap();
init_repo(tmp.path());
let watcher = GitStateWatcher::new(tmp.path()).unwrap();
assert_eq!(watcher.repo_root(), tmp.path());
assert_eq!(watcher.gitdir(), tmp.path().join(".git"));
}
#[test]
fn watcher_attaches_to_worktree_layout() {
let primary = TempDir::new().unwrap();
init_repo(primary.path());
let work = TempDir::new().unwrap();
let work_path = work.path().join("wt");
run_git(
primary.path(),
&[
"worktree",
"add",
"-b",
"feature",
work_path.to_str().unwrap(),
],
);
let watcher = GitStateWatcher::new(&work_path).unwrap();
assert_eq!(watcher.repo_root(), work_path);
assert!(
watcher.gitdir().starts_with(primary.path()),
"worktree gitdir should live under primary: got {}",
watcher.gitdir().display()
);
}
#[test]
fn classify_commit_on_same_branch_without_tree_change_is_local_commit() {
let tmp = TempDir::new().unwrap();
init_repo(tmp.path());
let watcher = GitStateWatcher::new(tmp.path()).unwrap();
let baseline = watcher.current_state();
assert!(baseline.head_ref.as_deref() == Some("refs/heads/main"));
run_git(
tmp.path(),
&["commit", "-q", "--allow-empty", "-m", "empty commit"],
);
let class = watcher.classify(&baseline);
assert_eq!(class, GitChangeClass::LocalCommit);
assert!(!class.requires_full_rebuild());
}
#[test]
fn classify_detached_head_falls_back_to_branch_switch() {
let tmp = TempDir::new().unwrap();
init_repo(tmp.path());
let watcher = GitStateWatcher::new(tmp.path()).unwrap();
let baseline = watcher.current_state();
assert!(baseline.head_ref.is_some());
let oid = baseline.head_commit_oid.as_deref().unwrap();
run_git(tmp.path(), &["checkout", "-q", "--detach", oid]);
let class = watcher.classify(&baseline);
assert_eq!(class, GitChangeClass::BranchSwitch);
assert!(class.requires_full_rebuild());
}
#[test]
fn classify_commit_that_changes_tree_is_tree_diverged() {
let tmp = TempDir::new().unwrap();
init_repo(tmp.path());
let watcher = GitStateWatcher::new(tmp.path()).unwrap();
let baseline = watcher.current_state();
fs::write(tmp.path().join("a.txt"), b"alpha-modified\n").unwrap();
run_git(tmp.path(), &["commit", "-q", "-am", "edit alpha"]);
let class = watcher.classify(&baseline);
assert_eq!(class, GitChangeClass::TreeDiverged);
assert!(class.requires_full_rebuild());
}
#[test]
fn classify_checkout_to_other_branch_is_branch_switch() {
let tmp = TempDir::new().unwrap();
init_repo(tmp.path());
let watcher = GitStateWatcher::new(tmp.path()).unwrap();
let baseline = watcher.current_state();
run_git(tmp.path(), &["checkout", "-q", "-b", "other"]);
let class = watcher.classify(&baseline);
assert_eq!(class, GitChangeClass::BranchSwitch);
assert!(class.requires_full_rebuild());
}
#[test]
fn classify_gc_is_noise() {
let tmp = TempDir::new().unwrap();
init_repo(tmp.path());
fs::write(tmp.path().join("b.txt"), b"bravo\n").unwrap();
run_git(tmp.path(), &["add", "b.txt"]);
run_git(tmp.path(), &["reset", "--hard", "HEAD"]);
let watcher = GitStateWatcher::new(tmp.path()).unwrap();
let baseline = watcher.current_state();
run_git(tmp.path(), &["gc", "--quiet", "--prune=now"]);
let class = watcher.classify(&baseline);
assert_eq!(class, GitChangeClass::Noise);
assert!(!class.requires_full_rebuild());
}
#[test]
fn classify_staging_operations_are_noise() {
let tmp = TempDir::new().unwrap();
init_repo(tmp.path());
let watcher = GitStateWatcher::new(tmp.path()).unwrap();
let baseline = watcher.current_state();
fs::write(tmp.path().join("c.txt"), b"charlie\n").unwrap();
run_git(tmp.path(), &["add", "c.txt"]);
run_git(tmp.path(), &["reset", "HEAD", "c.txt"]);
let class = watcher.classify(&baseline);
assert_eq!(class, GitChangeClass::Noise);
assert!(!class.requires_full_rebuild());
}
#[test]
fn classify_missing_baseline_fields_falls_back_to_branch_switch() {
let tmp = TempDir::new().unwrap();
init_repo(tmp.path());
let watcher = GitStateWatcher::new(tmp.path()).unwrap();
let empty = LastIndexedGitState::default();
assert_eq!(watcher.classify(&empty), GitChangeClass::BranchSwitch);
}
#[test]
fn poll_changed_reports_events_after_git_command() {
let tmp = TempDir::new().unwrap();
init_repo(tmp.path());
let watcher = GitStateWatcher::new(tmp.path()).unwrap();
let _ = watcher.poll_changed();
fs::write(tmp.path().join("a.txt"), b"alpha-modified\n").unwrap();
run_git(tmp.path(), &["commit", "-q", "-am", "edit alpha"]);
std::thread::sleep(std::time::Duration::from_millis(200));
assert!(
watcher.poll_changed(),
"watcher should report events after a commit touches .git/"
);
}
}