1use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5
6use anyhow::{Context, Result, bail};
7use tracing::{debug, info, warn};
8
9use super::git_cmd;
10use super::retry::{RetryConfig, retry_sync};
11
12#[cfg_attr(not(test), allow(dead_code))]
13fn priority_rank(p: &str) -> u32 {
14 match p {
15 "critical" => 0,
16 "high" => 1,
17 "medium" => 2,
18 "low" => 3,
19 _ => 4,
20 }
21}
22
23#[cfg_attr(not(test), allow(dead_code))]
24pub(crate) fn next_unclaimed_task(board_dir: &Path) -> Result<Option<crate::task::Task>> {
25 let tasks = crate::task::load_tasks_from_dir(&board_dir.join("tasks"))?;
26 let task_status_by_id: HashMap<u32, String> = tasks
27 .iter()
28 .map(|task| (task.id, task.status.clone()))
29 .collect();
30
31 let mut available: Vec<crate::task::Task> = tasks
32 .into_iter()
33 .filter(|task| matches!(task.status.as_str(), "backlog" | "todo"))
34 .filter(|task| task.claimed_by.is_none())
35 .filter(|task| task.blocked.is_none())
36 .filter(|task| task.blocked_on.is_none())
37 .filter(|task| {
38 task.depends_on.iter().all(|dep_id| {
39 task_status_by_id
40 .get(dep_id)
41 .is_none_or(|status| status == "done")
42 })
43 })
44 .collect();
45
46 available.sort_by_key(|task| (priority_rank(&task.priority), task.id));
47 Ok(available.into_iter().next())
48}
49
50pub(crate) fn run_tests_in_worktree(worktree_dir: &Path) -> Result<(bool, String)> {
51 let output = std::process::Command::new("cargo")
52 .arg("test")
53 .current_dir(worktree_dir)
54 .output()
55 .with_context(|| {
56 format!(
57 "failed while running `cargo test` in engineer worktree {}",
58 worktree_dir.display()
59 )
60 })?;
61
62 let stdout = String::from_utf8_lossy(&output.stdout);
63 let stderr = String::from_utf8_lossy(&output.stderr);
64 let mut combined = String::new();
65 combined.push_str(&stdout);
66 if !stdout.is_empty() && !stderr.is_empty() && !stdout.ends_with('\n') {
67 combined.push('\n');
68 }
69 combined.push_str(&stderr);
70
71 let lines: Vec<&str> = combined.lines().collect();
72 let trimmed = if lines.len() > 50 {
73 lines[lines.len() - 50..].join("\n")
74 } else {
75 combined
76 };
77
78 Ok((output.status.success(), trimmed))
79}
80
81fn retry_git<T, F>(operation: F) -> std::result::Result<T, git_cmd::GitError>
82where
83 F: Fn() -> std::result::Result<T, git_cmd::GitError>,
84{
85 retry_sync(&RetryConfig::fast(), operation)
86}
87
88fn map_git_error<T>(result: std::result::Result<T, git_cmd::GitError>, action: &str) -> Result<T> {
89 result.map_err(|error| anyhow::anyhow!("{action}: {error}"))
90}
91
92pub(crate) fn read_task_title(board_dir: &Path, task_id: u32) -> String {
93 let tasks_dir = board_dir.join("tasks");
94 let prefix = format!("{task_id:03}-");
95 if let Ok(entries) = std::fs::read_dir(&tasks_dir) {
96 for entry in entries.flatten() {
97 let name = entry.file_name().to_string_lossy().to_string();
98 if name.starts_with(&prefix)
99 && name.ends_with(".md")
100 && let Ok(content) = std::fs::read_to_string(entry.path())
101 {
102 for line in content.lines() {
103 if line.starts_with("title:") {
104 return line
105 .trim_start_matches("title:")
106 .trim()
107 .trim_matches(|c| c == '"' || c == '\'')
108 .to_string();
109 }
110 }
111 }
112 }
113 }
114 format!("Task #{task_id}")
115}
116
117pub(crate) fn setup_engineer_worktree(
119 project_root: &Path,
120 worktree_dir: &Path,
121 branch_name: &str,
122 team_config_dir: &Path,
123) -> Result<PathBuf> {
124 if let Some(parent) = worktree_dir.parent() {
125 std::fs::create_dir_all(parent)
126 .with_context(|| format!("failed to create {}", parent.display()))?;
127 }
128
129 if !worktree_dir.exists() {
130 let path = worktree_dir.to_string_lossy().to_string();
131 match retry_git(|| git_cmd::worktree_add(project_root, worktree_dir, branch_name, "main")) {
132 Ok(_) => {}
133 Err(git_cmd::GitError::Permanent { stderr, .. })
134 if stderr.contains("already exists") =>
135 {
136 map_git_error(
137 retry_git(|| {
138 git_cmd::run_git(project_root, &["worktree", "add", &path, branch_name])
139 }),
140 "failed to create git worktree",
141 )?;
142 }
143 Err(error) => {
144 return Err(anyhow::anyhow!("failed to create git worktree: {error}"));
145 }
146 }
147
148 info!(worktree = %worktree_dir.display(), branch = branch_name, "created engineer worktree");
149 }
150
151 ensure_engineer_worktree_links(worktree_dir, team_config_dir)?;
152
153 Ok(worktree_dir.to_path_buf())
154}
155
156pub(crate) fn prepare_engineer_assignment_worktree(
157 project_root: &Path,
158 worktree_dir: &Path,
159 engineer_name: &str,
160 task_branch: &str,
161 team_config_dir: &Path,
162) -> Result<PathBuf> {
163 let base_branch = engineer_base_branch_name(engineer_name);
164 ensure_engineer_worktree_health(project_root, worktree_dir, &base_branch)?;
165 setup_engineer_worktree(project_root, worktree_dir, &base_branch, team_config_dir)?;
166 maybe_migrate_legacy_engineer_worktree(
167 project_root,
168 worktree_dir,
169 engineer_name,
170 &base_branch,
171 )?;
172 ensure_task_branch_namespace_available(project_root, engineer_name)?;
173
174 if worktree_has_user_changes(worktree_dir)? {
175 auto_clean_worktree(worktree_dir)?;
176 }
177
178 let previous_branch = current_worktree_branch(worktree_dir)?;
179 if previous_branch != base_branch
180 && previous_branch != engineer_name
181 && previous_branch != task_branch
182 && !branch_is_merged_into(project_root, &previous_branch, "main")?
183 {
184 bail!(
185 "engineer worktree '{}' is on unmerged branch '{}'",
186 engineer_name,
187 previous_branch
188 );
189 }
190
191 checkout_worktree_branch_from_main(worktree_dir, &base_branch)?;
192
193 checkout_worktree_branch_from_main(worktree_dir, task_branch)?;
194 ensure_engineer_worktree_links(worktree_dir, team_config_dir)?;
195
196 if previous_branch != base_branch
197 && previous_branch != task_branch
198 && (previous_branch == engineer_name
199 || previous_branch.starts_with(&format!("{engineer_name}/")))
200 && branch_is_merged_into(project_root, &previous_branch, "main")?
201 {
202 delete_branch(project_root, &previous_branch)?;
203 }
204
205 Ok(worktree_dir.to_path_buf())
206}
207
208pub(crate) fn setup_multi_repo_worktree(
211 project_root: &Path,
212 worktree_dir: &Path,
213 branch_name: &str,
214 team_config_dir: &Path,
215 sub_repo_names: &[String],
216) -> Result<PathBuf> {
217 std::fs::create_dir_all(worktree_dir)
218 .with_context(|| format!("failed to create {}", worktree_dir.display()))?;
219
220 for repo_name in sub_repo_names {
221 let repo_root = project_root.join(repo_name);
222 let sub_wt = worktree_dir.join(repo_name);
223 setup_engineer_worktree(&repo_root, &sub_wt, branch_name, team_config_dir)?;
224 }
225
226 ensure_engineer_worktree_links(worktree_dir, team_config_dir)?;
227 Ok(worktree_dir.to_path_buf())
228}
229
230pub(crate) fn prepare_multi_repo_assignment_worktree(
233 project_root: &Path,
234 worktree_dir: &Path,
235 engineer_name: &str,
236 task_branch: &str,
237 team_config_dir: &Path,
238 sub_repo_names: &[String],
239) -> Result<PathBuf> {
240 std::fs::create_dir_all(worktree_dir)
241 .with_context(|| format!("failed to create {}", worktree_dir.display()))?;
242
243 for repo_name in sub_repo_names {
244 let repo_root = project_root.join(repo_name);
245 let sub_wt = worktree_dir.join(repo_name);
246 prepare_engineer_assignment_worktree(
247 &repo_root,
248 &sub_wt,
249 engineer_name,
250 task_branch,
251 team_config_dir,
252 )?;
253 }
254
255 ensure_engineer_worktree_links(worktree_dir, team_config_dir)?;
256 Ok(worktree_dir.to_path_buf())
257}
258
259fn ensure_engineer_worktree_health(
260 project_root: &Path,
261 worktree_dir: &Path,
262 _base_branch: &str,
263) -> Result<()> {
264 if !worktree_dir.exists() {
265 return Ok(());
266 }
267
268 if !worktree_registered(project_root, worktree_dir)? {
269 bail!(
270 "engineer worktree path exists but is not registered in git worktree list: {}",
271 worktree_dir.display()
272 );
273 }
274
275 Ok(())
276}
277
278#[allow(dead_code)] pub(crate) fn refresh_engineer_worktree(
280 project_root: &Path,
281 worktree_dir: &Path,
282 branch_name: &str,
283 team_config_dir: &Path,
284) -> Result<()> {
285 if !worktree_dir.exists() {
286 return Ok(());
287 }
288
289 if worktree_has_user_changes(worktree_dir)? {
290 warn!(
291 worktree = %worktree_dir.display(),
292 branch = branch_name,
293 "skipping worktree refresh because worktree is dirty"
294 );
295 return Ok(());
296 }
297
298 if map_git_error(
299 retry_git(|| git_cmd::merge_base_is_ancestor(project_root, "main", branch_name)),
300 "failed to compare worktree branch with main",
301 )? {
302 return Ok(());
303 }
304
305 let rebase_result = retry_git(|| git_cmd::rebase(worktree_dir, "main"));
306 if rebase_result.is_ok() {
307 info!(
308 worktree = %worktree_dir.display(),
309 branch = branch_name,
310 "refreshed engineer worktree"
311 );
312 return Ok(());
313 }
314
315 let stderr = match rebase_result {
316 Ok(_) => unreachable!("successful rebase returned early"),
317 Err(git_cmd::GitError::Transient { stderr, .. })
318 | Err(git_cmd::GitError::Permanent { stderr, .. })
319 | Err(git_cmd::GitError::RebaseFailed { stderr, .. })
320 | Err(git_cmd::GitError::MergeFailed { stderr, .. }) => stderr.trim().to_string(),
321 Err(git_cmd::GitError::RevParseFailed { stderr, .. }) => stderr.trim().to_string(),
322 Err(git_cmd::GitError::InvalidRevListCount { output, .. }) => output.trim().to_string(),
323 Err(git_cmd::GitError::Exec { source, .. }) => source.to_string(),
324 };
325 let _ = retry_git(|| git_cmd::rebase_abort(worktree_dir));
326
327 if !is_worktree_safe_to_mutate(worktree_dir)? {
328 bail!(
329 "worktree at {} has uncommitted changes on a task branch after failed rebase — refusing to destroy. Commit or stash first.",
330 worktree_dir.display()
331 );
332 }
333
334 map_git_error(
335 retry_git(|| git_cmd::worktree_remove(project_root, worktree_dir, true)),
336 &format!("failed to remove conflicted worktree after rebase error '{stderr}'"),
337 )?;
338
339 map_git_error(
340 retry_git(|| git_cmd::branch_delete(project_root, branch_name)),
341 &format!("failed to delete conflicted worktree branch after rebase error '{stderr}'"),
342 )?;
343
344 warn!(
345 worktree = %worktree_dir.display(),
346 branch = branch_name,
347 rebase_error = %stderr,
348 "recreating engineer worktree after rebase conflict"
349 );
350 setup_engineer_worktree(project_root, worktree_dir, branch_name, team_config_dir)?;
351 Ok(())
352}
353
354pub(crate) fn engineer_base_branch_name(engineer_name: &str) -> String {
355 format!("eng-main/{engineer_name}")
356}
357
358fn maybe_migrate_legacy_engineer_worktree(
359 project_root: &Path,
360 worktree_dir: &Path,
361 engineer_name: &str,
362 base_branch: &str,
363) -> Result<()> {
364 if !worktree_dir.exists() {
365 return Ok(());
366 }
367
368 let current_branch = current_worktree_branch(worktree_dir)?;
369 if current_branch != engineer_name {
370 return Ok(());
371 }
372
373 if worktree_has_user_changes(worktree_dir)? {
374 bail!(
375 "legacy engineer branch '{}' is still checked out in {} with uncommitted changes; resolve it before assigning a new task branch",
376 engineer_name,
377 worktree_dir.display()
378 );
379 }
380
381 checkout_worktree_branch_from_main(worktree_dir, base_branch)?;
382 if branch_is_merged_into(project_root, engineer_name, "main")? {
383 delete_branch(project_root, engineer_name)?;
384 info!(
385 branch = engineer_name,
386 base_branch,
387 worktree = %worktree_dir.display(),
388 "auto-migrated legacy engineer worktree to base branch"
389 );
390 return Ok(());
391 }
392
393 let archive_branch = archived_legacy_branch_name(project_root, engineer_name)?;
394 rename_branch(project_root, engineer_name, &archive_branch)?;
395 warn!(
396 old_branch = engineer_name,
397 new_branch = %archive_branch,
398 base_branch,
399 worktree = %worktree_dir.display(),
400 "auto-migrated unmerged legacy engineer worktree to base branch"
401 );
402 Ok(())
403}
404
405fn ensure_task_branch_namespace_available(project_root: &Path, engineer_name: &str) -> Result<()> {
406 if !branch_exists(project_root, engineer_name)? {
407 return Ok(());
408 }
409
410 if branch_is_checked_out_in_any_worktree(project_root, engineer_name)? {
411 bail!(
412 "legacy engineer branch '{}' is still checked out in a worktree; resolve it before assigning a new task branch",
413 engineer_name
414 );
415 }
416
417 if branch_is_merged_into(project_root, engineer_name, "main")? {
418 delete_branch(project_root, engineer_name)?;
419 info!(
420 branch = engineer_name,
421 "deleted merged legacy engineer branch to free task namespace"
422 );
423 return Ok(());
424 }
425
426 let archive_branch = archived_legacy_branch_name(project_root, engineer_name)?;
427 rename_branch(project_root, engineer_name, &archive_branch)?;
428 warn!(
429 old_branch = engineer_name,
430 new_branch = %archive_branch,
431 "archived legacy engineer branch to free task namespace"
432 );
433 Ok(())
434}
435
436fn ensure_engineer_worktree_links(worktree_dir: &Path, team_config_dir: &Path) -> Result<()> {
437 let wt_batty_dir = worktree_dir.join(".batty");
438 std::fs::create_dir_all(&wt_batty_dir).ok();
439 let wt_config_link = wt_batty_dir.join("team_config");
440
441 if !wt_config_link.exists() {
442 #[cfg(unix)]
443 std::os::unix::fs::symlink(team_config_dir, &wt_config_link).with_context(|| {
444 format!(
445 "failed to symlink {} -> {}",
446 wt_config_link.display(),
447 team_config_dir.display()
448 )
449 })?;
450
451 #[cfg(not(unix))]
452 {
453 warn!("symlinks not supported on this platform, copying config instead");
454 let _ = std::fs::create_dir_all(&wt_config_link);
455 }
456
457 debug!(
458 link = %wt_config_link.display(),
459 target = %team_config_dir.display(),
460 "symlinked team config into worktree"
461 );
462 }
463
464 Ok(())
465}
466
467fn worktree_has_user_changes(worktree_dir: &Path) -> Result<bool> {
468 Ok(map_git_error(
469 retry_git(|| git_cmd::status_porcelain(worktree_dir)),
470 "failed to inspect worktree status",
471 )?
472 .lines()
473 .any(|line| !line.starts_with("?? .batty/")))
474}
475
476pub(crate) fn is_worktree_safe_to_mutate(worktree_dir: &Path) -> Result<bool> {
480 if !worktree_dir.exists() {
481 return Ok(true);
482 }
483
484 let has_changes = worktree_has_user_changes(worktree_dir)?;
485 if !has_changes {
486 return Ok(true);
487 }
488
489 let branch = match map_git_error(
490 retry_git(|| git_cmd::rev_parse_branch(worktree_dir)),
491 "failed to determine worktree branch for safety check",
492 ) {
493 Ok(b) => b,
494 Err(_) => return Ok(true), };
496
497 if branch.starts_with("eng-main/") {
499 return Ok(true);
500 }
501
502 warn!(
504 worktree = %worktree_dir.display(),
505 branch = %branch,
506 "worktree has uncommitted changes on task branch, refusing to mutate"
507 );
508 Ok(false)
509}
510
511fn auto_clean_worktree(worktree_dir: &Path) -> Result<()> {
512 if auto_commit_before_reset(worktree_dir) {
514 return Ok(());
515 }
516
517 warn!(
519 worktree = %worktree_dir.display(),
520 "force-cleaning engineer worktree"
521 );
522 let _ = retry_git(|| git_cmd::run_git(worktree_dir, &["checkout", "--", "."]));
523 let _ = retry_git(|| git_cmd::run_git(worktree_dir, &["clean", "-fd", "--exclude=.batty/"]));
524
525 if worktree_has_user_changes(worktree_dir)? {
526 bail!(
527 "engineer worktree at {} still dirty after auto-clean",
528 worktree_dir.display()
529 );
530 }
531 Ok(())
532}
533
534pub(crate) fn auto_commit_before_reset(worktree_dir: &Path) -> bool {
538 let has_changes = match worktree_has_user_changes(worktree_dir) {
540 Ok(v) => v,
541 Err(_) => return false,
542 };
543 if !has_changes {
544 return true;
545 }
546
547 if retry_git(|| git_cmd::run_git(worktree_dir, &["add", "-A"])).is_err() {
549 warn!(
550 worktree = %worktree_dir.display(),
551 "auto-commit: git add failed"
552 );
553 return false;
554 }
555
556 let branch = retry_git(|| git_cmd::rev_parse_branch(worktree_dir)).unwrap_or_default();
558 let msg = format!("wip: auto-save before worktree reset [{}]", branch);
559
560 match retry_git(|| git_cmd::run_git(worktree_dir, &["commit", "-m", &msg])) {
561 Ok(_) => {
562 info!(
563 worktree = %worktree_dir.display(),
564 branch = %branch,
565 "auto-committed uncommitted changes before worktree reset"
566 );
567 true
568 }
569 Err(e) => {
570 warn!(
571 worktree = %worktree_dir.display(),
572 error = %e,
573 "auto-commit failed"
574 );
575 false
576 }
577 }
578}
579
580pub(crate) fn current_worktree_branch(worktree_dir: &Path) -> Result<String> {
581 map_git_error(
582 retry_git(|| git_cmd::rev_parse_branch(worktree_dir)),
583 "failed to determine worktree branch",
584 )
585}
586
587pub(crate) fn checkout_worktree_branch_from_main(
588 worktree_dir: &Path,
589 branch_name: &str,
590) -> Result<()> {
591 map_git_error(
592 retry_git(|| git_cmd::checkout_new_branch(worktree_dir, branch_name, "main")),
593 &format!("failed to switch worktree to branch '{branch_name}'"),
594 )
595}
596
597fn branch_exists(project_root: &Path, branch_name: &str) -> Result<bool> {
598 map_git_error(
599 retry_git(|| git_cmd::show_ref_exists(project_root, branch_name)),
600 &format!("failed to check whether branch '{branch_name}' exists"),
601 )
602}
603
604fn worktree_registered(project_root: &Path, worktree_dir: &Path) -> Result<bool> {
605 let output = map_git_error(
606 retry_git(|| git_cmd::worktree_list(project_root)),
607 "failed to list git worktrees",
608 )?;
609 let target = worktree_dir
610 .canonicalize()
611 .unwrap_or_else(|_| worktree_dir.to_path_buf());
612
613 for line in output.lines() {
614 let Some(candidate) = line.strip_prefix("worktree ") else {
615 continue;
616 };
617 let candidate = PathBuf::from(candidate.trim());
618 let candidate = candidate.canonicalize().unwrap_or(candidate);
619 if candidate == target {
620 return Ok(true);
621 }
622 }
623
624 Ok(false)
625}
626
627fn branch_is_checked_out_in_any_worktree(project_root: &Path, branch_name: &str) -> Result<bool> {
628 let output = map_git_error(
629 retry_git(|| git_cmd::worktree_list(project_root)),
630 "failed to list git worktrees",
631 )?;
632 let target = format!("branch refs/heads/{branch_name}");
633 Ok(output.lines().any(|line| line.trim() == target))
634}
635
636pub(crate) fn branch_is_merged_into(
637 project_root: &Path,
638 branch_name: &str,
639 base_branch: &str,
640) -> Result<bool> {
641 map_git_error(
642 retry_git(|| git_cmd::merge_base_is_ancestor(project_root, branch_name, base_branch)),
643 &format!("failed to compare branch '{branch_name}' with '{base_branch}'"),
644 )
645}
646
647pub(crate) fn engineer_worktree_ready_for_dispatch(
648 project_root: &Path,
649 worktree_dir: &Path,
650 engineer_name: &str,
651) -> Result<()> {
652 if !worktree_dir.exists() {
653 return Ok(());
654 }
655
656 if !worktree_registered(project_root, worktree_dir)? {
657 bail!(
658 "engineer worktree path exists but is not registered in git worktree list: {}",
659 worktree_dir.display()
660 );
661 }
662
663 let base_branch = engineer_base_branch_name(engineer_name);
664 let current_branch = current_worktree_branch(worktree_dir)?;
665 if current_branch != base_branch {
666 bail!(
667 "engineer worktree '{}' is checked out on '{}' instead of '{}'",
668 engineer_name,
669 current_branch,
670 base_branch
671 );
672 }
673
674 if worktree_has_user_changes(worktree_dir)? {
675 bail!(
676 "engineer worktree '{}' has uncommitted changes",
677 engineer_name
678 );
679 }
680
681 let ahead_of_main = map_git_error(
682 retry_git(|| git_cmd::rev_list_count(worktree_dir, "main..HEAD")),
683 "failed to compare worktree against main",
684 )?;
685 let behind_main = map_git_error(
686 retry_git(|| git_cmd::rev_list_count(worktree_dir, "HEAD..main")),
687 "failed to compare worktree against main",
688 )?;
689 if ahead_of_main != 0 || behind_main != 0 {
690 bail!(
691 "engineer worktree '{}' is not based on current main (ahead {}, behind {})",
692 engineer_name,
693 ahead_of_main,
694 behind_main
695 );
696 }
697
698 Ok(())
699}
700
701pub(crate) fn delete_branch(project_root: &Path, branch_name: &str) -> Result<()> {
702 map_git_error(
703 retry_git(|| git_cmd::branch_delete(project_root, branch_name)),
704 &format!("failed to delete branch '{branch_name}'"),
705 )
706}
707
708fn archived_legacy_branch_name(project_root: &Path, engineer_name: &str) -> Result<String> {
709 let short_sha = map_git_error(
710 retry_git(|| git_cmd::run_git(project_root, &["rev-parse", "--short", engineer_name])),
711 &format!("failed to resolve legacy branch '{engineer_name}'"),
712 )?
713 .stdout
714 .trim()
715 .to_string();
716 let mut candidate = format!("legacy/{engineer_name}-{short_sha}");
717 let mut counter = 1usize;
718 while branch_exists(project_root, &candidate)? {
719 counter += 1;
720 candidate = format!("legacy/{engineer_name}-{short_sha}-{counter}");
721 }
722 Ok(candidate)
723}
724
725fn rename_branch(project_root: &Path, old_branch: &str, new_branch: &str) -> Result<()> {
726 map_git_error(
727 retry_git(|| git_cmd::branch_rename(project_root, old_branch, new_branch)),
728 &format!("failed to rename branch '{old_branch}' to '{new_branch}'"),
729 )
730}
731
732pub(crate) fn recycle_cron_tasks(board_dir: &Path) -> Result<Vec<(u32, String)>> {
736 use chrono::Utc;
737 use cron::Schedule;
738 use serde_yaml::Value;
739 use std::str::FromStr;
740
741 use super::task_cmd::{find_task_path, set_optional_string, update_task_frontmatter, yaml_key};
742
743 let tasks_dir = board_dir.join("tasks");
744 let tasks = crate::task::load_tasks_from_dir(&tasks_dir)
745 .with_context(|| format!("failed to load tasks from {}", tasks_dir.display()))?;
746
747 let now = Utc::now();
748 let mut recycled = Vec::new();
749
750 for task in &tasks {
751 if task.status != "done" {
753 continue;
754 }
755
756 let cron_expr = match &task.cron_schedule {
758 Some(expr) => expr.clone(),
759 None => continue,
760 };
761
762 if task.tags.iter().any(|t| t == "archived") {
764 continue;
765 }
766
767 let schedule = match Schedule::from_str(&cron_expr) {
769 Ok(s) => s,
770 Err(err) => {
771 warn!(task_id = task.id, cron = %cron_expr, error = %err, "invalid cron expression, skipping");
772 continue;
773 }
774 };
775
776 let reference = task
778 .cron_last_run
779 .as_deref()
780 .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
781 .map(|dt| dt.with_timezone(&Utc))
782 .unwrap_or_else(|| now - chrono::Duration::days(1));
783
784 let next = match schedule.after(&reference).next() {
786 Some(dt) => dt,
787 None => continue,
788 };
789
790 if next > now {
792 continue;
793 }
794
795 let next_future = schedule.after(&now).next().map(|dt| dt.to_rfc3339());
797
798 let now_str = now.to_rfc3339();
799 let task_id = task.id;
800 let task_path = find_task_path(board_dir, task_id)?;
801
802 update_task_frontmatter(&task_path, |mapping| {
803 mapping.insert(yaml_key("status"), Value::String("todo".to_string()));
805
806 set_optional_string(mapping, "scheduled_for", next_future.as_deref());
808
809 set_optional_string(mapping, "cron_last_run", Some(&now_str));
811
812 mapping.remove(yaml_key("claimed_by"));
814 mapping.remove(yaml_key("branch"));
815 mapping.remove(yaml_key("commit"));
816 mapping.remove(yaml_key("artifacts"));
817 mapping.remove(yaml_key("next_action"));
818 mapping.remove(yaml_key("review_owner"));
819 mapping.remove(yaml_key("blocked_on"));
820 mapping.remove(yaml_key("worktree_path"));
821 })?;
822
823 info!(task_id, cron = %cron_expr, "recycled cron task back to todo");
824 recycled.push((task_id, cron_expr));
825 }
826
827 Ok(recycled)
828}
829
830#[cfg(test)]
831mod tests {
832 use super::*;
833 use crate::team::test_support::{git, git_ok, git_stdout};
834
835 fn production_unwrap_expect_count(path: &Path) -> usize {
836 let content = std::fs::read_to_string(path).unwrap();
837 let test_split = content.split("\n#[cfg(test)]").next().unwrap_or(&content);
838 test_split
839 .lines()
840 .filter(|line| line.contains(".unwrap(") || line.contains(".expect("))
841 .count()
842 }
843
844 fn init_git_repo(tmp: &tempfile::TempDir) -> PathBuf {
845 let repo = tmp.path();
846 git_ok(repo, &["init", "-b", "main"]);
847 git_ok(repo, &["config", "user.email", "batty-test@example.com"]);
848 git_ok(repo, &["config", "user.name", "Batty Test"]);
849 std::fs::create_dir_all(repo.join(".batty").join("team_config")).unwrap();
850 std::fs::write(repo.join("README.md"), "initial\n").unwrap();
851 git_ok(repo, &["add", "README.md", ".batty/team_config"]);
852 git_ok(repo, &["commit", "-m", "initial"]);
853 repo.to_path_buf()
854 }
855
856 fn write_task_file(
857 dir: &Path,
858 id: u32,
859 title: &str,
860 status: &str,
861 priority: &str,
862 claimed_by: Option<&str>,
863 depends_on: &[u32],
864 ) {
865 let tasks_dir = dir.join("tasks");
866 std::fs::create_dir_all(&tasks_dir).unwrap();
867 let mut content =
868 format!("---\nid: {id}\ntitle: {title}\nstatus: {status}\npriority: {priority}\n");
869 if let Some(cb) = claimed_by {
870 content.push_str(&format!("claimed_by: {cb}\n"));
871 }
872 if !depends_on.is_empty() {
873 content.push_str("depends_on:\n");
874 for dep in depends_on {
875 content.push_str(&format!(" - {dep}\n"));
876 }
877 }
878 content.push_str("class: standard\n---\n\nTask description.\n");
879 std::fs::write(tasks_dir.join(format!("{id:03}-{title}.md")), content).unwrap();
880 }
881
882 fn write_task_file_with_workflow_frontmatter(
883 dir: &Path,
884 id: u32,
885 title: &str,
886 extra_frontmatter: &str,
887 ) {
888 let tasks_dir = dir.join("tasks");
889 std::fs::create_dir_all(&tasks_dir).unwrap();
890 std::fs::write(
891 tasks_dir.join(format!("{id:03}-{title}.md")),
892 format!(
893 "---\nid: {id}\ntitle: {title}\nstatus: todo\npriority: critical\n{extra_frontmatter}class: standard\n---\n\nTask description.\n"
894 ),
895 )
896 .unwrap();
897 }
898
899 #[test]
900 fn test_refresh_worktree_rebases_behind_main() {
901 let tmp = tempfile::tempdir().unwrap();
902 let repo = init_git_repo(&tmp);
903 let worktree_dir = repo.join(".batty").join("worktrees").join("eng-1");
904 let team_config_dir = repo.join(".batty").join("team_config");
905
906 setup_engineer_worktree(&repo, &worktree_dir, "eng-1", &team_config_dir).unwrap();
907
908 std::fs::write(repo.join("main.txt"), "new main content\n").unwrap();
909 git_ok(&repo, &["add", "main.txt"]);
910 git_ok(&repo, &["commit", "-m", "advance main"]);
911
912 refresh_engineer_worktree(&repo, &worktree_dir, "eng-1", &team_config_dir).unwrap();
913
914 assert!(worktree_dir.join("main.txt").exists());
915 assert_eq!(
916 git_stdout(&repo, &["rev-parse", "main"]),
917 git_stdout(&worktree_dir, &["rev-parse", "HEAD"])
918 );
919 }
920
921 #[test]
922 fn test_refresh_worktree_recreates_on_conflict() {
923 let tmp = tempfile::tempdir().unwrap();
924 let repo = init_git_repo(&tmp);
925 let worktree_dir = repo.join(".batty").join("worktrees").join("eng-2");
926 let team_config_dir = repo.join(".batty").join("team_config");
927
928 std::fs::write(repo.join("file.txt"), "A\n").unwrap();
929 git_ok(&repo, &["add", "file.txt"]);
930 git_ok(&repo, &["commit", "-m", "add file"]);
931
932 setup_engineer_worktree(&repo, &worktree_dir, "eng-2", &team_config_dir).unwrap();
933
934 std::fs::write(worktree_dir.join("file.txt"), "B\n").unwrap();
935 git_ok(&worktree_dir, &["add", "file.txt"]);
936 git_ok(&worktree_dir, &["commit", "-m", "engineer change"]);
937
938 std::fs::write(repo.join("file.txt"), "C\n").unwrap();
939 git_ok(&repo, &["add", "file.txt"]);
940 git_ok(&repo, &["commit", "-m", "main change"]);
941
942 refresh_engineer_worktree(&repo, &worktree_dir, "eng-2", &team_config_dir).unwrap();
943
944 assert!(worktree_dir.exists());
945 assert_eq!(
946 std::fs::read_to_string(worktree_dir.join("file.txt")).unwrap(),
947 "C\n"
948 );
949 assert_eq!(
950 git_stdout(&repo, &["rev-parse", "main"]),
951 git_stdout(&worktree_dir, &["rev-parse", "HEAD"])
952 );
953 }
954
955 #[test]
956 fn test_refresh_worktree_skips_dirty() {
957 let tmp = tempfile::tempdir().unwrap();
958 let repo = init_git_repo(&tmp);
959 let worktree_dir = repo.join(".batty").join("worktrees").join("eng-3");
960 let team_config_dir = repo.join(".batty").join("team_config");
961
962 setup_engineer_worktree(&repo, &worktree_dir, "eng-3", &team_config_dir).unwrap();
963 std::fs::write(worktree_dir.join("scratch.txt"), "uncommitted\n").unwrap();
964
965 std::fs::write(repo.join("main.txt"), "new main content\n").unwrap();
966 git_ok(&repo, &["add", "main.txt"]);
967 git_ok(&repo, &["commit", "-m", "advance main"]);
968
969 refresh_engineer_worktree(&repo, &worktree_dir, "eng-3", &team_config_dir).unwrap();
970
971 assert!(!worktree_dir.join("main.txt").exists());
972 assert_eq!(
973 std::fs::read_to_string(worktree_dir.join("scratch.txt")).unwrap(),
974 "uncommitted\n"
975 );
976 }
977
978 #[test]
979 fn test_refresh_worktree_noop_when_current() {
980 let tmp = tempfile::tempdir().unwrap();
981 let repo = init_git_repo(&tmp);
982 let worktree_dir = repo.join(".batty").join("worktrees").join("eng-4");
983 let team_config_dir = repo.join(".batty").join("team_config");
984
985 setup_engineer_worktree(&repo, &worktree_dir, "eng-4", &team_config_dir).unwrap();
986 let before = git_stdout(&worktree_dir, &["rev-parse", "HEAD"]);
987
988 refresh_engineer_worktree(&repo, &worktree_dir, "eng-4", &team_config_dir).unwrap();
989
990 let after = git_stdout(&worktree_dir, &["rev-parse", "HEAD"]);
991 assert_eq!(before, after);
992 assert!(worktree_dir.exists());
993 }
994
995 #[test]
996 fn test_prepare_assignment_worktree_checks_out_task_branch_from_main() {
997 let tmp = tempfile::tempdir().unwrap();
998 let repo = init_git_repo(&tmp);
999 let worktree_dir = repo.join(".batty").join("worktrees").join("eng-5");
1000 let team_config_dir = repo.join(".batty").join("team_config");
1001
1002 prepare_engineer_assignment_worktree(
1003 &repo,
1004 &worktree_dir,
1005 "eng-5",
1006 "eng-5/123",
1007 &team_config_dir,
1008 )
1009 .unwrap();
1010
1011 assert_eq!(
1012 git_stdout(&worktree_dir, &["rev-parse", "--abbrev-ref", "HEAD"]),
1013 "eng-5/123"
1014 );
1015 assert_eq!(
1016 git_stdout(&repo, &["rev-parse", "main"]),
1017 git_stdout(&worktree_dir, &["rev-parse", "HEAD"])
1018 );
1019 assert!(worktree_dir.join(".batty").join("team_config").exists());
1020 }
1021
1022 #[test]
1023 fn test_prepare_assignment_worktree_auto_cleans_dirty() {
1024 let tmp = tempfile::tempdir().unwrap();
1025 let repo = init_git_repo(&tmp);
1026 let worktree_dir = repo.join(".batty").join("worktrees").join("eng-6");
1027 let team_config_dir = repo.join(".batty").join("team_config");
1028
1029 setup_engineer_worktree(
1030 &repo,
1031 &worktree_dir,
1032 &engineer_base_branch_name("eng-6"),
1033 &team_config_dir,
1034 )
1035 .unwrap();
1036 std::fs::write(worktree_dir.join("scratch.txt"), "uncommitted\n").unwrap();
1037
1038 prepare_engineer_assignment_worktree(
1040 &repo,
1041 &worktree_dir,
1042 "eng-6",
1043 "eng-6/7",
1044 &team_config_dir,
1045 )
1046 .unwrap();
1047
1048 assert!(!worktree_has_user_changes(&worktree_dir).unwrap());
1050
1051 let stash_list = git_stdout(&worktree_dir, &["stash", "list"]);
1053 assert!(
1054 stash_list.trim().is_empty(),
1055 "no stash should be created, changes should be auto-committed"
1056 );
1057 }
1058
1059 #[test]
1060 fn test_prepare_assignment_worktree_auto_migrates_clean_legacy_worktree_branch() {
1061 let tmp = tempfile::tempdir().unwrap();
1062 let repo = init_git_repo(&tmp);
1063 let worktree_dir = repo.join(".batty").join("worktrees").join("eng-6b");
1064 let team_config_dir = repo.join(".batty").join("team_config");
1065
1066 setup_engineer_worktree(&repo, &worktree_dir, "eng-6b", &team_config_dir).unwrap();
1067
1068 prepare_engineer_assignment_worktree(
1069 &repo,
1070 &worktree_dir,
1071 "eng-6b",
1072 "eng-6b/17",
1073 &team_config_dir,
1074 )
1075 .unwrap();
1076
1077 let legacy_check = git(&repo, &["rev-parse", "--verify", "eng-6b"]);
1078 assert!(!legacy_check.status.success());
1079 assert_eq!(
1080 git_stdout(&worktree_dir, &["rev-parse", "--abbrev-ref", "HEAD"]),
1081 "eng-6b/17"
1082 );
1083 assert_eq!(
1084 git_stdout(&repo, &["rev-parse", "--verify", "eng-main/eng-6b"]),
1085 git_stdout(&repo, &["rev-parse", "--verify", "main"])
1086 );
1087 }
1088
1089 #[test]
1090 fn test_prepare_assignment_worktree_deletes_merged_legacy_branch_namespace() {
1091 let tmp = tempfile::tempdir().unwrap();
1092 let repo = init_git_repo(&tmp);
1093 let worktree_dir = repo.join(".batty").join("worktrees").join("eng-7");
1094 let team_config_dir = repo.join(".batty").join("team_config");
1095
1096 git_ok(&repo, &["branch", "eng-7"]);
1097
1098 prepare_engineer_assignment_worktree(
1099 &repo,
1100 &worktree_dir,
1101 "eng-7",
1102 "eng-7/99",
1103 &team_config_dir,
1104 )
1105 .unwrap();
1106
1107 let legacy_check = git(&repo, &["rev-parse", "--verify", "eng-7"]);
1108 assert!(!legacy_check.status.success());
1109 assert_eq!(
1110 git_stdout(&worktree_dir, &["rev-parse", "--abbrev-ref", "HEAD"]),
1111 "eng-7/99"
1112 );
1113 }
1114
1115 #[test]
1116 fn test_prepare_assignment_worktree_archives_unmerged_legacy_branch_namespace() {
1117 let tmp = tempfile::tempdir().unwrap();
1118 let repo = init_git_repo(&tmp);
1119 let worktree_dir = repo.join(".batty").join("worktrees").join("eng-8");
1120 let team_config_dir = repo.join(".batty").join("team_config");
1121
1122 git_ok(&repo, &["checkout", "-b", "eng-8"]);
1123 std::fs::write(repo.join("legacy.txt"), "legacy branch work\n").unwrap();
1124 git_ok(&repo, &["add", "legacy.txt"]);
1125 git_ok(&repo, &["commit", "-m", "legacy work"]);
1126 git_ok(&repo, &["checkout", "main"]);
1127
1128 prepare_engineer_assignment_worktree(
1129 &repo,
1130 &worktree_dir,
1131 "eng-8",
1132 "eng-8/100",
1133 &team_config_dir,
1134 )
1135 .unwrap();
1136
1137 let legacy_check = git(&repo, &["rev-parse", "--verify", "eng-8"]);
1138 assert!(!legacy_check.status.success());
1139 assert!(!git_stdout(&repo, &["branch", "--list", "legacy/eng-8-*"]).is_empty());
1140 assert_eq!(
1141 git_stdout(&worktree_dir, &["rev-parse", "--abbrev-ref", "HEAD"]),
1142 "eng-8/100"
1143 );
1144 }
1145
1146 #[test]
1147 fn test_prepare_assignment_worktree_rejects_unregistered_existing_path() {
1148 let tmp = tempfile::tempdir().unwrap();
1149 let repo = init_git_repo(&tmp);
1150 let worktree_dir = repo.join(".batty").join("worktrees").join("eng-9");
1151 let team_config_dir = repo.join(".batty").join("team_config");
1152
1153 std::fs::create_dir_all(&worktree_dir).unwrap();
1154
1155 let err = prepare_engineer_assignment_worktree(
1156 &repo,
1157 &worktree_dir,
1158 "eng-9",
1159 "eng-9/1",
1160 &team_config_dir,
1161 )
1162 .unwrap_err();
1163
1164 assert!(
1165 err.to_string()
1166 .contains("not registered in git worktree list")
1167 );
1168 }
1169
1170 #[test]
1171 fn test_next_unclaimed_task_picks_highest_priority() {
1172 let tmp = tempfile::tempdir().unwrap();
1173 write_task_file(tmp.path(), 1, "low-task", "todo", "low", None, &[]);
1174 write_task_file(tmp.path(), 2, "high-task", "todo", "high", None, &[]);
1175 write_task_file(
1176 tmp.path(),
1177 3,
1178 "critical-task",
1179 "todo",
1180 "critical",
1181 None,
1182 &[],
1183 );
1184
1185 let task = next_unclaimed_task(tmp.path()).unwrap().unwrap();
1186 assert_eq!(task.id, 3);
1187 assert_eq!(task.title, "critical-task");
1188 }
1189
1190 #[test]
1191 fn test_next_unclaimed_task_skips_claimed() {
1192 let tmp = tempfile::tempdir().unwrap();
1193 write_task_file(
1194 tmp.path(),
1195 1,
1196 "claimed-task",
1197 "todo",
1198 "critical",
1199 Some("eng-1-1"),
1200 &[],
1201 );
1202 write_task_file(tmp.path(), 2, "open-task", "todo", "low", None, &[]);
1203
1204 let task = next_unclaimed_task(tmp.path()).unwrap().unwrap();
1205 assert_eq!(task.id, 2);
1206 assert_eq!(task.title, "open-task");
1207 }
1208
1209 #[test]
1210 fn test_next_unclaimed_task_skips_blocked_dependency() {
1211 let tmp = tempfile::tempdir().unwrap();
1212 write_task_file(tmp.path(), 1, "first-task", "backlog", "medium", None, &[]);
1213 write_task_file(tmp.path(), 2, "second-task", "todo", "critical", None, &[1]);
1214
1215 let task = next_unclaimed_task(tmp.path()).unwrap().unwrap();
1216 assert_eq!(task.id, 1);
1217 assert_eq!(task.title, "first-task");
1218 }
1219
1220 #[test]
1221 fn test_next_unclaimed_task_skips_blocked_on_frontmatter() {
1222 let tmp = tempfile::tempdir().unwrap();
1223 write_task_file_with_workflow_frontmatter(
1224 tmp.path(),
1225 1,
1226 "blocked-task",
1227 "blocked_on: waiting-for-review\n",
1228 );
1229 write_task_file(tmp.path(), 2, "open-task", "todo", "high", None, &[]);
1230
1231 let task = next_unclaimed_task(tmp.path()).unwrap().unwrap();
1232 assert_eq!(task.id, 2);
1233 assert_eq!(task.title, "open-task");
1234 }
1235
1236 #[test]
1237 fn test_next_unclaimed_task_returns_none_when_empty() {
1238 let tmp = tempfile::tempdir().unwrap();
1239 std::fs::create_dir_all(tmp.path().join("tasks")).unwrap();
1240
1241 let task = next_unclaimed_task(tmp.path()).unwrap();
1242 assert!(task.is_none());
1243 }
1244
1245 #[test]
1246 fn test_run_tests_in_worktree_returns_pass_fail() {
1247 let tmp = tempfile::tempdir().unwrap();
1248 let worktree = tmp.path();
1249 std::fs::create_dir_all(worktree.join("src")).unwrap();
1250 std::fs::write(
1251 worktree.join("Cargo.toml"),
1252 "[package]\nname = \"batty-testcrate\"\nversion = \"0.1.0\"\nedition = \"2024\"\n",
1253 )
1254 .unwrap();
1255
1256 std::fs::write(
1257 worktree.join("src").join("lib.rs"),
1258 "#[cfg(test)]\nmod tests {\n #[test]\n fn passes() {\n assert_eq!(2 + 2, 4);\n }\n}\n",
1259 )
1260 .unwrap();
1261 let (passed, output) = run_tests_in_worktree(worktree).unwrap();
1262 assert!(passed);
1263 assert!(output.contains("test result: ok"));
1264
1265 std::fs::write(
1266 worktree.join("src").join("lib.rs"),
1267 "#[cfg(test)]\nmod tests {\n #[test]\n fn fails() {\n assert_eq!(2 + 2, 5);\n }\n}\n",
1268 )
1269 .unwrap();
1270 let (passed, output) = run_tests_in_worktree(worktree).unwrap();
1271 assert!(!passed);
1272 assert!(output.contains("FAILED"));
1273 }
1274
1275 #[test]
1276 fn test_read_task_title_from_file() {
1277 let tmp = tempfile::tempdir().unwrap();
1278 let tasks_dir = tmp.path().join("tasks");
1279 std::fs::create_dir_all(&tasks_dir).unwrap();
1280 std::fs::write(
1281 tasks_dir.join("042-my-cool-task.md"),
1282 "---\ntitle: My Cool Task\nstatus: in-progress\npriority: high\n---\nBody here\n",
1283 )
1284 .unwrap();
1285 let title = read_task_title(tmp.path(), 42);
1286 assert_eq!(title, "My Cool Task");
1287 }
1288
1289 #[test]
1290 fn test_read_task_title_fallback() {
1291 let tmp = tempfile::tempdir().unwrap();
1292 let title = read_task_title(tmp.path(), 99);
1293 assert_eq!(title, "Task #99");
1294 }
1295
1296 #[test]
1297 fn production_task_loop_has_no_unwrap_or_expect_calls() {
1298 let count = production_unwrap_expect_count(Path::new(file!()));
1299 assert_eq!(
1300 count, 0,
1301 "production task_loop.rs should avoid unwrap/expect"
1302 );
1303 }
1304
1305 fn write_cron_task(board_dir: &Path, id: u32, status: &str, cron: &str, extra: &str) {
1308 let tasks_dir = board_dir.join("tasks");
1309 std::fs::create_dir_all(&tasks_dir).unwrap();
1310 let path = tasks_dir.join(format!("{id:03}-cron-task.md"));
1311 let content = format!(
1312 "---\nid: {id}\ntitle: Cron Task {id}\nstatus: {status}\npriority: medium\ncron_schedule: \"{cron}\"\n{extra}---\n\nCron task body.\n"
1313 );
1314 std::fs::write(path, content).unwrap();
1315 }
1316
1317 #[test]
1318 fn cron_recycle_resets_done_task_to_todo() {
1319 let tmp = tempfile::tempdir().unwrap();
1320 let board_dir = tmp.path();
1321 write_cron_task(
1322 board_dir,
1323 1,
1324 "done",
1325 "0 * * * * *",
1326 "cron_last_run: \"2020-01-01T00:00:00+00:00\"\n",
1327 );
1328
1329 let recycled = recycle_cron_tasks(board_dir).unwrap();
1330 assert_eq!(recycled.len(), 1);
1331 assert_eq!(recycled[0].0, 1);
1332
1333 let task = crate::task::Task::from_file(&board_dir.join("tasks").join("001-cron-task.md"))
1334 .unwrap();
1335 assert_eq!(task.status, "todo");
1336 assert!(task.cron_last_run.is_some(), "cron_last_run should be set");
1337 assert!(task.scheduled_for.is_some(), "scheduled_for should be set");
1338 assert!(task.claimed_by.is_none(), "claimed_by should be cleared");
1339 }
1340
1341 #[test]
1342 fn cron_recycle_skips_archived_task() {
1343 let tmp = tempfile::tempdir().unwrap();
1344 let board_dir = tmp.path();
1345 write_cron_task(
1346 board_dir,
1347 2,
1348 "done",
1349 "0 * * * * *",
1350 "cron_last_run: \"2020-01-01T00:00:00+00:00\"\ntags:\n - archived\n",
1351 );
1352
1353 let recycled = recycle_cron_tasks(board_dir).unwrap();
1354 assert!(recycled.is_empty(), "archived tasks should be skipped");
1355 }
1356
1357 #[test]
1358 fn cron_recycle_skips_in_progress_task() {
1359 let tmp = tempfile::tempdir().unwrap();
1360 let board_dir = tmp.path();
1361 write_cron_task(
1362 board_dir,
1363 3,
1364 "in-progress",
1365 "0 * * * * *",
1366 "cron_last_run: \"2020-01-01T00:00:00+00:00\"\n",
1367 );
1368
1369 let recycled = recycle_cron_tasks(board_dir).unwrap();
1370 assert!(recycled.is_empty(), "in-progress tasks should be skipped");
1371 }
1372
1373 #[test]
1374 fn cron_recycle_missed_trigger_skips_to_next_future() {
1375 let tmp = tempfile::tempdir().unwrap();
1376 let board_dir = tmp.path();
1377 write_cron_task(
1378 board_dir,
1379 4,
1380 "done",
1381 "0 * * * * *",
1382 "cron_last_run: \"2020-01-01T00:00:00+00:00\"\n",
1383 );
1384
1385 let recycled = recycle_cron_tasks(board_dir).unwrap();
1386 assert_eq!(recycled.len(), 1);
1387
1388 let task = crate::task::Task::from_file(&board_dir.join("tasks").join("004-cron-task.md"))
1389 .unwrap();
1390 assert_eq!(task.status, "todo");
1391
1392 let scheduled = task.scheduled_for.as_deref().unwrap();
1393 let scheduled_dt = chrono::DateTime::parse_from_rfc3339(scheduled).unwrap();
1394 assert!(
1395 scheduled_dt > chrono::Utc::now(),
1396 "scheduled_for should be in the future, got: {scheduled}"
1397 );
1398 }
1399
1400 #[test]
1401 fn cron_recycle_clears_transient_fields() {
1402 let tmp = tempfile::tempdir().unwrap();
1403 let board_dir = tmp.path();
1404 write_cron_task(
1405 board_dir,
1406 5,
1407 "done",
1408 "0 * * * * *",
1409 "cron_last_run: \"2020-01-01T00:00:00+00:00\"\nclaimed_by: eng-1-1\nbranch: eng-1-1/5\ncommit: abc123\nnext_action: review\nreview_owner: manager\nblocked_on: other\nworktree_path: /tmp/wt\n",
1410 );
1411
1412 let recycled = recycle_cron_tasks(board_dir).unwrap();
1413 assert_eq!(recycled.len(), 1);
1414
1415 let task = crate::task::Task::from_file(&board_dir.join("tasks").join("005-cron-task.md"))
1416 .unwrap();
1417 assert!(task.claimed_by.is_none());
1418 assert!(task.branch.is_none());
1419 assert!(task.commit.is_none());
1420 assert!(task.next_action.is_none());
1421 assert!(task.review_owner.is_none());
1422 assert!(task.blocked_on.is_none());
1423 assert!(task.worktree_path.is_none());
1424 }
1425
1426 #[test]
1427 fn cron_recycle_emits_event() {
1428 use crate::team::events::TeamEvent;
1429
1430 let event = TeamEvent::task_recycled(42, "0 9 * * 1");
1431 assert_eq!(event.event, "task_recycled");
1432 assert_eq!(event.task.as_deref(), Some("#42"));
1433 assert_eq!(event.reason.as_deref(), Some("0 9 * * 1"));
1434 }
1435
1436 #[test]
1437 fn task_recycled_event_format() {
1438 use crate::team::events::TeamEvent;
1439
1440 let event = TeamEvent::task_recycled(7, "30 8 * * *");
1441 let json = serde_json::to_string(&event).unwrap();
1442 assert!(json.contains("\"event\":\"task_recycled\""));
1443 assert!(json.contains("\"task\":\"#7\""));
1444 assert!(json.contains("\"reason\":\"30 8 * * *\""));
1445 }
1446
1447 #[test]
1450 fn cron_recycler_integration_resets_done_task() {
1451 let tmp = tempfile::tempdir().unwrap();
1452 let board_dir = tmp.path();
1453
1454 let two_min_ago = (chrono::Utc::now() - chrono::Duration::minutes(2)).to_rfc3339();
1456 write_cron_task(
1457 board_dir,
1458 10,
1459 "done",
1460 "0 * * * * *",
1461 &format!(
1462 "cron_last_run: \"{two_min_ago}\"\nclaimed_by: eng-1-1\nbranch: eng-1-1/10\ncommit: deadbeef\nnext_action: review\nreview_owner: manager\nblocked_on: other\nworktree_path: /tmp/wt\n"
1463 ),
1464 );
1465
1466 let recycled = recycle_cron_tasks(board_dir).unwrap();
1467 assert_eq!(recycled.len(), 1, "done cron task should be recycled");
1468 assert_eq!(recycled[0].0, 10);
1469
1470 let task = crate::task::Task::from_file(&board_dir.join("tasks").join("010-cron-task.md"))
1471 .unwrap();
1472
1473 assert_eq!(task.status, "todo");
1475
1476 let scheduled = task
1478 .scheduled_for
1479 .as_deref()
1480 .expect("scheduled_for should be set");
1481 let scheduled_dt = chrono::DateTime::parse_from_rfc3339(scheduled).unwrap();
1482 assert!(
1483 scheduled_dt > chrono::Utc::now(),
1484 "scheduled_for should be in the future, got: {scheduled}"
1485 );
1486
1487 let last_run = task
1489 .cron_last_run
1490 .as_deref()
1491 .expect("cron_last_run should be set");
1492 let last_run_dt = chrono::DateTime::parse_from_rfc3339(last_run).unwrap();
1493 let two_min_ago_dt = chrono::DateTime::parse_from_rfc3339(&two_min_ago).unwrap();
1494 assert!(
1495 last_run_dt > two_min_ago_dt,
1496 "cron_last_run should be updated to now, not the old value"
1497 );
1498
1499 assert!(task.claimed_by.is_none(), "claimed_by should be cleared");
1501 assert!(task.branch.is_none(), "branch should be cleared");
1502 assert!(task.commit.is_none(), "commit should be cleared");
1503 assert!(task.next_action.is_none(), "next_action should be cleared");
1504 assert!(
1505 task.review_owner.is_none(),
1506 "review_owner should be cleared"
1507 );
1508 assert!(task.blocked_on.is_none(), "blocked_on should be cleared");
1509 assert!(
1510 task.worktree_path.is_none(),
1511 "worktree_path should be cleared"
1512 );
1513 }
1514
1515 #[test]
1516 fn cron_recycler_skips_non_cron_done_task() {
1517 let tmp = tempfile::tempdir().unwrap();
1518 let board_dir = tmp.path();
1519
1520 let tasks_dir = board_dir.join("tasks");
1522 std::fs::create_dir_all(&tasks_dir).unwrap();
1523 let path = tasks_dir.join("011-regular-task.md");
1524 std::fs::write(
1525 &path,
1526 "---\nid: 11\ntitle: Regular Task\nstatus: done\npriority: medium\n---\n\nNon-cron task.\n",
1527 )
1528 .unwrap();
1529
1530 let recycled = recycle_cron_tasks(board_dir).unwrap();
1531 assert!(
1532 recycled.is_empty(),
1533 "non-cron done task should not be recycled"
1534 );
1535
1536 let task = crate::task::Task::from_file(&path).unwrap();
1538 assert_eq!(task.status, "done", "status should remain done");
1539 }
1540
1541 #[test]
1542 fn e2e_done_cron_task_recycled() {
1543 use crate::team::resolver::{ResolutionStatus, resolve_board};
1544 use crate::team::test_support::{engineer_member, manager_member};
1545
1546 let tmp = tempfile::tempdir().unwrap();
1547 let board_dir = tmp.path();
1548
1549 write_cron_task(
1551 board_dir,
1552 10,
1553 "done",
1554 "0 * * * * *",
1555 "cron_last_run: \"2020-01-01T00:00:00+00:00\"\n",
1556 );
1557
1558 let members = vec![
1560 manager_member("manager", None),
1561 engineer_member("eng-1", Some("manager"), false),
1562 ];
1563 let resolutions_before = resolve_board(board_dir, &members).unwrap();
1564 assert!(
1565 resolutions_before.is_empty(),
1566 "done task should not appear in resolve_board"
1567 );
1568
1569 let recycled = recycle_cron_tasks(board_dir).unwrap();
1571 assert_eq!(recycled.len(), 1, "one task should be recycled");
1572 assert_eq!(recycled[0].0, 10);
1573
1574 let task = crate::task::Task::from_file(&board_dir.join("tasks").join("010-cron-task.md"))
1576 .unwrap();
1577 assert_eq!(task.status, "todo", "status should be reset to todo");
1578 assert!(task.claimed_by.is_none(), "claimed_by should be cleared");
1579 assert!(
1580 task.cron_last_run.is_some(),
1581 "cron_last_run should be updated"
1582 );
1583
1584 let scheduled = task.scheduled_for.as_deref().unwrap();
1586 let scheduled_dt = chrono::DateTime::parse_from_rfc3339(scheduled).unwrap();
1587 assert!(
1588 scheduled_dt > chrono::Utc::now(),
1589 "scheduled_for should be in the future, got: {scheduled}"
1590 );
1591
1592 let resolutions_after = resolve_board(board_dir, &members).unwrap();
1594 assert_eq!(resolutions_after.len(), 1);
1595 assert_eq!(
1596 resolutions_after[0].status,
1597 ResolutionStatus::Blocked,
1598 "recycled cron task with future scheduled_for should be Blocked until its time"
1599 );
1600 assert!(
1601 resolutions_after[0]
1602 .blocking_reason
1603 .as_ref()
1604 .unwrap()
1605 .contains("scheduled for"),
1606 "blocking reason should mention 'scheduled for'"
1607 );
1608 }
1609
1610 #[test]
1613 fn safe_to_mutate_nonexistent_dir() {
1614 let tmp = tempfile::tempdir().unwrap();
1615 let missing = tmp.path().join("does-not-exist");
1616 assert!(is_worktree_safe_to_mutate(&missing).unwrap());
1617 }
1618
1619 #[test]
1620 fn safe_to_mutate_clean_worktree() {
1621 let tmp = tempfile::tempdir().unwrap();
1622 let repo = init_git_repo(&tmp);
1623 let wt_dir = repo.join(".batty").join("worktrees").join("eng-safe");
1624 let team_config_dir = repo.join(".batty").join("team_config");
1625
1626 prepare_engineer_assignment_worktree(
1627 &repo,
1628 &wt_dir,
1629 "eng-safe",
1630 "eng-safe/99",
1631 &team_config_dir,
1632 )
1633 .unwrap();
1634
1635 assert!(is_worktree_safe_to_mutate(&wt_dir).unwrap());
1637 }
1638
1639 #[test]
1640 fn unsafe_to_mutate_dirty_task_branch() {
1641 let tmp = tempfile::tempdir().unwrap();
1642 let repo = init_git_repo(&tmp);
1643 let wt_dir = repo.join(".batty").join("worktrees").join("eng-dirty");
1644 let team_config_dir = repo.join(".batty").join("team_config");
1645
1646 prepare_engineer_assignment_worktree(
1647 &repo,
1648 &wt_dir,
1649 "eng-dirty",
1650 "eng-dirty/42",
1651 &team_config_dir,
1652 )
1653 .unwrap();
1654
1655 std::fs::write(wt_dir.join("wip.txt"), "work in progress\n").unwrap();
1657 git_ok(&wt_dir, &["add", "wip.txt"]);
1658
1659 assert!(!is_worktree_safe_to_mutate(&wt_dir).unwrap());
1661 }
1662
1663 #[test]
1664 fn safe_to_mutate_dirty_base_branch() {
1665 let tmp = tempfile::tempdir().unwrap();
1666 let repo = init_git_repo(&tmp);
1667 let wt_dir = repo.join(".batty").join("worktrees").join("eng-base");
1668 let team_config_dir = repo.join(".batty").join("team_config");
1669
1670 let base = engineer_base_branch_name("eng-base");
1671 setup_engineer_worktree(&repo, &wt_dir, &base, &team_config_dir).unwrap();
1672
1673 std::fs::write(wt_dir.join("junk.txt"), "junk\n").unwrap();
1674 git_ok(&wt_dir, &["add", "junk.txt"]);
1675
1676 assert!(is_worktree_safe_to_mutate(&wt_dir).unwrap());
1678 }
1679
1680 #[test]
1681 fn unsafe_to_mutate_dirty_untracked_files_on_task_branch() {
1682 let tmp = tempfile::tempdir().unwrap();
1683 let repo = init_git_repo(&tmp);
1684 let wt_dir = repo.join(".batty").join("worktrees").join("eng-ut");
1685 let team_config_dir = repo.join(".batty").join("team_config");
1686
1687 prepare_engineer_assignment_worktree(
1688 &repo,
1689 &wt_dir,
1690 "eng-ut",
1691 "eng-ut/55",
1692 &team_config_dir,
1693 )
1694 .unwrap();
1695
1696 std::fs::write(wt_dir.join("new_file.rs"), "fn main() {}\n").unwrap();
1698
1699 assert!(!is_worktree_safe_to_mutate(&wt_dir).unwrap());
1700 }
1701
1702 #[test]
1703 fn safe_to_mutate_only_batty_untracked() {
1704 let tmp = tempfile::tempdir().unwrap();
1705 let repo = init_git_repo(&tmp);
1706 let wt_dir = repo.join(".batty").join("worktrees").join("eng-bt");
1707 let team_config_dir = repo.join(".batty").join("team_config");
1708
1709 prepare_engineer_assignment_worktree(
1710 &repo,
1711 &wt_dir,
1712 "eng-bt",
1713 "eng-bt/33",
1714 &team_config_dir,
1715 )
1716 .unwrap();
1717
1718 std::fs::create_dir_all(wt_dir.join(".batty").join("temp")).unwrap();
1720 std::fs::write(wt_dir.join(".batty").join("temp").join("log.txt"), "log\n").unwrap();
1721
1722 assert!(is_worktree_safe_to_mutate(&wt_dir).unwrap());
1723 }
1724
1725 #[test]
1728 fn auto_commit_saves_uncommitted_changes() {
1729 let tmp = tempfile::tempdir().unwrap();
1730 let repo = init_git_repo(&tmp);
1731 let wt_dir = repo.join(".batty").join("worktrees").join("eng-ac");
1732 let team_config_dir = repo.join(".batty").join("team_config");
1733
1734 prepare_engineer_assignment_worktree(
1735 &repo,
1736 &wt_dir,
1737 "eng-ac",
1738 "eng-ac/77",
1739 &team_config_dir,
1740 )
1741 .unwrap();
1742
1743 std::fs::write(wt_dir.join("work.rs"), "fn hello() {}\n").unwrap();
1745 git_ok(&wt_dir, &["add", "work.rs"]);
1746
1747 assert!(auto_commit_before_reset(&wt_dir));
1748
1749 let status = git_stdout(&wt_dir, &["status", "--porcelain"]);
1751 assert!(
1752 status.trim().is_empty(),
1753 "worktree should be clean after auto-commit"
1754 );
1755
1756 let log = git_stdout(&wt_dir, &["log", "--oneline", "-1"]);
1758 assert!(
1759 log.contains("wip: auto-save"),
1760 "commit should have wip marker, got: {log}"
1761 );
1762 }
1763
1764 #[test]
1765 fn auto_commit_noop_on_clean_worktree() {
1766 let tmp = tempfile::tempdir().unwrap();
1767 let repo = init_git_repo(&tmp);
1768 let wt_dir = repo.join(".batty").join("worktrees").join("eng-cl");
1769 let team_config_dir = repo.join(".batty").join("team_config");
1770
1771 prepare_engineer_assignment_worktree(
1772 &repo,
1773 &wt_dir,
1774 "eng-cl",
1775 "eng-cl/88",
1776 &team_config_dir,
1777 )
1778 .unwrap();
1779
1780 let before = git_stdout(&wt_dir, &["rev-parse", "HEAD"]);
1781
1782 assert!(auto_commit_before_reset(&wt_dir));
1784
1785 let after = git_stdout(&wt_dir, &["rev-parse", "HEAD"]);
1786 assert_eq!(
1787 before, after,
1788 "no new commit should be created for clean worktree"
1789 );
1790 }
1791
1792 #[test]
1793 fn auto_commit_saves_untracked_files() {
1794 let tmp = tempfile::tempdir().unwrap();
1795 let repo = init_git_repo(&tmp);
1796 let wt_dir = repo.join(".batty").join("worktrees").join("eng-ut2");
1797 let team_config_dir = repo.join(".batty").join("team_config");
1798
1799 prepare_engineer_assignment_worktree(
1800 &repo,
1801 &wt_dir,
1802 "eng-ut2",
1803 "eng-ut2/99",
1804 &team_config_dir,
1805 )
1806 .unwrap();
1807
1808 std::fs::write(wt_dir.join("new_file.txt"), "new content\n").unwrap();
1810
1811 assert!(auto_commit_before_reset(&wt_dir));
1812
1813 let status = git_stdout(&wt_dir, &["status", "--porcelain"]);
1815 assert!(
1816 status.trim().is_empty(),
1817 "worktree should be clean after auto-commit"
1818 );
1819 }
1820
1821 #[test]
1822 fn auto_clean_worktree_uses_commit_not_stash() {
1823 let tmp = tempfile::tempdir().unwrap();
1824 let repo = init_git_repo(&tmp);
1825 let wt_dir = repo.join(".batty").join("worktrees").join("eng-ns");
1826 let team_config_dir = repo.join(".batty").join("team_config");
1827
1828 prepare_engineer_assignment_worktree(
1829 &repo,
1830 &wt_dir,
1831 "eng-ns",
1832 "eng-ns/66",
1833 &team_config_dir,
1834 )
1835 .unwrap();
1836
1837 std::fs::write(wt_dir.join("work.txt"), "some work\n").unwrap();
1839
1840 auto_clean_worktree(&wt_dir).unwrap();
1841
1842 let status = git_stdout(&wt_dir, &["status", "--porcelain"]);
1844 assert!(status.trim().is_empty(), "worktree should be clean");
1845
1846 let stash = git_stdout(&wt_dir, &["stash", "list"]);
1848 assert!(
1849 stash.trim().is_empty(),
1850 "no stash should be created, got: {stash}"
1851 );
1852
1853 let log = git_stdout(&wt_dir, &["log", "--oneline", "-1"]);
1855 assert!(
1856 log.contains("wip: auto-save"),
1857 "should have wip commit, got: {log}"
1858 );
1859 }
1860
1861 #[test]
1864 fn priority_rank_known_values() {
1865 assert_eq!(priority_rank("critical"), 0);
1866 assert_eq!(priority_rank("high"), 1);
1867 assert_eq!(priority_rank("medium"), 2);
1868 assert_eq!(priority_rank("low"), 3);
1869 }
1870
1871 #[test]
1872 fn priority_rank_unknown_returns_lowest() {
1873 assert_eq!(priority_rank(""), 4);
1874 assert_eq!(priority_rank("urgent"), 4);
1875 assert_eq!(priority_rank("CRITICAL"), 4); }
1877
1878 #[test]
1881 fn next_unclaimed_task_all_done_returns_none() {
1882 let tmp = tempfile::tempdir().unwrap();
1883 write_task_file(tmp.path(), 1, "done-task", "done", "high", None, &[]);
1884 write_task_file(
1885 tmp.path(),
1886 2,
1887 "in-progress-task",
1888 "in-progress",
1889 "critical",
1890 None,
1891 &[],
1892 );
1893
1894 let task = next_unclaimed_task(tmp.path()).unwrap();
1895 assert!(task.is_none());
1896 }
1897
1898 #[test]
1899 fn next_unclaimed_task_respects_backlog_status() {
1900 let tmp = tempfile::tempdir().unwrap();
1901 write_task_file(
1902 tmp.path(),
1903 1,
1904 "backlog-task",
1905 "backlog",
1906 "medium",
1907 None,
1908 &[],
1909 );
1910
1911 let task = next_unclaimed_task(tmp.path()).unwrap().unwrap();
1912 assert_eq!(task.id, 1);
1913 }
1914
1915 #[test]
1916 fn next_unclaimed_task_tiebreaks_by_id() {
1917 let tmp = tempfile::tempdir().unwrap();
1918 write_task_file(tmp.path(), 10, "task-ten", "todo", "high", None, &[]);
1919 write_task_file(tmp.path(), 5, "task-five", "todo", "high", None, &[]);
1920 write_task_file(tmp.path(), 20, "task-twenty", "todo", "high", None, &[]);
1921
1922 let task = next_unclaimed_task(tmp.path()).unwrap().unwrap();
1923 assert_eq!(task.id, 5, "should pick lowest id when priority is tied");
1924 }
1925
1926 #[test]
1927 fn next_unclaimed_task_skips_blocked_frontmatter() {
1928 let tmp = tempfile::tempdir().unwrap();
1929 write_task_file_with_workflow_frontmatter(tmp.path(), 1, "blocked-task", "blocked: yes\n");
1930 write_task_file(tmp.path(), 2, "free-task", "todo", "low", None, &[]);
1931
1932 let task = next_unclaimed_task(tmp.path()).unwrap().unwrap();
1933 assert_eq!(task.id, 2);
1934 }
1935
1936 #[test]
1937 fn next_unclaimed_task_allows_done_dependency() {
1938 let tmp = tempfile::tempdir().unwrap();
1939 write_task_file(tmp.path(), 1, "done-dep", "done", "low", None, &[]);
1940 write_task_file(tmp.path(), 2, "depends-on-done", "todo", "high", None, &[1]);
1941
1942 let task = next_unclaimed_task(tmp.path()).unwrap().unwrap();
1943 assert_eq!(task.id, 2, "task with done dependency should be available");
1944 }
1945
1946 #[test]
1947 fn next_unclaimed_task_blocks_on_undone_dependency() {
1948 let tmp = tempfile::tempdir().unwrap();
1949 write_task_file(
1950 tmp.path(),
1951 1,
1952 "in-progress-dep",
1953 "in-progress",
1954 "low",
1955 None,
1956 &[],
1957 );
1958 write_task_file(
1959 tmp.path(),
1960 2,
1961 "blocked-by-dep",
1962 "todo",
1963 "critical",
1964 None,
1965 &[1],
1966 );
1967
1968 let task = next_unclaimed_task(tmp.path()).unwrap();
1970 assert!(
1971 task.is_none(),
1972 "task with in-progress dependency should not be available"
1973 );
1974 }
1975
1976 #[test]
1977 fn next_unclaimed_task_nonexistent_dependency_treated_as_done() {
1978 let tmp = tempfile::tempdir().unwrap();
1979 write_task_file(tmp.path(), 1, "orphan-dep", "todo", "high", None, &[999]);
1981
1982 let task = next_unclaimed_task(tmp.path()).unwrap().unwrap();
1983 assert_eq!(task.id, 1);
1984 }
1985
1986 #[test]
1989 fn read_task_title_quoted_title() {
1990 let tmp = tempfile::tempdir().unwrap();
1991 let tasks_dir = tmp.path().join("tasks");
1992 std::fs::create_dir_all(&tasks_dir).unwrap();
1993 std::fs::write(
1994 tasks_dir.join("007-quoted.md"),
1995 "---\ntitle: 'My Quoted Task'\nstatus: todo\n---\nBody\n",
1996 )
1997 .unwrap();
1998 let title = read_task_title(tmp.path(), 7);
1999 assert_eq!(title, "My Quoted Task");
2000 }
2001
2002 #[test]
2003 fn read_task_title_double_quoted() {
2004 let tmp = tempfile::tempdir().unwrap();
2005 let tasks_dir = tmp.path().join("tasks");
2006 std::fs::create_dir_all(&tasks_dir).unwrap();
2007 std::fs::write(
2008 tasks_dir.join("008-double.md"),
2009 "---\ntitle: \"Double Quoted\"\nstatus: todo\n---\nBody\n",
2010 )
2011 .unwrap();
2012 let title = read_task_title(tmp.path(), 8);
2013 assert_eq!(title, "Double Quoted");
2014 }
2015
2016 #[test]
2017 fn read_task_title_no_title_line_returns_fallback() {
2018 let tmp = tempfile::tempdir().unwrap();
2019 let tasks_dir = tmp.path().join("tasks");
2020 std::fs::create_dir_all(&tasks_dir).unwrap();
2021 std::fs::write(
2022 tasks_dir.join("009-no-title.md"),
2023 "---\nstatus: todo\npriority: low\n---\nBody\n",
2024 )
2025 .unwrap();
2026 let title = read_task_title(tmp.path(), 9);
2027 assert_eq!(title, "Task #9");
2028 }
2029
2030 #[test]
2031 fn read_task_title_three_digit_id_prefix() {
2032 let tmp = tempfile::tempdir().unwrap();
2033 let tasks_dir = tmp.path().join("tasks");
2034 std::fs::create_dir_all(&tasks_dir).unwrap();
2035 std::fs::write(
2036 tasks_dir.join("123-big-id.md"),
2037 "---\ntitle: Big ID Task\nstatus: todo\n---\n",
2038 )
2039 .unwrap();
2040 let title = read_task_title(tmp.path(), 123);
2041 assert_eq!(title, "Big ID Task");
2042 }
2043
2044 #[test]
2047 fn engineer_base_branch_name_format() {
2048 assert_eq!(engineer_base_branch_name("eng-1-1"), "eng-main/eng-1-1");
2049 assert_eq!(engineer_base_branch_name("eng-2"), "eng-main/eng-2");
2050 }
2051
2052 #[test]
2055 fn map_git_error_ok_passes_through() {
2056 let result: std::result::Result<i32, super::git_cmd::GitError> = Ok(42);
2057 let mapped = map_git_error(result, "test action");
2058 assert_eq!(mapped.unwrap(), 42);
2059 }
2060
2061 #[test]
2062 fn map_git_error_err_wraps_message() {
2063 let result: std::result::Result<i32, super::git_cmd::GitError> =
2064 Err(super::git_cmd::GitError::Permanent {
2065 message: "git status failed".to_string(),
2066 stderr: "fatal: something".to_string(),
2067 });
2068 let err = map_git_error(result, "checking status").unwrap_err();
2069 let msg = err.to_string();
2070 assert!(msg.contains("checking status"), "got: {msg}");
2071 }
2072
2073 #[test]
2076 fn cron_recycle_invalid_expression_skips() {
2077 let tmp = tempfile::tempdir().unwrap();
2078 write_cron_task(
2079 tmp.path(),
2080 1,
2081 "done",
2082 "not a cron expression",
2083 "cron_last_run: \"2020-01-01T00:00:00+00:00\"\n",
2084 );
2085
2086 let recycled = recycle_cron_tasks(tmp.path()).unwrap();
2087 assert!(
2088 recycled.is_empty(),
2089 "invalid cron expression should be skipped"
2090 );
2091 }
2092
2093 #[test]
2094 fn cron_recycle_no_last_run_defaults_to_yesterday() {
2095 let tmp = tempfile::tempdir().unwrap();
2096 write_cron_task(tmp.path(), 1, "done", "0 * * * * *", "");
2098
2099 let recycled = recycle_cron_tasks(tmp.path()).unwrap();
2100 assert_eq!(
2101 recycled.len(),
2102 1,
2103 "should recycle even without cron_last_run"
2104 );
2105 }
2106
2107 #[test]
2108 fn cron_recycle_future_trigger_skips() {
2109 let tmp = tempfile::tempdir().unwrap();
2110 let now = chrono::Utc::now().to_rfc3339();
2112 write_cron_task(
2113 tmp.path(),
2114 1,
2115 "done",
2116 "0 0 1 1 * 2099",
2117 &format!("cron_last_run: \"{now}\"\n"),
2118 );
2119
2120 let recycled = recycle_cron_tasks(tmp.path()).unwrap();
2121 assert!(recycled.is_empty(), "future trigger should be skipped");
2122 }
2123
2124 #[test]
2128 fn refresh_nonexistent_worktree_returns_ok() {
2129 let tmp = tempfile::tempdir().unwrap();
2130 let fake_worktree = tmp.path().join("does-not-exist");
2131 let team_cfg = tmp.path().join("team_config");
2132 std::fs::create_dir_all(&team_cfg).unwrap();
2133
2134 let result = refresh_engineer_worktree(tmp.path(), &fake_worktree, "no-branch", &team_cfg);
2135 assert!(
2137 result.is_ok(),
2138 "refresh on nonexistent worktree should not panic: {result:?}"
2139 );
2140 }
2141
2142 #[test]
2145 fn test_gating_missing_dir_returns_error() {
2146 let fake_dir = Path::new("/tmp/batty-nonexistent-worktree-test-311");
2147 let result = run_tests_in_worktree(fake_dir);
2148 assert!(
2150 result.is_err(),
2151 "run_tests_in_worktree on missing dir should return Err"
2152 );
2153 let err_msg = format!("{:#}", result.unwrap_err());
2154 assert!(
2155 err_msg.contains("cargo test") || err_msg.contains("failed"),
2156 "error should describe the failed cargo test operation, got: {err_msg}"
2157 );
2158 }
2159
2160 #[test]
2163 fn checkout_branch_in_non_git_dir_returns_error() {
2164 let tmp = tempfile::tempdir().unwrap();
2165 let result = checkout_worktree_branch_from_main(tmp.path(), "fake-branch");
2167 assert!(
2168 result.is_err(),
2169 "checkout on non-git dir should return Err, not panic"
2170 );
2171 }
2172
2173 #[test]
2176 fn no_panicking_unwraps_in_production_code() {
2177 let count = production_unwrap_expect_count(Path::new("src/team/task_loop.rs"));
2178 assert_eq!(
2179 count, 0,
2180 "production code should have zero bare .unwrap()/.expect() calls, found {count}"
2181 );
2182 }
2183}