1use 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 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 let policy = daemon.config.team_config.workflow_policy.auto_merge.clone();
133 let auto_merge_override = daemon.auto_merge_override(task_id);
134
135 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 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 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 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 }
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 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 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_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 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
615fn force_clean_worktree(worktree_dir: &Path, engineer_name: &str) {
619 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 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 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 assert_eq!(daemon.retry_count_for_test("eng-1"), None);
1440 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 git_ok(&repo, &["merge", "eng-reset/task-99", "--no-edit"]);
1720
1721 reset_engineer_worktree(&repo, "eng-reset").unwrap();
1722
1723 assert_eq!(
1725 git_stdout(&worktree_dir, &["rev-parse", "--abbrev-ref", "HEAD"]),
1726 engineer_base_branch_name("eng-reset")
1727 );
1728 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 setup_engineer_worktree(&repo, &worktree_dir, &base, &team_config_dir).unwrap();
1746
1747 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_engineer_worktree(&repo, "eng-dirty").unwrap();
1754
1755 assert_eq!(
1756 git_stdout(&worktree_dir, &["rev-parse", "--abbrev-ref", "HEAD"]),
1757 base
1758 );
1759 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 std::fs::write(worktree_dir.join("staged.txt"), "staged\n").unwrap();
1789 git_ok(&worktree_dir, &["add", "staged.txt"]);
1790
1791 reset_engineer_worktree(&repo, "eng-dirty-task").unwrap();
1793
1794 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 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 assert_eq!(
1819 git_stdout(&worktree_dir, &["rev-parse", "--abbrev-ref", "HEAD"]),
1820 "HEAD"
1821 );
1822
1823 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 use crate::team::config::AutoMergePolicy;
1860 use crate::team::events::read_events;
1861
1862 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 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 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 assert_eq!(
1915 std::fs::read_to_string(repo.join("note.txt")).unwrap(),
1916 "done\n"
1917 );
1918
1919 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 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 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 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 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 assert_eq!(
1995 std::fs::read_to_string(repo.join("note.txt")).unwrap(),
1996 "done\n"
1997 );
1998
1999 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); handle_engineer_completion(&mut daemon, "eng-1").unwrap();
2020
2021 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 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 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 git_ok(&repo, &["checkout", "--detach", "HEAD"]);
2087
2088 let result = merge_engineer_branch(&repo, "eng-off").unwrap();
2089 match result {
2093 MergeOutcome::Success => {
2094 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 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 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(&repo, "eng-wip").unwrap();
2152
2153 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}