use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Checkpoint {
pub role: String,
pub task_id: u32,
pub task_title: String,
pub task_description: String,
pub branch: Option<String>,
pub last_commit: Option<String>,
pub test_summary: Option<String>,
pub timestamp: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RestartContext {
pub role: String,
pub task_id: u32,
pub task_title: String,
pub task_description: String,
pub branch: Option<String>,
pub worktree_path: Option<String>,
pub restart_count: u32,
pub reason: String,
#[serde(default)]
pub output_bytes: Option<u64>,
#[serde(default)]
pub last_commit: Option<String>,
#[serde(default)]
pub created_at_epoch_secs: Option<u64>,
#[serde(default)]
pub handoff_consumed: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PreservedLaneArtifactKind {
Commit,
Snapshot,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PreservedLaneRecord {
pub role: String,
pub task_id: u32,
pub task_title: String,
pub task_status: String,
pub source_branch: String,
pub target_branch: String,
pub reason: String,
pub created_at: String,
pub source_head: Option<String>,
pub artifact_kind: PreservedLaneArtifactKind,
pub preserved_commit: Option<String>,
pub snapshot_path: Option<String>,
}
impl PreservedLaneRecord {
pub fn commit(
role: &str,
task: &crate::task::Task,
source_branch: &str,
target_branch: &str,
reason: &str,
source_head: Option<String>,
preserved_commit: String,
) -> Self {
Self {
role: role.to_string(),
task_id: task.id,
task_title: task.title.clone(),
task_status: task.status.clone(),
source_branch: source_branch.to_string(),
target_branch: target_branch.to_string(),
reason: reason.to_string(),
created_at: chrono_timestamp(),
source_head,
artifact_kind: PreservedLaneArtifactKind::Commit,
preserved_commit: Some(preserved_commit),
snapshot_path: None,
}
}
pub fn snapshot(
role: &str,
task: &crate::task::Task,
source_branch: &str,
target_branch: &str,
reason: &str,
source_head: Option<String>,
snapshot_path: String,
) -> Self {
Self {
role: role.to_string(),
task_id: task.id,
task_title: task.title.clone(),
task_status: task.status.clone(),
source_branch: source_branch.to_string(),
target_branch: target_branch.to_string(),
reason: reason.to_string(),
created_at: chrono_timestamp(),
source_head,
artifact_kind: PreservedLaneArtifactKind::Snapshot,
preserved_commit: None,
snapshot_path: Some(snapshot_path),
}
}
pub fn status_signal(&self) -> String {
match self.artifact_kind {
PreservedLaneArtifactKind::Commit => format!(
"saved completed lane #{} before cleanup (commit {} @ {})",
self.task_id,
self.source_branch,
abbreviate_commit(self.preserved_commit.as_deref())
),
PreservedLaneArtifactKind::Snapshot => format!(
"saved completed lane #{} before cleanup (snapshot {})",
self.task_id,
self.snapshot_path.as_deref().unwrap_or("-")
),
}
}
pub fn doctor_check_line(&self) -> String {
match self.artifact_kind {
PreservedLaneArtifactKind::Commit => format!(
"{} preserved completed task #{} before cleanup on {} ({})",
self.role,
self.task_id,
self.source_branch,
abbreviate_commit(self.preserved_commit.as_deref())
),
PreservedLaneArtifactKind::Snapshot => format!(
"{} preserved completed task #{} before cleanup in {}",
self.role,
self.task_id,
self.snapshot_path.as_deref().unwrap_or("-")
),
}
}
}
pub fn progress_dir(project_root: &Path) -> PathBuf {
project_root.join(".batty").join("progress")
}
pub fn checkpoint_path(project_root: &Path, role: &str) -> PathBuf {
progress_dir(project_root).join(format!("{role}.md"))
}
pub fn preserved_lane_dir(project_root: &Path) -> PathBuf {
progress_dir(project_root).join("preserved-lanes")
}
pub fn preserved_lane_record_path(project_root: &Path, role: &str) -> PathBuf {
preserved_lane_dir(project_root).join(format!("{role}.json"))
}
pub fn restart_context_path(worktree_dir: &Path) -> PathBuf {
worktree_dir.join("restart_context.json")
}
pub fn write_checkpoint(project_root: &Path, checkpoint: &Checkpoint) -> Result<()> {
let dir = progress_dir(project_root);
std::fs::create_dir_all(&dir)?;
let path = dir.join(format!("{}.md", checkpoint.role));
let content = format_checkpoint(checkpoint);
std::fs::write(&path, content)?;
Ok(())
}
pub fn read_checkpoint(project_root: &Path, role: &str) -> Option<String> {
let path = checkpoint_path(project_root, role);
std::fs::read_to_string(&path).ok()
}
pub fn remove_checkpoint(project_root: &Path, role: &str) {
let path = checkpoint_path(project_root, role);
let _ = std::fs::remove_file(&path);
}
pub fn write_preserved_lane_record(
project_root: &Path,
record: &PreservedLaneRecord,
) -> Result<PathBuf> {
let dir = preserved_lane_dir(project_root);
std::fs::create_dir_all(&dir)?;
let path = preserved_lane_record_path(project_root, &record.role);
let content = serde_json::to_vec_pretty(record)?;
std::fs::write(&path, content)?;
Ok(path)
}
pub fn read_preserved_lane_record(project_root: &Path, role: &str) -> Option<PreservedLaneRecord> {
let path = preserved_lane_record_path(project_root, role);
let content = std::fs::read(path).ok()?;
serde_json::from_slice(&content).ok()
}
pub fn list_preserved_lane_records(project_root: &Path) -> Vec<PreservedLaneRecord> {
let dir = preserved_lane_dir(project_root);
let mut records = Vec::new();
let Ok(entries) = std::fs::read_dir(dir) else {
return records;
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|ext| ext.to_str()) != Some("json") {
continue;
}
let Ok(content) = std::fs::read(&path) else {
continue;
};
let Ok(record) = serde_json::from_slice(&content) else {
continue;
};
records.push(record);
}
records.sort_by(|left, right| {
left.role
.cmp(&right.role)
.then(left.task_id.cmp(&right.task_id))
});
records
}
pub fn remove_preserved_lane_record(project_root: &Path, role: &str) {
let path = preserved_lane_record_path(project_root, role);
let _ = std::fs::remove_file(path);
}
#[allow(clippy::too_many_arguments)]
pub fn write_dirty_lane_snapshot(
project_root: &Path,
worktree_dir: &Path,
role: &str,
task: &crate::task::Task,
source_branch: &str,
target_branch: &str,
reason: &str,
source_head: Option<&str>,
) -> Result<PathBuf> {
let dir = preserved_lane_dir(project_root);
std::fs::create_dir_all(&dir)?;
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let path = dir.join(format!("{role}-task-{}-{stamp}.md", task.id));
let status = git_stdout(
worktree_dir,
&["status", "--short", "--untracked-files=all"],
)
.context("failed to capture worktree status for preserved lane snapshot")?;
let staged_diff = git_stdout(
worktree_dir,
&["diff", "--cached", "--binary", "--no-ext-diff"],
)
.context("failed to capture staged diff for preserved lane snapshot")?;
let unstaged_diff = git_stdout(worktree_dir, &["diff", "--binary", "--no-ext-diff"])
.context("failed to capture unstaged diff for preserved lane snapshot")?;
let untracked_paths = git_stdout(
worktree_dir,
&["ls-files", "--others", "--exclude-standard"],
)
.context("failed to capture untracked files for preserved lane snapshot")?;
let mut content = String::new();
content.push_str(&format!("# Preserved Dirty Lane Snapshot: {role}\n\n"));
content.push_str(&format!("Task: #{} - {}\n", task.id, task.title));
content.push_str(&format!("Task Status: {}\n", task.status));
content.push_str(&format!("Source Branch: {source_branch}\n"));
content.push_str(&format!("Target Branch: {target_branch}\n"));
content.push_str(&format!("Reason: {reason}\n"));
content.push_str(&format!("Created At: {}\n", chrono_timestamp()));
if let Some(head) = source_head {
content.push_str(&format!("Source HEAD: {head}\n"));
}
content.push_str("\n## Git Status\n\n```text\n");
content.push_str(status.trim_end());
content.push_str("\n```\n");
if !staged_diff.trim().is_empty() {
content.push_str("\n## Staged Diff\n\n```diff\n");
content.push_str(staged_diff.trim_end());
content.push_str("\n```\n");
}
if !unstaged_diff.trim().is_empty() {
content.push_str("\n## Unstaged Diff\n\n```diff\n");
content.push_str(unstaged_diff.trim_end());
content.push_str("\n```\n");
}
let tracked_untracked = untracked_paths
.lines()
.map(str::trim)
.filter(|path| {
!path.is_empty() && !path.starts_with(".batty/") && !path.starts_with(".cargo/")
})
.collect::<Vec<_>>();
if !tracked_untracked.is_empty() {
content.push_str("\n## Untracked Files\n");
for relative in tracked_untracked {
let file_path = worktree_dir.join(relative);
let file_body = std::fs::read(&file_path).with_context(|| {
format!("failed to read untracked file {}", file_path.display())
})?;
content.push_str(&format!("\n### {relative}\n\n```text\n"));
content.push_str(&String::from_utf8_lossy(&file_body));
if !content.ends_with('\n') {
content.push('\n');
}
content.push_str("```\n");
}
}
std::fs::write(&path, content)
.with_context(|| format!("failed to write preserved lane snapshot {}", path.display()))?;
Ok(path)
}
pub fn write_restart_context(worktree_dir: &Path, context: &RestartContext) -> Result<()> {
std::fs::create_dir_all(worktree_dir)?;
let path = restart_context_path(worktree_dir);
let content = serde_json::to_vec_pretty(context)?;
std::fs::write(path, content)?;
Ok(())
}
pub fn read_restart_context(worktree_dir: &Path) -> Option<RestartContext> {
let path = restart_context_path(worktree_dir);
let content = std::fs::read(path).ok()?;
serde_json::from_slice(&content).ok()
}
pub fn remove_restart_context(worktree_dir: &Path) {
let path = restart_context_path(worktree_dir);
let _ = std::fs::remove_file(path);
}
pub fn gather_checkpoint(project_root: &Path, role: &str, task: &crate::task::Task) -> Checkpoint {
let worktree_dir = project_root.join(".batty").join("worktrees").join(role);
let branch = task
.branch
.clone()
.or_else(|| git_current_branch(&worktree_dir));
let last_commit = git_last_commit(&worktree_dir);
let test_summary = last_test_output(&worktree_dir);
let timestamp = chrono_timestamp();
Checkpoint {
role: role.to_string(),
task_id: task.id,
task_title: task.title.clone(),
task_description: task.description.clone(),
branch,
last_commit,
test_summary,
timestamp,
}
}
fn format_checkpoint(cp: &Checkpoint) -> String {
let mut out = String::new();
out.push_str(&format!("# Progress Checkpoint: {}\n\n", cp.role));
out.push_str(&format!(
"**Task:** #{} — {}\n\n",
cp.task_id, cp.task_title
));
out.push_str(&format!("**Timestamp:** {}\n\n", cp.timestamp));
if let Some(branch) = &cp.branch {
out.push_str(&format!("**Branch:** {branch}\n\n"));
}
if let Some(commit) = &cp.last_commit {
out.push_str(&format!("**Last commit:** {commit}\n\n"));
}
out.push_str("## Task Description\n\n");
out.push_str(&cp.task_description);
out.push('\n');
if let Some(tests) = &cp.test_summary {
out.push_str("\n## Last Test Output\n\n");
out.push_str(tests);
out.push('\n');
}
out
}
fn git_current_branch(worktree_dir: &Path) -> Option<String> {
if !worktree_dir.exists() {
return None;
}
let output = std::process::Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.current_dir(worktree_dir)
.output()
.ok()?;
if output.status.success() {
let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
if branch.is_empty() || branch == "HEAD" {
None
} else {
Some(branch)
}
} else {
None
}
}
fn git_stdout(worktree_dir: &Path, args: &[&str]) -> Result<String> {
Ok(crate::team::git_cmd::run_git(worktree_dir, args)?.stdout)
}
fn abbreviate_commit(commit: Option<&str>) -> String {
commit
.map(|commit| commit.chars().take(12).collect::<String>())
.unwrap_or_else(|| "unknown".to_string())
}
fn git_last_commit(worktree_dir: &Path) -> Option<String> {
if !worktree_dir.exists() {
return None;
}
let output = std::process::Command::new("git")
.args(["log", "-1", "--oneline"])
.current_dir(worktree_dir)
.output()
.ok()?;
if output.status.success() {
let line = String::from_utf8_lossy(&output.stdout).trim().to_string();
if line.is_empty() { None } else { Some(line) }
} else {
None
}
}
fn last_test_output(worktree_dir: &Path) -> Option<String> {
let test_output_path = worktree_dir.join(".batty_test_output");
if test_output_path.exists() {
if let Ok(content) = std::fs::read_to_string(&test_output_path) {
if !content.is_empty() {
let lines: Vec<&str> = content.lines().collect();
let start = lines.len().saturating_sub(50);
return Some(lines[start..].join("\n"));
}
}
}
None
}
fn chrono_timestamp() -> String {
use std::time::SystemTime;
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default();
let secs = now.as_secs();
let hours = (secs / 3600) % 24;
let minutes = (secs / 60) % 60;
let seconds = secs % 60;
let days_since_epoch = secs / 86400;
let (year, month, day) = epoch_days_to_date(days_since_epoch);
format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}Z")
}
fn epoch_days_to_date(days: u64) -> (u64, u64, u64) {
let z = days + 719468;
let era = z / 146097;
let doe = z - era * 146097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
(y, m, d)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn make_task(id: u32, title: &str, description: &str) -> crate::task::Task {
crate::task::Task {
id,
title: title.to_string(),
status: "in-progress".to_string(),
priority: "high".to_string(),
assignee: None,
claimed_by: None,
claimed_at: None,
claim_ttl_secs: None,
claim_expires_at: None,
last_progress_at: None,
claim_warning_sent_at: None,
claim_extensions: None,
last_output_bytes: None,
blocked: None,
tags: vec![],
depends_on: vec![],
review_owner: None,
blocked_on: None,
worktree_path: None,
branch: Some("eng-1-2/42".to_string()),
commit: None,
artifacts: vec![],
next_action: None,
scheduled_for: None,
cron_schedule: None,
cron_last_run: None,
completed: None,
description: description.to_string(),
batty_config: None,
source_path: PathBuf::from("/tmp/fake.md"),
}
}
#[test]
fn write_and_read_checkpoint() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let cp = Checkpoint {
role: "eng-1-1".to_string(),
task_id: 42,
task_title: "Fix the widget".to_string(),
task_description: "Widget is broken, needs fixing.".to_string(),
branch: Some("eng-1-1/42".to_string()),
last_commit: Some("abc1234 fix widget rendering".to_string()),
test_summary: Some("test result: ok. 10 passed".to_string()),
timestamp: "2026-03-22T10:00:00Z".to_string(),
};
write_checkpoint(root, &cp).unwrap();
let content = read_checkpoint(root, "eng-1-1").unwrap();
assert!(content.contains("# Progress Checkpoint: eng-1-1"));
assert!(content.contains("**Task:** #42 — Fix the widget"));
assert!(content.contains("**Branch:** eng-1-1/42"));
assert!(content.contains("**Last commit:** abc1234 fix widget rendering"));
assert!(content.contains("Widget is broken, needs fixing."));
assert!(content.contains("test result: ok. 10 passed"));
assert!(content.contains("**Timestamp:** 2026-03-22T10:00:00Z"));
}
#[test]
fn read_checkpoint_returns_none_when_missing() {
let tmp = tempfile::tempdir().unwrap();
assert!(read_checkpoint(tmp.path(), "eng-nonexistent").is_none());
}
#[test]
fn remove_checkpoint_deletes_file() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let cp = Checkpoint {
role: "eng-1-1".to_string(),
task_id: 1,
task_title: "t".to_string(),
task_description: "d".to_string(),
branch: None,
last_commit: None,
test_summary: None,
timestamp: "2026-01-01T00:00:00Z".to_string(),
};
write_checkpoint(root, &cp).unwrap();
assert!(checkpoint_path(root, "eng-1-1").exists());
remove_checkpoint(root, "eng-1-1");
assert!(!checkpoint_path(root, "eng-1-1").exists());
}
#[test]
fn remove_checkpoint_noop_when_missing() {
let tmp = tempfile::tempdir().unwrap();
remove_checkpoint(tmp.path(), "eng-nonexistent");
}
#[test]
fn checkpoint_creates_progress_directory() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let dir = progress_dir(root);
assert!(!dir.exists());
let cp = Checkpoint {
role: "eng-1-1".to_string(),
task_id: 1,
task_title: "t".to_string(),
task_description: "d".to_string(),
branch: None,
last_commit: None,
test_summary: None,
timestamp: "2026-01-01T00:00:00Z".to_string(),
};
write_checkpoint(root, &cp).unwrap();
assert!(dir.exists());
}
#[test]
fn preserved_lane_record_round_trips() {
let tmp = tempfile::tempdir().unwrap();
let task = crate::task::Task {
status: "done".to_string(),
assignee: None,
claimed_by: Some("eng-1".to_string()),
worktree_path: Some(".batty/worktrees/eng-1".to_string()),
..make_task(628, "done lane", "done lane description")
};
let record = PreservedLaneRecord::commit(
"eng-1",
&task,
"eng-1/628",
"eng-main/eng-1",
"completed task no longer needs engineer lane",
Some("abc123456789".to_string()),
"def4567890abc".to_string(),
);
let path = write_preserved_lane_record(tmp.path(), &record).unwrap();
assert!(path.exists());
let loaded = read_preserved_lane_record(tmp.path(), "eng-1").unwrap();
assert_eq!(loaded, record);
assert_eq!(
loaded.status_signal(),
"saved completed lane #628 before cleanup (commit eng-1/628 @ def4567890ab)"
);
}
#[test]
fn write_dirty_lane_snapshot_captures_diffs_and_untracked_files() {
let tmp = tempfile::tempdir().unwrap();
let repo = crate::team::test_support::init_git_repo(&tmp, "checkpoint-snapshot");
let worktree_dir = repo.join(".batty").join("worktrees").join("eng-1");
let team_config_dir = repo.join(".batty").join("team_config");
crate::team::task_loop::prepare_engineer_assignment_worktree(
&repo,
&worktree_dir,
"eng-1",
"eng-1/628",
&team_config_dir,
)
.unwrap();
fs::write(worktree_dir.join("tracked.txt"), "tracked\n").unwrap();
crate::team::test_support::git_ok(&worktree_dir, &["add", "tracked.txt"]);
fs::write(worktree_dir.join("unstaged.txt"), "unstaged\n").unwrap();
let task = crate::task::Task {
status: "done".to_string(),
assignee: None,
claimed_by: Some("eng-1".to_string()),
worktree_path: Some(".batty/worktrees/eng-1".to_string()),
..make_task(628, "done lane", "done lane description")
};
let snapshot_path = write_dirty_lane_snapshot(
&repo,
&worktree_dir,
"eng-1",
&task,
"eng-1/628",
"eng-main/eng-1",
"completed task no longer needs engineer lane",
Some("abc123456789"),
)
.unwrap();
let content = fs::read_to_string(&snapshot_path).unwrap();
assert!(content.contains("Source Branch: eng-1/628"));
assert!(content.contains("tracked.txt"));
assert!(content.contains("unstaged.txt"));
assert!(content.contains("completed task no longer needs engineer lane"));
}
#[test]
fn format_checkpoint_without_optional_fields() {
let cp = Checkpoint {
role: "eng-1-1".to_string(),
task_id: 99,
task_title: "Minimal task".to_string(),
task_description: "Do the thing.".to_string(),
branch: None,
last_commit: None,
test_summary: None,
timestamp: "2026-03-22T12:00:00Z".to_string(),
};
let content = format_checkpoint(&cp);
assert!(content.contains("# Progress Checkpoint: eng-1-1"));
assert!(content.contains("**Task:** #99 — Minimal task"));
assert!(!content.contains("**Branch:**"));
assert!(!content.contains("**Last commit:**"));
assert!(!content.contains("## Last Test Output"));
}
#[test]
fn gather_checkpoint_uses_task_branch() {
let tmp = tempfile::tempdir().unwrap();
let task = make_task(42, "Test task", "Test description");
let cp = gather_checkpoint(tmp.path(), "eng-1-2", &task);
assert_eq!(cp.task_id, 42);
assert_eq!(cp.task_title, "Test task");
assert_eq!(cp.task_description, "Test description");
assert_eq!(cp.branch, Some("eng-1-2/42".to_string()));
assert_eq!(cp.role, "eng-1-2");
assert!(!cp.timestamp.is_empty());
}
#[test]
fn last_test_output_reads_batty_test_file() {
let tmp = tempfile::tempdir().unwrap();
let worktree = tmp.path();
let test_file = worktree.join(".batty_test_output");
fs::write(&test_file, "test result: ok. 5 passed; 0 failed").unwrap();
let summary = last_test_output(worktree);
assert!(summary.is_some());
assert!(summary.unwrap().contains("5 passed"));
}
#[test]
fn last_test_output_returns_none_when_no_file() {
let tmp = tempfile::tempdir().unwrap();
assert!(last_test_output(tmp.path()).is_none());
}
#[test]
fn last_test_output_truncates_long_output() {
let tmp = tempfile::tempdir().unwrap();
let test_file = tmp.path().join(".batty_test_output");
let lines: Vec<String> = (0..100).map(|i| format!("line {i}")).collect();
fs::write(&test_file, lines.join("\n")).unwrap();
let summary = last_test_output(tmp.path()).unwrap();
let result_lines: Vec<&str> = summary.lines().collect();
assert_eq!(result_lines.len(), 50);
assert!(result_lines[0].contains("line 50"));
assert!(result_lines[49].contains("line 99"));
}
#[test]
fn epoch_days_to_date_known_values() {
let (y, m, d) = epoch_days_to_date(0);
assert_eq!((y, m, d), (1970, 1, 1));
let (y, m, d) = epoch_days_to_date(10957);
assert_eq!((y, m, d), (2000, 1, 1));
}
#[test]
fn checkpoint_path_correct() {
let root = Path::new("/project");
assert_eq!(
checkpoint_path(root, "eng-1-1"),
PathBuf::from("/project/.batty/progress/eng-1-1.md")
);
}
#[test]
fn overwrite_existing_checkpoint() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let cp1 = Checkpoint {
role: "eng-1-1".to_string(),
task_id: 1,
task_title: "First".to_string(),
task_description: "First task".to_string(),
branch: None,
last_commit: None,
test_summary: None,
timestamp: "2026-01-01T00:00:00Z".to_string(),
};
write_checkpoint(root, &cp1).unwrap();
let cp2 = Checkpoint {
role: "eng-1-1".to_string(),
task_id: 2,
task_title: "Second".to_string(),
task_description: "Second task".to_string(),
branch: Some("eng-1-1/2".to_string()),
last_commit: None,
test_summary: None,
timestamp: "2026-01-02T00:00:00Z".to_string(),
};
write_checkpoint(root, &cp2).unwrap();
let content = read_checkpoint(root, "eng-1-1").unwrap();
assert!(content.contains("**Task:** #2 — Second"));
assert!(!content.contains("First"));
}
#[test]
fn write_and_read_restart_context_round_trip() {
let tmp = tempfile::tempdir().unwrap();
let worktree_dir = tmp.path().join("eng-1-1");
let context = RestartContext {
role: "eng-1-1".to_string(),
task_id: 42,
task_title: "Fix the widget".to_string(),
task_description: "Widget is broken, needs fixing.".to_string(),
branch: Some("eng-1-1/42".to_string()),
worktree_path: Some("/tmp/worktrees/eng-1-1".to_string()),
restart_count: 2,
reason: "context_exhausted".to_string(),
output_bytes: Some(512_000),
last_commit: Some("abc1234 fix widget".to_string()),
created_at_epoch_secs: Some(1_234_567_890),
handoff_consumed: false,
};
write_restart_context(&worktree_dir, &context).unwrap();
let loaded = read_restart_context(&worktree_dir).unwrap();
assert_eq!(loaded, context);
}
#[test]
fn remove_restart_context_deletes_file() {
let tmp = tempfile::tempdir().unwrap();
let worktree_dir = tmp.path().join("eng-1-1");
let context = RestartContext {
role: "eng-1-1".to_string(),
task_id: 42,
task_title: "Fix the widget".to_string(),
task_description: "Widget is broken, needs fixing.".to_string(),
branch: None,
worktree_path: None,
restart_count: 1,
reason: "stalled".to_string(),
output_bytes: None,
last_commit: None,
created_at_epoch_secs: None,
handoff_consumed: false,
};
write_restart_context(&worktree_dir, &context).unwrap();
assert!(restart_context_path(&worktree_dir).exists());
remove_restart_context(&worktree_dir);
assert!(!restart_context_path(&worktree_dir).exists());
}
#[test]
fn read_restart_context_returns_none_when_missing_or_invalid() {
let tmp = tempfile::tempdir().unwrap();
let worktree_dir = tmp.path().join("eng-1-1");
assert!(read_restart_context(&worktree_dir).is_none());
std::fs::create_dir_all(&worktree_dir).unwrap();
std::fs::write(restart_context_path(&worktree_dir), b"{not json").unwrap();
assert!(read_restart_context(&worktree_dir).is_none());
}
#[test]
fn write_checkpoint_to_readonly_dir_fails() {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let readonly = tmp.path().join("readonly_root");
fs::create_dir(&readonly).unwrap();
let batty_dir = readonly.join(".batty");
fs::create_dir(&batty_dir).unwrap();
fs::set_permissions(&batty_dir, fs::Permissions::from_mode(0o444)).unwrap();
let cp = Checkpoint {
role: "eng-1-1".to_string(),
task_id: 1,
task_title: "t".to_string(),
task_description: "d".to_string(),
branch: None,
last_commit: None,
test_summary: None,
timestamp: "2026-01-01T00:00:00Z".to_string(),
};
let result = write_checkpoint(&readonly, &cp);
assert!(result.is_err());
fs::set_permissions(&batty_dir, fs::Permissions::from_mode(0o755)).unwrap();
}
}
#[test]
fn git_current_branch_returns_none_for_nonexistent_dir() {
let result = git_current_branch(Path::new("/tmp/__batty_no_dir_here__"));
assert!(result.is_none());
}
#[test]
fn git_current_branch_returns_none_for_non_git_dir() {
let tmp = tempfile::tempdir().unwrap();
let result = git_current_branch(tmp.path());
assert!(result.is_none());
}
#[test]
fn git_last_commit_returns_none_for_nonexistent_dir() {
let result = git_last_commit(Path::new("/tmp/__batty_no_dir_here__"));
assert!(result.is_none());
}
#[test]
fn git_last_commit_returns_none_for_non_git_dir() {
let tmp = tempfile::tempdir().unwrap();
let result = git_last_commit(tmp.path());
assert!(result.is_none());
}
#[test]
fn last_test_output_returns_none_for_empty_file() {
let tmp = tempfile::tempdir().unwrap();
let test_file = tmp.path().join(".batty_test_output");
fs::write(&test_file, "").unwrap();
assert!(last_test_output(tmp.path()).is_none());
}
#[test]
fn chrono_timestamp_returns_valid_format() {
let ts = chrono_timestamp();
assert!(ts.ends_with('Z'));
assert!(ts.contains('T'));
assert_eq!(ts.len(), 20); }
}