use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::UNIX_EPOCH;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use crate::team::merge::inspect_root_dirty_state;
pub const DEFAULT_STALE_THRESHOLD_SECS: i64 = 600; pub const STALE_RECOVERY_COMMAND: &str = "batty daemon-restart-if-stale";
pub const STALE_RECOVERY_DRY_RUN_COMMAND: &str = "batty daemon-restart-if-stale --dry-run";
pub const STALE_MANUAL_RECOVERY_COMMAND: &str = "cargo build --release && cp target/release/batty ~/.cargo/bin/batty && codesign --force --sign - ~/.cargo/bin/batty && batty stop && batty start";
pub const BINARY_REFRESH_STATE_FILE: &str = "daemon-binary-refresh.json";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BinaryFreshness {
pub fresh: bool,
pub commits_behind: u32,
pub last_subject: String,
pub last_hash: String,
pub binary_mtime: i64,
pub head_ts: i64,
pub worktree_dirty: bool,
}
impl BinaryFreshness {
fn fresh_with_stamps(binary_mtime: i64, head_ts: i64) -> Self {
Self {
fresh: true,
commits_behind: 0,
last_subject: String::new(),
last_hash: String::new(),
binary_mtime,
head_ts,
worktree_dirty: false,
}
}
pub fn recovery_action(&self) -> String {
if self.worktree_dirty {
format!(
"auto-restart refused: source worktree has uncommitted changes; next: inspect `git status --short`, commit/stash/clear the source edits, then run `{}`; manual fallback: `{}`",
STALE_RECOVERY_COMMAND, STALE_MANUAL_RECOVERY_COMMAND
)
} else {
format!(
"next: run `{}` to inspect, then `{}`",
STALE_RECOVERY_DRY_RUN_COMMAND, STALE_RECOVERY_COMMAND
)
}
}
pub fn status_line(&self) -> String {
if self.fresh {
"Daemon Binary: fresh".to_string()
} else if self.commits_behind == 1 {
format!(
"Daemon Binary: STALE — 1 commit behind main (last: {}); {}",
self.last_subject,
self.recovery_action()
)
} else {
format!(
"Daemon Binary: STALE — {} commits behind main (last: {}); {}",
self.commits_behind,
self.last_subject,
self.recovery_action()
)
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum DaemonBinaryRefreshPhase {
Pending,
Scheduled,
Blocked,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DaemonBinaryRefreshState {
pub phase: DaemonBinaryRefreshPhase,
pub commits_behind: u32,
pub last_subject: String,
pub last_hash: String,
pub binary_mtime: i64,
pub head_ts: i64,
pub updated_at: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub blocked_reason: Option<String>,
}
impl DaemonBinaryRefreshState {
pub fn pending(report: &BinaryFreshness, updated_at: u64) -> Self {
Self::from_report(DaemonBinaryRefreshPhase::Pending, report, updated_at, None)
}
pub fn scheduled(report: &BinaryFreshness, updated_at: u64) -> Self {
Self::from_report(
DaemonBinaryRefreshPhase::Scheduled,
report,
updated_at,
None,
)
}
pub fn blocked(report: &BinaryFreshness, updated_at: u64, reason: impl Into<String>) -> Self {
Self::from_report(
DaemonBinaryRefreshPhase::Blocked,
report,
updated_at,
Some(reason.into()),
)
}
fn from_report(
phase: DaemonBinaryRefreshPhase,
report: &BinaryFreshness,
updated_at: u64,
blocked_reason: Option<String>,
) -> Self {
Self {
phase,
commits_behind: report.commits_behind,
last_subject: report.last_subject.clone(),
last_hash: report.last_hash.clone(),
binary_mtime: report.binary_mtime,
head_ts: report.head_ts,
updated_at,
blocked_reason,
}
}
pub fn matches_report(&self, report: &BinaryFreshness) -> bool {
self.commits_behind == report.commits_behind
&& self.last_hash == report.last_hash
&& self.binary_mtime == report.binary_mtime
&& self.head_ts == report.head_ts
}
pub fn status_line(&self) -> String {
let count = if self.commits_behind == 1 {
"1 commit".to_string()
} else {
format!("{} commits", self.commits_behind)
};
match self.phase {
DaemonBinaryRefreshPhase::Pending => format!(
"Daemon Binary: restart pending — {count} behind main (last: {})",
self.last_subject
),
DaemonBinaryRefreshPhase::Scheduled => format!(
"Daemon Binary: restart scheduled — {count} behind main (last: {})",
self.last_subject
),
DaemonBinaryRefreshPhase::Blocked => format!(
"Daemon Binary: restart blocked: {} — {count} behind main (last: {})",
self.blocked_reason.as_deref().unwrap_or("unknown blocker"),
self.last_subject
),
}
}
}
pub fn binary_refresh_state_path(project_root: &Path) -> PathBuf {
project_root.join(".batty").join(BINARY_REFRESH_STATE_FILE)
}
pub fn load_binary_refresh_state(project_root: &Path) -> Result<Option<DaemonBinaryRefreshState>> {
let path = binary_refresh_state_path(project_root);
if !path.exists() {
return Ok(None);
}
let content =
fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
let state = serde_json::from_str::<DaemonBinaryRefreshState>(&content)
.with_context(|| format!("failed to parse {}", path.display()))?;
Ok(Some(state))
}
pub fn save_binary_refresh_state(
project_root: &Path,
state: &DaemonBinaryRefreshState,
) -> Result<()> {
let path = binary_refresh_state_path(project_root);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
let content =
serde_json::to_string_pretty(state).context("failed to serialize binary refresh state")?;
fs::write(&path, content).with_context(|| format!("failed to write {}", path.display()))
}
pub fn clear_binary_refresh_state(project_root: &Path) -> Result<()> {
let path = binary_refresh_state_path(project_root);
if !path.exists() {
return Ok(());
}
fs::remove_file(&path).with_context(|| format!("failed to remove {}", path.display()))
}
pub fn evaluate_binary_freshness(
binary_path: &Path,
repo_root: &Path,
) -> Result<Option<BinaryFreshness>> {
let Some(binary_mtime) = binary_mtime_unix(binary_path)? else {
return Ok(None);
};
let Some(head_ts) = head_commit_ts(repo_root)? else {
return Ok(None);
};
Ok(Some(evaluate_with_stamps(
repo_root,
binary_mtime,
head_ts,
DEFAULT_STALE_THRESHOLD_SECS,
)?))
}
pub fn evaluate_with_stamps(
repo_root: &Path,
binary_mtime: i64,
head_ts: i64,
stale_threshold_secs: i64,
) -> Result<BinaryFreshness> {
if head_ts <= binary_mtime + stale_threshold_secs {
return Ok(BinaryFreshness::fresh_with_stamps(binary_mtime, head_ts));
}
let (commits_behind, last_subject, last_hash) =
commits_touching_src_since(repo_root, binary_mtime)?;
if commits_behind == 0 {
return Ok(BinaryFreshness::fresh_with_stamps(binary_mtime, head_ts));
}
let root_dirty = inspect_root_dirty_state(repo_root)?;
let worktree_dirty = !root_dirty.source_paths.is_empty();
Ok(BinaryFreshness {
fresh: false,
commits_behind,
last_subject,
last_hash,
binary_mtime,
head_ts,
worktree_dirty,
})
}
fn binary_mtime_unix(binary_path: &Path) -> Result<Option<i64>> {
let metadata = match fs::metadata(binary_path) {
Ok(meta) => meta,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => {
return Err(error).with_context(|| {
format!("failed to stat daemon binary {}", binary_path.display())
});
}
};
let modified = metadata
.modified()
.with_context(|| format!("mtime unavailable for {}", binary_path.display()))?;
let secs = modified
.duration_since(UNIX_EPOCH)
.context("binary mtime before unix epoch")?
.as_secs() as i64;
Ok(Some(secs))
}
fn head_commit_ts(repo_root: &Path) -> Result<Option<i64>> {
let output = Command::new("git")
.args(["log", "-1", "--format=%ct", "HEAD"])
.current_dir(repo_root)
.output()
.with_context(|| format!("failed to invoke git in {}", repo_root.display()))?;
if !output.status.success() {
return Ok(None);
}
let raw = String::from_utf8_lossy(&output.stdout);
let trimmed = raw.trim();
if trimmed.is_empty() {
return Ok(None);
}
let ts: i64 = trimmed
.parse()
.with_context(|| format!("unparseable git HEAD timestamp: {trimmed:?}"))?;
Ok(Some(ts))
}
fn commits_touching_src_since(repo_root: &Path, since_ts: i64) -> Result<(u32, String, String)> {
let output = Command::new("git")
.args([
"log",
"HEAD",
"--no-merges",
"--format=%ct%x09%h%x09%s",
"--",
"src",
])
.current_dir(repo_root)
.output()
.with_context(|| format!("failed to invoke git log in {}", repo_root.display()))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
anyhow::bail!(
"git log failed while checking src/** commits in {}: {}",
repo_root.display(),
stderr
);
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut count: u32 = 0;
let mut newest: Option<(i64, String, String)> = None;
for line in stdout.lines() {
let mut parts = line.splitn(3, '\t');
let Some(ts_s) = parts.next() else { continue };
let Some(hash) = parts.next() else { continue };
let subject = parts.next().unwrap_or("").to_string();
let ts: i64 = match ts_s.parse() {
Ok(v) => v,
Err(_) => continue,
};
if ts <= since_ts {
break;
}
count += 1;
if newest
.as_ref()
.map(|(existing_ts, ..)| ts > *existing_ts)
.unwrap_or(true)
{
newest = Some((ts, hash.to_string(), subject));
}
}
let (_, hash, subject) = newest.unwrap_or_default();
Ok((count, subject, hash))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::process::Command;
fn init_repo(dir: &Path) {
run_git(dir, &["init", "-q", "-b", "main"]);
run_git(dir, &["config", "user.email", "test@example.com"]);
run_git(dir, &["config", "user.name", "Test"]);
}
fn run_git(dir: &Path, args: &[&str]) {
let status = Command::new("git")
.args(args)
.current_dir(dir)
.status()
.expect("git binary");
assert!(status.success(), "git {args:?} failed in {dir:?}");
}
fn commit_file_with_time(dir: &Path, rel: &str, content: &str, unix_ts: i64) {
let path = dir.join(rel);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(path, content).unwrap();
run_git(dir, &["add", rel]);
let date = format!("{unix_ts} +0000");
let status = Command::new("git")
.args(["commit", "-q", "-m", &format!("commit {rel}")])
.env("GIT_AUTHOR_DATE", &date)
.env("GIT_COMMITTER_DATE", &date)
.current_dir(dir)
.status()
.expect("git commit");
assert!(status.success());
}
#[test]
fn fresh_when_head_within_threshold_of_binary() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path();
init_repo(repo);
commit_file_with_time(repo, "src/foo.rs", "fn a() {}", 1_700_000_000);
let report = evaluate_with_stamps(repo, 1_700_000_000, 1_700_000_300, 600).unwrap();
assert!(report.fresh, "delta 300s <= 600s threshold should be fresh");
assert_eq!(report.commits_behind, 0);
assert_eq!(report.status_line(), "Daemon Binary: fresh");
}
#[test]
fn stale_when_src_commit_newer_than_binary_by_more_than_threshold() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path();
init_repo(repo);
commit_file_with_time(repo, "src/foo.rs", "fn a() {}", 1_700_000_000);
commit_file_with_time(repo, "src/bar.rs", "fn b() {}", 1_700_001_000);
let report = evaluate_with_stamps(repo, 1_700_000_000, 1_700_001_800, 600).unwrap();
assert!(!report.fresh);
assert_eq!(report.commits_behind, 1);
assert!(!report.worktree_dirty);
assert!(report.last_subject.contains("src/bar.rs"));
assert!(
report.status_line().contains("STALE"),
"expected STALE in status line, got {:?}",
report.status_line()
);
assert!(
report.status_line().contains(STALE_RECOVERY_COMMAND),
"stale status should include the recovery command, got {:?}",
report.status_line()
);
}
#[test]
fn docs_only_commit_does_not_flip_binary_to_stale() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path();
init_repo(repo);
commit_file_with_time(repo, "src/foo.rs", "fn a() {}", 1_700_000_000);
commit_file_with_time(repo, "docs/changelog.md", "# Changelog\n", 1_700_001_000);
let report = evaluate_with_stamps(repo, 1_700_000_000, 1_700_001_800, 600).unwrap();
assert!(
report.fresh,
"docs-only commit should not mark binary stale"
);
assert_eq!(report.commits_behind, 0);
}
#[test]
fn counts_multiple_src_commits_since_binary_mtime() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path();
init_repo(repo);
commit_file_with_time(repo, "src/foo.rs", "fn a() {}", 1_700_000_000);
commit_file_with_time(repo, "src/bar.rs", "fn b() {}", 1_700_001_000);
commit_file_with_time(repo, "src/baz.rs", "fn c() {}", 1_700_002_000);
commit_file_with_time(repo, "src/qux.rs", "fn d() {}", 1_700_003_000);
let report = evaluate_with_stamps(repo, 1_700_000_500, 1_700_003_000, 60).unwrap();
assert!(!report.fresh);
assert_eq!(report.commits_behind, 3);
assert!(
report.last_subject.contains("src/qux.rs"),
"last subject should be newest src commit, got {:?}",
report.last_subject
);
assert!(report.status_line().contains("3 commits behind"));
}
#[test]
fn status_line_handles_single_commit_pluralization() {
let report = BinaryFreshness {
fresh: false,
commits_behind: 1,
last_subject: "fix: bug".to_string(),
last_hash: "abc1234".to_string(),
binary_mtime: 0,
head_ts: 0,
worktree_dirty: false,
};
assert!(
report.status_line().contains("1 commit behind"),
"expected singular 'commit', got {:?}",
report.status_line()
);
}
#[test]
fn binary_refresh_state_status_lines_report_schedule_and_blockers() {
let report = BinaryFreshness {
fresh: false,
commits_behind: 2,
last_subject: "merge task".to_string(),
last_hash: "abc1234".to_string(),
binary_mtime: 100,
head_ts: 200,
worktree_dirty: false,
};
let scheduled = DaemonBinaryRefreshState::scheduled(&report, 300);
assert!(
scheduled.status_line().contains("restart scheduled"),
"scheduled status should be explicit: {:?}",
scheduled.status_line()
);
let blocked = DaemonBinaryRefreshState::blocked(&report, 300, "dirty main");
assert!(
blocked
.status_line()
.contains("restart blocked: dirty main"),
"blocked status should include reason: {:?}",
blocked.status_line()
);
}
#[test]
fn binary_refresh_state_round_trips() {
let tmp = tempfile::tempdir().unwrap();
let report = BinaryFreshness {
fresh: false,
commits_behind: 1,
last_subject: "src update".to_string(),
last_hash: "def5678".to_string(),
binary_mtime: 100,
head_ts: 200,
worktree_dirty: false,
};
let state = DaemonBinaryRefreshState::pending(&report, 300);
save_binary_refresh_state(tmp.path(), &state).unwrap();
let loaded = load_binary_refresh_state(tmp.path()).unwrap().unwrap();
assert_eq!(loaded, state);
assert!(loaded.matches_report(&report));
clear_binary_refresh_state(tmp.path()).unwrap();
assert!(load_binary_refresh_state(tmp.path()).unwrap().is_none());
}
#[test]
fn stale_status_refuses_auto_rebuild_when_worktree_is_dirty() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path();
init_repo(repo);
commit_file_with_time(repo, "src/foo.rs", "fn a() {}", 1_700_000_000);
commit_file_with_time(repo, "src/bar.rs", "fn b() {}", 1_700_001_000);
fs::write(repo.join("scratch.txt"), "dirty\n").unwrap();
let report = evaluate_with_stamps(repo, 1_700_000_000, 1_700_001_800, 600).unwrap();
assert!(!report.fresh);
assert!(report.worktree_dirty);
assert!(
report.status_line().contains("auto-restart refused"),
"dirty stale report should refuse daemon-owned restart, got {:?}",
report.status_line()
);
assert!(
report.status_line().contains(STALE_MANUAL_RECOVERY_COMMAND),
"dirty stale report should include manual fallback, got {:?}",
report.status_line()
);
}
#[test]
fn runtime_only_dirty_state_does_not_refuse_auto_restart() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path();
init_repo(repo);
commit_file_with_time(repo, "src/foo.rs", "fn a() {}", 1_700_000_000);
commit_file_with_time(repo, "src/bar.rs", "fn b() {}", 1_700_001_000);
let telemetry = repo.join(".batty").join("telemetry.db");
fs::create_dir_all(telemetry.parent().unwrap()).unwrap();
fs::write(telemetry, "runtime noise\n").unwrap();
let report = evaluate_with_stamps(repo, 1_700_000_000, 1_700_001_800, 600).unwrap();
assert!(!report.fresh);
assert!(
!report.worktree_dirty,
"runtime-only dirty state should not block safe daemon restart"
);
assert!(
report.status_line().contains(STALE_RECOVERY_COMMAND),
"stale runtime-only report should point to safe command, got {:?}",
report.status_line()
);
}
#[test]
fn evaluate_binary_freshness_returns_none_when_binary_missing() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path();
init_repo(repo);
commit_file_with_time(repo, "src/foo.rs", "fn a() {}", 1_700_000_000);
let result = evaluate_binary_freshness(&repo.join("does-not-exist"), repo).unwrap();
assert!(result.is_none());
}
#[test]
fn evaluate_binary_freshness_returns_none_outside_git_repo() {
let tmp = tempfile::tempdir().unwrap();
let bin = tmp.path().join("batty");
fs::write(&bin, "fake binary").unwrap();
let result = evaluate_binary_freshness(&bin, tmp.path()).unwrap();
assert!(
result.is_none(),
"non-git dir should return None, got {result:?}"
);
}
}