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