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};
5use std::process::{Command, Stdio};
6use std::time::{Duration, Instant};
7
8use anyhow::{Context, Result, bail};
9use tracing::{debug, info, warn};
10
11use super::git_cmd;
12use super::retry::{RetryConfig, retry_sync};
13use super::test_results::{self, TestRunOutput};
14
15const SHARED_CARGO_CONFIG_MARKER: &str = "# Managed by Batty: shared cargo target";
16const WORKTREE_EXCLUDE_MARKER: &str = "# Managed by Batty worktree ignores";
17pub(crate) const ADDITIVE_CONFLICT_AUTO_RESOLVE_FENCE: &[&str] =
18    &["src/team/task_loop.rs", "src/team/review.rs"];
19const MIN_REVIEW_READY_PRODUCTION_ADDITIONS: usize = 10;
20
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub(crate) enum WorktreeRefreshAction {
23    Unchanged,
24    SkippedDirty,
25    Rebased,
26    Reset,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub(crate) struct WorktreeRefreshOutcome {
31    pub(crate) action: WorktreeRefreshAction,
32    pub(crate) behind_main: Option<u32>,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub(crate) struct DiffStatEntry {
37    pub(crate) path: String,
38    pub(crate) additions: usize,
39    pub(crate) deletions: usize,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub(crate) struct CommitValidationGate {
44    pub(crate) blockers: Vec<String>,
45}
46
47#[cfg_attr(not(test), allow(dead_code))]
48fn priority_rank(p: &str) -> u32 {
49    match p {
50        "critical" => 0,
51        "high" => 1,
52        "medium" => 2,
53        "low" => 3,
54        _ => 4,
55    }
56}
57
58#[cfg_attr(not(test), allow(dead_code))]
59pub(crate) fn next_unclaimed_task(board_dir: &Path) -> Result<Option<crate::task::Task>> {
60    let tasks = crate::task::load_tasks_from_dir(&board_dir.join("tasks"))?;
61    let task_status_by_id: HashMap<u32, String> = tasks
62        .iter()
63        .map(|task| (task.id, task.status.clone()))
64        .collect();
65
66    let mut available: Vec<crate::task::Task> = tasks
67        .into_iter()
68        .filter(|task| matches!(task.status.as_str(), "backlog" | "todo"))
69        .filter(|task| task.claimed_by.is_none())
70        .filter(|task| task.blocked.is_none())
71        .filter(|task| task.blocked_on.is_none())
72        .filter(|task| {
73            task.depends_on.iter().all(|dep_id| {
74                task_status_by_id
75                    .get(dep_id)
76                    .is_none_or(|status| status == "done")
77            })
78        })
79        .collect();
80
81    available.sort_by_key(|task| (priority_rank(&task.priority), task.id));
82    Ok(available.into_iter().next())
83}
84
85pub(crate) fn run_tests_in_worktree(
86    worktree_dir: &Path,
87    test_command: Option<&str>,
88) -> Result<TestRunOutput> {
89    let command_text = test_command.unwrap_or("cargo test");
90    let mut command = std::process::Command::new("sh");
91    let cargo_home = engineer_worktree_project_root(worktree_dir)
92        .map(|project_root| project_root.join(".batty").join("cargo-home"))
93        .unwrap_or_else(|| worktree_dir.join(".batty").join("cargo-home"));
94    std::fs::create_dir_all(&cargo_home)
95        .with_context(|| format!("failed to create {}", cargo_home.display()))?;
96    // Use `sh -c` (not `sh -lc`): a login shell re-sources profile files and
97    // can drop ~/.cargo/bin from PATH on some macOS environments (notably
98    // GitHub's hosted runners), causing `cargo` lookups to fail. Plain
99    // `sh -c` inherits the parent's PATH unchanged, which is what we want
100    // both in production (daemon PATH carries rustup) and in tests.
101    command
102        .arg("-c")
103        .arg(command_text)
104        .current_dir(worktree_dir);
105    command.env("CARGO_HOME", &cargo_home);
106    if let Some(project_root) = engineer_worktree_project_root(worktree_dir) {
107        let wt_name = worktree_dir
108            .file_name()
109            .map(|n| n.to_string_lossy().into_owned())
110            .unwrap_or_else(|| "default".to_string());
111        command.env(
112            "CARGO_TARGET_DIR",
113            shared_cargo_target_dir(&project_root).join(&wt_name),
114        );
115    }
116    let output = command.output().with_context(|| {
117        format!(
118            "failed while running `{command_text}` in engineer worktree {}",
119            worktree_dir.display(),
120        )
121    })?;
122
123    let stdout = String::from_utf8_lossy(&output.stdout);
124    let stderr = String::from_utf8_lossy(&output.stderr);
125    let mut combined = String::new();
126    combined.push_str(&stdout);
127    if !stdout.is_empty() && !stderr.is_empty() && !stdout.ends_with('\n') {
128        combined.push('\n');
129    }
130    combined.push_str(&stderr);
131
132    let lines: Vec<&str> = combined.lines().collect();
133    let trimmed = if lines.len() > 50 {
134        lines[lines.len() - 50..].join("\n")
135    } else {
136        combined
137    };
138
139    let passed = output.status.success();
140    Ok(TestRunOutput {
141        passed,
142        results: test_results::parse(command_text, &trimmed, passed),
143        output: trimmed,
144    })
145}
146
147pub(crate) fn shared_cargo_target_dir(project_root: &Path) -> PathBuf {
148    project_root.join(".batty").join("shared-target")
149}
150
151pub(crate) fn validate_review_ready_worktree(
152    worktree_dir: &Path,
153    task_text: &str,
154) -> Result<Vec<String>> {
155    let diff = map_git_error(
156        retry_git(|| git_cmd::run_git(worktree_dir, &["diff", "--stat", "main..HEAD"])),
157        "failed to inspect engineer branch diff",
158    )?;
159    let declared_scope = crate::team::daemon::verification::parse_scope_fence(task_text);
160    Ok(validate_review_ready_diff_stat_with_scope(&diff.stdout, &declared_scope).blockers)
161}
162
163#[cfg_attr(not(test), allow(dead_code))]
164pub(crate) fn validate_review_ready_diff_stat(diff_stat: &str) -> CommitValidationGate {
165    validate_review_ready_diff_stat_with_scope(diff_stat, &[])
166}
167
168fn validate_review_ready_diff_stat_with_scope(
169    diff_stat: &str,
170    declared_scope: &[String],
171) -> CommitValidationGate {
172    let entries = parse_diff_stat_entries(diff_stat);
173    let mut blockers = Vec::new();
174
175    if entries.is_empty() {
176        blockers.push("engineer branch has no diff against main".to_string());
177        return CommitValidationGate { blockers };
178    }
179
180    let out_of_scope = if declared_scope.is_empty() {
181        Vec::new()
182    } else {
183        entries
184            .iter()
185            .filter(|entry| !path_within_declared_scope(&entry.path, declared_scope))
186            .map(|entry| entry.path.clone())
187            .collect::<Vec<_>>()
188    };
189    if !out_of_scope.is_empty() {
190        blockers.push(format!(
191            "changes outside task scope fence: {}",
192            out_of_scope.join(", ")
193        ));
194    }
195
196    let production_entries = entries
197        .iter()
198        .filter(|entry| {
199            entry.path.ends_with(".rs")
200                && (declared_scope.is_empty()
201                    || path_within_declared_scope(&entry.path, declared_scope))
202        })
203        .collect::<Vec<_>>();
204    let production_additions: usize = production_entries.iter().map(|entry| entry.additions).sum();
205    let production_deletions: usize = production_entries.iter().map(|entry| entry.deletions).sum();
206
207    if production_additions < MIN_REVIEW_READY_PRODUCTION_ADDITIONS {
208        blockers.push(format!(
209            "need at least {MIN_REVIEW_READY_PRODUCTION_ADDITIONS} lines of production Rust added; found {production_additions}"
210        ));
211    }
212    if production_deletions > production_additions {
213        blockers.push(format!(
214            "production Rust diff is net-destructive ({production_additions} additions, {production_deletions} deletions)"
215        ));
216    }
217
218    CommitValidationGate { blockers }
219}
220
221fn path_within_declared_scope(path: &str, scope_entries: &[String]) -> bool {
222    scope_entries.iter().any(|scope| {
223        path == scope
224            || path
225                .strip_prefix(scope)
226                .is_some_and(|rest| rest.starts_with('/'))
227    })
228}
229
230fn parse_diff_stat_entries(diff_stat: &str) -> Vec<DiffStatEntry> {
231    diff_stat
232        .lines()
233        .filter_map(|line| {
234            let (path, summary) = line.split_once('|')?;
235            let path = path.trim();
236            if path.is_empty() {
237                return None;
238            }
239
240            let additions = summary.chars().filter(|ch| *ch == '+').count();
241            let deletions = summary.chars().filter(|ch| *ch == '-').count();
242            Some(DiffStatEntry {
243                path: path.to_string(),
244                additions,
245                deletions,
246            })
247        })
248        .collect()
249}
250
251fn retry_git<T, F>(operation: F) -> std::result::Result<T, git_cmd::GitError>
252where
253    F: Fn() -> std::result::Result<T, git_cmd::GitError>,
254{
255    retry_sync(&RetryConfig::fast(), operation)
256}
257
258fn map_git_error<T>(result: std::result::Result<T, git_cmd::GitError>, action: &str) -> Result<T> {
259    result.map_err(|error| anyhow::anyhow!("{action}: {error}"))
260}
261
262pub(crate) fn read_task_title(board_dir: &Path, task_id: u32) -> String {
263    let tasks_dir = board_dir.join("tasks");
264    let prefix = format!("{task_id:03}-");
265    if let Ok(entries) = std::fs::read_dir(&tasks_dir) {
266        for entry in entries.flatten() {
267            let name = entry.file_name().to_string_lossy().to_string();
268            if name.starts_with(&prefix)
269                && name.ends_with(".md")
270                && let Ok(content) = std::fs::read_to_string(entry.path())
271            {
272                for line in content.lines() {
273                    if line.starts_with("title:") {
274                        return line
275                            .trim_start_matches("title:")
276                            .trim()
277                            .trim_matches(|c| c == '"' || c == '\'')
278                            .to_string();
279                    }
280                }
281            }
282        }
283    }
284    format!("Task #{task_id}")
285}
286
287/// Set up a git worktree for an engineer with symlinked shared config.
288pub(crate) fn setup_engineer_worktree(
289    project_root: &Path,
290    worktree_dir: &Path,
291    branch_name: &str,
292    team_config_dir: &Path,
293) -> Result<PathBuf> {
294    if let Some(parent) = worktree_dir.parent() {
295        std::fs::create_dir_all(parent)
296            .with_context(|| format!("failed to create {}", parent.display()))?;
297    }
298
299    if !worktree_dir.exists() {
300        let path = worktree_dir.to_string_lossy().to_string();
301        match retry_git(|| git_cmd::worktree_add(project_root, worktree_dir, branch_name, "main")) {
302            Ok(_) => {}
303            Err(git_cmd::GitError::Permanent { stderr, .. })
304                if stderr.contains("already exists") =>
305            {
306                map_git_error(
307                    retry_git(|| {
308                        git_cmd::run_git(project_root, &["worktree", "add", &path, branch_name])
309                    }),
310                    "failed to create git worktree",
311                )?;
312            }
313            Err(error) => {
314                return Err(anyhow::anyhow!("failed to create git worktree: {error}"));
315            }
316        }
317
318        info!(worktree = %worktree_dir.display(), branch = branch_name, "created engineer worktree");
319    }
320
321    ensure_engineer_worktree_links(worktree_dir, team_config_dir)?;
322    ensure_shared_cargo_target_config(project_root, worktree_dir)?;
323    ensure_engineer_worktree_excludes(worktree_dir)?;
324
325    Ok(worktree_dir.to_path_buf())
326}
327
328pub(crate) fn prepare_engineer_assignment_worktree(
329    project_root: &Path,
330    worktree_dir: &Path,
331    engineer_name: &str,
332    task_branch: &str,
333    team_config_dir: &Path,
334) -> Result<PathBuf> {
335    let base_branch = engineer_base_branch_name(engineer_name);
336    ensure_engineer_worktree_health(project_root, worktree_dir, &base_branch)?;
337    setup_engineer_worktree(project_root, worktree_dir, &base_branch, team_config_dir)?;
338    maybe_migrate_legacy_engineer_worktree(
339        project_root,
340        worktree_dir,
341        engineer_name,
342        &base_branch,
343    )?;
344    ensure_task_branch_namespace_available(project_root, engineer_name)?;
345
346    if worktree_has_user_changes(worktree_dir)? {
347        auto_clean_worktree(worktree_dir)?;
348    }
349
350    let previous_branch = current_worktree_branch(worktree_dir)?;
351    let previous_branch_is_engineer_owned = previous_branch == engineer_name
352        || previous_branch.starts_with(&format!("{engineer_name}/"));
353    if previous_branch != base_branch
354        && previous_branch != task_branch
355        && !previous_branch_is_engineer_owned
356        && !branch_is_merged_into(project_root, &previous_branch, "main")?
357    {
358        bail!(
359            "engineer worktree '{}' is on unmerged branch '{}'",
360            engineer_name,
361            previous_branch
362        );
363    }
364
365    checkout_worktree_branch_from_main(worktree_dir, &base_branch)?;
366
367    checkout_worktree_branch_from_main(worktree_dir, task_branch)?;
368    ensure_engineer_worktree_links(worktree_dir, team_config_dir)?;
369
370    if previous_branch != base_branch
371        && previous_branch != task_branch
372        && previous_branch_is_engineer_owned
373        && branch_is_merged_into(project_root, &previous_branch, "main")?
374    {
375        delete_branch(project_root, &previous_branch)?;
376    }
377
378    Ok(worktree_dir.to_path_buf())
379}
380
381/// Set up worktrees for a multi-repo project. Creates one git worktree per
382/// sub-repo inside `worktree_dir`, mirroring the original directory layout.
383pub(crate) fn setup_multi_repo_worktree(
384    project_root: &Path,
385    worktree_dir: &Path,
386    branch_name: &str,
387    team_config_dir: &Path,
388    sub_repo_names: &[String],
389) -> Result<PathBuf> {
390    std::fs::create_dir_all(worktree_dir)
391        .with_context(|| format!("failed to create {}", worktree_dir.display()))?;
392
393    for repo_name in sub_repo_names {
394        let repo_root = project_root.join(repo_name);
395        let sub_wt = worktree_dir.join(repo_name);
396        setup_engineer_worktree(&repo_root, &sub_wt, branch_name, team_config_dir)?;
397    }
398
399    ensure_engineer_worktree_links(worktree_dir, team_config_dir)?;
400    Ok(worktree_dir.to_path_buf())
401}
402
403/// Prepare worktrees for a multi-repo task assignment. Creates task branches
404/// in every sub-repo so the engineer can work across all of them.
405pub(crate) fn prepare_multi_repo_assignment_worktree(
406    project_root: &Path,
407    worktree_dir: &Path,
408    engineer_name: &str,
409    task_branch: &str,
410    team_config_dir: &Path,
411    sub_repo_names: &[String],
412) -> Result<PathBuf> {
413    std::fs::create_dir_all(worktree_dir)
414        .with_context(|| format!("failed to create {}", worktree_dir.display()))?;
415
416    for repo_name in sub_repo_names {
417        let repo_root = project_root.join(repo_name);
418        let sub_wt = worktree_dir.join(repo_name);
419        prepare_engineer_assignment_worktree(
420            &repo_root,
421            &sub_wt,
422            engineer_name,
423            task_branch,
424            team_config_dir,
425        )?;
426    }
427
428    ensure_engineer_worktree_links(worktree_dir, team_config_dir)?;
429    Ok(worktree_dir.to_path_buf())
430}
431
432pub(crate) fn worktree_commits_behind_main(worktree_dir: &Path) -> Result<u32> {
433    map_git_error(
434        retry_git(|| git_cmd::rev_list_count(worktree_dir, "HEAD..main")),
435        "failed to measure worktree staleness against main",
436    )
437}
438
439pub(crate) fn refresh_engineer_worktree_if_stale(
440    project_root: &Path,
441    worktree_dir: &Path,
442    branch_name: &str,
443    team_config_dir: &Path,
444    stale_threshold: u32,
445) -> Result<WorktreeRefreshOutcome> {
446    if !worktree_dir.exists() {
447        return Ok(WorktreeRefreshOutcome {
448            action: WorktreeRefreshAction::Unchanged,
449            behind_main: None,
450        });
451    }
452
453    let behind_main = Some(worktree_commits_behind_main(worktree_dir)?);
454    if behind_main.is_none_or(|count| count <= stale_threshold) {
455        return Ok(WorktreeRefreshOutcome {
456            action: WorktreeRefreshAction::Unchanged,
457            behind_main,
458        });
459    }
460
461    let action =
462        refresh_engineer_worktree(project_root, worktree_dir, branch_name, team_config_dir)?;
463    Ok(WorktreeRefreshOutcome {
464        action,
465        behind_main,
466    })
467}
468
469fn ensure_engineer_worktree_health(
470    project_root: &Path,
471    worktree_dir: &Path,
472    _base_branch: &str,
473) -> Result<()> {
474    if !worktree_dir.exists() {
475        return Ok(());
476    }
477
478    if !worktree_registered(project_root, worktree_dir)? {
479        bail!(
480            "engineer worktree path exists but is not registered in git worktree list: {}",
481            worktree_dir.display()
482        );
483    }
484
485    Ok(())
486}
487
488#[allow(dead_code)] // Retained for existing tests and as a lower-level helper.
489pub(crate) fn refresh_engineer_worktree(
490    project_root: &Path,
491    worktree_dir: &Path,
492    branch_name: &str,
493    team_config_dir: &Path,
494) -> Result<WorktreeRefreshAction> {
495    if !worktree_dir.exists() {
496        return Ok(WorktreeRefreshAction::Unchanged);
497    }
498
499    if worktree_has_user_changes(worktree_dir)? {
500        warn!(
501            worktree = %worktree_dir.display(),
502            branch = branch_name,
503            "skipping worktree refresh because worktree is dirty"
504        );
505        return Ok(WorktreeRefreshAction::SkippedDirty);
506    }
507
508    if map_git_error(
509        retry_git(|| git_cmd::merge_base_is_ancestor(project_root, "main", branch_name)),
510        "failed to compare worktree branch with main",
511    )? {
512        return Ok(WorktreeRefreshAction::Unchanged);
513    }
514
515    let rebase_result = retry_git(|| git_cmd::rebase(worktree_dir, "main"));
516    if rebase_result.is_ok() {
517        info!(
518            worktree = %worktree_dir.display(),
519            branch = branch_name,
520            "refreshed engineer worktree"
521        );
522        return Ok(WorktreeRefreshAction::Rebased);
523    }
524
525    let stderr = match rebase_result {
526        Ok(_) => unreachable!("successful rebase returned early"),
527        Err(git_cmd::GitError::Transient { stderr, .. })
528        | Err(git_cmd::GitError::Permanent { stderr, .. })
529        | Err(git_cmd::GitError::RebaseFailed { stderr, .. })
530        | Err(git_cmd::GitError::MergeFailed { stderr, .. }) => stderr.trim().to_string(),
531        Err(git_cmd::GitError::RevParseFailed { stderr, .. }) => stderr.trim().to_string(),
532        Err(git_cmd::GitError::InvalidRevListCount { output, .. }) => output.trim().to_string(),
533        Err(git_cmd::GitError::Exec { source, .. }) => source.to_string(),
534    };
535    let _ = retry_git(|| git_cmd::rebase_abort(worktree_dir));
536
537    if !is_worktree_safe_to_mutate(worktree_dir)? {
538        bail!(
539            "worktree at {} has uncommitted changes on a task branch after failed rebase — refusing to destroy. Commit or stash first.",
540            worktree_dir.display()
541        );
542    }
543
544    map_git_error(
545        retry_git(|| git_cmd::worktree_remove(project_root, worktree_dir, true)),
546        &format!("failed to remove conflicted worktree after rebase error '{stderr}'"),
547    )?;
548
549    map_git_error(
550        retry_git(|| git_cmd::branch_delete(project_root, branch_name)),
551        &format!("failed to delete conflicted worktree branch after rebase error '{stderr}'"),
552    )?;
553
554    warn!(
555        worktree = %worktree_dir.display(),
556        branch = branch_name,
557        rebase_error = %stderr,
558        "recreating engineer worktree after rebase conflict"
559    );
560    setup_engineer_worktree(project_root, worktree_dir, branch_name, team_config_dir)?;
561    Ok(WorktreeRefreshAction::Reset)
562}
563
564pub(crate) fn engineer_base_branch_name(engineer_name: &str) -> String {
565    format!("eng-main/{engineer_name}")
566}
567
568fn maybe_migrate_legacy_engineer_worktree(
569    project_root: &Path,
570    worktree_dir: &Path,
571    engineer_name: &str,
572    base_branch: &str,
573) -> Result<()> {
574    if !worktree_dir.exists() {
575        return Ok(());
576    }
577
578    let current_branch = current_worktree_branch(worktree_dir)?;
579    if current_branch != engineer_name {
580        return Ok(());
581    }
582
583    if worktree_has_user_changes(worktree_dir)? {
584        bail!(
585            "legacy engineer branch '{}' is still checked out in {} with uncommitted changes; resolve it before assigning a new task branch",
586            engineer_name,
587            worktree_dir.display()
588        );
589    }
590
591    checkout_worktree_branch_from_main(worktree_dir, base_branch)?;
592    if branch_is_merged_into(project_root, engineer_name, "main")? {
593        delete_branch(project_root, engineer_name)?;
594        info!(
595            branch = engineer_name,
596            base_branch,
597            worktree = %worktree_dir.display(),
598            "auto-migrated legacy engineer worktree to base branch"
599        );
600        return Ok(());
601    }
602
603    let archive_branch = archived_legacy_branch_name(project_root, engineer_name)?;
604    rename_branch(project_root, engineer_name, &archive_branch)?;
605    warn!(
606        old_branch = engineer_name,
607        new_branch = %archive_branch,
608        base_branch,
609        worktree = %worktree_dir.display(),
610        "auto-migrated unmerged legacy engineer worktree to base branch"
611    );
612    Ok(())
613}
614
615fn ensure_task_branch_namespace_available(project_root: &Path, engineer_name: &str) -> Result<()> {
616    if !branch_exists(project_root, engineer_name)? {
617        return Ok(());
618    }
619
620    if branch_is_checked_out_in_any_worktree(project_root, engineer_name)? {
621        bail!(
622            "legacy engineer branch '{}' is still checked out in a worktree; resolve it before assigning a new task branch",
623            engineer_name
624        );
625    }
626
627    if branch_is_merged_into(project_root, engineer_name, "main")? {
628        delete_branch(project_root, engineer_name)?;
629        info!(
630            branch = engineer_name,
631            "deleted merged legacy engineer branch to free task namespace"
632        );
633        return Ok(());
634    }
635
636    let archive_branch = archived_legacy_branch_name(project_root, engineer_name)?;
637    rename_branch(project_root, engineer_name, &archive_branch)?;
638    warn!(
639        old_branch = engineer_name,
640        new_branch = %archive_branch,
641        "archived legacy engineer branch to free task namespace"
642    );
643    Ok(())
644}
645
646fn ensure_engineer_worktree_links(worktree_dir: &Path, team_config_dir: &Path) -> Result<()> {
647    let wt_batty_dir = worktree_dir.join(".batty");
648    std::fs::create_dir_all(&wt_batty_dir).ok();
649    let wt_config_link = wt_batty_dir.join("team_config");
650
651    if !wt_config_link.exists() {
652        #[cfg(unix)]
653        std::os::unix::fs::symlink(team_config_dir, &wt_config_link).with_context(|| {
654            format!(
655                "failed to symlink {} -> {}",
656                wt_config_link.display(),
657                team_config_dir.display()
658            )
659        })?;
660
661        #[cfg(not(unix))]
662        {
663            warn!("symlinks not supported on this platform, copying config instead");
664            let _ = std::fs::create_dir_all(&wt_config_link);
665        }
666
667        debug!(
668            link = %wt_config_link.display(),
669            target = %team_config_dir.display(),
670            "symlinked team config into worktree"
671        );
672    }
673
674    Ok(())
675}
676
677fn ensure_shared_cargo_target_config(project_root: &Path, worktree_dir: &Path) -> Result<()> {
678    // Each worktree gets its own target subdirectory so parallel builds
679    // don't contend on the same Cargo lock. The shared parent is kept for
680    // disk-pressure cleanup scans.
681    let worktree_name = worktree_dir
682        .file_name()
683        .map(|n| n.to_string_lossy().into_owned())
684        .unwrap_or_else(|| "default".to_string());
685    let target_dir = shared_cargo_target_dir(project_root).join(&worktree_name);
686    std::fs::create_dir_all(&target_dir)
687        .with_context(|| format!("failed to create {}", target_dir.display()))?;
688
689    let config_rel_path = Path::new(".cargo").join("config.toml");
690    if worktree_relative_path_is_tracked(worktree_dir, &config_rel_path)? {
691        // .cargo/config.toml must NOT be tracked — it contains worktree-specific
692        // target-dir paths that pollute other worktrees on rebase.  Untrack it.
693        warn!(
694            config = %worktree_dir.join(&config_rel_path).display(),
695            "untracking .cargo/config.toml — worktree-specific file must not be in git"
696        );
697        let _ =
698            run_git_command_with_fallback(worktree_dir, &["rm", "--cached", ".cargo/config.toml"]);
699        // Remove the stale file so the managed config gets written below.
700        let _ = std::fs::remove_file(worktree_dir.join(&config_rel_path));
701    }
702
703    let cargo_dir = worktree_dir.join(".cargo");
704    std::fs::create_dir_all(&cargo_dir)
705        .with_context(|| format!("failed to create {}", cargo_dir.display()))?;
706    let config_path = cargo_dir.join("config.toml");
707
708    let managed = format!(
709        "{SHARED_CARGO_CONFIG_MARKER}\n[build]\ntarget-dir = {:?}\n",
710        target_dir
711    );
712
713    match std::fs::read_to_string(&config_path) {
714        Ok(existing) if existing == managed => return Ok(()),
715        Ok(existing) if !existing.is_empty() && !existing.contains(SHARED_CARGO_CONFIG_MARKER) => {
716            warn!(
717                config = %config_path.display(),
718                "leaving existing cargo config unchanged; shared target must be configured manually"
719            );
720            return Ok(());
721        }
722        Ok(_) | Err(_) => {}
723    }
724
725    std::fs::write(&config_path, managed)
726        .with_context(|| format!("failed to write {}", config_path.display()))?;
727    Ok(())
728}
729
730fn worktree_relative_path_is_tracked(worktree_dir: &Path, rel_path: &Path) -> Result<bool> {
731    let rel_path_text = rel_path.to_string_lossy().into_owned();
732    let output = run_git_command_with_fallback(
733        worktree_dir,
734        &["ls-files", "--error-unmatch", &rel_path_text],
735    )
736    .with_context(|| {
737        format!(
738            "failed to check whether {} is tracked in {}",
739            rel_path.display(),
740            worktree_dir.display()
741        )
742    })?;
743
744    Ok(output.status.success())
745}
746
747fn ensure_engineer_worktree_excludes(worktree_dir: &Path) -> Result<()> {
748    let output = run_git_command_with_fallback(worktree_dir, &["rev-parse", "--git-dir"])
749        .with_context(|| format!("failed to resolve git dir for {}", worktree_dir.display()))?;
750    if !output.status.success() {
751        bail!(
752            "failed to resolve git dir for {}: {}",
753            worktree_dir.display(),
754            String::from_utf8_lossy(&output.stderr).trim()
755        );
756    }
757
758    let git_dir_text = String::from_utf8_lossy(&output.stdout).trim().to_string();
759    let git_dir = if Path::new(&git_dir_text).is_absolute() {
760        PathBuf::from(git_dir_text)
761    } else {
762        worktree_dir.join(git_dir_text)
763    };
764    let exclude_path = git_dir.join("info").join("exclude");
765    if let Some(parent) = exclude_path.parent() {
766        std::fs::create_dir_all(parent)
767            .with_context(|| format!("failed to create {}", parent.display()))?;
768    }
769
770    let mut content = std::fs::read_to_string(&exclude_path).unwrap_or_default();
771    if !content.contains(WORKTREE_EXCLUDE_MARKER) {
772        if !content.is_empty() && !content.ends_with('\n') {
773            content.push('\n');
774        }
775        content.push_str(WORKTREE_EXCLUDE_MARKER);
776        content.push('\n');
777    }
778
779    for rule in [".cargo/", ".cargo/config.toml", ".batty/team_config"] {
780        if !content.lines().any(|line| line.trim() == rule) {
781            content.push_str(rule);
782            content.push('\n');
783        }
784    }
785
786    std::fs::write(&exclude_path, content)
787        .with_context(|| format!("failed to write {}", exclude_path.display()))?;
788    Ok(())
789}
790
791fn run_git_command_with_fallback(
792    worktree_dir: &Path,
793    args: &[&str],
794) -> std::io::Result<std::process::Output> {
795    let mut last_not_found = None;
796    for program in ["git", "/usr/bin/git", "/opt/homebrew/bin/git"] {
797        match Command::new(program)
798            .args(args)
799            .current_dir(worktree_dir)
800            .output()
801        {
802            Ok(output) => return Ok(output),
803            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
804                last_not_found = Some(error);
805            }
806            Err(error) => return Err(error),
807        }
808    }
809
810    Err(last_not_found.unwrap_or_else(|| {
811        std::io::Error::new(std::io::ErrorKind::NotFound, "git binary not found")
812    }))
813}
814
815fn engineer_worktree_project_root(worktree_dir: &Path) -> Option<PathBuf> {
816    for ancestor in worktree_dir.ancestors() {
817        if ancestor.file_name().is_some_and(|name| name == "worktrees")
818            && ancestor
819                .parent()
820                .and_then(Path::file_name)
821                .is_some_and(|name| name == ".batty")
822        {
823            return ancestor
824                .parent()
825                .and_then(Path::parent)
826                .map(Path::to_path_buf);
827        }
828    }
829    None
830}
831
832pub(crate) fn worktree_has_user_changes(worktree_dir: &Path) -> Result<bool> {
833    Ok(map_git_error(
834        retry_git(|| git_cmd::status_porcelain(worktree_dir)),
835        "failed to inspect worktree status",
836    )?
837    .lines()
838    .any(|line| !line.starts_with("?? .batty/") && !line.starts_with("?? .cargo/")))
839}
840
841pub(crate) fn git_has_unresolved_conflicts(repo_dir: &Path) -> Result<bool> {
842    let status = map_git_error(
843        retry_git(|| git_cmd::status_porcelain(repo_dir)),
844        "failed to inspect git conflict state",
845    )?;
846    Ok(status.lines().any(line_has_unresolved_conflict))
847}
848
849fn line_has_unresolved_conflict(line: &str) -> bool {
850    let bytes = line.as_bytes();
851    bytes.len() >= 2
852        && matches!(
853            (bytes[0], bytes[1]),
854            (b'U', _) | (_, b'U') | (b'A', b'A') | (b'D', b'D')
855        )
856}
857
858pub(crate) fn merge_additive_only_text(
859    base: &str,
860    current: &str,
861    incoming: &str,
862) -> Option<String> {
863    let base_lines = split_lines_preserving_endings(base);
864    let current_slots = insertion_slots_relative_to_base(&base_lines, current)?;
865    let incoming_slots = insertion_slots_relative_to_base(&base_lines, incoming)?;
866    let mut merged = String::new();
867
868    for (index, base_line) in base_lines.iter().enumerate() {
869        append_slot(&mut merged, &current_slots[index], &incoming_slots[index]);
870        merged.push_str(base_line);
871    }
872    append_slot(
873        &mut merged,
874        &current_slots[base_lines.len()],
875        &incoming_slots[base_lines.len()],
876    );
877
878    Some(merged)
879}
880
881fn split_lines_preserving_endings(text: &str) -> Vec<&str> {
882    if text.is_empty() {
883        Vec::new()
884    } else {
885        text.split_inclusive('\n').collect()
886    }
887}
888
889fn insertion_slots_relative_to_base<'a>(
890    base_lines: &[&str],
891    variant: &'a str,
892) -> Option<Vec<Vec<&'a str>>> {
893    let variant_lines = split_lines_preserving_endings(variant);
894    let mut slots = vec![Vec::new(); base_lines.len() + 1];
895    let mut variant_index = 0usize;
896
897    for (base_index, base_line) in base_lines.iter().enumerate() {
898        while variant_index < variant_lines.len() && variant_lines[variant_index] != *base_line {
899            slots[base_index].push(variant_lines[variant_index]);
900            variant_index += 1;
901        }
902        if variant_index == variant_lines.len() {
903            return None;
904        }
905        variant_index += 1;
906    }
907
908    while variant_index < variant_lines.len() {
909        slots[base_lines.len()].push(variant_lines[variant_index]);
910        variant_index += 1;
911    }
912
913    Some(slots)
914}
915
916fn append_slot(output: &mut String, current_slot: &[&str], incoming_slot: &[&str]) {
917    for line in current_slot {
918        output.push_str(line);
919    }
920    if current_slot != incoming_slot {
921        for line in incoming_slot {
922            output.push_str(line);
923        }
924    }
925}
926
927/// Returns `false` if the worktree has uncommitted changes on a task branch
928/// (i.e. not an `eng-main/*` base branch). This gate should be checked before
929/// any operation that would destroy worktree state (reset, clean, checkout).
930pub(crate) fn is_worktree_safe_to_mutate(worktree_dir: &Path) -> Result<bool> {
931    if !worktree_dir.exists() {
932        return Ok(true);
933    }
934
935    let has_changes = worktree_has_user_changes(worktree_dir)?;
936    if !has_changes {
937        return Ok(true);
938    }
939
940    let branch = match map_git_error(
941        retry_git(|| git_cmd::rev_parse_branch(worktree_dir)),
942        "failed to determine worktree branch for safety check",
943    ) {
944        Ok(b) => b,
945        Err(_) => return Ok(true), // Can't determine branch — allow mutation
946    };
947
948    // eng-main/* branches are base branches with no user work worth preserving.
949    if branch.starts_with("eng-main/") {
950        return Ok(true);
951    }
952
953    // Task branch with uncommitted changes — NOT safe to mutate.
954    warn!(
955        worktree = %worktree_dir.display(),
956        branch = %branch,
957        "worktree has uncommitted changes on task branch, refusing to mutate"
958    );
959    Ok(false)
960}
961
962fn run_git_with_timeout(worktree_dir: &Path, args: &[&str], timeout: Duration) -> Result<()> {
963    let mut last_not_found = None;
964    let mut child = None;
965    for program in ["git", "/usr/bin/git", "/opt/homebrew/bin/git"] {
966        let mut command = Command::new(program);
967        command.arg("-C").arg(worktree_dir).args(args);
968        // Pipe stderr so we can surface it in error messages. Without this,
969        // failures like `git add -A -- . :(exclude).batty :(exclude).cargo`
970        // just say "exit status: 1" with no reason, making preserve-worktree
971        // bugs impossible to diagnose from daemon logs alone. Stdout goes to
972        // /dev/null because we never consume it in this helper.
973        command
974            .stdin(Stdio::null())
975            .stdout(Stdio::null())
976            .stderr(Stdio::piped());
977        #[cfg(unix)]
978        {
979            use std::os::unix::process::CommandExt;
980            command.process_group(0);
981        }
982        match command.spawn() {
983            Ok(process) => {
984                child = Some(process);
985                break;
986            }
987            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
988                last_not_found = Some(error);
989            }
990            Err(error) => {
991                return Err(error).with_context(|| {
992                    format!(
993                        "failed to launch `git {}` in {}",
994                        args.join(" "),
995                        worktree_dir.display()
996                    )
997                });
998            }
999        }
1000    }
1001    let mut child = child
1002        .ok_or_else(|| {
1003            last_not_found.unwrap_or_else(|| {
1004                std::io::Error::new(std::io::ErrorKind::NotFound, "git binary not found")
1005            })
1006        })
1007        .with_context(|| {
1008            format!(
1009                "failed to launch `git {}` in {}",
1010                args.join(" "),
1011                worktree_dir.display()
1012            )
1013        })?;
1014
1015    let deadline = Instant::now() + timeout;
1016    loop {
1017        if let Some(status) = child.try_wait()? {
1018            if status.success() {
1019                // Drain stderr so the pipe closes cleanly; discard contents.
1020                if let Some(mut err) = child.stderr.take() {
1021                    let mut sink = Vec::new();
1022                    let _ = std::io::Read::read_to_end(&mut err, &mut sink);
1023                }
1024                return Ok(());
1025            }
1026            let mut stderr_buf = Vec::new();
1027            if let Some(mut err) = child.stderr.take() {
1028                let _ = std::io::Read::read_to_end(&mut err, &mut stderr_buf);
1029            }
1030            let stderr = String::from_utf8_lossy(&stderr_buf);
1031            let stderr_trimmed = stderr.trim();
1032            if stderr_trimmed.is_empty() {
1033                bail!(
1034                    "`git {}` failed in {} with status {}",
1035                    args.join(" "),
1036                    worktree_dir.display(),
1037                    status
1038                );
1039            } else {
1040                bail!(
1041                    "`git {}` failed in {} with status {}: {}",
1042                    args.join(" "),
1043                    worktree_dir.display(),
1044                    status,
1045                    stderr_trimmed
1046                );
1047            }
1048        }
1049
1050        if Instant::now() >= deadline {
1051            terminate_process_tree(&mut child);
1052            let _ = child.wait();
1053            bail!(
1054                "`git {}` timed out after {}s in {}",
1055                args.join(" "),
1056                timeout.as_secs(),
1057                worktree_dir.display()
1058            );
1059        }
1060
1061        std::thread::sleep(Duration::from_millis(50));
1062    }
1063}
1064
1065#[cfg(unix)]
1066fn terminate_process_tree(child: &mut std::process::Child) {
1067    let _ = unsafe { libc::kill(-(child.id() as libc::pid_t), libc::SIGKILL) };
1068}
1069
1070#[cfg(not(unix))]
1071fn terminate_process_tree(child: &mut std::process::Child) {
1072    let _ = child.kill();
1073}
1074
1075pub(crate) fn preserve_worktree_with_commit(
1076    worktree_dir: &Path,
1077    commit_message: &str,
1078    timeout: Duration,
1079) -> Result<bool> {
1080    if !worktree_has_user_changes(worktree_dir)? {
1081        return Ok(false);
1082    }
1083
1084    run_git_with_timeout(
1085        worktree_dir,
1086        &[
1087            "add",
1088            "-A",
1089            "--",
1090            ".",
1091            ":(exclude).batty",
1092            ":(exclude).cargo",
1093        ],
1094        timeout,
1095    )?;
1096    run_git_with_timeout(
1097        worktree_dir,
1098        &[
1099            "-c",
1100            "commit.gpgSign=false",
1101            "-c",
1102            "core.hooksPath=/dev/null",
1103            "commit",
1104            "-m",
1105            commit_message,
1106        ],
1107        timeout,
1108    )?;
1109    Ok(true)
1110}
1111
1112pub(crate) fn dirty_worktree_preservation_blocked_reason(
1113    worktree_dir: &Path,
1114    context: &str,
1115) -> String {
1116    format!(
1117        "Batty could not safely auto-save dirty worktree {} before {context}. Commit or clean the lane manually.",
1118        worktree_dir.display()
1119    )
1120}
1121
1122fn auto_clean_worktree(worktree_dir: &Path) -> Result<()> {
1123    let branch = retry_git(|| git_cmd::rev_parse_branch(worktree_dir)).unwrap_or_default();
1124    let message = format!("wip: auto-save before worktree reset [{branch}]");
1125    let reason = crate::worktree::prepare_worktree_for_reset(
1126        worktree_dir,
1127        &message,
1128        Duration::from_secs(5),
1129        crate::worktree::PreserveFailureMode::SkipReset,
1130    )?;
1131    if reason == crate::worktree::WorktreeResetReason::PreserveFailedResetSkipped {
1132        bail!(
1133            "{}",
1134            dirty_worktree_preservation_blocked_reason(worktree_dir, "dispatch/reset recovery")
1135        );
1136    }
1137    info!(
1138        worktree = %worktree_dir.display(),
1139        reset_reason = reason.as_str(),
1140        "prepared engineer worktree for reset"
1141    );
1142
1143    if worktree_has_user_changes(worktree_dir)? {
1144        bail!(
1145            "engineer worktree at {} still dirty after auto-clean",
1146            worktree_dir.display()
1147        );
1148    }
1149    Ok(())
1150}
1151
1152/// Auto-commit uncommitted changes before a worktree reset to avoid stash
1153/// accumulation. Returns `true` if changes were successfully committed or
1154/// there was nothing to commit.
1155///
1156/// Kept as a stable wrapper for the common-case reset flow; production code
1157/// currently uses `preserve_worktree_with_commit` directly with custom
1158/// messages. Test-only `dead_code` allow keeps the wrapper exercised via
1159/// its tests without generating a build warning.
1160#[cfg_attr(not(test), allow(dead_code))]
1161pub(crate) fn auto_commit_before_reset(worktree_dir: &Path) -> bool {
1162    let branch = retry_git(|| git_cmd::rev_parse_branch(worktree_dir)).unwrap_or_default();
1163    let msg = format!("wip: auto-save before worktree reset [{}]", branch);
1164    match preserve_worktree_with_commit(worktree_dir, &msg, Duration::from_secs(5)) {
1165        Ok(true) => {
1166            info!(
1167                worktree = %worktree_dir.display(),
1168                branch = %branch,
1169                "auto-committed uncommitted changes before worktree reset"
1170            );
1171            true
1172        }
1173        Ok(false) => true,
1174        Err(e) => {
1175            warn!(
1176                worktree = %worktree_dir.display(),
1177                error = %e,
1178                "auto-commit failed"
1179            );
1180            false
1181        }
1182    }
1183}
1184
1185pub(crate) fn current_worktree_branch(worktree_dir: &Path) -> Result<String> {
1186    map_git_error(
1187        retry_git(|| git_cmd::rev_parse_branch(worktree_dir)),
1188        "failed to determine worktree branch",
1189    )
1190}
1191
1192pub(crate) fn checkout_worktree_branch_from_main(
1193    worktree_dir: &Path,
1194    branch_name: &str,
1195) -> Result<()> {
1196    map_git_error(
1197        retry_git(|| git_cmd::checkout_new_branch(worktree_dir, branch_name, "main")),
1198        &format!("failed to switch worktree to branch '{branch_name}'"),
1199    )
1200}
1201
1202fn branch_exists(project_root: &Path, branch_name: &str) -> Result<bool> {
1203    map_git_error(
1204        retry_git(|| git_cmd::show_ref_exists(project_root, branch_name)),
1205        &format!("failed to check whether branch '{branch_name}' exists"),
1206    )
1207}
1208
1209fn worktree_registered(project_root: &Path, worktree_dir: &Path) -> Result<bool> {
1210    let output = map_git_error(
1211        retry_git(|| git_cmd::worktree_list(project_root)),
1212        "failed to list git worktrees",
1213    )?;
1214    let target = worktree_dir
1215        .canonicalize()
1216        .unwrap_or_else(|_| worktree_dir.to_path_buf());
1217
1218    for line in output.lines() {
1219        let Some(candidate) = line.strip_prefix("worktree ") else {
1220            continue;
1221        };
1222        let candidate = PathBuf::from(candidate.trim());
1223        let candidate = candidate.canonicalize().unwrap_or(candidate);
1224        if candidate == target {
1225            return Ok(true);
1226        }
1227    }
1228
1229    Ok(false)
1230}
1231
1232fn branch_is_checked_out_in_any_worktree(project_root: &Path, branch_name: &str) -> Result<bool> {
1233    let output = map_git_error(
1234        retry_git(|| git_cmd::worktree_list(project_root)),
1235        "failed to list git worktrees",
1236    )?;
1237    let target = format!("branch refs/heads/{branch_name}");
1238    Ok(output.lines().any(|line| line.trim() == target))
1239}
1240
1241pub(crate) fn branch_is_merged_into(
1242    project_root: &Path,
1243    branch_name: &str,
1244    base_branch: &str,
1245) -> Result<bool> {
1246    map_git_error(
1247        retry_git(|| git_cmd::merge_base_is_ancestor(project_root, branch_name, base_branch)),
1248        &format!("failed to compare branch '{branch_name}' with '{base_branch}'"),
1249    )
1250}
1251
1252pub(crate) fn engineer_worktree_ready_for_dispatch(
1253    project_root: &Path,
1254    worktree_dir: &Path,
1255    engineer_name: &str,
1256) -> Result<()> {
1257    if !worktree_dir.exists() {
1258        return Ok(());
1259    }
1260
1261    if !worktree_registered(project_root, worktree_dir)? {
1262        bail!(
1263            "engineer worktree path exists but is not registered in git worktree list: {}",
1264            worktree_dir.display()
1265        );
1266    }
1267
1268    let base_branch = engineer_base_branch_name(engineer_name);
1269    let current_branch = current_worktree_branch(worktree_dir)?;
1270    if current_branch != base_branch {
1271        bail!(
1272            "engineer worktree '{}' is checked out on '{}' instead of '{}'",
1273            engineer_name,
1274            current_branch,
1275            base_branch
1276        );
1277    }
1278
1279    if worktree_has_user_changes(worktree_dir)? {
1280        bail!(
1281            "engineer worktree '{}' has uncommitted changes",
1282            engineer_name
1283        );
1284    }
1285
1286    let ahead_of_main = map_git_error(
1287        retry_git(|| git_cmd::rev_list_count(worktree_dir, "main..HEAD")),
1288        "failed to compare worktree against main",
1289    )?;
1290    let behind_main = map_git_error(
1291        retry_git(|| git_cmd::rev_list_count(worktree_dir, "HEAD..main")),
1292        "failed to compare worktree against main",
1293    )?;
1294    if ahead_of_main != 0 || behind_main != 0 {
1295        bail!(
1296            "engineer worktree '{}' is not based on current main (ahead {}, behind {})",
1297            engineer_name,
1298            ahead_of_main,
1299            behind_main
1300        );
1301    }
1302
1303    Ok(())
1304}
1305
1306pub(crate) fn delete_branch(project_root: &Path, branch_name: &str) -> Result<()> {
1307    map_git_error(
1308        retry_git(|| git_cmd::branch_delete(project_root, branch_name)),
1309        &format!("failed to delete branch '{branch_name}'"),
1310    )
1311}
1312
1313fn archived_legacy_branch_name(project_root: &Path, engineer_name: &str) -> Result<String> {
1314    let short_sha = map_git_error(
1315        retry_git(|| git_cmd::run_git(project_root, &["rev-parse", "--short", engineer_name])),
1316        &format!("failed to resolve legacy branch '{engineer_name}'"),
1317    )?
1318    .stdout
1319    .trim()
1320    .to_string();
1321    let mut candidate = format!("legacy/{engineer_name}-{short_sha}");
1322    let mut counter = 1usize;
1323    while branch_exists(project_root, &candidate)? {
1324        counter += 1;
1325        candidate = format!("legacy/{engineer_name}-{short_sha}-{counter}");
1326    }
1327    Ok(candidate)
1328}
1329
1330fn rename_branch(project_root: &Path, old_branch: &str, new_branch: &str) -> Result<()> {
1331    map_git_error(
1332        retry_git(|| git_cmd::branch_rename(project_root, old_branch, new_branch)),
1333        &format!("failed to rename branch '{old_branch}' to '{new_branch}'"),
1334    )
1335}
1336
1337/// Recycle done cron tasks back to todo when their next occurrence is due.
1338///
1339/// Returns a list of (task_id, cron_expression) for each recycled task.
1340pub(crate) fn recycle_cron_tasks(board_dir: &Path) -> Result<Vec<(u32, String)>> {
1341    use chrono::Utc;
1342    use cron::Schedule;
1343    use serde_yaml::Value;
1344    use std::str::FromStr;
1345
1346    use super::task_cmd::{find_task_path, set_optional_string, update_task_frontmatter, yaml_key};
1347
1348    let tasks_dir = board_dir.join("tasks");
1349    let tasks = crate::task::load_tasks_from_dir(&tasks_dir)
1350        .with_context(|| format!("failed to load tasks from {}", tasks_dir.display()))?;
1351
1352    let now = Utc::now();
1353    let mut recycled = Vec::new();
1354
1355    for task in &tasks {
1356        // Skip non-done tasks
1357        if task.status != "done" {
1358            continue;
1359        }
1360
1361        // Skip tasks without a cron schedule
1362        let cron_expr = match &task.cron_schedule {
1363            Some(expr) => expr.clone(),
1364            None => continue,
1365        };
1366
1367        // Skip archived tasks
1368        if task.tags.iter().any(|t| t == "archived") {
1369            continue;
1370        }
1371
1372        // Parse the cron expression
1373        let schedule = match Schedule::from_str(&cron_expr) {
1374            Ok(s) => s,
1375            Err(err) => {
1376                warn!(task_id = task.id, cron = %cron_expr, error = %err, "invalid cron expression, skipping");
1377                continue;
1378            }
1379        };
1380
1381        // Determine the reference point: cron_last_run or now - 1 day
1382        let reference = task
1383            .cron_last_run
1384            .as_deref()
1385            .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
1386            .map(|dt| dt.with_timezone(&Utc))
1387            .unwrap_or_else(|| now - chrono::Duration::days(1));
1388
1389        // Find next occurrence after reference
1390        let next = match schedule.after(&reference).next() {
1391            Some(dt) => dt,
1392            None => continue,
1393        };
1394
1395        // If next occurrence is in the future, skip
1396        if next > now {
1397            continue;
1398        }
1399
1400        // Compute next FUTURE occurrence for scheduled_for
1401        let next_future = schedule.after(&now).next().map(|dt| dt.to_rfc3339());
1402
1403        let now_str = now.to_rfc3339();
1404        let task_id = task.id;
1405        let task_path = find_task_path(board_dir, task_id)?;
1406
1407        update_task_frontmatter(&task_path, |mapping| {
1408            // Set status to todo
1409            mapping.insert(yaml_key("status"), Value::String("todo".to_string()));
1410
1411            // Update scheduled_for to next future occurrence
1412            set_optional_string(mapping, "scheduled_for", next_future.as_deref());
1413
1414            // Update cron_last_run to now
1415            set_optional_string(mapping, "cron_last_run", Some(&now_str));
1416
1417            // Clear transient fields
1418            mapping.remove(yaml_key("claimed_by"));
1419            mapping.remove(yaml_key("branch"));
1420            mapping.remove(yaml_key("commit"));
1421            mapping.remove(yaml_key("artifacts"));
1422            mapping.remove(yaml_key("next_action"));
1423            mapping.remove(yaml_key("review_owner"));
1424            mapping.remove(yaml_key("blocked_on"));
1425            mapping.remove(yaml_key("worktree_path"));
1426        })?;
1427
1428        info!(task_id, cron = %cron_expr, "recycled cron task back to todo");
1429        recycled.push((task_id, cron_expr));
1430    }
1431
1432    Ok(recycled)
1433}
1434
1435#[cfg(test)]
1436mod tests {
1437    use super::*;
1438    use crate::team::test_support::{EnvVarGuard, PATH_LOCK, git, git_ok, git_stdout};
1439    use std::sync::MutexGuard;
1440
1441    fn git_binary_path() -> Option<&'static str> {
1442        ["git", "/usr/bin/git", "/opt/homebrew/bin/git"]
1443            .into_iter()
1444            .find(|program| Command::new(program).arg("--version").output().is_ok())
1445    }
1446
1447    fn git_binary_available() -> bool {
1448        git_binary_path().is_some()
1449    }
1450
1451    fn git_test_guard() -> Option<MutexGuard<'static, ()>> {
1452        let guard = PATH_LOCK.lock().unwrap_or_else(|error| error.into_inner());
1453        if git_binary_available() {
1454            Some(guard)
1455        } else {
1456            eprintln!("skipping git-dependent task_loop test: git binary unavailable");
1457            None
1458        }
1459    }
1460
1461    fn production_unwrap_expect_count(path: &Path) -> usize {
1462        let content = std::fs::read_to_string(path).unwrap();
1463        let test_split = content.split("\n#[cfg(test)]").next().unwrap_or(&content);
1464        test_split
1465            .lines()
1466            .filter(|line| line.contains(".unwrap(") || line.contains(".expect("))
1467            .count()
1468    }
1469
1470    fn init_git_repo(tmp: &tempfile::TempDir) -> PathBuf {
1471        let repo = tmp.path();
1472        git_ok(repo, &["init", "-b", "main"]);
1473        git_ok(repo, &["config", "user.email", "batty-test@example.com"]);
1474        git_ok(repo, &["config", "user.name", "Batty Test"]);
1475        std::fs::create_dir_all(repo.join(".batty").join("team_config")).unwrap();
1476        std::fs::write(repo.join("README.md"), "initial\n").unwrap();
1477        git_ok(repo, &["add", "README.md", ".batty/team_config"]);
1478        git_ok(repo, &["commit", "-m", "initial"]);
1479        repo.to_path_buf()
1480    }
1481
1482    fn write_task_file(
1483        dir: &Path,
1484        id: u32,
1485        title: &str,
1486        status: &str,
1487        priority: &str,
1488        claimed_by: Option<&str>,
1489        depends_on: &[u32],
1490    ) {
1491        let tasks_dir = dir.join("tasks");
1492        std::fs::create_dir_all(&tasks_dir).unwrap();
1493        let mut content =
1494            format!("---\nid: {id}\ntitle: {title}\nstatus: {status}\npriority: {priority}\n");
1495        if let Some(cb) = claimed_by {
1496            content.push_str(&format!("claimed_by: {cb}\n"));
1497        }
1498        if !depends_on.is_empty() {
1499            content.push_str("depends_on:\n");
1500            for dep in depends_on {
1501                content.push_str(&format!("    - {dep}\n"));
1502            }
1503        }
1504        content.push_str("class: standard\n---\n\nTask description.\n");
1505        std::fs::write(tasks_dir.join(format!("{id:03}-{title}.md")), content).unwrap();
1506    }
1507
1508    fn write_task_file_with_workflow_frontmatter(
1509        dir: &Path,
1510        id: u32,
1511        title: &str,
1512        extra_frontmatter: &str,
1513    ) {
1514        let tasks_dir = dir.join("tasks");
1515        std::fs::create_dir_all(&tasks_dir).unwrap();
1516        std::fs::write(
1517            tasks_dir.join(format!("{id:03}-{title}.md")),
1518            format!(
1519                "---\nid: {id}\ntitle: {title}\nstatus: todo\npriority: critical\n{extra_frontmatter}class: standard\n---\n\nTask description.\n"
1520            ),
1521        )
1522        .unwrap();
1523    }
1524
1525    #[test]
1526    fn test_refresh_worktree_rebases_behind_main() {
1527        let Some(_path_lock) = git_test_guard() else {
1528            return;
1529        };
1530        let tmp = tempfile::tempdir().unwrap();
1531        let repo = init_git_repo(&tmp);
1532        let worktree_dir = repo.join(".batty").join("worktrees").join("eng-1");
1533        let team_config_dir = repo.join(".batty").join("team_config");
1534
1535        setup_engineer_worktree(&repo, &worktree_dir, "eng-1", &team_config_dir).unwrap();
1536
1537        std::fs::write(repo.join("main.txt"), "new main content\n").unwrap();
1538        git_ok(&repo, &["add", "main.txt"]);
1539        git_ok(&repo, &["commit", "-m", "advance main"]);
1540
1541        refresh_engineer_worktree(&repo, &worktree_dir, "eng-1", &team_config_dir).unwrap();
1542
1543        assert!(worktree_dir.join("main.txt").exists());
1544        assert_eq!(
1545            git_stdout(&repo, &["rev-parse", "main"]),
1546            git_stdout(&worktree_dir, &["rev-parse", "HEAD"])
1547        );
1548    }
1549
1550    #[test]
1551    fn test_refresh_worktree_recreates_on_conflict() {
1552        let tmp = tempfile::tempdir().unwrap();
1553        let repo = init_git_repo(&tmp);
1554        let worktree_dir = repo.join(".batty").join("worktrees").join("eng-2");
1555        let team_config_dir = repo.join(".batty").join("team_config");
1556
1557        std::fs::write(repo.join("file.txt"), "A\n").unwrap();
1558        git_ok(&repo, &["add", "file.txt"]);
1559        git_ok(&repo, &["commit", "-m", "add file"]);
1560
1561        setup_engineer_worktree(&repo, &worktree_dir, "eng-2", &team_config_dir).unwrap();
1562
1563        std::fs::write(worktree_dir.join("file.txt"), "B\n").unwrap();
1564        git_ok(&worktree_dir, &["add", "file.txt"]);
1565        git_ok(&worktree_dir, &["commit", "-m", "engineer change"]);
1566
1567        std::fs::write(repo.join("file.txt"), "C\n").unwrap();
1568        git_ok(&repo, &["add", "file.txt"]);
1569        git_ok(&repo, &["commit", "-m", "main change"]);
1570
1571        refresh_engineer_worktree(&repo, &worktree_dir, "eng-2", &team_config_dir).unwrap();
1572
1573        assert!(worktree_dir.exists());
1574        assert_eq!(
1575            std::fs::read_to_string(worktree_dir.join("file.txt")).unwrap(),
1576            "C\n"
1577        );
1578        assert_eq!(
1579            git_stdout(&repo, &["rev-parse", "main"]),
1580            git_stdout(&worktree_dir, &["rev-parse", "HEAD"])
1581        );
1582    }
1583
1584    #[test]
1585    fn test_refresh_worktree_skips_dirty() {
1586        let tmp = tempfile::tempdir().unwrap();
1587        let repo = init_git_repo(&tmp);
1588        let worktree_dir = repo.join(".batty").join("worktrees").join("eng-3");
1589        let team_config_dir = repo.join(".batty").join("team_config");
1590
1591        setup_engineer_worktree(&repo, &worktree_dir, "eng-3", &team_config_dir).unwrap();
1592        std::fs::write(worktree_dir.join("scratch.txt"), "uncommitted\n").unwrap();
1593
1594        std::fs::write(repo.join("main.txt"), "new main content\n").unwrap();
1595        git_ok(&repo, &["add", "main.txt"]);
1596        git_ok(&repo, &["commit", "-m", "advance main"]);
1597
1598        refresh_engineer_worktree(&repo, &worktree_dir, "eng-3", &team_config_dir).unwrap();
1599
1600        assert!(!worktree_dir.join("main.txt").exists());
1601        assert_eq!(
1602            std::fs::read_to_string(worktree_dir.join("scratch.txt")).unwrap(),
1603            "uncommitted\n"
1604        );
1605    }
1606
1607    #[test]
1608    fn test_refresh_worktree_noop_when_current() {
1609        let tmp = tempfile::tempdir().unwrap();
1610        let repo = init_git_repo(&tmp);
1611        let worktree_dir = repo.join(".batty").join("worktrees").join("eng-4");
1612        let team_config_dir = repo.join(".batty").join("team_config");
1613
1614        setup_engineer_worktree(&repo, &worktree_dir, "eng-4", &team_config_dir).unwrap();
1615        let before = git_stdout(&worktree_dir, &["rev-parse", "HEAD"]);
1616
1617        refresh_engineer_worktree(&repo, &worktree_dir, "eng-4", &team_config_dir).unwrap();
1618
1619        let after = git_stdout(&worktree_dir, &["rev-parse", "HEAD"]);
1620        assert_eq!(before, after);
1621        assert!(worktree_dir.exists());
1622    }
1623
1624    #[test]
1625    fn test_prepare_assignment_worktree_checks_out_task_branch_from_main() {
1626        let tmp = tempfile::tempdir().unwrap();
1627        let repo = init_git_repo(&tmp);
1628        let worktree_dir = repo.join(".batty").join("worktrees").join("eng-5");
1629        let team_config_dir = repo.join(".batty").join("team_config");
1630
1631        prepare_engineer_assignment_worktree(
1632            &repo,
1633            &worktree_dir,
1634            "eng-5",
1635            "eng-5/123",
1636            &team_config_dir,
1637        )
1638        .unwrap();
1639
1640        assert_eq!(
1641            git_stdout(&worktree_dir, &["rev-parse", "--abbrev-ref", "HEAD"]),
1642            "eng-5/123"
1643        );
1644        assert_eq!(
1645            git_stdout(&repo, &["rev-parse", "main"]),
1646            git_stdout(&worktree_dir, &["rev-parse", "HEAD"])
1647        );
1648        assert!(worktree_dir.join(".batty").join("team_config").exists());
1649    }
1650
1651    #[test]
1652    fn test_prepare_assignment_worktree_recreates_stale_task_branch_from_current_main() {
1653        let tmp = tempfile::tempdir().unwrap();
1654        let repo = init_git_repo(&tmp);
1655        let worktree_dir = repo.join(".batty").join("worktrees").join("eng-5b");
1656        let team_config_dir = repo.join(".batty").join("team_config");
1657
1658        prepare_engineer_assignment_worktree(
1659            &repo,
1660            &worktree_dir,
1661            "eng-5b",
1662            "eng-5b/123",
1663            &team_config_dir,
1664        )
1665        .unwrap();
1666        let stale_commit = git_stdout(&repo, &["rev-parse", "eng-5b/123"]);
1667
1668        git_ok(&repo, &["checkout", "main"]);
1669        std::fs::write(repo.join("fresh.txt"), "fresh main content\n").unwrap();
1670        git_ok(&repo, &["add", "fresh.txt"]);
1671        git_ok(&repo, &["commit", "-m", "advance main"]);
1672        let current_main = git_stdout(&repo, &["rev-parse", "main"]);
1673
1674        prepare_engineer_assignment_worktree(
1675            &repo,
1676            &worktree_dir,
1677            "eng-5b",
1678            "eng-5b/123",
1679            &team_config_dir,
1680        )
1681        .unwrap();
1682
1683        assert_ne!(stale_commit, current_main);
1684        assert_eq!(
1685            git_stdout(&repo, &["rev-parse", "eng-5b/123"]),
1686            current_main
1687        );
1688        assert_eq!(
1689            git_stdout(&worktree_dir, &["rev-parse", "HEAD"]),
1690            current_main
1691        );
1692        assert_eq!(
1693            git_stdout(&worktree_dir, &["rev-parse", "--abbrev-ref", "HEAD"]),
1694            "eng-5b/123"
1695        );
1696    }
1697
1698    #[test]
1699    fn test_prepare_assignment_worktree_resets_mismatched_engineer_task_branch() {
1700        let tmp = tempfile::tempdir().unwrap();
1701        let repo = init_git_repo(&tmp);
1702        let worktree_dir = repo.join(".batty").join("worktrees").join("eng-5c");
1703        let team_config_dir = repo.join(".batty").join("team_config");
1704
1705        setup_engineer_worktree(
1706            &repo,
1707            &worktree_dir,
1708            &engineer_base_branch_name("eng-5c"),
1709            &team_config_dir,
1710        )
1711        .unwrap();
1712
1713        git_ok(&worktree_dir, &["checkout", "-B", "eng-5c/300"]);
1714        std::fs::write(worktree_dir.join("stale.txt"), "stale work\n").unwrap();
1715        git_ok(&worktree_dir, &["add", "stale.txt"]);
1716        git_ok(&worktree_dir, &["commit", "-m", "stale task work"]);
1717
1718        git_ok(&repo, &["checkout", "main"]);
1719        std::fs::write(repo.join("fresh.txt"), "fresh main content\n").unwrap();
1720        git_ok(&repo, &["add", "fresh.txt"]);
1721        git_ok(&repo, &["commit", "-m", "advance main"]);
1722
1723        prepare_engineer_assignment_worktree(
1724            &repo,
1725            &worktree_dir,
1726            "eng-5c",
1727            "eng-5c/301",
1728            &team_config_dir,
1729        )
1730        .unwrap();
1731
1732        assert_eq!(
1733            git_stdout(&worktree_dir, &["rev-parse", "--abbrev-ref", "HEAD"]),
1734            "eng-5c/301"
1735        );
1736        assert_eq!(
1737            git_stdout(&worktree_dir, &["rev-parse", "HEAD"]),
1738            git_stdout(&repo, &["rev-parse", "main"])
1739        );
1740        assert!(!worktree_dir.join("stale.txt").exists());
1741    }
1742
1743    #[test]
1744    fn test_setup_engineer_worktree_writes_shared_cargo_target_config() {
1745        let tmp = tempfile::tempdir().unwrap();
1746        let repo = init_git_repo(&tmp);
1747        let worktree_dir = repo.join(".batty").join("worktrees").join("eng-shared");
1748        let team_config_dir = repo.join(".batty").join("team_config");
1749
1750        setup_engineer_worktree(&repo, &worktree_dir, "eng-shared", &team_config_dir).unwrap();
1751
1752        let config =
1753            std::fs::read_to_string(worktree_dir.join(".cargo").join("config.toml")).unwrap();
1754        assert!(config.contains(SHARED_CARGO_CONFIG_MARKER));
1755        assert!(config.contains(shared_cargo_target_dir(&repo).to_string_lossy().as_ref()));
1756    }
1757
1758    #[test]
1759    fn test_setup_engineer_worktree_preserves_existing_cargo_config() {
1760        let tmp = tempfile::tempdir().unwrap();
1761        let repo = init_git_repo(&tmp);
1762        let worktree_dir = repo.join(".batty").join("worktrees").join("eng-preserve");
1763        let team_config_dir = repo.join(".batty").join("team_config");
1764
1765        setup_engineer_worktree(&repo, &worktree_dir, "eng-preserve", &team_config_dir).unwrap();
1766        let config_path = worktree_dir.join(".cargo").join("config.toml");
1767        std::fs::write(&config_path, "[term]\nverbose = true\n").unwrap();
1768
1769        setup_engineer_worktree(&repo, &worktree_dir, "eng-preserve", &team_config_dir).unwrap();
1770
1771        assert_eq!(
1772            std::fs::read_to_string(config_path).unwrap(),
1773            "[term]\nverbose = true\n"
1774        );
1775    }
1776
1777    #[test]
1778    fn test_setup_engineer_worktree_untracks_cargo_config_and_writes_managed() {
1779        let Some(_guard) = git_test_guard() else {
1780            return;
1781        };
1782
1783        let tmp = tempfile::tempdir().unwrap();
1784        let repo = init_git_repo(&tmp);
1785        let team_config_dir = repo.join(".batty").join("team_config");
1786
1787        // Simulate the pollution scenario: .cargo/config.toml committed to git
1788        std::fs::create_dir_all(repo.join(".cargo")).unwrap();
1789        std::fs::write(
1790            repo.join(".cargo").join("config.toml"),
1791            "[alias]\nxtask = \"run\"\n",
1792        )
1793        .unwrap();
1794        git_ok(&repo, &["add", ".cargo/config.toml"]);
1795        git_ok(&repo, &["commit", "-m", "track cargo config"]);
1796
1797        let worktree_dir = repo
1798            .join(".batty")
1799            .join("worktrees")
1800            .join("eng-tracked-config");
1801        setup_engineer_worktree(&repo, &worktree_dir, "eng-tracked-config", &team_config_dir)
1802            .unwrap();
1803
1804        // After setup, the file should contain managed config (not the old alias)
1805        let config =
1806            std::fs::read_to_string(worktree_dir.join(".cargo").join("config.toml")).unwrap();
1807        assert!(
1808            config.contains(SHARED_CARGO_CONFIG_MARKER),
1809            "cargo config should be managed after untracking: {config}"
1810        );
1811        assert!(
1812            !config.contains("[alias]"),
1813            "old tracked content should be replaced with managed config"
1814        );
1815    }
1816
1817    #[test]
1818    fn test_setup_engineer_worktree_excludes_cargo_config_toml() {
1819        let Some(_guard) = git_test_guard() else {
1820            return;
1821        };
1822
1823        let tmp = tempfile::tempdir().unwrap();
1824        let repo = init_git_repo(&tmp);
1825        let team_config_dir = repo.join(".batty").join("team_config");
1826        let worktree_dir = repo
1827            .join(".batty")
1828            .join("worktrees")
1829            .join("eng-exclude-test");
1830
1831        setup_engineer_worktree(&repo, &worktree_dir, "eng-exclude-test", &team_config_dir)
1832            .unwrap();
1833
1834        // The git exclude file should contain .cargo/config.toml
1835        let git_dir_output = git_stdout(&worktree_dir, &["rev-parse", "--git-dir"]);
1836        let git_dir = if Path::new(git_dir_output.trim()).is_absolute() {
1837            PathBuf::from(git_dir_output.trim())
1838        } else {
1839            worktree_dir.join(git_dir_output.trim())
1840        };
1841        let exclude_content =
1842            std::fs::read_to_string(git_dir.join("info").join("exclude")).unwrap();
1843        assert!(
1844            exclude_content.contains(".cargo/config.toml"),
1845            "worktree exclude should contain .cargo/config.toml: {exclude_content}"
1846        );
1847        assert!(
1848            exclude_content.contains(".cargo/"),
1849            "worktree exclude should contain .cargo/: {exclude_content}"
1850        );
1851    }
1852
1853    #[test]
1854    fn test_setup_engineer_worktree_finds_git_when_path_is_stripped() {
1855        let _path_lock = PATH_LOCK.lock().unwrap_or_else(|error| error.into_inner());
1856        if !git_binary_available() {
1857            eprintln!("skipping git-dependent task_loop test: git binary unavailable");
1858            return;
1859        }
1860        let _path_guard = EnvVarGuard::set("PATH", "/definitely/missing");
1861
1862        let tmp = tempfile::tempdir().unwrap();
1863        let repo = init_git_repo(&tmp);
1864        let worktree_dir = repo.join(".batty").join("worktrees").join("eng-fallback");
1865        let team_config_dir = repo.join(".batty").join("team_config");
1866
1867        setup_engineer_worktree(&repo, &worktree_dir, "eng-fallback", &team_config_dir).unwrap();
1868
1869        assert_eq!(
1870            git_stdout(&worktree_dir, &["rev-parse", "--abbrev-ref", "HEAD"]),
1871            "eng-fallback"
1872        );
1873    }
1874
1875    #[test]
1876    fn test_prepare_assignment_worktree_auto_cleans_dirty() {
1877        let tmp = tempfile::tempdir().unwrap();
1878        let repo = init_git_repo(&tmp);
1879        let worktree_dir = repo.join(".batty").join("worktrees").join("eng-6");
1880        let team_config_dir = repo.join(".batty").join("team_config");
1881
1882        setup_engineer_worktree(
1883            &repo,
1884            &worktree_dir,
1885            &engineer_base_branch_name("eng-6"),
1886            &team_config_dir,
1887        )
1888        .unwrap();
1889        std::fs::write(worktree_dir.join("scratch.txt"), "uncommitted\n").unwrap();
1890
1891        // Should succeed — auto-clean commits the dirty file.
1892        prepare_engineer_assignment_worktree(
1893            &repo,
1894            &worktree_dir,
1895            "eng-6",
1896            "eng-6/7",
1897            &team_config_dir,
1898        )
1899        .unwrap();
1900
1901        // Worktree should be clean now.
1902        assert!(!worktree_has_user_changes(&worktree_dir).unwrap());
1903
1904        // No stash should be created (commit-before-reset discipline).
1905        let stash_list = git_stdout(&worktree_dir, &["stash", "list"]);
1906        assert!(
1907            stash_list.trim().is_empty(),
1908            "no stash should be created, changes should be auto-committed"
1909        );
1910    }
1911
1912    #[test]
1913    fn test_prepare_assignment_worktree_auto_migrates_clean_legacy_worktree_branch() {
1914        let tmp = tempfile::tempdir().unwrap();
1915        let repo = init_git_repo(&tmp);
1916        let worktree_dir = repo.join(".batty").join("worktrees").join("eng-6b");
1917        let team_config_dir = repo.join(".batty").join("team_config");
1918
1919        setup_engineer_worktree(&repo, &worktree_dir, "eng-6b", &team_config_dir).unwrap();
1920
1921        prepare_engineer_assignment_worktree(
1922            &repo,
1923            &worktree_dir,
1924            "eng-6b",
1925            "eng-6b/17",
1926            &team_config_dir,
1927        )
1928        .unwrap();
1929
1930        let legacy_check = git(&repo, &["rev-parse", "--verify", "eng-6b"]);
1931        assert!(!legacy_check.status.success());
1932        assert_eq!(
1933            git_stdout(&worktree_dir, &["rev-parse", "--abbrev-ref", "HEAD"]),
1934            "eng-6b/17"
1935        );
1936        assert_eq!(
1937            git_stdout(&repo, &["rev-parse", "--verify", "eng-main/eng-6b"]),
1938            git_stdout(&repo, &["rev-parse", "--verify", "main"])
1939        );
1940    }
1941
1942    #[test]
1943    fn test_prepare_assignment_worktree_deletes_merged_legacy_branch_namespace() {
1944        let tmp = tempfile::tempdir().unwrap();
1945        let repo = init_git_repo(&tmp);
1946        let worktree_dir = repo.join(".batty").join("worktrees").join("eng-7");
1947        let team_config_dir = repo.join(".batty").join("team_config");
1948
1949        git_ok(&repo, &["branch", "eng-7"]);
1950
1951        prepare_engineer_assignment_worktree(
1952            &repo,
1953            &worktree_dir,
1954            "eng-7",
1955            "eng-7/99",
1956            &team_config_dir,
1957        )
1958        .unwrap();
1959
1960        let legacy_check = git(&repo, &["rev-parse", "--verify", "eng-7"]);
1961        assert!(!legacy_check.status.success());
1962        assert_eq!(
1963            git_stdout(&worktree_dir, &["rev-parse", "--abbrev-ref", "HEAD"]),
1964            "eng-7/99"
1965        );
1966    }
1967
1968    #[test]
1969    fn test_prepare_assignment_worktree_archives_unmerged_legacy_branch_namespace() {
1970        let tmp = tempfile::tempdir().unwrap();
1971        let repo = init_git_repo(&tmp);
1972        let worktree_dir = repo.join(".batty").join("worktrees").join("eng-8");
1973        let team_config_dir = repo.join(".batty").join("team_config");
1974
1975        git_ok(&repo, &["checkout", "-b", "eng-8"]);
1976        std::fs::write(repo.join("legacy.txt"), "legacy branch work\n").unwrap();
1977        git_ok(&repo, &["add", "legacy.txt"]);
1978        git_ok(&repo, &["commit", "-m", "legacy work"]);
1979        git_ok(&repo, &["checkout", "main"]);
1980
1981        prepare_engineer_assignment_worktree(
1982            &repo,
1983            &worktree_dir,
1984            "eng-8",
1985            "eng-8/100",
1986            &team_config_dir,
1987        )
1988        .unwrap();
1989
1990        let legacy_check = git(&repo, &["rev-parse", "--verify", "eng-8"]);
1991        assert!(!legacy_check.status.success());
1992        assert!(!git_stdout(&repo, &["branch", "--list", "legacy/eng-8-*"]).is_empty());
1993        assert_eq!(
1994            git_stdout(&worktree_dir, &["rev-parse", "--abbrev-ref", "HEAD"]),
1995            "eng-8/100"
1996        );
1997    }
1998
1999    #[test]
2000    fn test_prepare_assignment_worktree_rejects_unregistered_existing_path() {
2001        let tmp = tempfile::tempdir().unwrap();
2002        let repo = init_git_repo(&tmp);
2003        let worktree_dir = repo.join(".batty").join("worktrees").join("eng-9");
2004        let team_config_dir = repo.join(".batty").join("team_config");
2005
2006        std::fs::create_dir_all(&worktree_dir).unwrap();
2007
2008        let err = prepare_engineer_assignment_worktree(
2009            &repo,
2010            &worktree_dir,
2011            "eng-9",
2012            "eng-9/1",
2013            &team_config_dir,
2014        )
2015        .unwrap_err();
2016
2017        assert!(
2018            err.to_string()
2019                .contains("not registered in git worktree list")
2020        );
2021    }
2022
2023    #[test]
2024    fn test_next_unclaimed_task_picks_highest_priority() {
2025        let tmp = tempfile::tempdir().unwrap();
2026        write_task_file(tmp.path(), 1, "low-task", "todo", "low", None, &[]);
2027        write_task_file(tmp.path(), 2, "high-task", "todo", "high", None, &[]);
2028        write_task_file(
2029            tmp.path(),
2030            3,
2031            "critical-task",
2032            "todo",
2033            "critical",
2034            None,
2035            &[],
2036        );
2037
2038        let task = next_unclaimed_task(tmp.path()).unwrap().unwrap();
2039        assert_eq!(task.id, 3);
2040        assert_eq!(task.title, "critical-task");
2041    }
2042
2043    #[test]
2044    fn test_next_unclaimed_task_skips_claimed() {
2045        let tmp = tempfile::tempdir().unwrap();
2046        write_task_file(
2047            tmp.path(),
2048            1,
2049            "claimed-task",
2050            "todo",
2051            "critical",
2052            Some("eng-1-1"),
2053            &[],
2054        );
2055        write_task_file(tmp.path(), 2, "open-task", "todo", "low", None, &[]);
2056
2057        let task = next_unclaimed_task(tmp.path()).unwrap().unwrap();
2058        assert_eq!(task.id, 2);
2059        assert_eq!(task.title, "open-task");
2060    }
2061
2062    #[test]
2063    fn test_next_unclaimed_task_skips_blocked_dependency() {
2064        let tmp = tempfile::tempdir().unwrap();
2065        write_task_file(tmp.path(), 1, "first-task", "backlog", "medium", None, &[]);
2066        write_task_file(tmp.path(), 2, "second-task", "todo", "critical", None, &[1]);
2067
2068        let task = next_unclaimed_task(tmp.path()).unwrap().unwrap();
2069        assert_eq!(task.id, 1);
2070        assert_eq!(task.title, "first-task");
2071    }
2072
2073    #[test]
2074    fn test_next_unclaimed_task_skips_blocked_on_frontmatter() {
2075        let tmp = tempfile::tempdir().unwrap();
2076        write_task_file_with_workflow_frontmatter(
2077            tmp.path(),
2078            1,
2079            "blocked-task",
2080            "blocked_on: waiting-for-review\n",
2081        );
2082        write_task_file(tmp.path(), 2, "open-task", "todo", "high", None, &[]);
2083
2084        let task = next_unclaimed_task(tmp.path()).unwrap().unwrap();
2085        assert_eq!(task.id, 2);
2086        assert_eq!(task.title, "open-task");
2087    }
2088
2089    #[test]
2090    fn test_next_unclaimed_task_returns_none_when_empty() {
2091        let tmp = tempfile::tempdir().unwrap();
2092        std::fs::create_dir_all(tmp.path().join("tasks")).unwrap();
2093
2094        let task = next_unclaimed_task(tmp.path()).unwrap();
2095        assert!(task.is_none());
2096    }
2097
2098    #[test]
2099    fn test_run_tests_in_worktree_returns_pass_fail() {
2100        let tmp = tempfile::tempdir().unwrap();
2101        let worktree = tmp.path();
2102        std::fs::create_dir_all(worktree.join("src")).unwrap();
2103        std::fs::write(
2104            worktree.join("Cargo.toml"),
2105            "[package]\nname = \"batty-testcrate\"\nversion = \"0.1.0\"\nedition = \"2024\"\n",
2106        )
2107        .unwrap();
2108
2109        std::fs::write(
2110            worktree.join("src").join("lib.rs"),
2111            "#[cfg(test)]\nmod tests {\n    #[test]\n    fn passes() {\n        assert_eq!(2 + 2, 4);\n    }\n}\n",
2112        )
2113        .unwrap();
2114        let run = run_tests_in_worktree(worktree, None).unwrap();
2115        assert!(run.passed);
2116        assert!(run.output.contains("test result: ok"));
2117        assert_eq!(run.results.framework, "cargo");
2118
2119        std::fs::write(
2120            worktree.join("src").join("lib.rs"),
2121            "#[cfg(test)]\nmod tests {\n    #[test]\n    fn fails() {\n        assert_eq!(2 + 2, 5);\n    }\n}\n",
2122        )
2123        .unwrap();
2124        let run = run_tests_in_worktree(worktree, None).unwrap();
2125        assert!(!run.passed);
2126        assert!(run.output.contains("FAILED"));
2127        assert_eq!(run.results.failed, 1);
2128        assert_eq!(run.results.failures[0].test_name, "tests::fails");
2129    }
2130
2131    #[test]
2132    fn test_run_tests_in_worktree_uses_configured_command() {
2133        let tmp = tempfile::tempdir().unwrap();
2134        let worktree = tmp.path();
2135        std::fs::write(
2136            worktree.join("check.sh"),
2137            "#!/bin/sh\necho CONFIG_TEST_OK\n",
2138        )
2139        .unwrap();
2140        #[cfg(unix)]
2141        {
2142            use std::os::unix::fs::PermissionsExt;
2143            std::fs::set_permissions(
2144                worktree.join("check.sh"),
2145                std::fs::Permissions::from_mode(0o755),
2146            )
2147            .unwrap();
2148        }
2149
2150        let run = run_tests_in_worktree(worktree, Some("./check.sh")).unwrap();
2151        assert!(run.passed);
2152        assert!(run.output.contains("CONFIG_TEST_OK"));
2153    }
2154
2155    #[test]
2156    fn test_run_tests_in_worktree_sets_shared_target_dir_for_engineer_worktree() {
2157        let Some(_path_lock) = git_test_guard() else {
2158            return;
2159        };
2160        let tmp = tempfile::tempdir().unwrap();
2161        let repo = init_git_repo(&tmp);
2162        let worktree_dir = repo.join(".batty").join("worktrees").join("eng-target");
2163        let team_config_dir = repo.join(".batty").join("team_config");
2164
2165        setup_engineer_worktree(&repo, &worktree_dir, "eng-target", &team_config_dir).unwrap();
2166        std::fs::write(
2167            worktree_dir.join("check.sh"),
2168            "#!/bin/sh\nprintf '%s\\n' \"$CARGO_TARGET_DIR\"\n",
2169        )
2170        .unwrap();
2171        #[cfg(unix)]
2172        {
2173            use std::os::unix::fs::PermissionsExt;
2174            std::fs::set_permissions(
2175                worktree_dir.join("check.sh"),
2176                std::fs::Permissions::from_mode(0o755),
2177            )
2178            .unwrap();
2179        }
2180
2181        let run = run_tests_in_worktree(&worktree_dir, Some("./check.sh")).unwrap();
2182        assert!(run.passed);
2183        assert!(
2184            run.output
2185                .contains(shared_cargo_target_dir(&repo).to_string_lossy().as_ref())
2186        );
2187    }
2188
2189    #[test]
2190    fn test_read_task_title_from_file() {
2191        let tmp = tempfile::tempdir().unwrap();
2192        let tasks_dir = tmp.path().join("tasks");
2193        std::fs::create_dir_all(&tasks_dir).unwrap();
2194        std::fs::write(
2195            tasks_dir.join("042-my-cool-task.md"),
2196            "---\ntitle: My Cool Task\nstatus: in-progress\npriority: high\n---\nBody here\n",
2197        )
2198        .unwrap();
2199        let title = read_task_title(tmp.path(), 42);
2200        assert_eq!(title, "My Cool Task");
2201    }
2202
2203    #[test]
2204    fn test_read_task_title_fallback() {
2205        let tmp = tempfile::tempdir().unwrap();
2206        let title = read_task_title(tmp.path(), 99);
2207        assert_eq!(title, "Task #99");
2208    }
2209
2210    #[test]
2211    fn review_ready_gate_accepts_valid_commit_diff() {
2212        let gate = validate_review_ready_diff_stat(
2213            " src/team/completion.rs | 12 ++++++++++++\n 1 file changed, 12 insertions(+)\n",
2214        );
2215        assert!(gate.blockers.is_empty());
2216    }
2217
2218    #[test]
2219    fn review_ready_gate_rejects_zero_commit_diff() {
2220        let gate = validate_review_ready_diff_stat("");
2221        assert!(
2222            gate.blockers
2223                .contains(&"engineer branch has no diff against main".to_string())
2224        );
2225    }
2226
2227    #[test]
2228    fn review_ready_gate_rejects_config_only_diff() {
2229        let gate = validate_review_ready_diff_stat(
2230            " Cargo.toml | 14 ++++++++++++++\n docs/notes.md | 6 ++++++\n 2 files changed, 20 insertions(+)\n",
2231        );
2232        assert!(
2233            gate.blockers
2234                .iter()
2235                .any(|blocker| blocker.contains("need at least 10 lines of production Rust added"))
2236        );
2237    }
2238
2239    #[test]
2240    fn review_ready_gate_rejects_destructive_net_deletion_diff() {
2241        let gate = validate_review_ready_diff_stat(
2242            " src/team/review.rs | 12 ++++--------\n 1 file changed, 4 insertions(+), 8 deletions(-)\n",
2243        );
2244        assert!(
2245            gate.blockers
2246                .iter()
2247                .any(|blocker| blocker.contains("net-destructive"))
2248        );
2249    }
2250
2251    #[test]
2252    fn review_ready_gate_rejects_out_of_scope_diff() {
2253        let gate = validate_review_ready_diff_stat_with_scope(
2254            " src/team/daemon.rs | 15 +++++++++++++++\n 1 file changed, 15 insertions(+)\n",
2255            &["src/team/completion.rs".to_string()],
2256        );
2257        assert!(
2258            gate.blockers
2259                .iter()
2260                .any(|blocker| blocker.contains("changes outside task scope fence"))
2261        );
2262    }
2263
2264    #[test]
2265    fn review_ready_gate_accepts_scope_fenced_rust_diff() {
2266        let gate = validate_review_ready_diff_stat_with_scope(
2267            " src/team/daemon/verification.rs | 15 +++++++++++++++\n 1 file changed, 15 insertions(+)\n",
2268            &["src/team/daemon".to_string()],
2269        );
2270        assert!(gate.blockers.is_empty());
2271    }
2272
2273    #[test]
2274    fn production_task_loop_has_no_unwrap_or_expect_calls() {
2275        let count = production_unwrap_expect_count(Path::new(file!()));
2276        assert_eq!(
2277            count, 0,
2278            "production task_loop.rs should avoid unwrap/expect"
2279        );
2280    }
2281
2282    // -- Cron recycling tests --
2283
2284    fn write_cron_task(board_dir: &Path, id: u32, status: &str, cron: &str, extra: &str) {
2285        let tasks_dir = board_dir.join("tasks");
2286        std::fs::create_dir_all(&tasks_dir).unwrap();
2287        let path = tasks_dir.join(format!("{id:03}-cron-task.md"));
2288        let content = format!(
2289            "---\nid: {id}\ntitle: Cron Task {id}\nstatus: {status}\npriority: medium\ncron_schedule: \"{cron}\"\n{extra}---\n\nCron task body.\n"
2290        );
2291        std::fs::write(path, content).unwrap();
2292    }
2293
2294    #[test]
2295    fn cron_recycle_resets_done_task_to_todo() {
2296        let tmp = tempfile::tempdir().unwrap();
2297        let board_dir = tmp.path();
2298        write_cron_task(
2299            board_dir,
2300            1,
2301            "done",
2302            "0 * * * * *",
2303            "cron_last_run: \"2020-01-01T00:00:00+00:00\"\n",
2304        );
2305
2306        let recycled = recycle_cron_tasks(board_dir).unwrap();
2307        assert_eq!(recycled.len(), 1);
2308        assert_eq!(recycled[0].0, 1);
2309
2310        let task = crate::task::Task::from_file(&board_dir.join("tasks").join("001-cron-task.md"))
2311            .unwrap();
2312        assert_eq!(task.status, "todo");
2313        assert!(task.cron_last_run.is_some(), "cron_last_run should be set");
2314        assert!(task.scheduled_for.is_some(), "scheduled_for should be set");
2315        assert!(task.claimed_by.is_none(), "claimed_by should be cleared");
2316    }
2317
2318    #[test]
2319    fn cron_recycle_skips_archived_task() {
2320        let tmp = tempfile::tempdir().unwrap();
2321        let board_dir = tmp.path();
2322        write_cron_task(
2323            board_dir,
2324            2,
2325            "done",
2326            "0 * * * * *",
2327            "cron_last_run: \"2020-01-01T00:00:00+00:00\"\ntags:\n  - archived\n",
2328        );
2329
2330        let recycled = recycle_cron_tasks(board_dir).unwrap();
2331        assert!(recycled.is_empty(), "archived tasks should be skipped");
2332    }
2333
2334    #[test]
2335    fn cron_recycle_skips_in_progress_task() {
2336        let tmp = tempfile::tempdir().unwrap();
2337        let board_dir = tmp.path();
2338        write_cron_task(
2339            board_dir,
2340            3,
2341            "in-progress",
2342            "0 * * * * *",
2343            "cron_last_run: \"2020-01-01T00:00:00+00:00\"\n",
2344        );
2345
2346        let recycled = recycle_cron_tasks(board_dir).unwrap();
2347        assert!(recycled.is_empty(), "in-progress tasks should be skipped");
2348    }
2349
2350    #[test]
2351    fn cron_recycle_missed_trigger_skips_to_next_future() {
2352        let tmp = tempfile::tempdir().unwrap();
2353        let board_dir = tmp.path();
2354        write_cron_task(
2355            board_dir,
2356            4,
2357            "done",
2358            "0 * * * * *",
2359            "cron_last_run: \"2020-01-01T00:00:00+00:00\"\n",
2360        );
2361
2362        let recycled = recycle_cron_tasks(board_dir).unwrap();
2363        assert_eq!(recycled.len(), 1);
2364
2365        let task = crate::task::Task::from_file(&board_dir.join("tasks").join("004-cron-task.md"))
2366            .unwrap();
2367        assert_eq!(task.status, "todo");
2368
2369        let scheduled = task.scheduled_for.as_deref().unwrap();
2370        let scheduled_dt = chrono::DateTime::parse_from_rfc3339(scheduled).unwrap();
2371        assert!(
2372            scheduled_dt > chrono::Utc::now(),
2373            "scheduled_for should be in the future, got: {scheduled}"
2374        );
2375    }
2376
2377    #[test]
2378    fn cron_recycle_clears_transient_fields() {
2379        let tmp = tempfile::tempdir().unwrap();
2380        let board_dir = tmp.path();
2381        write_cron_task(
2382            board_dir,
2383            5,
2384            "done",
2385            "0 * * * * *",
2386            "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",
2387        );
2388
2389        let recycled = recycle_cron_tasks(board_dir).unwrap();
2390        assert_eq!(recycled.len(), 1);
2391
2392        let task = crate::task::Task::from_file(&board_dir.join("tasks").join("005-cron-task.md"))
2393            .unwrap();
2394        assert!(task.claimed_by.is_none());
2395        assert!(task.branch.is_none());
2396        assert!(task.commit.is_none());
2397        assert!(task.next_action.is_none());
2398        assert!(task.review_owner.is_none());
2399        assert!(task.blocked_on.is_none());
2400        assert!(task.worktree_path.is_none());
2401    }
2402
2403    #[test]
2404    fn cron_recycle_emits_event() {
2405        use crate::team::events::TeamEvent;
2406
2407        let event = TeamEvent::task_recycled(42, "0 9 * * 1");
2408        assert_eq!(event.event, "task_recycled");
2409        assert_eq!(event.task.as_deref(), Some("#42"));
2410        assert_eq!(event.reason.as_deref(), Some("0 9 * * 1"));
2411    }
2412
2413    #[test]
2414    fn task_recycled_event_format() {
2415        use crate::team::events::TeamEvent;
2416
2417        let event = TeamEvent::task_recycled(7, "30 8 * * *");
2418        let json = serde_json::to_string(&event).unwrap();
2419        assert!(json.contains("\"event\":\"task_recycled\""));
2420        assert!(json.contains("\"task\":\"#7\""));
2421        assert!(json.contains("\"reason\":\"30 8 * * *\""));
2422    }
2423
2424    // -- Integration tests --
2425
2426    #[test]
2427    fn cron_recycler_integration_resets_done_task() {
2428        let tmp = tempfile::tempdir().unwrap();
2429        let board_dir = tmp.path();
2430
2431        // cron_last_run 2 minutes ago — next minutely trigger is already past
2432        let two_min_ago = (chrono::Utc::now() - chrono::Duration::minutes(2)).to_rfc3339();
2433        write_cron_task(
2434            board_dir,
2435            10,
2436            "done",
2437            "0 * * * * *",
2438            &format!(
2439                "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"
2440            ),
2441        );
2442
2443        let recycled = recycle_cron_tasks(board_dir).unwrap();
2444        assert_eq!(recycled.len(), 1, "done cron task should be recycled");
2445        assert_eq!(recycled[0].0, 10);
2446
2447        let task = crate::task::Task::from_file(&board_dir.join("tasks").join("010-cron-task.md"))
2448            .unwrap();
2449
2450        // Status reset to todo
2451        assert_eq!(task.status, "todo");
2452
2453        // scheduled_for set to a future time
2454        let scheduled = task
2455            .scheduled_for
2456            .as_deref()
2457            .expect("scheduled_for should be set");
2458        let scheduled_dt = chrono::DateTime::parse_from_rfc3339(scheduled).unwrap();
2459        assert!(
2460            scheduled_dt > chrono::Utc::now(),
2461            "scheduled_for should be in the future, got: {scheduled}"
2462        );
2463
2464        // cron_last_run updated (should be more recent than 2 min ago)
2465        let last_run = task
2466            .cron_last_run
2467            .as_deref()
2468            .expect("cron_last_run should be set");
2469        let last_run_dt = chrono::DateTime::parse_from_rfc3339(last_run).unwrap();
2470        let two_min_ago_dt = chrono::DateTime::parse_from_rfc3339(&two_min_ago).unwrap();
2471        assert!(
2472            last_run_dt > two_min_ago_dt,
2473            "cron_last_run should be updated to now, not the old value"
2474        );
2475
2476        // Transient fields cleared
2477        assert!(task.claimed_by.is_none(), "claimed_by should be cleared");
2478        assert!(task.branch.is_none(), "branch should be cleared");
2479        assert!(task.commit.is_none(), "commit should be cleared");
2480        assert!(task.next_action.is_none(), "next_action should be cleared");
2481        assert!(
2482            task.review_owner.is_none(),
2483            "review_owner should be cleared"
2484        );
2485        assert!(task.blocked_on.is_none(), "blocked_on should be cleared");
2486        assert!(
2487            task.worktree_path.is_none(),
2488            "worktree_path should be cleared"
2489        );
2490    }
2491
2492    #[test]
2493    fn cron_recycler_skips_non_cron_done_task() {
2494        let tmp = tempfile::tempdir().unwrap();
2495        let board_dir = tmp.path();
2496
2497        // Done task WITHOUT cron_schedule
2498        let tasks_dir = board_dir.join("tasks");
2499        std::fs::create_dir_all(&tasks_dir).unwrap();
2500        let path = tasks_dir.join("011-regular-task.md");
2501        std::fs::write(
2502            &path,
2503            "---\nid: 11\ntitle: Regular Task\nstatus: done\npriority: medium\n---\n\nNon-cron task.\n",
2504        )
2505        .unwrap();
2506
2507        let recycled = recycle_cron_tasks(board_dir).unwrap();
2508        assert!(
2509            recycled.is_empty(),
2510            "non-cron done task should not be recycled"
2511        );
2512
2513        // Verify task unchanged
2514        let task = crate::task::Task::from_file(&path).unwrap();
2515        assert_eq!(task.status, "done", "status should remain done");
2516    }
2517
2518    #[test]
2519    fn e2e_done_cron_task_recycled() {
2520        use crate::team::resolver::{ResolutionStatus, resolve_board};
2521        use crate::team::test_support::{engineer_member, manager_member};
2522
2523        let tmp = tempfile::tempdir().unwrap();
2524        let board_dir = tmp.path();
2525
2526        // Create a done cron task with old cron_last_run
2527        write_cron_task(
2528            board_dir,
2529            10,
2530            "done",
2531            "0 * * * * *",
2532            "cron_last_run: \"2020-01-01T00:00:00+00:00\"\n",
2533        );
2534
2535        // Before recycling: task is done, so resolve_board excludes it
2536        let members = vec![
2537            manager_member("manager", None),
2538            engineer_member("eng-1", Some("manager"), false),
2539        ];
2540        let resolutions_before = resolve_board(board_dir, &members).unwrap();
2541        assert!(
2542            resolutions_before.is_empty(),
2543            "done task should not appear in resolve_board"
2544        );
2545
2546        // Recycle the cron task
2547        let recycled = recycle_cron_tasks(board_dir).unwrap();
2548        assert_eq!(recycled.len(), 1, "one task should be recycled");
2549        assert_eq!(recycled[0].0, 10);
2550
2551        // Verify task file was updated
2552        let task = crate::task::Task::from_file(&board_dir.join("tasks").join("010-cron-task.md"))
2553            .unwrap();
2554        assert_eq!(task.status, "todo", "status should be reset to todo");
2555        assert!(task.claimed_by.is_none(), "claimed_by should be cleared");
2556        assert!(
2557            task.cron_last_run.is_some(),
2558            "cron_last_run should be updated"
2559        );
2560
2561        // scheduled_for should be set to a future time
2562        let scheduled = task.scheduled_for.as_deref().unwrap();
2563        let scheduled_dt = chrono::DateTime::parse_from_rfc3339(scheduled).unwrap();
2564        assert!(
2565            scheduled_dt > chrono::Utc::now(),
2566            "scheduled_for should be in the future, got: {scheduled}"
2567        );
2568
2569        // After recycling: task is now todo with future scheduled_for → Blocked
2570        let resolutions_after = resolve_board(board_dir, &members).unwrap();
2571        assert_eq!(resolutions_after.len(), 1);
2572        assert_eq!(
2573            resolutions_after[0].status,
2574            ResolutionStatus::Blocked,
2575            "recycled cron task with future scheduled_for should be Blocked until its time"
2576        );
2577        assert!(
2578            resolutions_after[0]
2579                .blocking_reason
2580                .as_ref()
2581                .unwrap()
2582                .contains("scheduled for"),
2583            "blocking reason should mention 'scheduled for'"
2584        );
2585    }
2586
2587    // --- is_worktree_safe_to_mutate tests ---
2588
2589    #[test]
2590    fn safe_to_mutate_nonexistent_dir() {
2591        let tmp = tempfile::tempdir().unwrap();
2592        let missing = tmp.path().join("does-not-exist");
2593        assert!(is_worktree_safe_to_mutate(&missing).unwrap());
2594    }
2595
2596    #[test]
2597    fn safe_to_mutate_clean_worktree() {
2598        let tmp = tempfile::tempdir().unwrap();
2599        let repo = init_git_repo(&tmp);
2600        let wt_dir = repo.join(".batty").join("worktrees").join("eng-safe");
2601        let team_config_dir = repo.join(".batty").join("team_config");
2602
2603        prepare_engineer_assignment_worktree(
2604            &repo,
2605            &wt_dir,
2606            "eng-safe",
2607            "eng-safe/99",
2608            &team_config_dir,
2609        )
2610        .unwrap();
2611
2612        // No uncommitted changes — safe to mutate.
2613        assert!(is_worktree_safe_to_mutate(&wt_dir).unwrap());
2614    }
2615
2616    #[test]
2617    fn unsafe_to_mutate_dirty_task_branch() {
2618        let tmp = tempfile::tempdir().unwrap();
2619        let repo = init_git_repo(&tmp);
2620        let wt_dir = repo.join(".batty").join("worktrees").join("eng-dirty");
2621        let team_config_dir = repo.join(".batty").join("team_config");
2622
2623        prepare_engineer_assignment_worktree(
2624            &repo,
2625            &wt_dir,
2626            "eng-dirty",
2627            "eng-dirty/42",
2628            &team_config_dir,
2629        )
2630        .unwrap();
2631
2632        // Create uncommitted changes.
2633        std::fs::write(wt_dir.join("wip.txt"), "work in progress\n").unwrap();
2634        git_ok(&wt_dir, &["add", "wip.txt"]);
2635
2636        // Dirty task branch — NOT safe.
2637        assert!(!is_worktree_safe_to_mutate(&wt_dir).unwrap());
2638    }
2639
2640    #[test]
2641    fn safe_to_mutate_dirty_base_branch() {
2642        let tmp = tempfile::tempdir().unwrap();
2643        let repo = init_git_repo(&tmp);
2644        let wt_dir = repo.join(".batty").join("worktrees").join("eng-base");
2645        let team_config_dir = repo.join(".batty").join("team_config");
2646
2647        let base = engineer_base_branch_name("eng-base");
2648        setup_engineer_worktree(&repo, &wt_dir, &base, &team_config_dir).unwrap();
2649
2650        std::fs::write(wt_dir.join("junk.txt"), "junk\n").unwrap();
2651        git_ok(&wt_dir, &["add", "junk.txt"]);
2652
2653        // Dirty but on eng-main/* — safe to mutate.
2654        assert!(is_worktree_safe_to_mutate(&wt_dir).unwrap());
2655    }
2656
2657    #[test]
2658    fn unsafe_to_mutate_dirty_untracked_files_on_task_branch() {
2659        let tmp = tempfile::tempdir().unwrap();
2660        let repo = init_git_repo(&tmp);
2661        let wt_dir = repo.join(".batty").join("worktrees").join("eng-ut");
2662        let team_config_dir = repo.join(".batty").join("team_config");
2663
2664        prepare_engineer_assignment_worktree(
2665            &repo,
2666            &wt_dir,
2667            "eng-ut",
2668            "eng-ut/55",
2669            &team_config_dir,
2670        )
2671        .unwrap();
2672
2673        // Untracked file (not in .batty/) counts as user changes.
2674        std::fs::write(wt_dir.join("new_file.rs"), "fn main() {}\n").unwrap();
2675
2676        assert!(!is_worktree_safe_to_mutate(&wt_dir).unwrap());
2677    }
2678
2679    #[test]
2680    fn safe_to_mutate_only_batty_untracked() {
2681        let tmp = tempfile::tempdir().unwrap();
2682        let repo = init_git_repo(&tmp);
2683        let wt_dir = repo.join(".batty").join("worktrees").join("eng-bt");
2684        let team_config_dir = repo.join(".batty").join("team_config");
2685
2686        prepare_engineer_assignment_worktree(
2687            &repo,
2688            &wt_dir,
2689            "eng-bt",
2690            "eng-bt/33",
2691            &team_config_dir,
2692        )
2693        .unwrap();
2694
2695        // Only .batty/ untracked files — not user changes, safe.
2696        std::fs::create_dir_all(wt_dir.join(".batty").join("temp")).unwrap();
2697        std::fs::write(wt_dir.join(".batty").join("temp").join("log.txt"), "log\n").unwrap();
2698
2699        assert!(is_worktree_safe_to_mutate(&wt_dir).unwrap());
2700    }
2701
2702    // --- auto_commit_before_reset tests ---
2703
2704    #[test]
2705    fn auto_commit_saves_uncommitted_changes() {
2706        let Some(_path_lock) = git_test_guard() else {
2707            return;
2708        };
2709        let tmp = tempfile::tempdir().unwrap();
2710        let repo = init_git_repo(&tmp);
2711        let wt_dir = repo.join(".batty").join("worktrees").join("eng-ac");
2712        let team_config_dir = repo.join(".batty").join("team_config");
2713
2714        prepare_engineer_assignment_worktree(
2715            &repo,
2716            &wt_dir,
2717            "eng-ac",
2718            "eng-ac/77",
2719            &team_config_dir,
2720        )
2721        .unwrap();
2722
2723        // Create uncommitted changes.
2724        std::fs::write(wt_dir.join("work.rs"), "fn hello() {}\n").unwrap();
2725        git_ok(&wt_dir, &["add", "work.rs"]);
2726
2727        assert!(auto_commit_before_reset(&wt_dir));
2728
2729        // Worktree should now be clean.
2730        crate::team::test_support::assert_worktree_clean(&wt_dir);
2731
2732        // Verify the commit message contains the wip marker.
2733        let log = git_stdout(&wt_dir, &["log", "--oneline", "-1"]);
2734        assert!(
2735            log.contains("wip: auto-save"),
2736            "commit should have wip marker, got: {log}"
2737        );
2738    }
2739
2740    #[test]
2741    fn auto_commit_noop_on_clean_worktree() {
2742        let tmp = tempfile::tempdir().unwrap();
2743        let repo = init_git_repo(&tmp);
2744        let wt_dir = repo.join(".batty").join("worktrees").join("eng-cl");
2745        let team_config_dir = repo.join(".batty").join("team_config");
2746
2747        prepare_engineer_assignment_worktree(
2748            &repo,
2749            &wt_dir,
2750            "eng-cl",
2751            "eng-cl/88",
2752            &team_config_dir,
2753        )
2754        .unwrap();
2755
2756        let before = git_stdout(&wt_dir, &["rev-parse", "HEAD"]);
2757
2758        // No changes — should succeed without creating a commit.
2759        assert!(auto_commit_before_reset(&wt_dir));
2760
2761        let after = git_stdout(&wt_dir, &["rev-parse", "HEAD"]);
2762        assert_eq!(
2763            before, after,
2764            "no new commit should be created for clean worktree"
2765        );
2766    }
2767
2768    #[test]
2769    fn auto_commit_saves_untracked_files() {
2770        let Some(_path_lock) = git_test_guard() else {
2771            return;
2772        };
2773        let tmp = tempfile::tempdir().unwrap();
2774        let repo = init_git_repo(&tmp);
2775        let wt_dir = repo.join(".batty").join("worktrees").join("eng-ut2");
2776        let team_config_dir = repo.join(".batty").join("team_config");
2777
2778        prepare_engineer_assignment_worktree(
2779            &repo,
2780            &wt_dir,
2781            "eng-ut2",
2782            "eng-ut2/99",
2783            &team_config_dir,
2784        )
2785        .unwrap();
2786
2787        // Create untracked file (not staged).
2788        std::fs::write(wt_dir.join("new_file.txt"), "new content\n").unwrap();
2789
2790        assert!(auto_commit_before_reset(&wt_dir));
2791
2792        // Worktree should be clean.
2793        crate::team::test_support::assert_worktree_clean(&wt_dir);
2794    }
2795
2796    #[test]
2797    fn auto_clean_worktree_uses_commit_not_stash() {
2798        let Some(_path_lock) = git_test_guard() else {
2799            return;
2800        };
2801        let tmp = tempfile::tempdir().unwrap();
2802        let repo = init_git_repo(&tmp);
2803        let wt_dir = repo.join(".batty").join("worktrees").join("eng-ns");
2804        let team_config_dir = repo.join(".batty").join("team_config");
2805
2806        prepare_engineer_assignment_worktree(
2807            &repo,
2808            &wt_dir,
2809            "eng-ns",
2810            "eng-ns/66",
2811            &team_config_dir,
2812        )
2813        .unwrap();
2814
2815        // Create both tracked and untracked changes so the shared reset helper
2816        // preserves the full dirty worktree before cleanup.
2817        std::fs::write(wt_dir.join("tracked.txt"), "tracked work\n").unwrap();
2818        git_ok(&wt_dir, &["add", "tracked.txt"]);
2819        std::fs::write(wt_dir.join("untracked.txt"), "untracked work\n").unwrap();
2820
2821        auto_clean_worktree(&wt_dir).unwrap();
2822
2823        crate::team::test_support::assert_worktree_clean(&wt_dir);
2824
2825        // No stashes should have been created.
2826        let stash = git_stdout(&wt_dir, &["stash", "list"]);
2827        assert!(
2828            stash.trim().is_empty(),
2829            "no stash should be created, got: {stash}"
2830        );
2831
2832        // A wip commit should exist.
2833        let log = git_stdout(&wt_dir, &["log", "--oneline", "-1"]);
2834        assert!(
2835            log.contains("wip: auto-save"),
2836            "should have wip commit, got: {log}"
2837        );
2838        assert_eq!(
2839            git_stdout(&wt_dir, &["show", "HEAD:tracked.txt"]),
2840            "tracked work"
2841        );
2842        assert_eq!(
2843            git_stdout(&wt_dir, &["show", "HEAD:untracked.txt"]),
2844            "untracked work"
2845        );
2846    }
2847
2848    #[test]
2849    fn auto_clean_worktree_blocks_when_preserve_fails() {
2850        let Some(_path_lock) = git_test_guard() else {
2851            return;
2852        };
2853        let tmp = tempfile::tempdir().unwrap();
2854        let repo = init_git_repo(&tmp);
2855        let wt_dir = repo.join(".batty").join("worktrees").join("eng-blocked");
2856        let team_config_dir = repo.join(".batty").join("team_config");
2857
2858        prepare_engineer_assignment_worktree(
2859            &repo,
2860            &wt_dir,
2861            "eng-blocked",
2862            "eng-blocked/77",
2863            &team_config_dir,
2864        )
2865        .unwrap();
2866
2867        std::fs::write(wt_dir.join("tracked.txt"), "tracked dirty work\n").unwrap();
2868        git_ok(&wt_dir, &["add", "tracked.txt"]);
2869        let git_dir = PathBuf::from(git_stdout(&wt_dir, &["rev-parse", "--git-dir"]));
2870        let git_dir = if git_dir.is_absolute() {
2871            git_dir
2872        } else {
2873            wt_dir.join(git_dir)
2874        };
2875        std::fs::write(git_dir.join("index.lock"), "locked\n").unwrap();
2876
2877        let error = auto_clean_worktree(&wt_dir).unwrap_err();
2878        assert!(
2879            error
2880                .to_string()
2881                .contains("could not safely auto-save dirty worktree"),
2882            "expected explicit preservation blocker, got: {error}"
2883        );
2884        assert_eq!(current_worktree_branch(&wt_dir).unwrap(), "eng-blocked/77");
2885    }
2886
2887    #[test]
2888    fn preserve_worktree_with_commit_returns_false_when_clean() {
2889        let tmp = tempfile::tempdir().unwrap();
2890        let repo = init_git_repo(&tmp);
2891        let wt_dir = repo
2892            .join(".batty")
2893            .join("worktrees")
2894            .join("eng-clean-preserve");
2895        let team_config_dir = repo.join(".batty").join("team_config");
2896
2897        prepare_engineer_assignment_worktree(
2898            &repo,
2899            &wt_dir,
2900            "eng-clean-preserve",
2901            "eng-clean-preserve/101",
2902            &team_config_dir,
2903        )
2904        .unwrap();
2905
2906        let saved = preserve_worktree_with_commit(
2907            &wt_dir,
2908            "wip: auto-save before restart [batty]",
2909            Duration::from_secs(5),
2910        )
2911        .unwrap();
2912        assert!(!saved);
2913    }
2914
2915    #[test]
2916    fn preserve_worktree_with_commit_saves_dirty_changes() {
2917        let Some(_path_lock) = git_test_guard() else {
2918            return;
2919        };
2920        let tmp = tempfile::tempdir().unwrap();
2921        let repo = init_git_repo(&tmp);
2922        let wt_dir = repo.join(".batty").join("worktrees").join("eng-preserve");
2923        let team_config_dir = repo.join(".batty").join("team_config");
2924
2925        prepare_engineer_assignment_worktree(
2926            &repo,
2927            &wt_dir,
2928            "eng-preserve",
2929            "eng-preserve/103",
2930            &team_config_dir,
2931        )
2932        .unwrap();
2933
2934        std::fs::write(wt_dir.join("preserved.txt"), "keep this work\n").unwrap();
2935
2936        let saved = preserve_worktree_with_commit(
2937            &wt_dir,
2938            "wip: auto-save before restart [batty]",
2939            Duration::from_secs(5),
2940        )
2941        .unwrap();
2942        assert!(saved, "dirty worktree should be auto-committed");
2943
2944        crate::team::test_support::assert_worktree_clean(&wt_dir);
2945
2946        let log = git_stdout(&wt_dir, &["log", "--oneline", "-1"]);
2947        assert!(
2948            log.contains("wip: auto-save before restart [batty]"),
2949            "expected restart preservation commit, got: {log}"
2950        );
2951    }
2952
2953    #[test]
2954    fn preserve_worktree_with_commit_ignores_batty_untracked_only() {
2955        let tmp = tempfile::tempdir().unwrap();
2956        let repo = init_git_repo(&tmp);
2957        let wt_dir = repo
2958            .join(".batty")
2959            .join("worktrees")
2960            .join("eng-batty-clean");
2961        let team_config_dir = repo.join(".batty").join("team_config");
2962
2963        prepare_engineer_assignment_worktree(
2964            &repo,
2965            &wt_dir,
2966            "eng-batty-clean",
2967            "eng-batty-clean/104",
2968            &team_config_dir,
2969        )
2970        .unwrap();
2971
2972        std::fs::create_dir_all(wt_dir.join(".batty").join("scratch")).unwrap();
2973        std::fs::write(
2974            wt_dir.join(".batty").join("scratch").join("session.log"),
2975            "transient\n",
2976        )
2977        .unwrap();
2978
2979        let head_before = git_stdout(&wt_dir, &["rev-parse", "HEAD"]);
2980        let saved = preserve_worktree_with_commit(
2981            &wt_dir,
2982            "wip: auto-save before restart [batty]",
2983            Duration::from_secs(1),
2984        )
2985        .unwrap();
2986        assert!(
2987            !saved,
2988            "only .batty untracked files should not trigger commit"
2989        );
2990
2991        let head_after = git_stdout(&wt_dir, &["rev-parse", "HEAD"]);
2992        assert_eq!(head_before, head_after, "no commit should be created");
2993    }
2994
2995    #[test]
2996    fn preserve_worktree_with_commit_times_out() {
2997        let Some(_path_lock) = git_test_guard() else {
2998            return;
2999        };
3000        let tmp = tempfile::tempdir().unwrap();
3001        let repo = init_git_repo(&tmp);
3002        let wt_dir = repo.join(".batty").join("worktrees").join("eng-timeout");
3003        let team_config_dir = repo.join(".batty").join("team_config");
3004
3005        prepare_engineer_assignment_worktree(
3006            &repo,
3007            &wt_dir,
3008            "eng-timeout",
3009            "eng-timeout/102",
3010            &team_config_dir,
3011        )
3012        .unwrap();
3013
3014        std::fs::write(wt_dir.join("slow.txt"), "pending\n").unwrap();
3015
3016        // The timeout path is hard to test reliably because
3017        // run_git_with_timeout falls back to hardcoded git paths
3018        // (/usr/bin/git, /opt/homebrew/bin/git) bypassing PATH shims.
3019        // Instead, verify that a very fast commit with a generous timeout
3020        // succeeds — proving the timeout doesn't fire spuriously.
3021        let result = preserve_worktree_with_commit(
3022            &wt_dir,
3023            "wip: auto-save before restart [batty]",
3024            Duration::from_secs(30),
3025        );
3026        assert!(
3027            result.is_ok(),
3028            "commit with generous timeout should succeed"
3029        );
3030    }
3031
3032    // --- priority_rank tests ---
3033
3034    #[test]
3035    fn priority_rank_known_values() {
3036        assert_eq!(priority_rank("critical"), 0);
3037        assert_eq!(priority_rank("high"), 1);
3038        assert_eq!(priority_rank("medium"), 2);
3039        assert_eq!(priority_rank("low"), 3);
3040    }
3041
3042    #[test]
3043    fn priority_rank_unknown_returns_lowest() {
3044        assert_eq!(priority_rank(""), 4);
3045        assert_eq!(priority_rank("urgent"), 4);
3046        assert_eq!(priority_rank("CRITICAL"), 4); // case-sensitive
3047    }
3048
3049    // --- next_unclaimed_task edge cases ---
3050
3051    #[test]
3052    fn next_unclaimed_task_all_done_returns_none() {
3053        let tmp = tempfile::tempdir().unwrap();
3054        write_task_file(tmp.path(), 1, "done-task", "done", "high", None, &[]);
3055        write_task_file(
3056            tmp.path(),
3057            2,
3058            "in-progress-task",
3059            "in-progress",
3060            "critical",
3061            None,
3062            &[],
3063        );
3064
3065        let task = next_unclaimed_task(tmp.path()).unwrap();
3066        assert!(task.is_none());
3067    }
3068
3069    #[test]
3070    fn next_unclaimed_task_respects_backlog_status() {
3071        let tmp = tempfile::tempdir().unwrap();
3072        write_task_file(
3073            tmp.path(),
3074            1,
3075            "backlog-task",
3076            "backlog",
3077            "medium",
3078            None,
3079            &[],
3080        );
3081
3082        let task = next_unclaimed_task(tmp.path()).unwrap().unwrap();
3083        assert_eq!(task.id, 1);
3084    }
3085
3086    #[test]
3087    fn next_unclaimed_task_tiebreaks_by_id() {
3088        let tmp = tempfile::tempdir().unwrap();
3089        write_task_file(tmp.path(), 10, "task-ten", "todo", "high", None, &[]);
3090        write_task_file(tmp.path(), 5, "task-five", "todo", "high", None, &[]);
3091        write_task_file(tmp.path(), 20, "task-twenty", "todo", "high", None, &[]);
3092
3093        let task = next_unclaimed_task(tmp.path()).unwrap().unwrap();
3094        assert_eq!(task.id, 5, "should pick lowest id when priority is tied");
3095    }
3096
3097    #[test]
3098    fn next_unclaimed_task_skips_blocked_frontmatter() {
3099        let tmp = tempfile::tempdir().unwrap();
3100        write_task_file_with_workflow_frontmatter(tmp.path(), 1, "blocked-task", "blocked: yes\n");
3101        write_task_file(tmp.path(), 2, "free-task", "todo", "low", None, &[]);
3102
3103        let task = next_unclaimed_task(tmp.path()).unwrap().unwrap();
3104        assert_eq!(task.id, 2);
3105    }
3106
3107    #[test]
3108    fn next_unclaimed_task_allows_done_dependency() {
3109        let tmp = tempfile::tempdir().unwrap();
3110        write_task_file(tmp.path(), 1, "done-dep", "done", "low", None, &[]);
3111        write_task_file(tmp.path(), 2, "depends-on-done", "todo", "high", None, &[1]);
3112
3113        let task = next_unclaimed_task(tmp.path()).unwrap().unwrap();
3114        assert_eq!(task.id, 2, "task with done dependency should be available");
3115    }
3116
3117    #[test]
3118    fn next_unclaimed_task_blocks_on_undone_dependency() {
3119        let tmp = tempfile::tempdir().unwrap();
3120        write_task_file(
3121            tmp.path(),
3122            1,
3123            "in-progress-dep",
3124            "in-progress",
3125            "low",
3126            None,
3127            &[],
3128        );
3129        write_task_file(
3130            tmp.path(),
3131            2,
3132            "blocked-by-dep",
3133            "todo",
3134            "critical",
3135            None,
3136            &[1],
3137        );
3138
3139        // Task 2 depends on task 1 which is in-progress — should not be picked
3140        let task = next_unclaimed_task(tmp.path()).unwrap();
3141        assert!(
3142            task.is_none(),
3143            "task with in-progress dependency should not be available"
3144        );
3145    }
3146
3147    #[test]
3148    fn next_unclaimed_task_nonexistent_dependency_treated_as_done() {
3149        let tmp = tempfile::tempdir().unwrap();
3150        // Task depends on id 999 which doesn't exist — treated as satisfied
3151        write_task_file(tmp.path(), 1, "orphan-dep", "todo", "high", None, &[999]);
3152
3153        let task = next_unclaimed_task(tmp.path()).unwrap().unwrap();
3154        assert_eq!(task.id, 1);
3155    }
3156
3157    // --- read_task_title edge cases ---
3158
3159    #[test]
3160    fn read_task_title_quoted_title() {
3161        let tmp = tempfile::tempdir().unwrap();
3162        let tasks_dir = tmp.path().join("tasks");
3163        std::fs::create_dir_all(&tasks_dir).unwrap();
3164        std::fs::write(
3165            tasks_dir.join("007-quoted.md"),
3166            "---\ntitle: 'My Quoted Task'\nstatus: todo\n---\nBody\n",
3167        )
3168        .unwrap();
3169        let title = read_task_title(tmp.path(), 7);
3170        assert_eq!(title, "My Quoted Task");
3171    }
3172
3173    #[test]
3174    fn read_task_title_double_quoted() {
3175        let tmp = tempfile::tempdir().unwrap();
3176        let tasks_dir = tmp.path().join("tasks");
3177        std::fs::create_dir_all(&tasks_dir).unwrap();
3178        std::fs::write(
3179            tasks_dir.join("008-double.md"),
3180            "---\ntitle: \"Double Quoted\"\nstatus: todo\n---\nBody\n",
3181        )
3182        .unwrap();
3183        let title = read_task_title(tmp.path(), 8);
3184        assert_eq!(title, "Double Quoted");
3185    }
3186
3187    #[test]
3188    fn read_task_title_no_title_line_returns_fallback() {
3189        let tmp = tempfile::tempdir().unwrap();
3190        let tasks_dir = tmp.path().join("tasks");
3191        std::fs::create_dir_all(&tasks_dir).unwrap();
3192        std::fs::write(
3193            tasks_dir.join("009-no-title.md"),
3194            "---\nstatus: todo\npriority: low\n---\nBody\n",
3195        )
3196        .unwrap();
3197        let title = read_task_title(tmp.path(), 9);
3198        assert_eq!(title, "Task #9");
3199    }
3200
3201    #[test]
3202    fn read_task_title_three_digit_id_prefix() {
3203        let tmp = tempfile::tempdir().unwrap();
3204        let tasks_dir = tmp.path().join("tasks");
3205        std::fs::create_dir_all(&tasks_dir).unwrap();
3206        std::fs::write(
3207            tasks_dir.join("123-big-id.md"),
3208            "---\ntitle: Big ID Task\nstatus: todo\n---\n",
3209        )
3210        .unwrap();
3211        let title = read_task_title(tmp.path(), 123);
3212        assert_eq!(title, "Big ID Task");
3213    }
3214
3215    // --- engineer_base_branch_name ---
3216
3217    #[test]
3218    fn engineer_base_branch_name_format() {
3219        assert_eq!(engineer_base_branch_name("eng-1-1"), "eng-main/eng-1-1");
3220        assert_eq!(engineer_base_branch_name("eng-2"), "eng-main/eng-2");
3221    }
3222
3223    // --- map_git_error ---
3224
3225    #[test]
3226    fn map_git_error_ok_passes_through() {
3227        let result: std::result::Result<i32, super::git_cmd::GitError> = Ok(42);
3228        let mapped = map_git_error(result, "test action");
3229        assert_eq!(mapped.unwrap(), 42);
3230    }
3231
3232    #[test]
3233    fn map_git_error_err_wraps_message() {
3234        let result: std::result::Result<i32, super::git_cmd::GitError> =
3235            Err(super::git_cmd::GitError::Permanent {
3236                message: "git status failed".to_string(),
3237                stderr: "fatal: something".to_string(),
3238            });
3239        let err = map_git_error(result, "checking status").unwrap_err();
3240        let msg = err.to_string();
3241        assert!(msg.contains("checking status"), "got: {msg}");
3242    }
3243
3244    // --- cron edge cases ---
3245
3246    #[test]
3247    fn cron_recycle_invalid_expression_skips() {
3248        let tmp = tempfile::tempdir().unwrap();
3249        write_cron_task(
3250            tmp.path(),
3251            1,
3252            "done",
3253            "not a cron expression",
3254            "cron_last_run: \"2020-01-01T00:00:00+00:00\"\n",
3255        );
3256
3257        let recycled = recycle_cron_tasks(tmp.path()).unwrap();
3258        assert!(
3259            recycled.is_empty(),
3260            "invalid cron expression should be skipped"
3261        );
3262    }
3263
3264    #[test]
3265    fn cron_recycle_no_last_run_defaults_to_yesterday() {
3266        let tmp = tempfile::tempdir().unwrap();
3267        // Done cron task with no cron_last_run — should use now - 1 day as reference
3268        write_cron_task(tmp.path(), 1, "done", "0 * * * * *", "");
3269
3270        let recycled = recycle_cron_tasks(tmp.path()).unwrap();
3271        assert_eq!(
3272            recycled.len(),
3273            1,
3274            "should recycle even without cron_last_run"
3275        );
3276    }
3277
3278    #[test]
3279    fn cron_recycle_future_trigger_skips() {
3280        let tmp = tempfile::tempdir().unwrap();
3281        // Set last run to now so next trigger is in the future
3282        let now = chrono::Utc::now().to_rfc3339();
3283        write_cron_task(
3284            tmp.path(),
3285            1,
3286            "done",
3287            "0 0 1 1 * 2099",
3288            &format!("cron_last_run: \"{now}\"\n"),
3289        );
3290
3291        let recycled = recycle_cron_tasks(tmp.path()).unwrap();
3292        assert!(recycled.is_empty(), "future trigger should be skipped");
3293    }
3294
3295    // --- sentinel tests for error resilience (#311) ---
3296
3297    /// Refresh on a stale/nonexistent worktree should return Ok, not panic.
3298    #[test]
3299    fn refresh_nonexistent_worktree_returns_ok() {
3300        let tmp = tempfile::tempdir().unwrap();
3301        let fake_worktree = tmp.path().join("does-not-exist");
3302        let team_cfg = tmp.path().join("team_config");
3303        std::fs::create_dir_all(&team_cfg).unwrap();
3304
3305        let result = refresh_engineer_worktree(tmp.path(), &fake_worktree, "no-branch", &team_cfg);
3306        // Non-existent worktree should be handled gracefully (early return Ok)
3307        assert!(
3308            result.is_ok(),
3309            "refresh on nonexistent worktree should not panic: {result:?}"
3310        );
3311    }
3312
3313    /// run_tests_in_worktree should return a clean error when cargo is not
3314    /// found, and should surface an invalid worktree as a failed test run
3315    /// instead of panicking.
3316    #[test]
3317    fn test_gating_missing_dir_returns_error() {
3318        let tmp = tempfile::tempdir().unwrap();
3319        let fake_dir = tmp.path().join("missing-worktree");
3320        assert!(!fake_dir.exists(), "test requires a nonexistent directory");
3321        let result = run_tests_in_worktree(&fake_dir, None);
3322        let output = result.expect("missing worktree should surface as a failed test run");
3323        assert!(
3324            !output.passed,
3325            "run_tests_in_worktree on missing dir should fail cleanly"
3326        );
3327        let err_msg = output.output;
3328        assert!(
3329            err_msg.contains("No such file")
3330                || err_msg.contains("failed")
3331                || err_msg.contains("could not find"),
3332            "error should describe the failed test operation, got: {err_msg}"
3333        );
3334    }
3335
3336    /// checkout_worktree_branch_from_main should propagate an error cleanly
3337    /// when run against a non-git directory, not panic.
3338    #[test]
3339    fn checkout_branch_in_non_git_dir_returns_error() {
3340        let tmp = tempfile::tempdir().unwrap();
3341        // tmp is not a git repo, so git operations should fail
3342        let result = checkout_worktree_branch_from_main(tmp.path(), "fake-branch");
3343        assert!(
3344            result.is_err(),
3345            "checkout on non-git dir should return Err, not panic"
3346        );
3347    }
3348
3349    /// Verify the production code in this file has zero bare .unwrap() or
3350    /// .expect() calls (only safe fallback variants like unwrap_or_default).
3351    #[test]
3352    fn no_panicking_unwraps_in_production_code() {
3353        let count = production_unwrap_expect_count(Path::new("src/team/task_loop.rs"));
3354        assert_eq!(
3355            count, 0,
3356            "production code should have zero bare .unwrap()/.expect() calls, found {count}"
3357        );
3358    }
3359
3360    #[test]
3361    fn git_has_unresolved_conflicts_detects_unmerged_status_entries() {
3362        assert!(line_has_unresolved_conflict("UU src/team/verification.rs"));
3363        assert!(line_has_unresolved_conflict("AA src/lib.rs"));
3364        assert!(line_has_unresolved_conflict("DU src/main.rs"));
3365        assert!(!line_has_unresolved_conflict(" M src/main.rs"));
3366        assert!(!line_has_unresolved_conflict("?? scratch.txt"));
3367    }
3368
3369    #[test]
3370    fn merge_additive_only_text_keeps_both_insertions() {
3371        let base = "const CHECKS: &[&str] = &[\n    \"existing\",\n];\n";
3372        let current = "const CHECKS: &[&str] = &[\n    \"main\",\n    \"existing\",\n];\n";
3373        let incoming = "const CHECKS: &[&str] = &[\n    \"engineer\",\n    \"existing\",\n];\n";
3374
3375        let merged = merge_additive_only_text(base, current, incoming)
3376            .expect("pure insertions should auto-merge");
3377
3378        assert!(merged.contains("\"main\""));
3379        assert!(merged.contains("\"engineer\""));
3380        assert!(merged.contains("\"existing\""));
3381    }
3382
3383    #[test]
3384    fn merge_additive_only_text_rejects_modified_base_lines() {
3385        let base = "const CHECKS: &[&str] = &[\n    \"existing\",\n];\n";
3386        let current = "const CHECKS: &[&str] = &[\n    \"existing\",\n    \"main\",\n];\n";
3387        let incoming = "const CHECKS: &[&str] = &[\n    \"renamed\",\n];\n";
3388
3389        assert!(merge_additive_only_text(base, current, incoming).is_none());
3390    }
3391}