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