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