batty-cli 0.11.63

Supervised agent execution for software teams
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
//! Binary-vs-HEAD freshness detection (#675).
//!
//! During a quota outage on 2026-04-15, four critical fixes landed on
//! `main` while the running daemon was executing a 5+ hour old binary.
//! The fixes didn't take effect until the binary was rebuilt and the
//! daemon restarted — there was no automated signal surfacing the gap.
//!
//! This module computes whether the running daemon binary is stale
//! relative to the git HEAD of the batty source tree, filtered by
//! commits that actually touched `src/**` (docs-only commits don't make
//! the binary stale).
//!
//! The goal is safe refresh orchestration: detect stale binaries, record
//! daemon-owned refresh state, and surface exact blockers when automatic
//! refresh is not safe.
//!
//! Public surface:
//! - [`BinaryFreshness`] — the result type rendered by status.
//! - [`DaemonBinaryRefreshState`] — persisted refresh pending/scheduled/blocked state.
//! - [`evaluate_binary_freshness`] — pure entry point: takes binary path
//!   and repo root, returns freshness report.
//! - [`DEFAULT_STALE_THRESHOLD_SECS`] — 10 minute tolerance.

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;

/// Commits newer than the binary by less than this window are treated
/// as "fresh enough" — avoids false alarms for a just-rebuilt binary
/// racing a freshly-pushed commit.
pub const DEFAULT_STALE_THRESHOLD_SECS: i64 = 600; // 10 minutes
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";

/// Result of a binary-vs-HEAD freshness check.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BinaryFreshness {
    /// True when the binary is considered up-to-date.
    pub fresh: bool,
    /// Number of commits on main that touched `src/**` and landed after
    /// the binary was built. Zero when fresh.
    pub commits_behind: u32,
    /// Subject line of the most recent commit that would flip the binary
    /// to stale. Empty when fresh.
    pub last_subject: String,
    /// Hash (short) of the last stale-triggering commit. Empty when fresh.
    pub last_hash: String,
    /// Unix timestamp of the binary's mtime.
    pub binary_mtime: i64,
    /// Unix timestamp of the HEAD commit (author date).
    pub head_ts: i64,
    /// Whether the source worktree has uncommitted non-runtime changes.
    /// Batty-managed runtime noise is safe for daemon-owned recovery.
    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
            )
        }
    }

    /// Formatted one-line message suitable for the status output.
    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()))
}

/// Compute binary freshness against the given repo root.
///
/// Returns Ok(freshness) on success; returns Ok(None) when binary path
/// does not exist or repo is not a git worktree — those cases should
/// not fail-close the daemon. Hard errors bubble up from
/// [`anyhow::Context`].
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,
    )?))
}

/// Test-friendly variant that accepts pre-computed timestamps and
/// a configurable stale threshold.
pub fn evaluate_with_stamps(
    repo_root: &Path,
    binary_mtime: i64,
    head_ts: i64,
    stale_threshold_secs: i64,
) -> Result<BinaryFreshness> {
    // HEAD older than or very close to the binary — nothing to flag.
    if head_ts <= binary_mtime + stale_threshold_secs {
        return Ok(BinaryFreshness::fresh_with_stamps(binary_mtime, head_ts));
    }

    // HEAD is meaningfully newer. Count commits that touched src/** and
    // whose author timestamp is newer than the binary.
    let (commits_behind, last_subject, last_hash) =
        commits_touching_src_since(repo_root, binary_mtime)?;

    if commits_behind == 0 {
        // HEAD moved forward but only docs/** or similar — binary still
        // fresh from a runtime-behavior perspective.
        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() {
        // Not a git worktree, or git unavailable — skip the check.
        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))
}

/// Enumerate commits touching `src/**` whose committer timestamp is
/// strictly greater than `since_ts`. Returns (count, last_subject,
/// last_hash) — "last" meaning the newest commit, which is what the
/// operator most likely wants to see.
fn commits_touching_src_since(repo_root: &Path, since_ts: i64) -> Result<(u32, String, String)> {
    // --format=%ct\t%h\t%s — tab-separated commit time, short hash,
    // subject. --no-merges keeps the count focused on source changes
    // rather than noise from merge commits.
    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 {
            // git log is in newest-first order; once we cross the binary
            // mtime we can stop iterating.
            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);

        // Binary is 1800s older than HEAD; threshold 600s; one src commit
        // happened after the binary was built.
        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);

        // HEAD ts is 1800s newer than binary, but the commit only touched
        // docs/**, so src-path filtering should report fresh.
        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:?}"
        );
    }
}