Skip to main content

batty_cli/team/
task_loop.rs

1//! Task-loop helpers extracted from the team daemon.
2
3use 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
122/// Set up a git worktree for an engineer with symlinked shared config.
123pub(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
213/// Set up worktrees for a multi-repo project. Creates one git worktree per
214/// sub-repo inside `worktree_dir`, mirroring the original directory layout.
215pub(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
235/// Prepare worktrees for a multi-repo task assignment. Creates task branches
236/// in every sub-repo so the engineer can work across all of them.
237pub(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)] // Retained for existing tests and as a lower-level helper.
284pub(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
481/// Returns `false` if the worktree has uncommitted changes on a task branch
482/// (i.e. not an `eng-main/*` base branch). This gate should be checked before
483/// any operation that would destroy worktree state (reset, clean, checkout).
484pub(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), // Can't determine branch — allow mutation
500    };
501
502    // eng-main/* branches are base branches with no user work worth preserving.
503    if branch.starts_with("eng-main/") {
504        return Ok(true);
505    }
506
507    // Task branch with uncommitted changes — NOT safe to mutate.
508    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    // Try commit first — preserves work in git history (no stash accumulation).
518    if auto_commit_before_reset(worktree_dir) {
519        return Ok(());
520    }
521
522    // Commit failed — fall back to force clean.
523    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
539/// Auto-commit uncommitted changes before a worktree reset to avoid stash
540/// accumulation. Returns `true` if changes were successfully committed or
541/// there was nothing to commit.
542pub(crate) fn auto_commit_before_reset(worktree_dir: &Path) -> bool {
543    // Check for user changes first (excludes .batty/ untracked files).
544    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    // Stage all changes including untracked files.
553    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    // Build a descriptive commit message.
562    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
737/// Recycle done cron tasks back to todo when their next occurrence is due.
738///
739/// Returns a list of (task_id, cron_expression) for each recycled task.
740pub(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        // Skip non-done tasks
757        if task.status != "done" {
758            continue;
759        }
760
761        // Skip tasks without a cron schedule
762        let cron_expr = match &task.cron_schedule {
763            Some(expr) => expr.clone(),
764            None => continue,
765        };
766
767        // Skip archived tasks
768        if task.tags.iter().any(|t| t == "archived") {
769            continue;
770        }
771
772        // Parse the cron expression
773        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        // Determine the reference point: cron_last_run or now - 1 day
782        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        // Find next occurrence after reference
790        let next = match schedule.after(&reference).next() {
791            Some(dt) => dt,
792            None => continue,
793        };
794
795        // If next occurrence is in the future, skip
796        if next > now {
797            continue;
798        }
799
800        // Compute next FUTURE occurrence for scheduled_for
801        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            // Set status to todo
809            mapping.insert(yaml_key("status"), Value::String("todo".to_string()));
810
811            // Update scheduled_for to next future occurrence
812            set_optional_string(mapping, "scheduled_for", next_future.as_deref());
813
814            // Update cron_last_run to now
815            set_optional_string(mapping, "cron_last_run", Some(&now_str));
816
817            // Clear transient fields
818            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        // Should succeed — auto-clean commits the dirty file.
1044        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        // Worktree should be clean now.
1054        assert!(!worktree_has_user_changes(&worktree_dir).unwrap());
1055
1056        // No stash should be created (commit-before-reset discipline).
1057        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    // -- Cron recycling tests --
1335
1336    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    // -- Integration tests --
1477
1478    #[test]
1479    fn cron_recycler_integration_resets_done_task() {
1480        let tmp = tempfile::tempdir().unwrap();
1481        let board_dir = tmp.path();
1482
1483        // cron_last_run 2 minutes ago — next minutely trigger is already past
1484        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        // Status reset to todo
1503        assert_eq!(task.status, "todo");
1504
1505        // scheduled_for set to a future time
1506        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        // cron_last_run updated (should be more recent than 2 min ago)
1517        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        // Transient fields cleared
1529        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        // Done task WITHOUT cron_schedule
1550        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        // Verify task unchanged
1566        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        // Create a done cron task with old cron_last_run
1579        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        // Before recycling: task is done, so resolve_board excludes it
1588        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        // Recycle the cron task
1599        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        // Verify task file was updated
1604        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        // scheduled_for should be set to a future time
1614        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        // After recycling: task is now todo with future scheduled_for → Blocked
1622        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    // --- is_worktree_safe_to_mutate tests ---
1640
1641    #[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        // No uncommitted changes — safe to mutate.
1665        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        // Create uncommitted changes.
1685        std::fs::write(wt_dir.join("wip.txt"), "work in progress\n").unwrap();
1686        git_ok(&wt_dir, &["add", "wip.txt"]);
1687
1688        // Dirty task branch — NOT safe.
1689        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        // Dirty but on eng-main/* — safe to mutate.
1706        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        // Untracked file (not in .batty/) counts as user changes.
1726        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        // Only .batty/ untracked files — not user changes, safe.
1748        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    // --- auto_commit_before_reset tests ---
1755
1756    #[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        // Create uncommitted changes.
1773        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        // Worktree should now be clean.
1779        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        // Verify the commit message contains the wip marker.
1786        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        // No changes — should succeed without creating a commit.
1812        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        // Create untracked file (not staged).
1838        std::fs::write(wt_dir.join("new_file.txt"), "new content\n").unwrap();
1839
1840        assert!(auto_commit_before_reset(&wt_dir));
1841
1842        // Worktree should be clean.
1843        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        // Create uncommitted changes.
1867        std::fs::write(wt_dir.join("work.txt"), "some work\n").unwrap();
1868
1869        auto_clean_worktree(&wt_dir).unwrap();
1870
1871        // Should be clean.
1872        let status = git_stdout(&wt_dir, &["status", "--porcelain"]);
1873        assert!(status.trim().is_empty(), "worktree should be clean");
1874
1875        // No stashes should have been created.
1876        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        // A wip commit should exist.
1883        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    // --- priority_rank tests ---
1891
1892    #[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); // case-sensitive
1905    }
1906
1907    // --- next_unclaimed_task edge cases ---
1908
1909    #[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        // Task 2 depends on task 1 which is in-progress — should not be picked
1998        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        // Task depends on id 999 which doesn't exist — treated as satisfied
2009        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    // --- read_task_title edge cases ---
2016
2017    #[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    // --- engineer_base_branch_name ---
2074
2075    #[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    // --- map_git_error ---
2082
2083    #[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    // --- cron edge cases ---
2103
2104    #[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        // Done cron task with no cron_last_run — should use now - 1 day as reference
2126        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        // Set last run to now so next trigger is in the future
2140        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    // --- sentinel tests for error resilience (#311) ---
2154
2155    /// Refresh on a stale/nonexistent worktree should return Ok, not panic.
2156    #[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        // Non-existent worktree should be handled gracefully (early return Ok)
2165        assert!(
2166            result.is_ok(),
2167            "refresh on nonexistent worktree should not panic: {result:?}"
2168        );
2169    }
2170
2171    /// run_tests_in_worktree should return a clean error when cargo is not
2172    /// found or the directory is invalid, not panic.
2173    #[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        // Should propagate an error via context, not panic
2178        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    /// checkout_worktree_branch_from_main should propagate an error cleanly
2190    /// when run against a non-git directory, not panic.
2191    #[test]
2192    fn checkout_branch_in_non_git_dir_returns_error() {
2193        let tmp = tempfile::tempdir().unwrap();
2194        // tmp is not a git repo, so git operations should fail
2195        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    /// Verify the production code in this file has zero bare .unwrap() or
2203    /// .expect() calls (only safe fallback variants like unwrap_or_default).
2204    #[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}