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