Skip to main content

batty_cli/team/
merge.rs

1//! Merge orchestration extracted from the team daemon.
2//!
3//! This module owns the completion path after an engineer reports a task as
4//! done in a worktree-based flow. It validates that the branch contains real
5//! work, runs the configured test gate, serializes merges with a lock, and
6//! either lands the branch on `main` or escalates conflicts and failures back
7//! through the daemon.
8//!
9//! The daemon calls into this module so the poll loop can stay focused on
10//! orchestration while merge-specific retries and board transitions remain in
11//! one place.
12
13use std::fs::OpenOptions;
14use std::path::{Path, PathBuf};
15use std::time::{Instant, SystemTime, UNIX_EPOCH};
16
17use anyhow::{Context, Result, bail};
18use tracing::{info, warn};
19
20use super::artifact::append_test_timing_record;
21#[cfg(test)]
22use super::artifact::read_test_timing_log;
23use super::auto_merge::{self, AutoMergeDecision};
24use super::daemon::TeamDaemon;
25use super::task_loop::{
26    auto_commit_before_reset, branch_is_merged_into, checkout_worktree_branch_from_main,
27    current_worktree_branch, delete_branch, engineer_base_branch_name, is_worktree_safe_to_mutate,
28    read_task_title, run_tests_in_worktree,
29};
30
31fn run_git_with_context(
32    repo_dir: &Path,
33    args: &[&str],
34    intent: &str,
35) -> Result<std::process::Output> {
36    let command = format!("git {}", args.join(" "));
37    std::process::Command::new("git")
38        .args(args)
39        .current_dir(repo_dir)
40        .output()
41        .with_context(|| {
42            format!(
43                "failed while trying to {intent}: could not execute `{command}` in {}",
44                repo_dir.display()
45            )
46        })
47}
48
49fn describe_git_failure(repo_dir: &Path, args: &[&str], intent: &str, stderr: &str) -> String {
50    format!(
51        "failed while trying to {intent}: `git {}` in {} returned: {}",
52        args.join(" "),
53        repo_dir.display(),
54        stderr.trim()
55    )
56}
57
58pub(crate) struct MergeLock {
59    path: PathBuf,
60}
61
62impl MergeLock {
63    pub fn acquire(project_root: &Path) -> Result<Self> {
64        let path = project_root.join(".batty").join("merge.lock");
65        if let Some(parent) = path.parent() {
66            std::fs::create_dir_all(parent)?;
67        }
68        let start = std::time::Instant::now();
69        loop {
70            match OpenOptions::new().write(true).create_new(true).open(&path) {
71                Ok(_) => return Ok(Self { path }),
72                Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
73                    if start.elapsed() > std::time::Duration::from_secs(60) {
74                        bail!("merge lock timeout after 60s: {}", path.display());
75                    }
76                    std::thread::sleep(std::time::Duration::from_millis(500));
77                }
78                Err(error) => bail!("failed to acquire merge lock: {error}"),
79            }
80        }
81    }
82}
83
84impl Drop for MergeLock {
85    fn drop(&mut self) {
86        let _ = std::fs::remove_file(&self.path);
87    }
88}
89
90#[derive(Debug)]
91pub(crate) enum MergeOutcome {
92    Success,
93    RebaseConflict(String),
94    MergeFailure(String),
95}
96
97pub(crate) fn handle_engineer_completion(daemon: &mut TeamDaemon, engineer: &str) -> Result<()> {
98    let Some(task_id) = daemon.active_task_id(engineer) else {
99        return Ok(());
100    };
101
102    if !daemon.member_uses_worktrees(engineer) {
103        return Ok(());
104    }
105
106    let worktree_dir = daemon.worktree_dir(engineer);
107    let board_dir = daemon.board_dir();
108    let board_dir_str = board_dir.to_string_lossy().to_string();
109    let manager_name = daemon.manager_name(engineer);
110
111    if commits_ahead_of_main(&worktree_dir)? == 0 {
112        // Do NOT clear active task or set idle — the engineer still owns this task.
113        // Clearing would orphan the board task in-progress with no engineer tracking it.
114        let msg = "Completion rejected: your branch has no commits ahead of main. Commit your changes before reporting done again.";
115        daemon.queue_message("batty", engineer, msg)?;
116        warn!(
117            engineer,
118            task_id,
119            "engineer idle but no commits on task branch — keeping task #{task_id} active for {engineer}"
120        );
121        return Ok(());
122    }
123
124    let task_branch = current_worktree_branch(&worktree_dir)?;
125    let test_started = Instant::now();
126    let (tests_passed, output_truncated) = run_tests_in_worktree(&worktree_dir)?;
127    let test_duration_ms = test_started.elapsed().as_millis() as u64;
128    if tests_passed {
129        let task_title = read_task_title(&board_dir, task_id);
130
131        // --- Confidence scoring (always runs for observability) ---
132        let policy = daemon.config.team_config.workflow_policy.auto_merge.clone();
133        let auto_merge_override = daemon.auto_merge_override(task_id);
134
135        // Analyze diff and emit confidence score for every completed task
136        let diff_analysis = auto_merge::analyze_diff(daemon.project_root(), "main", &task_branch);
137        if let Ok(ref summary) = diff_analysis {
138            let confidence = auto_merge::compute_merge_confidence(summary, &policy);
139            let task_str = task_id.to_string();
140            let info = super::events::MergeConfidenceInfo {
141                engineer,
142                task: &task_str,
143                confidence,
144                files_changed: summary.files_changed,
145                lines_changed: summary.total_lines(),
146                has_migrations: summary.has_migrations,
147                has_config_changes: summary.has_config_changes,
148                rename_count: summary.rename_count,
149            };
150            daemon.record_merge_confidence_scored(&info);
151        }
152
153        // If override explicitly disables auto-merge, route to manual review
154        if auto_merge_override == Some(false) {
155            info!(
156                engineer,
157                task_id, "auto-merge disabled by per-task override, routing to manual review"
158            );
159            if let Some(ref manager_name) = manager_name {
160                let msg = format!(
161                    "[{engineer}] Task #{task_id} passed tests. Auto-merge disabled by override — awaiting manual review.\nTitle: {task_title}"
162                );
163                daemon.queue_message(engineer, manager_name, &msg)?;
164                daemon.mark_member_working(manager_name);
165            }
166            return Ok(());
167        }
168
169        // Evaluate auto-merge if policy is enabled or override forces it
170        let should_try_auto_merge = auto_merge_override == Some(true) || policy.enabled;
171        if should_try_auto_merge {
172            match diff_analysis {
173                Ok(ref summary) => {
174                    let decision = if auto_merge_override == Some(true) {
175                        // Force auto-merge regardless of policy thresholds
176                        AutoMergeDecision::AutoMerge {
177                            confidence: auto_merge::compute_merge_confidence(summary, &policy),
178                        }
179                    } else {
180                        auto_merge::should_auto_merge(summary, &policy, true)
181                    };
182
183                    match decision {
184                        AutoMergeDecision::AutoMerge { confidence } => {
185                            info!(
186                                engineer,
187                                task_id,
188                                confidence,
189                                files = summary.files_changed,
190                                lines = summary.total_lines(),
191                                "auto-merging task"
192                            );
193                            daemon.record_task_auto_merged(
194                                engineer,
195                                task_id,
196                                confidence,
197                                summary.files_changed,
198                                summary.total_lines(),
199                            );
200                            // Fall through to normal merge path below
201                        }
202                        AutoMergeDecision::ManualReview {
203                            confidence,
204                            reasons,
205                        } => {
206                            info!(
207                                engineer,
208                                task_id,
209                                confidence,
210                                ?reasons,
211                                "routing to manual review"
212                            );
213                            if let Some(ref manager_name) = manager_name {
214                                let reason_text = reasons.join("; ");
215                                let msg = format!(
216                                    "[{engineer}] Task #{task_id} passed tests but requires manual review.\nTitle: {task_title}\nConfidence: {confidence:.2}\nReasons: {reason_text}"
217                                );
218                                daemon.queue_message(engineer, manager_name, &msg)?;
219                                daemon.mark_member_working(manager_name);
220                            }
221                            return Ok(());
222                        }
223                    }
224                }
225                Err(ref error) => {
226                    warn!(engineer, task_id, error = %error, "auto-merge diff analysis failed, falling through to normal merge");
227                }
228            }
229        }
230
231        let lock =
232            MergeLock::acquire(daemon.project_root()).context("failed to acquire merge lock")?;
233
234        match merge_engineer_branch(daemon.project_root(), engineer)? {
235            MergeOutcome::Success => {
236                drop(lock);
237
238                if let Err(error) = record_merge_test_timing(
239                    daemon,
240                    task_id,
241                    engineer,
242                    &task_branch,
243                    test_duration_ms,
244                ) {
245                    warn!(
246                        engineer,
247                        task_id,
248                        error = %error,
249                        "failed to record merge test timing"
250                    );
251                }
252
253                let board_update_ok = daemon.run_kanban_md_nonfatal(
254                    &[
255                        "move",
256                        &task_id.to_string(),
257                        "done",
258                        "--claim",
259                        engineer,
260                        "--dir",
261                        &board_dir_str,
262                    ],
263                    &format!("move task #{task_id} to done"),
264                    manager_name
265                        .as_deref()
266                        .into_iter()
267                        .chain(std::iter::once(engineer)),
268                );
269
270                if let Some(ref manager_name) = manager_name {
271                    let msg = format!(
272                        "[{engineer}] Task #{task_id} completed.\nTitle: {task_title}\nTests: passed\nMerge: success{}",
273                        if board_update_ok {
274                            ""
275                        } else {
276                            "\nBoard: update failed; decide next board action manually."
277                        }
278                    );
279                    daemon.queue_message(engineer, manager_name, &msg)?;
280                    daemon.mark_member_working(manager_name);
281                }
282
283                if let Some(ref manager_name) = manager_name {
284                    let rollup = format!(
285                        "Rollup: Task #{task_id} completed by {engineer}. Tests passed, merged to main.{}",
286                        if board_update_ok {
287                            ""
288                        } else {
289                            " Board automation failed; decide manually."
290                        }
291                    );
292                    daemon.notify_reports_to(manager_name, &rollup)?;
293                }
294
295                daemon.clear_active_task(engineer);
296                daemon.record_task_completed(engineer, Some(task_id));
297                daemon.set_member_idle(engineer);
298            }
299            MergeOutcome::RebaseConflict(conflict_info) => {
300                drop(lock);
301
302                let attempt = daemon.increment_retry(engineer);
303                if attempt <= 2 {
304                    let msg = format!(
305                        "Merge conflict during rebase onto main (attempt {attempt}/2). Fix the conflicts in your worktree and try again:\n{conflict_info}"
306                    );
307                    daemon.queue_message("batty", engineer, &msg)?;
308                    daemon.mark_member_working(engineer);
309                    info!(engineer, attempt, "rebase conflict, sending back for retry");
310                } else {
311                    if let Some(ref manager_name) = manager_name {
312                        let msg = format!(
313                            "[{engineer}] task #{task_id} has unresolvable merge conflicts after 2 retries. Escalating.\n{conflict_info}"
314                        );
315                        daemon.queue_message(engineer, manager_name, &msg)?;
316                        daemon.mark_member_working(manager_name);
317                    }
318
319                    daemon.record_task_escalated(
320                        engineer,
321                        task_id.to_string(),
322                        Some("merge_conflict"),
323                    );
324
325                    if let Some(ref manager_name) = manager_name {
326                        let escalation = format!(
327                            "ESCALATION: Task #{task_id} assigned to {engineer} has unresolvable merge conflicts. Task blocked on board."
328                        );
329                        daemon.notify_reports_to(manager_name, &escalation)?;
330                    }
331
332                    daemon.run_kanban_md_nonfatal(
333                        &[
334                            "edit",
335                            &task_id.to_string(),
336                            "--block",
337                            "merge conflicts after 2 retries",
338                            "--dir",
339                            &board_dir_str,
340                        ],
341                        &format!("block task #{task_id} after merge conflict retries"),
342                        manager_name
343                            .as_deref()
344                            .into_iter()
345                            .chain(std::iter::once(engineer)),
346                    );
347
348                    daemon.clear_active_task(engineer);
349                    daemon.set_member_idle(engineer);
350                }
351            }
352            MergeOutcome::MergeFailure(merge_info) => {
353                drop(lock);
354
355                let manager_notice = format!(
356                    "Task #{task_id} from {engineer} passed tests but could not be merged to main.\n{merge_info}\nDecide whether to clean the main worktree, retry the merge, or redirect the engineer."
357                );
358                if let Some(ref manager_name) = manager_name {
359                    daemon.queue_message("daemon", manager_name, &manager_notice)?;
360                    daemon.mark_member_working(manager_name);
361                    daemon.notify_reports_to(manager_name, &manager_notice)?;
362                }
363
364                let engineer_notice = format!(
365                    "Your task passed tests, but Batty could not merge it into main.\n{merge_info}\nWait for lead direction before making more changes."
366                );
367                daemon.queue_message("daemon", engineer, &engineer_notice)?;
368
369                daemon.record_task_escalated(engineer, task_id.to_string(), Some("merge_failure"));
370                daemon.clear_active_task(engineer);
371                daemon.set_member_idle(engineer);
372                warn!(
373                    engineer,
374                    task_id,
375                    error = %merge_info,
376                    "merge into main failed after passing tests; escalated without exiting daemon"
377                );
378            }
379        }
380        return Ok(());
381    }
382
383    let attempt = daemon.increment_retry(engineer);
384    if attempt <= 2 {
385        let msg = format!(
386            "Tests failed (attempt {attempt}/2). Fix the failures and try again:\n{output_truncated}"
387        );
388        daemon.queue_message("batty", engineer, &msg)?;
389        daemon.mark_member_working(engineer);
390        info!(engineer, attempt, "test failure, sending back for retry");
391        return Ok(());
392    }
393
394    if let Some(ref manager_name) = manager_name {
395        let msg = format!(
396            "[{engineer}] task #{task_id} failed tests after 2 retries. Escalating.\nLast output:\n{output_truncated}"
397        );
398        daemon.queue_message(engineer, manager_name, &msg)?;
399        daemon.mark_member_working(manager_name);
400    }
401
402    daemon.record_task_escalated(engineer, task_id.to_string(), Some("tests_failed"));
403
404    if let Some(ref manager_name) = manager_name {
405        let escalation = format!(
406            "ESCALATION: Task #{task_id} assigned to {engineer} failed tests after 2 retries. Task blocked on board."
407        );
408        daemon.notify_reports_to(manager_name, &escalation)?;
409    }
410
411    daemon.run_kanban_md_nonfatal(
412        &[
413            "edit",
414            &task_id.to_string(),
415            "--block",
416            "tests failed after 2 retries",
417            "--dir",
418            &board_dir_str,
419        ],
420        &format!("block task #{task_id} after max test retries"),
421        manager_name
422            .as_deref()
423            .into_iter()
424            .chain(std::iter::once(engineer)),
425    );
426
427    daemon.clear_active_task(engineer);
428    daemon.set_member_idle(engineer);
429    info!(engineer, task_id, "escalated to manager after max retries");
430    Ok(())
431}
432
433pub(crate) fn merge_engineer_branch(
434    project_root: &Path,
435    engineer_name: &str,
436) -> Result<MergeOutcome> {
437    let worktree_dir = project_root
438        .join(".batty")
439        .join("worktrees")
440        .join(engineer_name);
441
442    if !worktree_dir.exists() {
443        bail!(
444            "no worktree found for '{}' at {}",
445            engineer_name,
446            worktree_dir.display()
447        );
448    }
449
450    let branch = current_worktree_branch(&worktree_dir)?;
451    info!(engineer = engineer_name, branch = %branch, "merging worktree branch");
452
453    // Ensure project_root is on main before merging. Without this check,
454    // the merge silently lands on whatever branch happens to be checked out,
455    // causing "merge reported success but commits not on main" (#189, #198).
456    let main_branch = current_worktree_branch(project_root)?;
457    if main_branch != "main" {
458        warn!(
459            engineer = engineer_name,
460            branch = %branch,
461            actual_branch = %main_branch,
462            "project root not on main before merge, attempting checkout"
463        );
464        let checkout = run_git_with_context(
465            project_root,
466            &["checkout", "main"],
467            "checkout main in project root before merge",
468        )?;
469        if !checkout.status.success() {
470            let stderr = String::from_utf8_lossy(&checkout.stderr).trim().to_string();
471            return Ok(MergeOutcome::MergeFailure(format!(
472                "project root is on '{main_branch}', not 'main', and checkout failed: {stderr}"
473            )));
474        }
475    }
476
477    let rebase = run_git_with_context(
478        &worktree_dir,
479        &["rebase", "main"],
480        &format!(
481            "rebase engineer branch '{branch}' onto main before merging for '{engineer_name}'"
482        ),
483    )?;
484
485    if !rebase.status.success() {
486        let stderr = String::from_utf8_lossy(&rebase.stderr).trim().to_string();
487        let _ = run_git_with_context(
488            &worktree_dir,
489            &["rebase", "--abort"],
490            &format!("abort rebase for engineer branch '{branch}' after conflict"),
491        );
492        warn!(engineer = engineer_name, branch = %branch, "rebase conflict during merge");
493        return Ok(MergeOutcome::RebaseConflict(describe_git_failure(
494            &worktree_dir,
495            &["rebase", "main"],
496            &format!(
497                "rebase engineer branch '{branch}' onto main before merging for '{engineer_name}'"
498            ),
499            &stderr,
500        )));
501    }
502
503    let output = run_git_with_context(
504        project_root,
505        &["merge", &branch, "--no-edit"],
506        &format!("merge engineer branch '{branch}' from '{engineer_name}' into main"),
507    )?;
508
509    if !output.status.success() {
510        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
511        warn!(engineer = engineer_name, branch = %branch, "git merge failed");
512        return Ok(MergeOutcome::MergeFailure(describe_git_failure(
513            project_root,
514            &["merge", &branch, "--no-edit"],
515            &format!("merge engineer branch '{branch}' from '{engineer_name}' into main"),
516            &stderr,
517        )));
518    }
519
520    println!("Merged branch '{branch}' from {engineer_name}");
521
522    if let Err(error) = reset_engineer_worktree(project_root, engineer_name) {
523        warn!(
524            engineer = engineer_name,
525            error = %error,
526            "worktree reset failed after merge"
527        );
528    }
529
530    Ok(MergeOutcome::Success)
531}
532
533pub(crate) fn reset_engineer_worktree(project_root: &Path, engineer_name: &str) -> Result<()> {
534    let worktree_dir = project_root
535        .join(".batty")
536        .join("worktrees")
537        .join(engineer_name);
538
539    if !worktree_dir.exists() {
540        return Ok(());
541    }
542
543    let previous_branch = current_worktree_branch(&worktree_dir)?;
544    let base_branch = engineer_base_branch_name(engineer_name);
545
546    // Guard: refuse to destroy uncommitted work on a task branch.
547    if !is_worktree_safe_to_mutate(&worktree_dir)? {
548        warn!(
549            engineer = engineer_name,
550            worktree = %worktree_dir.display(),
551            "skipping worktree reset — uncommitted changes on task branch"
552        );
553        return Ok(());
554    }
555
556    // Force-clean uncommitted changes before switching branches.
557    // Without this, `checkout -B` fails when the worktree is dirty.
558    force_clean_worktree(&worktree_dir, engineer_name);
559
560    if let Err(error) = checkout_worktree_branch_from_main(&worktree_dir, &base_branch) {
561        warn!(
562            engineer = engineer_name,
563            current_branch = %previous_branch,
564            expected_branch = %base_branch,
565            error = %error,
566            "failed to reset worktree after merge"
567        );
568        return Ok(());
569    }
570
571    // Verify HEAD landed on the base branch.
572    match current_worktree_branch(&worktree_dir) {
573        Ok(actual) if actual == base_branch => {}
574        Ok(actual) => {
575            warn!(
576                engineer = engineer_name,
577                current_branch = %actual,
578                expected_branch = %base_branch,
579                "worktree reset did not land on expected branch"
580            );
581        }
582        Err(error) => {
583            warn!(
584                engineer = engineer_name,
585                error = %error,
586                "could not verify worktree branch after reset"
587            );
588        }
589    }
590
591    if previous_branch != base_branch
592        && previous_branch != "HEAD"
593        && (previous_branch == engineer_name
594            || previous_branch.starts_with(&format!("{engineer_name}/")))
595        && branch_is_merged_into(project_root, &previous_branch, "main")?
596        && let Err(error) = delete_branch(project_root, &previous_branch)
597    {
598        warn!(
599            engineer = engineer_name,
600            branch = %previous_branch,
601            error = %error,
602            "failed to delete merged engineer task branch"
603        );
604    }
605
606    info!(
607        engineer = engineer_name,
608        branch = %base_branch,
609        worktree = %worktree_dir.display(),
610        "reset worktree to main after merge"
611    );
612    Ok(())
613}
614
615/// Preserve uncommitted changes via auto-commit, then force-clean the worktree
616/// so `checkout -B` can succeed.
617/// Best-effort: failures are logged but do not block the reset attempt.
618fn force_clean_worktree(worktree_dir: &Path, engineer_name: &str) {
619    // Try to auto-commit first to preserve work in git history.
620    if !auto_commit_before_reset(worktree_dir) {
621        info!(
622            engineer = engineer_name,
623            "auto-commit skipped or failed, proceeding with force-clean"
624        );
625    }
626
627    if let Err(error) = run_git_with_context(
628        worktree_dir,
629        &["reset", "--hard"],
630        "discard staged/unstaged changes before worktree reset",
631    ) {
632        warn!(
633            engineer = engineer_name,
634            error = %error,
635            "git reset --hard failed during worktree cleanup"
636        );
637    }
638    if let Err(error) = run_git_with_context(
639        worktree_dir,
640        &["clean", "-fd", "--exclude=.batty/"],
641        "remove untracked files before worktree reset",
642    ) {
643        warn!(
644            engineer = engineer_name,
645            error = %error,
646            "git clean failed during worktree cleanup"
647        );
648    }
649}
650
651fn record_merge_test_timing(
652    daemon: &mut TeamDaemon,
653    task_id: u32,
654    engineer: &str,
655    task_branch: &str,
656    test_duration_ms: u64,
657) -> Result<()> {
658    let log_path = daemon
659        .project_root()
660        .join(".batty")
661        .join("test_timing.jsonl");
662    let record = append_test_timing_record(
663        &log_path,
664        task_id,
665        engineer,
666        task_branch,
667        now_unix(),
668        test_duration_ms,
669    )?;
670
671    if record.regression_detected {
672        let rolling_average_ms = record.rolling_average_ms.unwrap_or_default();
673        let regression_pct = record.regression_pct.unwrap_or_default();
674        let reason = format!(
675            "runtime_ms={} avg_ms={} pct={}",
676            record.duration_ms, rolling_average_ms, regression_pct
677        );
678        daemon.record_performance_regression(task_id.to_string(), &reason);
679        warn!(
680            engineer,
681            task_id,
682            runtime_ms = record.duration_ms,
683            rolling_average_ms,
684            regression_pct,
685            "post-merge test runtime exceeded rolling average"
686        );
687    }
688
689    Ok(())
690}
691
692fn commits_ahead_of_main(worktree_dir: &Path) -> Result<u32> {
693    let output = run_git_with_context(
694        worktree_dir,
695        &["rev-list", "--count", "main..HEAD"],
696        "count commits ahead of main before accepting engineer completion",
697    )?;
698
699    if !output.status.success() {
700        let stderr = String::from_utf8_lossy(&output.stderr);
701        bail!(
702            "{}",
703            describe_git_failure(
704                worktree_dir,
705                &["rev-list", "--count", "main..HEAD"],
706                "count commits ahead of main before accepting engineer completion",
707                &stderr,
708            )
709        );
710    }
711
712    let stdout = String::from_utf8_lossy(&output.stdout);
713    stdout.trim().parse::<u32>().with_context(|| {
714        format!(
715            "failed to parse git rev-list --count main..HEAD output: {:?}",
716            stdout.trim()
717        )
718    })
719}
720
721fn now_unix() -> u64 {
722    SystemTime::now()
723        .duration_since(UNIX_EPOCH)
724        .unwrap_or_default()
725        .as_secs()
726}
727
728#[cfg(test)]
729mod tests {
730    use super::*;
731    use crate::team::hierarchy::MemberInstance;
732    use crate::team::inbox;
733    use crate::team::standup::MemberState;
734    use crate::team::task_loop::{prepare_engineer_assignment_worktree, setup_engineer_worktree};
735    use crate::team::test_helpers::make_test_daemon;
736    use crate::team::test_support::{
737        engineer_member, git, git_ok, git_stdout, init_git_repo, manager_member,
738    };
739    use std::path::Path;
740    use std::sync::{
741        Arc, Barrier,
742        atomic::{AtomicBool, Ordering},
743    };
744    use std::thread;
745    use std::time::Duration;
746
747    fn write_task_file(project_root: &Path, id: u32, title: &str) {
748        let tasks_dir = project_root
749            .join(".batty")
750            .join("team_config")
751            .join("board")
752            .join("tasks");
753        std::fs::create_dir_all(&tasks_dir).unwrap();
754        std::fs::write(
755            tasks_dir.join(format!("{id:03}-{title}.md")),
756            format!(
757                "---\nid: {id}\ntitle: {title}\nstatus: in-progress\npriority: high\nclaimed_by: eng-1\nclass: standard\n---\n\nTask description.\n"
758            ),
759        )
760        .unwrap();
761    }
762
763    fn engineer_worktree_paths(repo: &Path, engineer: &str) -> (PathBuf, PathBuf) {
764        let worktree_dir = repo.join(".batty").join("worktrees").join(engineer);
765        let team_config_dir = repo.join(".batty").join("team_config");
766        (worktree_dir, team_config_dir)
767    }
768
769    fn setup_completion_daemon(repo: &Path, engineer: &str) -> TeamDaemon {
770        let members = vec![
771            manager_member("manager", None),
772            engineer_member(engineer, Some("manager"), true),
773        ];
774        make_test_daemon(repo, members)
775    }
776
777    #[test]
778    fn commits_ahead_of_main_error_includes_command_and_intent() {
779        let tmp = tempfile::tempdir().unwrap();
780        let error = commits_ahead_of_main(tmp.path()).unwrap_err().to_string();
781        assert!(error.contains("count commits ahead of main before accepting engineer completion"));
782        assert!(error.contains("git rev-list --count main..HEAD"));
783    }
784
785    fn setup_rebase_conflict_repo(
786        engineer: &str,
787    ) -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) {
788        let tmp = tempfile::tempdir().unwrap();
789        let repo = init_git_repo(&tmp, "batty-merge-test");
790        let (worktree_dir, team_config_dir) = engineer_worktree_paths(&repo, engineer);
791
792        std::fs::write(repo.join("conflict.txt"), "original\n").unwrap();
793        git_ok(&repo, &["add", "conflict.txt"]);
794        git_ok(&repo, &["commit", "-m", "add conflict file"]);
795
796        setup_engineer_worktree(&repo, &worktree_dir, engineer, &team_config_dir).unwrap();
797
798        std::fs::write(worktree_dir.join("conflict.txt"), "engineer version\n").unwrap();
799        git_ok(&worktree_dir, &["add", "conflict.txt"]);
800        git_ok(&worktree_dir, &["commit", "-m", "engineer change"]);
801
802        std::fs::write(repo.join("conflict.txt"), "main version\n").unwrap();
803        git_ok(&repo, &["add", "conflict.txt"]);
804        git_ok(&repo, &["commit", "-m", "main change"]);
805
806        (tmp, repo, worktree_dir, team_config_dir)
807    }
808
809    #[test]
810    fn merge_rejects_missing_worktree() {
811        let tmp = tempfile::tempdir().unwrap();
812        let err = merge_engineer_branch(tmp.path(), "eng-1-1").unwrap_err();
813        assert!(err.to_string().contains("no worktree found"));
814    }
815
816    #[test]
817    fn merge_lock_acquire_release() {
818        let tmp = tempfile::tempdir().unwrap();
819        std::fs::create_dir_all(tmp.path().join(".batty")).unwrap();
820        let lock_path = tmp.path().join(".batty").join("merge.lock");
821
822        {
823            let lock = MergeLock::acquire(tmp.path()).unwrap();
824            assert!(lock_path.exists());
825            drop(lock);
826        }
827        assert!(!lock_path.exists());
828    }
829
830    #[test]
831    fn merge_lock_second_acquire_waits_for_release() {
832        let tmp = tempfile::tempdir().unwrap();
833        std::fs::create_dir_all(tmp.path().join(".batty")).unwrap();
834
835        let first_lock = MergeLock::acquire(tmp.path()).unwrap();
836        let project_root = tmp.path().to_path_buf();
837        let barrier = Arc::new(Barrier::new(2));
838        let acquired = Arc::new(AtomicBool::new(false));
839
840        let thread_barrier = Arc::clone(&barrier);
841        let thread_acquired = Arc::clone(&acquired);
842        let handle = thread::spawn(move || {
843            thread_barrier.wait();
844            let second_lock = MergeLock::acquire(&project_root).unwrap();
845            thread_acquired.store(true, Ordering::SeqCst);
846            drop(second_lock);
847        });
848
849        barrier.wait();
850        thread::sleep(Duration::from_millis(600));
851        assert!(!acquired.load(Ordering::SeqCst));
852
853        drop(first_lock);
854        handle.join().unwrap();
855        assert!(acquired.load(Ordering::SeqCst));
856    }
857
858    #[test]
859    fn merge_with_rebase_picks_up_main() {
860        let tmp = tempfile::tempdir().unwrap();
861        let repo = init_git_repo(&tmp, "batty-merge-test");
862        let worktree_dir = repo.join(".batty").join("worktrees").join("eng-1");
863        let team_config_dir = repo.join(".batty").join("team_config");
864
865        setup_engineer_worktree(&repo, &worktree_dir, "eng-1", &team_config_dir).unwrap();
866
867        std::fs::write(worktree_dir.join("feature.txt"), "engineer work\n").unwrap();
868        git_ok(&worktree_dir, &["add", "feature.txt"]);
869        git_ok(&worktree_dir, &["commit", "-m", "engineer feature"]);
870
871        std::fs::write(repo.join("other.txt"), "main work\n").unwrap();
872        git_ok(&repo, &["add", "other.txt"]);
873        git_ok(&repo, &["commit", "-m", "main advance"]);
874
875        let result = merge_engineer_branch(&repo, "eng-1").unwrap();
876        assert!(matches!(result, MergeOutcome::Success));
877        assert!(repo.join("feature.txt").exists());
878        assert!(repo.join("other.txt").exists());
879    }
880
881    #[test]
882    fn reset_worktree_after_merge() {
883        let tmp = tempfile::tempdir().unwrap();
884        let repo = init_git_repo(&tmp, "batty-merge-test");
885        let worktree_dir = repo.join(".batty").join("worktrees").join("eng-1");
886        let team_config_dir = repo.join(".batty").join("team_config");
887
888        setup_engineer_worktree(&repo, &worktree_dir, "eng-1", &team_config_dir).unwrap();
889
890        std::fs::write(worktree_dir.join("feature.txt"), "work\n").unwrap();
891        git_ok(&worktree_dir, &["add", "feature.txt"]);
892        git_ok(&worktree_dir, &["commit", "-m", "engineer work"]);
893
894        let result = merge_engineer_branch(&repo, "eng-1").unwrap();
895        assert!(matches!(result, MergeOutcome::Success));
896
897        let main_head = git_stdout(&repo, &["rev-parse", "HEAD"]);
898        let worktree_head = git_stdout(&worktree_dir, &["rev-parse", "HEAD"]);
899        assert_eq!(main_head, worktree_head);
900    }
901
902    #[test]
903    fn merge_empty_diff_returns_success() {
904        let tmp = tempfile::tempdir().unwrap();
905        let repo = init_git_repo(&tmp, "batty-merge-test");
906        let (worktree_dir, team_config_dir) = engineer_worktree_paths(&repo, "eng-empty");
907
908        setup_engineer_worktree(&repo, &worktree_dir, "eng-empty", &team_config_dir).unwrap();
909        let main_before = git_stdout(&repo, &["rev-parse", "main"]);
910
911        let result = merge_engineer_branch(&repo, "eng-empty").unwrap();
912
913        assert!(matches!(result, MergeOutcome::Success));
914        assert_eq!(git_stdout(&repo, &["rev-parse", "main"]), main_before);
915    }
916
917    #[test]
918    fn merge_empty_diff_resets_worktree_to_engineer_base_branch() {
919        let tmp = tempfile::tempdir().unwrap();
920        let repo = init_git_repo(&tmp, "batty-merge-test");
921        let (worktree_dir, team_config_dir) = engineer_worktree_paths(&repo, "eng-empty");
922
923        setup_engineer_worktree(&repo, &worktree_dir, "eng-empty", &team_config_dir).unwrap();
924
925        let result = merge_engineer_branch(&repo, "eng-empty").unwrap();
926
927        assert!(matches!(result, MergeOutcome::Success));
928        assert_eq!(
929            git_stdout(&worktree_dir, &["rev-parse", "--abbrev-ref", "HEAD"]),
930            engineer_base_branch_name("eng-empty")
931        );
932    }
933
934    #[test]
935    fn merge_with_two_main_advances_rebases_cleanly() {
936        let tmp = tempfile::tempdir().unwrap();
937        let repo = init_git_repo(&tmp, "batty-merge-test");
938        let (worktree_dir, team_config_dir) = engineer_worktree_paths(&repo, "eng-stale");
939
940        setup_engineer_worktree(&repo, &worktree_dir, "eng-stale", &team_config_dir).unwrap();
941
942        std::fs::write(worktree_dir.join("feature.txt"), "engineer work\n").unwrap();
943        git_ok(&worktree_dir, &["add", "feature.txt"]);
944        git_ok(&worktree_dir, &["commit", "-m", "engineer feature"]);
945
946        std::fs::write(repo.join("main-one.txt"), "main one\n").unwrap();
947        git_ok(&repo, &["add", "main-one.txt"]);
948        git_ok(&repo, &["commit", "-m", "main advance 1"]);
949
950        std::fs::write(repo.join("main-two.txt"), "main two\n").unwrap();
951        git_ok(&repo, &["add", "main-two.txt"]);
952        git_ok(&repo, &["commit", "-m", "main advance 2"]);
953
954        let result = merge_engineer_branch(&repo, "eng-stale").unwrap();
955
956        assert!(matches!(result, MergeOutcome::Success));
957        assert!(repo.join("feature.txt").exists());
958        assert!(repo.join("main-one.txt").exists());
959        assert!(repo.join("main-two.txt").exists());
960    }
961
962    #[test]
963    fn reset_worktree_restores_engineer_base_branch_after_task_merge() {
964        let tmp = tempfile::tempdir().unwrap();
965        let repo = init_git_repo(&tmp, "batty-merge-test");
966        let worktree_dir = repo.join(".batty").join("worktrees").join("eng-1");
967        let team_config_dir = repo.join(".batty").join("team_config");
968
969        prepare_engineer_assignment_worktree(
970            &repo,
971            &worktree_dir,
972            "eng-1",
973            "eng-1/42",
974            &team_config_dir,
975        )
976        .unwrap();
977
978        std::fs::write(worktree_dir.join("feature.txt"), "work\n").unwrap();
979        git_ok(&worktree_dir, &["add", "feature.txt"]);
980        git_ok(&worktree_dir, &["commit", "-m", "engineer work"]);
981
982        let result = merge_engineer_branch(&repo, "eng-1").unwrap();
983        assert!(matches!(result, MergeOutcome::Success));
984        assert_eq!(
985            git_stdout(&worktree_dir, &["rev-parse", "--abbrev-ref", "HEAD"]),
986            engineer_base_branch_name("eng-1")
987        );
988
989        let branch_check = git(&repo, &["rev-parse", "--verify", "eng-1/42"]);
990        assert!(
991            !branch_check.status.success(),
992            "merged task branch should be deleted"
993        );
994    }
995
996    #[test]
997    fn reset_worktree_leaves_clean_state() {
998        let tmp = tempfile::tempdir().unwrap();
999        let repo = init_git_repo(&tmp, "batty-merge-test");
1000        let worktree_dir = repo.join(".batty").join("worktrees").join("eng-1");
1001        let team_config_dir = repo.join(".batty").join("team_config");
1002
1003        setup_engineer_worktree(&repo, &worktree_dir, "eng-1", &team_config_dir).unwrap();
1004
1005        std::fs::write(worktree_dir.join("new.txt"), "content\n").unwrap();
1006        git_ok(&worktree_dir, &["add", "new.txt"]);
1007        git_ok(&worktree_dir, &["commit", "-m", "add file"]);
1008
1009        let result = merge_engineer_branch(&repo, "eng-1").unwrap();
1010        assert!(matches!(result, MergeOutcome::Success));
1011
1012        let status = git_stdout(&worktree_dir, &["status", "--porcelain"]);
1013        let tracked_changes: Vec<&str> = status
1014            .lines()
1015            .filter(|line| !line.starts_with("?? .batty/"))
1016            .collect();
1017        assert!(
1018            tracked_changes.is_empty(),
1019            "worktree has tracked changes: {:?}",
1020            tracked_changes
1021        );
1022    }
1023
1024    #[test]
1025    fn reset_worktree_noops_when_worktree_is_missing() {
1026        let tmp = tempfile::tempdir().unwrap();
1027        let repo = init_git_repo(&tmp, "batty-merge-test");
1028
1029        reset_engineer_worktree(&repo, "eng-missing").unwrap();
1030    }
1031
1032    #[test]
1033    fn reset_worktree_keeps_unmerged_task_branch() {
1034        let tmp = tempfile::tempdir().unwrap();
1035        let repo = init_git_repo(&tmp, "batty-merge-test");
1036        let (worktree_dir, team_config_dir) = engineer_worktree_paths(&repo, "eng-keep");
1037
1038        prepare_engineer_assignment_worktree(
1039            &repo,
1040            &worktree_dir,
1041            "eng-keep",
1042            "eng-keep/77",
1043            &team_config_dir,
1044        )
1045        .unwrap();
1046
1047        std::fs::write(worktree_dir.join("feature.txt"), "keep me\n").unwrap();
1048        git_ok(&worktree_dir, &["add", "feature.txt"]);
1049        git_ok(&worktree_dir, &["commit", "-m", "unmerged feature"]);
1050
1051        reset_engineer_worktree(&repo, "eng-keep").unwrap();
1052
1053        assert_eq!(
1054            git_stdout(&worktree_dir, &["rev-parse", "--abbrev-ref", "HEAD"]),
1055            engineer_base_branch_name("eng-keep")
1056        );
1057        assert!(
1058            git(&repo, &["rev-parse", "--verify", "eng-keep/77"])
1059                .status
1060                .success()
1061        );
1062    }
1063
1064    #[test]
1065    fn reset_worktree_deletes_merged_legacy_task_branch() {
1066        let tmp = tempfile::tempdir().unwrap();
1067        let repo = init_git_repo(&tmp, "batty-merge-test");
1068        let (worktree_dir, team_config_dir) = engineer_worktree_paths(&repo, "eng-legacy");
1069
1070        setup_engineer_worktree(
1071            &repo,
1072            &worktree_dir,
1073            &engineer_base_branch_name("eng-legacy"),
1074            &team_config_dir,
1075        )
1076        .unwrap();
1077        git_ok(
1078            &worktree_dir,
1079            &["checkout", "-B", "eng-legacy/task-55", "main"],
1080        );
1081        std::fs::write(worktree_dir.join("legacy.txt"), "legacy branch work\n").unwrap();
1082        git_ok(&worktree_dir, &["add", "legacy.txt"]);
1083        git_ok(&worktree_dir, &["commit", "-m", "legacy task work"]);
1084        git_ok(&repo, &["merge", "eng-legacy/task-55", "--no-edit"]);
1085
1086        reset_engineer_worktree(&repo, "eng-legacy").unwrap();
1087
1088        assert!(
1089            !git(&repo, &["rev-parse", "--verify", "eng-legacy/task-55"])
1090                .status
1091                .success()
1092        );
1093        assert_eq!(
1094            git_stdout(&worktree_dir, &["rev-parse", "--abbrev-ref", "HEAD"]),
1095            engineer_base_branch_name("eng-legacy")
1096        );
1097    }
1098
1099    #[test]
1100    fn reset_worktree_keeps_non_engineer_namespace_branch() {
1101        let tmp = tempfile::tempdir().unwrap();
1102        let repo = init_git_repo(&tmp, "batty-merge-test");
1103        let (worktree_dir, team_config_dir) = engineer_worktree_paths(&repo, "eng-keep");
1104
1105        setup_engineer_worktree(&repo, &worktree_dir, "eng-keep", &team_config_dir).unwrap();
1106        git_ok(&worktree_dir, &["checkout", "-B", "feature/custom", "main"]);
1107        std::fs::write(worktree_dir.join("feature.txt"), "non engineer branch\n").unwrap();
1108        git_ok(&worktree_dir, &["add", "feature.txt"]);
1109        git_ok(&worktree_dir, &["commit", "-m", "feature branch work"]);
1110
1111        reset_engineer_worktree(&repo, "eng-keep").unwrap();
1112
1113        assert!(
1114            git(&repo, &["rev-parse", "--verify", "feature/custom"])
1115                .status
1116                .success()
1117        );
1118    }
1119
1120    #[test]
1121    fn merge_success_deletes_merged_engineer_branch_namespace() {
1122        let tmp = tempfile::tempdir().unwrap();
1123        let repo = init_git_repo(&tmp, "batty-merge-test");
1124        let (worktree_dir, team_config_dir) = engineer_worktree_paths(&repo, "eng-delete");
1125
1126        setup_engineer_worktree(&repo, &worktree_dir, "eng-delete", &team_config_dir).unwrap();
1127
1128        std::fs::write(worktree_dir.join("feature.txt"), "remove branch\n").unwrap();
1129        git_ok(&worktree_dir, &["add", "feature.txt"]);
1130        git_ok(&worktree_dir, &["commit", "-m", "engineer work"]);
1131
1132        let result = merge_engineer_branch(&repo, "eng-delete").unwrap();
1133
1134        assert!(matches!(result, MergeOutcome::Success));
1135        assert!(
1136            !git(&repo, &["rev-parse", "--verify", "eng-delete"])
1137                .status
1138                .success()
1139        );
1140    }
1141
1142    #[test]
1143    fn merge_rebase_conflict_returns_conflict() {
1144        let tmp = tempfile::tempdir().unwrap();
1145        let repo = init_git_repo(&tmp, "batty-merge-test");
1146        let worktree_dir = repo.join(".batty").join("worktrees").join("eng-2");
1147        let team_config_dir = repo.join(".batty").join("team_config");
1148
1149        std::fs::write(repo.join("conflict.txt"), "original\n").unwrap();
1150        git_ok(&repo, &["add", "conflict.txt"]);
1151        git_ok(&repo, &["commit", "-m", "add conflict file"]);
1152
1153        setup_engineer_worktree(&repo, &worktree_dir, "eng-2", &team_config_dir).unwrap();
1154
1155        std::fs::write(worktree_dir.join("conflict.txt"), "engineer version\n").unwrap();
1156        git_ok(&worktree_dir, &["add", "conflict.txt"]);
1157        git_ok(&worktree_dir, &["commit", "-m", "engineer change"]);
1158
1159        std::fs::write(repo.join("conflict.txt"), "main version\n").unwrap();
1160        git_ok(&repo, &["add", "conflict.txt"]);
1161        git_ok(&repo, &["commit", "-m", "main change"]);
1162
1163        let result = merge_engineer_branch(&repo, "eng-2").unwrap();
1164        assert!(matches!(result, MergeOutcome::RebaseConflict(_)));
1165
1166        let status = git(&worktree_dir, &["status", "--porcelain"]);
1167        assert!(status.status.success());
1168    }
1169
1170    #[test]
1171    fn merge_rebase_conflict_aborts_rebase_state() {
1172        let (_tmp, repo, worktree_dir, _team_config_dir) = setup_rebase_conflict_repo("eng-4");
1173
1174        let result = merge_engineer_branch(&repo, "eng-4").unwrap();
1175
1176        assert!(matches!(result, MergeOutcome::RebaseConflict(_)));
1177        assert!(
1178            !git(&worktree_dir, &["rev-parse", "--verify", "REBASE_HEAD"])
1179                .status
1180                .success()
1181        );
1182    }
1183
1184    #[test]
1185    fn merge_with_dirty_main_returns_merge_failure() {
1186        let tmp = tempfile::tempdir().unwrap();
1187        let repo = init_git_repo(&tmp, "batty-merge-test");
1188        let worktree_dir = repo.join(".batty").join("worktrees").join("eng-3");
1189        let team_config_dir = repo.join(".batty").join("team_config");
1190
1191        std::fs::write(repo.join("journal.md"), "base\n").unwrap();
1192        git_ok(&repo, &["add", "journal.md"]);
1193        git_ok(&repo, &["commit", "-m", "add journal"]);
1194
1195        setup_engineer_worktree(&repo, &worktree_dir, "eng-3", &team_config_dir).unwrap();
1196
1197        std::fs::write(worktree_dir.join("journal.md"), "engineer version\n").unwrap();
1198        git_ok(&worktree_dir, &["add", "journal.md"]);
1199        git_ok(&worktree_dir, &["commit", "-m", "engineer update"]);
1200
1201        std::fs::write(repo.join("journal.md"), "dirty main\n").unwrap();
1202
1203        let result = merge_engineer_branch(&repo, "eng-3").unwrap();
1204        match result {
1205            MergeOutcome::MergeFailure(stderr) => {
1206                assert!(
1207                    stderr.contains("would be overwritten by merge")
1208                        || stderr.contains("Please commit your changes or stash them"),
1209                    "unexpected merge failure stderr: {stderr}"
1210                );
1211            }
1212            other => panic!("expected merge failure outcome, got {other:?}"),
1213        }
1214    }
1215
1216    #[test]
1217    fn merge_failure_retains_engineer_branch_for_manual_recovery() {
1218        let tmp = tempfile::tempdir().unwrap();
1219        let repo = init_git_repo(&tmp, "batty-merge-test");
1220        let (worktree_dir, team_config_dir) = engineer_worktree_paths(&repo, "eng-3");
1221
1222        std::fs::write(repo.join("journal.md"), "base\n").unwrap();
1223        git_ok(&repo, &["add", "journal.md"]);
1224        git_ok(&repo, &["commit", "-m", "add journal"]);
1225
1226        setup_engineer_worktree(&repo, &worktree_dir, "eng-3", &team_config_dir).unwrap();
1227
1228        std::fs::write(worktree_dir.join("journal.md"), "engineer version\n").unwrap();
1229        git_ok(&worktree_dir, &["add", "journal.md"]);
1230        git_ok(&worktree_dir, &["commit", "-m", "engineer update"]);
1231
1232        std::fs::write(repo.join("journal.md"), "dirty main\n").unwrap();
1233
1234        let result = merge_engineer_branch(&repo, "eng-3").unwrap();
1235
1236        assert!(matches!(result, MergeOutcome::MergeFailure(_)));
1237        assert_eq!(current_worktree_branch(&worktree_dir).unwrap(), "eng-3");
1238        assert!(
1239            git(&repo, &["rev-parse", "--verify", "eng-3"])
1240                .status
1241                .success()
1242        );
1243    }
1244
1245    #[test]
1246    fn completion_routes_engineers_with_tasks() {
1247        let tmp = tempfile::tempdir().unwrap();
1248        let engineer = MemberInstance {
1249            name: "eng-1".to_string(),
1250            role_name: "eng-1".to_string(),
1251            role_type: super::super::config::RoleType::Engineer,
1252            agent: Some("claude".to_string()),
1253            prompt: None,
1254            reports_to: Some("manager".to_string()),
1255            use_worktrees: false,
1256        };
1257        let mut daemon = make_test_daemon(tmp.path(), vec![engineer]);
1258
1259        daemon.set_active_task_for_test("eng-1", 42);
1260        handle_engineer_completion(&mut daemon, "eng-1").unwrap();
1261        assert_eq!(daemon.active_task_id("eng-1"), Some(42));
1262    }
1263
1264    #[test]
1265    fn completion_gate_rejects_zero_commits_but_keeps_task_active() {
1266        let tmp = tempfile::tempdir().unwrap();
1267        let repo = init_git_repo(&tmp, "batty-merge-test");
1268        write_task_file(&repo, 42, "zero-commit-task");
1269
1270        let team_config_dir = repo.join(".batty").join("team_config");
1271        let worktree_dir = repo.join(".batty").join("worktrees").join("eng-1");
1272        setup_engineer_worktree(&repo, &worktree_dir, "eng-1", &team_config_dir).unwrap();
1273        std::fs::remove_file(worktree_dir.join("Cargo.toml")).unwrap();
1274
1275        let engineer = MemberInstance {
1276            name: "eng-1".to_string(),
1277            role_name: "eng-1".to_string(),
1278            role_type: super::super::config::RoleType::Engineer,
1279            agent: Some("claude".to_string()),
1280            prompt: None,
1281            reports_to: None,
1282            use_worktrees: true,
1283        };
1284        let mut daemon = make_test_daemon(&repo, vec![engineer]);
1285
1286        daemon.set_active_task_for_test("eng-1", 42);
1287        daemon.set_member_state_for_test("eng-1", MemberState::Working);
1288        handle_engineer_completion(&mut daemon, "eng-1").unwrap();
1289
1290        // Task stays active — engineer still owns it (false-done prevention)
1291        assert_eq!(daemon.active_task_id("eng-1"), Some(42));
1292        assert_eq!(daemon.retry_count_for_test("eng-1"), None);
1293    }
1294
1295    #[test]
1296    fn completion_gate_passes_with_commits() {
1297        let tmp = tempfile::tempdir().unwrap();
1298        let repo = init_git_repo(&tmp, "batty-merge-test");
1299        write_task_file(&repo, 42, "commit-gate-success");
1300
1301        let team_config_dir = repo.join(".batty").join("team_config");
1302        let worktree_dir = repo.join(".batty").join("worktrees").join("eng-1");
1303        setup_engineer_worktree(&repo, &worktree_dir, "eng-1", &team_config_dir).unwrap();
1304
1305        std::fs::write(worktree_dir.join("note.txt"), "done\n").unwrap();
1306        git_ok(&worktree_dir, &["add", "note.txt"]);
1307        git_ok(&worktree_dir, &["commit", "-m", "add note"]);
1308
1309        let engineer = MemberInstance {
1310            name: "eng-1".to_string(),
1311            role_name: "eng-1".to_string(),
1312            role_type: super::super::config::RoleType::Engineer,
1313            agent: Some("claude".to_string()),
1314            prompt: None,
1315            reports_to: None,
1316            use_worktrees: true,
1317        };
1318        let mut daemon = make_test_daemon(&repo, vec![engineer]);
1319
1320        daemon.set_active_task_for_test("eng-1", 42);
1321        daemon.set_member_state_for_test("eng-1", MemberState::Working);
1322
1323        handle_engineer_completion(&mut daemon, "eng-1").unwrap();
1324
1325        assert_eq!(daemon.active_task_id("eng-1"), None);
1326        assert_eq!(
1327            daemon.member_state_for_test("eng-1"),
1328            Some(MemberState::Idle)
1329        );
1330        assert_eq!(
1331            std::fs::read_to_string(repo.join("note.txt")).unwrap(),
1332            "done\n"
1333        );
1334
1335        let timing_log = repo.join(".batty").join("test_timing.jsonl");
1336        let timings = read_test_timing_log(&timing_log).unwrap();
1337        assert_eq!(timings.len(), 1);
1338        assert_eq!(timings[0].task_id, 42);
1339        assert_eq!(timings[0].engineer, "eng-1");
1340        assert_eq!(timings[0].branch, "eng-1");
1341        assert!(!timings[0].regression_detected);
1342    }
1343
1344    #[test]
1345    fn zero_commit_retry_message_sent() {
1346        let tmp = tempfile::tempdir().unwrap();
1347        let repo = init_git_repo(&tmp, "batty-merge-test");
1348        write_task_file(&repo, 42, "zero-commit-message");
1349
1350        let team_config_dir = repo.join(".batty").join("team_config");
1351        let worktree_dir = repo.join(".batty").join("worktrees").join("eng-1");
1352        setup_engineer_worktree(&repo, &worktree_dir, "eng-1", &team_config_dir).unwrap();
1353        std::fs::remove_file(worktree_dir.join("Cargo.toml")).unwrap();
1354
1355        let manager = MemberInstance {
1356            name: "manager".to_string(),
1357            role_name: "manager".to_string(),
1358            role_type: super::super::config::RoleType::Manager,
1359            agent: Some("claude".to_string()),
1360            prompt: None,
1361            reports_to: None,
1362            use_worktrees: false,
1363        };
1364        let engineer = MemberInstance {
1365            name: "eng-1".to_string(),
1366            role_name: "eng-1".to_string(),
1367            role_type: super::super::config::RoleType::Engineer,
1368            agent: Some("claude".to_string()),
1369            prompt: None,
1370            reports_to: Some("manager".to_string()),
1371            use_worktrees: true,
1372        };
1373        let mut daemon = make_test_daemon(&repo, vec![manager, engineer]);
1374
1375        daemon.set_active_task_for_test("eng-1", 42);
1376        handle_engineer_completion(&mut daemon, "eng-1").unwrap();
1377
1378        let engineer_messages =
1379            inbox::pending_messages(&inbox::inboxes_root(&repo), "eng-1").unwrap();
1380        assert_eq!(engineer_messages.len(), 1);
1381        assert_eq!(engineer_messages[0].from, "batty");
1382        assert!(
1383            engineer_messages[0]
1384                .body
1385                .contains("no commits ahead of main")
1386        );
1387        assert!(
1388            engineer_messages[0]
1389                .body
1390                .contains("Commit your changes before reporting done again")
1391        );
1392
1393        let manager_messages =
1394            inbox::pending_messages(&inbox::inboxes_root(&repo), "manager").unwrap();
1395        assert!(manager_messages.is_empty());
1396    }
1397
1398    #[test]
1399    fn no_commits_rejection_keeps_assignment() {
1400        let tmp = tempfile::tempdir().unwrap();
1401        let repo = init_git_repo(&tmp, "batty-merge-test");
1402        write_task_file(&repo, 42, "no-commits-keep");
1403
1404        let team_config_dir = repo.join(".batty").join("team_config");
1405        let worktree_dir = repo.join(".batty").join("worktrees").join("eng-1");
1406        setup_engineer_worktree(&repo, &worktree_dir, "eng-1", &team_config_dir).unwrap();
1407        std::fs::remove_file(worktree_dir.join("Cargo.toml")).unwrap();
1408
1409        let mut daemon = setup_completion_daemon(&repo, "eng-1");
1410
1411        daemon.set_active_task_for_test("eng-1", 42);
1412        daemon.set_member_state_for_test("eng-1", MemberState::Working);
1413
1414        handle_engineer_completion(&mut daemon, "eng-1").unwrap();
1415
1416        // Assignment kept — engineer still owns the task (false-done prevention)
1417        assert_eq!(daemon.active_task_id("eng-1"), Some(42));
1418    }
1419
1420    #[test]
1421    fn no_commits_rejection_does_not_retry_and_keeps_task() {
1422        let tmp = tempfile::tempdir().unwrap();
1423        let repo = init_git_repo(&tmp, "batty-merge-test");
1424        write_task_file(&repo, 42, "no-commits-no-retry");
1425
1426        let team_config_dir = repo.join(".batty").join("team_config");
1427        let worktree_dir = repo.join(".batty").join("worktrees").join("eng-1");
1428        setup_engineer_worktree(&repo, &worktree_dir, "eng-1", &team_config_dir).unwrap();
1429        std::fs::remove_file(worktree_dir.join("Cargo.toml")).unwrap();
1430
1431        let mut daemon = setup_completion_daemon(&repo, "eng-1");
1432
1433        daemon.set_active_task_for_test("eng-1", 42);
1434        daemon.set_member_state_for_test("eng-1", MemberState::Working);
1435
1436        handle_engineer_completion(&mut daemon, "eng-1").unwrap();
1437
1438        // Retry count should not be incremented
1439        assert_eq!(daemon.retry_count_for_test("eng-1"), None);
1440        // Active task kept — engineer still owns it (false-done prevention)
1441        assert_eq!(daemon.active_task_id("eng-1"), Some(42));
1442    }
1443
1444    #[test]
1445    fn rebase_conflict_first_retry_messages_engineer() {
1446        let (_tmp, repo, _worktree_dir, _team_config_dir) = setup_rebase_conflict_repo("eng-1");
1447        write_task_file(&repo, 42, "rebase-conflict-retry");
1448
1449        let mut daemon = setup_completion_daemon(&repo, "eng-1");
1450        daemon.set_active_task_for_test("eng-1", 42);
1451        daemon.set_member_state_for_test("eng-1", MemberState::Working);
1452
1453        handle_engineer_completion(&mut daemon, "eng-1").unwrap();
1454
1455        let engineer_messages =
1456            inbox::pending_messages(&inbox::inboxes_root(&repo), "eng-1").unwrap();
1457        assert_eq!(engineer_messages.len(), 1);
1458        assert_eq!(engineer_messages[0].from, "batty");
1459        assert!(
1460            engineer_messages[0]
1461                .body
1462                .contains("Merge conflict during rebase onto main")
1463        );
1464    }
1465
1466    #[test]
1467    fn rebase_conflict_first_retry_keeps_task_active_and_counts_retry() {
1468        let (_tmp, repo, _worktree_dir, _team_config_dir) = setup_rebase_conflict_repo("eng-1");
1469        write_task_file(&repo, 42, "rebase-conflict-state");
1470
1471        let mut daemon = setup_completion_daemon(&repo, "eng-1");
1472        daemon.set_active_task_for_test("eng-1", 42);
1473        daemon.set_member_state_for_test("eng-1", MemberState::Working);
1474
1475        handle_engineer_completion(&mut daemon, "eng-1").unwrap();
1476
1477        assert_eq!(daemon.active_task_id("eng-1"), Some(42));
1478        assert_eq!(daemon.retry_count_for_test("eng-1"), Some(1));
1479        assert_eq!(
1480            daemon.member_state_for_test("eng-1"),
1481            Some(MemberState::Working)
1482        );
1483    }
1484
1485    #[test]
1486    fn rebase_conflict_third_attempt_escalates_to_manager() {
1487        let (_tmp, repo, _worktree_dir, _team_config_dir) = setup_rebase_conflict_repo("eng-1");
1488        write_task_file(&repo, 42, "rebase-conflict-escalation");
1489
1490        let mut daemon = setup_completion_daemon(&repo, "eng-1");
1491        daemon.set_active_task_for_test("eng-1", 42);
1492        daemon.set_member_state_for_test("eng-1", MemberState::Working);
1493        daemon.increment_retry("eng-1");
1494        daemon.increment_retry("eng-1");
1495
1496        handle_engineer_completion(&mut daemon, "eng-1").unwrap();
1497
1498        let manager_messages =
1499            inbox::pending_messages(&inbox::inboxes_root(&repo), "manager").unwrap();
1500        assert!(manager_messages.iter().any(|msg| {
1501            msg.from == "eng-1"
1502                && msg
1503                    .body
1504                    .contains("unresolvable merge conflicts after 2 retries")
1505        }));
1506    }
1507
1508    #[test]
1509    fn rebase_conflict_third_attempt_clears_task_and_sets_idle() {
1510        let (_tmp, repo, _worktree_dir, _team_config_dir) = setup_rebase_conflict_repo("eng-1");
1511        write_task_file(&repo, 42, "rebase-conflict-reset");
1512
1513        let mut daemon = setup_completion_daemon(&repo, "eng-1");
1514        daemon.set_active_task_for_test("eng-1", 42);
1515        daemon.set_member_state_for_test("eng-1", MemberState::Working);
1516        daemon.increment_retry("eng-1");
1517        daemon.increment_retry("eng-1");
1518
1519        handle_engineer_completion(&mut daemon, "eng-1").unwrap();
1520
1521        assert_eq!(daemon.active_task_id("eng-1"), None);
1522        assert_eq!(daemon.retry_count_for_test("eng-1"), None);
1523        assert_eq!(
1524            daemon.member_state_for_test("eng-1"),
1525            Some(MemberState::Idle)
1526        );
1527    }
1528
1529    #[test]
1530    fn rebase_conflict_third_attempt_records_escalation_event() {
1531        let (_tmp, repo, _worktree_dir, _team_config_dir) = setup_rebase_conflict_repo("eng-1");
1532        write_task_file(&repo, 42, "rebase-conflict-event");
1533
1534        let mut daemon = setup_completion_daemon(&repo, "eng-1");
1535        daemon.set_active_task_for_test("eng-1", 42);
1536        daemon.increment_retry("eng-1");
1537        daemon.increment_retry("eng-1");
1538
1539        handle_engineer_completion(&mut daemon, "eng-1").unwrap();
1540
1541        let events = crate::team::events::read_events(
1542            &repo.join(".batty").join("team_config").join("events.jsonl"),
1543        )
1544        .unwrap();
1545        assert!(events.iter().any(|event| {
1546            event.event == "task_escalated"
1547                && event.role.as_deref() == Some("eng-1")
1548                && event.task.as_deref() == Some("42")
1549        }));
1550    }
1551
1552    #[test]
1553    fn handle_engineer_completion_escalates_merge_failures_without_crashing() {
1554        let tmp = tempfile::tempdir().unwrap();
1555        let repo = init_git_repo(&tmp, "batty-merge-test");
1556        write_task_file(&repo, 42, "merge-blocked-task");
1557
1558        std::fs::write(repo.join("journal.md"), "base\n").unwrap();
1559        git_ok(&repo, &["add", "journal.md"]);
1560        git_ok(&repo, &["commit", "-m", "add journal"]);
1561
1562        let team_config_dir = repo.join(".batty").join("team_config");
1563        let worktree_dir = repo.join(".batty").join("worktrees").join("eng-1");
1564        setup_engineer_worktree(&repo, &worktree_dir, "eng-1", &team_config_dir).unwrap();
1565
1566        std::fs::write(worktree_dir.join("journal.md"), "engineer version\n").unwrap();
1567        git_ok(&worktree_dir, &["add", "journal.md"]);
1568        git_ok(&worktree_dir, &["commit", "-m", "engineer update"]);
1569
1570        std::fs::write(repo.join("journal.md"), "dirty main\n").unwrap();
1571
1572        let members = vec![
1573            MemberInstance {
1574                name: "manager".to_string(),
1575                role_name: "manager".to_string(),
1576                role_type: super::super::config::RoleType::Manager,
1577                agent: Some("claude".to_string()),
1578                prompt: None,
1579                reports_to: None,
1580                use_worktrees: false,
1581            },
1582            MemberInstance {
1583                name: "eng-1".to_string(),
1584                role_name: "eng-1".to_string(),
1585                role_type: super::super::config::RoleType::Engineer,
1586                agent: Some("claude".to_string()),
1587                prompt: None,
1588                reports_to: Some("manager".to_string()),
1589                use_worktrees: true,
1590            },
1591        ];
1592
1593        let mut daemon = make_test_daemon(&repo, members);
1594        daemon.set_active_task_for_test("eng-1", 42);
1595        daemon.set_member_state_for_test("eng-1", MemberState::Working);
1596
1597        handle_engineer_completion(&mut daemon, "eng-1").unwrap();
1598
1599        assert_eq!(daemon.active_task_id("eng-1"), None);
1600        assert_eq!(
1601            daemon.member_state_for_test("eng-1"),
1602            Some(MemberState::Idle)
1603        );
1604
1605        let manager_messages =
1606            inbox::pending_messages(&inbox::inboxes_root(&repo), "manager").unwrap();
1607        assert_eq!(manager_messages.len(), 1);
1608        assert_eq!(manager_messages[0].from, "daemon");
1609        assert!(
1610            manager_messages[0]
1611                .body
1612                .contains("could not be merged to main")
1613        );
1614        assert!(
1615            manager_messages[0]
1616                .body
1617                .contains("would be overwritten by merge")
1618                || manager_messages[0]
1619                    .body
1620                    .contains("Please commit your changes or stash them")
1621        );
1622
1623        let engineer_messages =
1624            inbox::pending_messages(&inbox::inboxes_root(&repo), "eng-1").unwrap();
1625        assert_eq!(engineer_messages.len(), 1);
1626        assert_eq!(engineer_messages[0].from, "daemon");
1627        assert!(
1628            engineer_messages[0]
1629                .body
1630                .contains("could not merge it into main")
1631        );
1632    }
1633
1634    #[test]
1635    fn handle_engineer_completion_emits_performance_regression_event() {
1636        let tmp = tempfile::tempdir().unwrap();
1637        let repo = init_git_repo(&tmp, "batty-merge-test");
1638        write_task_file(&repo, 42, "runtime-regression-task");
1639
1640        let timing_log = repo.join(".batty").join("test_timing.jsonl");
1641        for task_id in 1..=5 {
1642            super::super::artifact::record_test_timing(
1643                &timing_log,
1644                &super::super::artifact::TestTimingRecord {
1645                    task_id,
1646                    engineer: "eng-1".to_string(),
1647                    branch: format!("eng-1/task-{task_id}"),
1648                    measured_at: 1_777_000_000 + task_id as u64,
1649                    duration_ms: 1,
1650                    rolling_average_ms: Some(1),
1651                    regression_pct: Some(0),
1652                    regression_detected: false,
1653                },
1654            )
1655            .unwrap();
1656        }
1657
1658        let team_config_dir = repo.join(".batty").join("team_config");
1659        let worktree_dir = repo.join(".batty").join("worktrees").join("eng-1");
1660        setup_engineer_worktree(&repo, &worktree_dir, "eng-1", &team_config_dir).unwrap();
1661
1662        std::fs::write(worktree_dir.join("note.txt"), "done\n").unwrap();
1663        git_ok(&worktree_dir, &["add", "note.txt"]);
1664        git_ok(&worktree_dir, &["commit", "-m", "add note"]);
1665
1666        let engineer = MemberInstance {
1667            name: "eng-1".to_string(),
1668            role_name: "eng-1".to_string(),
1669            role_type: super::super::config::RoleType::Engineer,
1670            agent: Some("claude".to_string()),
1671            prompt: None,
1672            reports_to: None,
1673            use_worktrees: true,
1674        };
1675        let mut daemon = make_test_daemon(&repo, vec![engineer]);
1676        daemon.set_active_task_for_test("eng-1", 42);
1677        daemon.set_member_state_for_test("eng-1", MemberState::Working);
1678
1679        handle_engineer_completion(&mut daemon, "eng-1").unwrap();
1680
1681        let events = crate::team::events::read_events(
1682            &repo.join(".batty").join("team_config").join("events.jsonl"),
1683        )
1684        .unwrap();
1685        assert!(events.iter().any(|event| {
1686            event.event == "performance_regression"
1687                && event.task.as_deref() == Some("42")
1688                && event
1689                    .reason
1690                    .as_deref()
1691                    .is_some_and(|reason| reason.contains("runtime_ms="))
1692        }));
1693
1694        let timings = read_test_timing_log(&timing_log).unwrap();
1695        assert_eq!(timings.len(), 6);
1696        assert!(timings.last().unwrap().regression_detected);
1697    }
1698
1699    #[test]
1700    fn reset_clears_task_branch() {
1701        let tmp = tempfile::tempdir().unwrap();
1702        let repo = init_git_repo(&tmp, "batty-merge-test");
1703        let (worktree_dir, team_config_dir) = engineer_worktree_paths(&repo, "eng-reset");
1704
1705        prepare_engineer_assignment_worktree(
1706            &repo,
1707            &worktree_dir,
1708            "eng-reset",
1709            "eng-reset/task-99",
1710            &team_config_dir,
1711        )
1712        .unwrap();
1713
1714        std::fs::write(worktree_dir.join("done.txt"), "work done\n").unwrap();
1715        git_ok(&worktree_dir, &["add", "done.txt"]);
1716        git_ok(&worktree_dir, &["commit", "-m", "task work"]);
1717
1718        // Merge the task branch into main so it's considered merged.
1719        git_ok(&repo, &["merge", "eng-reset/task-99", "--no-edit"]);
1720
1721        reset_engineer_worktree(&repo, "eng-reset").unwrap();
1722
1723        // Verify on base branch.
1724        assert_eq!(
1725            git_stdout(&worktree_dir, &["rev-parse", "--abbrev-ref", "HEAD"]),
1726            engineer_base_branch_name("eng-reset")
1727        );
1728        // Verify task branch is deleted.
1729        assert!(
1730            !git(&repo, &["rev-parse", "--verify", "eng-reset/task-99"])
1731                .status
1732                .success(),
1733            "merged task branch should have been deleted"
1734        );
1735    }
1736
1737    #[test]
1738    fn reset_handles_uncommitted_changes_on_base_branch() {
1739        let tmp = tempfile::tempdir().unwrap();
1740        let repo = init_git_repo(&tmp, "batty-merge-test");
1741        let (worktree_dir, team_config_dir) = engineer_worktree_paths(&repo, "eng-dirty");
1742        let base = engineer_base_branch_name("eng-dirty");
1743
1744        // Set up worktree on the base branch (not a task branch).
1745        setup_engineer_worktree(&repo, &worktree_dir, &base, &team_config_dir).unwrap();
1746
1747        // Leave uncommitted staged and unstaged changes.
1748        std::fs::write(worktree_dir.join("staged.txt"), "staged\n").unwrap();
1749        git_ok(&worktree_dir, &["add", "staged.txt"]);
1750        std::fs::write(worktree_dir.join("unstaged.txt"), "unstaged\n").unwrap();
1751
1752        // Reset should succeed — base branch is safe to mutate even when dirty.
1753        reset_engineer_worktree(&repo, "eng-dirty").unwrap();
1754
1755        assert_eq!(
1756            git_stdout(&worktree_dir, &["rev-parse", "--abbrev-ref", "HEAD"]),
1757            base
1758        );
1759        // Worktree should be clean after reset.
1760        let status = git_stdout(&worktree_dir, &["status", "--porcelain"]);
1761        let tracked_changes: Vec<&str> = status
1762            .lines()
1763            .filter(|line| !line.starts_with("?? .batty/"))
1764            .collect();
1765        assert!(
1766            tracked_changes.is_empty(),
1767            "worktree should be clean after reset, got: {:?}",
1768            tracked_changes
1769        );
1770    }
1771
1772    #[test]
1773    fn reset_skips_when_dirty_task_branch() {
1774        let tmp = tempfile::tempdir().unwrap();
1775        let repo = init_git_repo(&tmp, "batty-merge-test");
1776        let (worktree_dir, team_config_dir) = engineer_worktree_paths(&repo, "eng-dirty-task");
1777
1778        prepare_engineer_assignment_worktree(
1779            &repo,
1780            &worktree_dir,
1781            "eng-dirty-task",
1782            "eng-dirty-task/task-88",
1783            &team_config_dir,
1784        )
1785        .unwrap();
1786
1787        // Leave uncommitted staged changes on a task branch.
1788        std::fs::write(worktree_dir.join("staged.txt"), "staged\n").unwrap();
1789        git_ok(&worktree_dir, &["add", "staged.txt"]);
1790
1791        // Reset should skip — worktree is dirty on a task branch.
1792        reset_engineer_worktree(&repo, "eng-dirty-task").unwrap();
1793
1794        // Worktree should remain on the task branch with changes intact.
1795        assert_eq!(
1796            git_stdout(&worktree_dir, &["rev-parse", "--abbrev-ref", "HEAD"]),
1797            "eng-dirty-task/task-88"
1798        );
1799        assert!(worktree_dir.join("staged.txt").exists());
1800    }
1801
1802    #[test]
1803    fn reset_handles_detached_head() {
1804        let tmp = tempfile::tempdir().unwrap();
1805        let repo = init_git_repo(&tmp, "batty-merge-test");
1806        let (worktree_dir, team_config_dir) = engineer_worktree_paths(&repo, "eng-detach");
1807
1808        setup_engineer_worktree(&repo, &worktree_dir, "eng-detach", &team_config_dir).unwrap();
1809
1810        // Create a commit and detach HEAD.
1811        std::fs::write(worktree_dir.join("file.txt"), "content\n").unwrap();
1812        git_ok(&worktree_dir, &["add", "file.txt"]);
1813        git_ok(&worktree_dir, &["commit", "-m", "a commit"]);
1814        let commit_sha = git_stdout(&worktree_dir, &["rev-parse", "HEAD"]);
1815        git_ok(&worktree_dir, &["checkout", &commit_sha]);
1816
1817        // Verify we are in detached HEAD state.
1818        assert_eq!(
1819            git_stdout(&worktree_dir, &["rev-parse", "--abbrev-ref", "HEAD"]),
1820            "HEAD"
1821        );
1822
1823        // Reset should still check out the base branch.
1824        reset_engineer_worktree(&repo, "eng-detach").unwrap();
1825
1826        assert_eq!(
1827            git_stdout(&worktree_dir, &["rev-parse", "--abbrev-ref", "HEAD"]),
1828            engineer_base_branch_name("eng-detach")
1829        );
1830    }
1831
1832    fn production_unwrap_expect_count(source: &str) -> usize {
1833        let prod = if let Some(pos) = source.find("\n#[cfg(test)]\nmod tests") {
1834            &source[..pos]
1835        } else {
1836            source
1837        };
1838        prod.lines()
1839            .filter(|line| {
1840                let trimmed = line.trim();
1841                !trimmed.starts_with("#[cfg(test)]")
1842                    && (trimmed.contains(".unwrap(") || trimmed.contains(".expect("))
1843            })
1844            .count()
1845    }
1846
1847    #[test]
1848    fn production_merge_has_no_unwrap_or_expect_calls() {
1849        let src = include_str!("merge.rs");
1850        assert_eq!(
1851            production_unwrap_expect_count(src),
1852            0,
1853            "production merge.rs should avoid unwrap/expect"
1854        );
1855    }
1856
1857    // --- Auto-merge integration tests ---
1858
1859    use crate::team::config::AutoMergePolicy;
1860    use crate::team::events::read_events;
1861
1862    /// Helper: set up a repo + worktree with a small clean diff (one .txt file).
1863    fn setup_auto_merge_repo(
1864        engineer: &str,
1865    ) -> (tempfile::TempDir, std::path::PathBuf, std::path::PathBuf) {
1866        let tmp = tempfile::tempdir().unwrap();
1867        let repo = init_git_repo(&tmp, "batty-auto-merge-test");
1868        write_task_file(&repo, 42, "auto-merge-task");
1869
1870        let team_config_dir = repo.join(".batty").join("team_config");
1871        let worktree_dir = repo.join(".batty").join("worktrees").join(engineer);
1872        setup_engineer_worktree(&repo, &worktree_dir, engineer, &team_config_dir).unwrap();
1873
1874        // Create a small change in the worktree
1875        std::fs::write(worktree_dir.join("note.txt"), "done\n").unwrap();
1876        git_ok(&worktree_dir, &["add", "note.txt"]);
1877        git_ok(&worktree_dir, &["commit", "-m", "add note"]);
1878
1879        (tmp, repo, worktree_dir)
1880    }
1881
1882    fn auto_merge_daemon(repo: &Path, policy: AutoMergePolicy) -> super::super::daemon::TeamDaemon {
1883        let members = vec![
1884            manager_member("manager", None),
1885            engineer_member("eng-1", Some("manager"), true),
1886        ];
1887        let mut daemon = make_test_daemon(repo, members);
1888        daemon.config.team_config.workflow_policy.auto_merge = policy;
1889        daemon.set_active_task_for_test("eng-1", 42);
1890        daemon.set_member_state_for_test("eng-1", MemberState::Working);
1891        daemon
1892    }
1893
1894    #[test]
1895    fn completion_auto_merges_small_clean_diff() {
1896        let (_tmp, repo, _worktree_dir) = setup_auto_merge_repo("eng-1");
1897
1898        let policy = AutoMergePolicy {
1899            enabled: true,
1900            ..AutoMergePolicy::default()
1901        };
1902        let mut daemon = auto_merge_daemon(&repo, policy);
1903
1904        handle_engineer_completion(&mut daemon, "eng-1").unwrap();
1905
1906        // Task should be completed and cleared
1907        assert_eq!(daemon.active_task_id("eng-1"), None);
1908        assert_eq!(
1909            daemon.member_state_for_test("eng-1"),
1910            Some(MemberState::Idle)
1911        );
1912
1913        // note.txt should be merged into main
1914        assert_eq!(
1915            std::fs::read_to_string(repo.join("note.txt")).unwrap(),
1916            "done\n"
1917        );
1918
1919        // Verify auto-merge event was emitted
1920        let events_path = repo.join(".batty").join("team_config").join("events.jsonl");
1921        let events = read_events(&events_path).unwrap();
1922        let auto_merge_events: Vec<_> = events
1923            .iter()
1924            .filter(|e| e.event == "task_auto_merged")
1925            .collect();
1926        assert_eq!(auto_merge_events.len(), 1);
1927        assert_eq!(auto_merge_events[0].role.as_deref(), Some("eng-1"));
1928        assert_eq!(auto_merge_events[0].task.as_deref(), Some("42"));
1929    }
1930
1931    #[test]
1932    fn completion_routes_large_diff_to_review() {
1933        let tmp = tempfile::tempdir().unwrap();
1934        let repo = init_git_repo(&tmp, "batty-auto-merge-test");
1935        write_task_file(&repo, 42, "large-diff-task");
1936
1937        let team_config_dir = repo.join(".batty").join("team_config");
1938        let worktree_dir = repo.join(".batty").join("worktrees").join("eng-1");
1939        setup_engineer_worktree(&repo, &worktree_dir, "eng-1", &team_config_dir).unwrap();
1940
1941        // Create a large diff: many files across multiple modules
1942        for i in 0..10 {
1943            let dir = worktree_dir.join(format!("module_{i}"));
1944            std::fs::create_dir_all(&dir).unwrap();
1945            let content: String = (0..50).map(|j| format!("line {j}\n")).collect();
1946            std::fs::write(dir.join("file.rs"), content).unwrap();
1947        }
1948        git_ok(&worktree_dir, &["add", "."]);
1949        git_ok(&worktree_dir, &["commit", "-m", "large change"]);
1950
1951        let policy = AutoMergePolicy {
1952            enabled: true,
1953            max_files_changed: 5,
1954            max_diff_lines: 200,
1955            ..AutoMergePolicy::default()
1956        };
1957        let mut daemon = auto_merge_daemon(&repo, policy);
1958
1959        handle_engineer_completion(&mut daemon, "eng-1").unwrap();
1960
1961        // Task should NOT be merged — routed to manual review.
1962        // The active task stays set (we only clear on merge success or rejection).
1963        // Manager should have received a review message.
1964        let manager_messages =
1965            inbox::pending_messages(&inbox::inboxes_root(&repo), "manager").unwrap();
1966        assert!(
1967            manager_messages
1968                .iter()
1969                .any(|m| m.body.contains("manual review")),
1970            "manager should receive manual review message: {:?}",
1971            manager_messages
1972        );
1973    }
1974
1975    #[test]
1976    fn completion_respects_disabled_policy() {
1977        let (_tmp, repo, _worktree_dir) = setup_auto_merge_repo("eng-1");
1978
1979        // Default policy has enabled: false
1980        let policy = AutoMergePolicy::default();
1981        assert!(!policy.enabled);
1982
1983        let mut daemon = auto_merge_daemon(&repo, policy);
1984
1985        handle_engineer_completion(&mut daemon, "eng-1").unwrap();
1986
1987        // With auto-merge disabled, should fall through to normal merge (no review gate)
1988        assert_eq!(daemon.active_task_id("eng-1"), None);
1989        assert_eq!(
1990            daemon.member_state_for_test("eng-1"),
1991            Some(MemberState::Idle)
1992        );
1993        // note.txt merged into main
1994        assert_eq!(
1995            std::fs::read_to_string(repo.join("note.txt")).unwrap(),
1996            "done\n"
1997        );
1998
1999        // No auto-merge event should be emitted
2000        let events_path = repo.join(".batty").join("team_config").join("events.jsonl");
2001        let events = read_events(&events_path).unwrap();
2002        assert!(
2003            !events.iter().any(|e| e.event == "task_auto_merged"),
2004            "no auto-merge event should be emitted when policy is disabled"
2005        );
2006    }
2007
2008    #[test]
2009    fn completion_respects_per_task_override() {
2010        let (_tmp, repo, _worktree_dir) = setup_auto_merge_repo("eng-1");
2011
2012        let policy = AutoMergePolicy {
2013            enabled: true,
2014            ..AutoMergePolicy::default()
2015        };
2016        let mut daemon = auto_merge_daemon(&repo, policy);
2017        daemon.set_auto_merge_override(42, false); // Force manual review
2018
2019        handle_engineer_completion(&mut daemon, "eng-1").unwrap();
2020
2021        // Manager should have received a message about override
2022        let manager_messages =
2023            inbox::pending_messages(&inbox::inboxes_root(&repo), "manager").unwrap();
2024        assert!(
2025            manager_messages
2026                .iter()
2027                .any(|m| m.body.contains("Auto-merge disabled by override")),
2028            "manager should receive override message: {:?}",
2029            manager_messages
2030        );
2031    }
2032
2033    #[test]
2034    fn auto_merge_emits_event() {
2035        let (_tmp, repo, _worktree_dir) = setup_auto_merge_repo("eng-1");
2036
2037        let policy = AutoMergePolicy {
2038            enabled: true,
2039            ..AutoMergePolicy::default()
2040        };
2041        let mut daemon = auto_merge_daemon(&repo, policy);
2042
2043        handle_engineer_completion(&mut daemon, "eng-1").unwrap();
2044
2045        let events_path = repo.join(".batty").join("team_config").join("events.jsonl");
2046        let events = read_events(&events_path).unwrap();
2047        let auto_event = events
2048            .iter()
2049            .find(|e| e.event == "task_auto_merged")
2050            .expect("should have task_auto_merged event");
2051
2052        assert_eq!(auto_event.role.as_deref(), Some("eng-1"));
2053        assert_eq!(auto_event.task.as_deref(), Some("42"));
2054        // Confidence should be stored in load field
2055        assert!(auto_event.load.is_some());
2056        let confidence = auto_event.load.unwrap();
2057        assert!(
2058            confidence > 0.0 && confidence <= 1.0,
2059            "confidence should be between 0 and 1, got {}",
2060            confidence
2061        );
2062        // Reason should contain files and lines info
2063        assert!(
2064            auto_event
2065                .reason
2066                .as_ref()
2067                .is_some_and(|r| r.contains("files=") && r.contains("lines=")),
2068            "reason should contain diff stats: {:?}",
2069            auto_event.reason
2070        );
2071    }
2072
2073    #[test]
2074    fn merge_fails_when_project_root_not_on_main() {
2075        let tmp = tempfile::tempdir().unwrap();
2076        let repo = init_git_repo(&tmp, "batty-merge-test");
2077        let (worktree_dir, team_config_dir) = engineer_worktree_paths(&repo, "eng-off");
2078
2079        setup_engineer_worktree(&repo, &worktree_dir, "eng-off", &team_config_dir).unwrap();
2080
2081        std::fs::write(worktree_dir.join("feature.txt"), "engineer work\n").unwrap();
2082        git_ok(&worktree_dir, &["add", "feature.txt"]);
2083        git_ok(&worktree_dir, &["commit", "-m", "engineer feature"]);
2084
2085        // Move project root off main onto a detached HEAD.
2086        git_ok(&repo, &["checkout", "--detach", "HEAD"]);
2087
2088        let result = merge_engineer_branch(&repo, "eng-off").unwrap();
2089        // Should attempt to checkout main — detached HEAD means checkout succeeds
2090        // and merge proceeds normally. The key fix is that it TRIES to checkout.
2091        // But if we create a scenario where checkout fails, we get MergeFailure.
2092        match result {
2093            MergeOutcome::Success => {
2094                // checkout main succeeded, merge proceeded — verify we're on main
2095                let branch = git_stdout(&repo, &["rev-parse", "--abbrev-ref", "HEAD"]);
2096                assert_eq!(branch, "main");
2097            }
2098            MergeOutcome::MergeFailure(msg) => {
2099                assert!(
2100                    msg.contains("not 'main'"),
2101                    "expected branch mismatch message, got: {msg}"
2102                );
2103            }
2104            other => panic!("expected Success or MergeFailure, got {other:?}"),
2105        }
2106    }
2107
2108    #[test]
2109    fn merge_succeeds_when_project_root_on_main() {
2110        let tmp = tempfile::tempdir().unwrap();
2111        let repo = init_git_repo(&tmp, "batty-merge-test");
2112        let (worktree_dir, team_config_dir) = engineer_worktree_paths(&repo, "eng-ok");
2113
2114        setup_engineer_worktree(&repo, &worktree_dir, "eng-ok", &team_config_dir).unwrap();
2115
2116        std::fs::write(worktree_dir.join("feature.txt"), "work\n").unwrap();
2117        git_ok(&worktree_dir, &["add", "feature.txt"]);
2118        git_ok(&worktree_dir, &["commit", "-m", "engineer work"]);
2119
2120        // project root stays on main (the default) — merge should succeed
2121        assert_eq!(
2122            git_stdout(&repo, &["rev-parse", "--abbrev-ref", "HEAD"]),
2123            "main"
2124        );
2125
2126        let result = merge_engineer_branch(&repo, "eng-ok").unwrap();
2127        assert!(matches!(result, MergeOutcome::Success));
2128        assert!(repo.join("feature.txt").exists());
2129    }
2130
2131    #[test]
2132    fn reset_worktree_skips_when_dirty_task_branch() {
2133        let tmp = tempfile::tempdir().unwrap();
2134        let repo = init_git_repo(&tmp, "batty-merge-test");
2135        let (worktree_dir, team_config_dir) = engineer_worktree_paths(&repo, "eng-wip");
2136
2137        prepare_engineer_assignment_worktree(
2138            &repo,
2139            &worktree_dir,
2140            "eng-wip",
2141            "eng-wip/88",
2142            &team_config_dir,
2143        )
2144        .unwrap();
2145
2146        // Create uncommitted changes on the task branch.
2147        std::fs::write(worktree_dir.join("wip.txt"), "work in progress\n").unwrap();
2148        git_ok(&worktree_dir, &["add", "wip.txt"]);
2149
2150        // reset_engineer_worktree should skip (not error) when dirty on task branch.
2151        reset_engineer_worktree(&repo, "eng-wip").unwrap();
2152
2153        // Verify the worktree was NOT reset — still on task branch with changes.
2154        let branch = current_worktree_branch(&worktree_dir).unwrap();
2155        assert_eq!(branch, "eng-wip/88");
2156        assert!(worktree_dir.join("wip.txt").exists());
2157    }
2158}