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