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