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