1#![allow(dead_code)]
9
10use std::collections::HashSet;
11use std::ffi::OsStr;
12use std::path::{Path, PathBuf};
13use std::process::{Command, Output};
14use std::time::{Duration, SystemTime, UNIX_EPOCH};
15
16use anyhow::{Context, Result, bail};
17use tracing::{info, warn};
18
19#[derive(Debug, Clone)]
20pub struct PhaseWorktree {
21 pub repo_root: PathBuf,
22 pub base_branch: String,
23 pub start_commit: String,
24 pub branch: String,
25 pub path: PathBuf,
26}
27
28#[derive(Debug, Clone)]
29pub struct AgentWorktree {
30 pub branch: String,
31 pub path: PathBuf,
32}
33
34#[derive(Debug)]
35pub struct IntegrationWorktree {
36 repo_root: PathBuf,
37 path: PathBuf,
38}
39
40impl IntegrationWorktree {
41 pub fn path(&self) -> &Path {
42 &self.path
43 }
44}
45
46impl Drop for IntegrationWorktree {
47 fn drop(&mut self) {
48 let path = self.path.to_string_lossy().into_owned();
49 let _ = run_git(
50 &self.repo_root,
51 ["worktree", "remove", "--force", path.as_str()],
52 );
53 }
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct MainStartRefSelection {
58 pub ref_name: String,
59 pub fallback_reason: Option<String>,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct DispatchBranchReset {
64 pub changed: bool,
65 pub start_ref: String,
66 pub fallback_reason: Option<String>,
67 pub reset_reason: Option<WorktreeResetReason>,
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum RunOutcome {
72 Completed,
73 Failed,
74 DryRun,
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub enum CleanupDecision {
79 Cleaned,
80 KeptForReview,
81 KeptForFailure,
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub enum PreserveFailureMode {
86 SkipReset,
87 ForceReset,
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub enum WorktreeResetReason {
92 PreservedBeforeReset,
93 CleanReset,
94 PreserveFailedResetSkipped,
95 PreserveFailedForceReset,
96}
97
98impl WorktreeResetReason {
99 pub fn as_str(self) -> &'static str {
100 match self {
101 Self::PreservedBeforeReset => "preserved_before_reset",
102 Self::CleanReset => "clean_reset",
103 Self::PreserveFailedResetSkipped => "preserve_failed_reset_skipped",
104 Self::PreserveFailedForceReset => "preserve_failed_force_reset",
105 }
106 }
107
108 pub fn reset_performed(self) -> bool {
109 self != Self::PreserveFailedResetSkipped
110 }
111}
112
113impl PhaseWorktree {
114 pub fn finalize(&self, outcome: RunOutcome) -> Result<CleanupDecision> {
115 match outcome {
116 RunOutcome::Failed => Ok(CleanupDecision::KeptForFailure),
117 RunOutcome::DryRun => {
118 remove_worktree(&self.repo_root, &self.path)?;
119 delete_branch(&self.repo_root, &self.branch)?;
120 Ok(CleanupDecision::Cleaned)
121 }
122 RunOutcome::Completed => {
123 let branch_tip = current_commit(&self.repo_root, &self.branch)?;
124 if branch_tip == self.start_commit {
125 return Ok(CleanupDecision::KeptForReview);
126 }
127
128 if is_merged_into_base(&self.repo_root, &self.branch, &self.base_branch)? {
129 remove_worktree(&self.repo_root, &self.path)?;
130 delete_branch(&self.repo_root, &self.branch)?;
131 Ok(CleanupDecision::Cleaned)
132 } else {
133 Ok(CleanupDecision::KeptForReview)
134 }
135 }
136 }
137 }
138}
139
140pub fn prepare_phase_worktree(project_root: &Path, phase: &str) -> Result<PhaseWorktree> {
142 let repo_root = resolve_repo_root(project_root)?;
143 let base_branch = current_branch(&repo_root)?;
144 let start_commit = current_commit(&repo_root, "HEAD")?;
145 let worktrees_root = repo_root.join(".batty").join("worktrees");
146
147 std::fs::create_dir_all(&worktrees_root).with_context(|| {
148 format!(
149 "failed to create worktrees directory {}",
150 worktrees_root.display()
151 )
152 })?;
153
154 let phase_slug = sanitize_phase_for_branch(phase);
155 let prefix = format!("{phase_slug}-run-");
156 let mut run_number = next_run_number(&repo_root, &worktrees_root, &prefix)?;
157
158 loop {
159 let branch = format!("{prefix}{run_number:03}");
160 let path = worktrees_root.join(&branch);
161
162 if path.exists() || branch_exists(&repo_root, &branch)? {
163 run_number += 1;
164 continue;
165 }
166
167 let path_s = path.to_string_lossy().to_string();
168 let add_output = run_git(
169 &repo_root,
170 [
171 "worktree",
172 "add",
173 "-b",
174 branch.as_str(),
175 path_s.as_str(),
176 base_branch.as_str(),
177 ],
178 )?;
179 if !add_output.status.success() {
180 bail!(
181 "git worktree add failed: {}",
182 String::from_utf8_lossy(&add_output.stderr).trim()
183 );
184 }
185
186 return Ok(PhaseWorktree {
187 repo_root,
188 base_branch,
189 start_commit,
190 branch,
191 path,
192 });
193 }
194}
195
196pub fn resolve_phase_worktree(
204 project_root: &Path,
205 phase: &str,
206 force_new: bool,
207) -> Result<(PhaseWorktree, bool)> {
208 if !force_new && let Some(existing) = latest_phase_worktree(project_root, phase)? {
209 return Ok((existing, true));
210 }
211
212 Ok((prepare_phase_worktree(project_root, phase)?, false))
213}
214
215pub fn prepare_agent_worktrees(
221 project_root: &Path,
222 phase: &str,
223 agent_names: &[String],
224 force_new: bool,
225) -> Result<Vec<AgentWorktree>> {
226 if agent_names.is_empty() {
227 bail!("parallel agent worktree preparation requires at least one agent");
228 }
229
230 let repo_root = resolve_repo_root(project_root)?;
231 let base_branch = current_branch(&repo_root)?;
232 let phase_slug = sanitize_phase_for_branch(phase);
233 let phase_dir = repo_root.join(".batty").join("worktrees").join(phase);
234 std::fs::create_dir_all(&phase_dir).with_context(|| {
235 format!(
236 "failed to create agent worktree phase directory {}",
237 phase_dir.display()
238 )
239 })?;
240
241 let mut seen_agent_slugs = HashSet::new();
242 for agent in agent_names {
243 let slug = sanitize_phase_for_branch(agent);
244 if !seen_agent_slugs.insert(slug.clone()) {
245 bail!(
246 "agent names contain duplicate sanitized slug '{}'; use unique agent names",
247 slug
248 );
249 }
250 }
251
252 let mut worktrees = Vec::with_capacity(agent_names.len());
253 for agent in agent_names {
254 let agent_slug = sanitize_phase_for_branch(agent);
255 let branch = format!("batty/{phase_slug}/{agent_slug}");
256 let path = phase_dir.join(&agent_slug);
257
258 if force_new {
259 let _ = remove_worktree(&repo_root, &path);
260 let _ = delete_branch(&repo_root, &branch);
261 }
262
263 if path.exists() {
264 if !branch_exists(&repo_root, &branch)? {
265 bail!(
266 "agent worktree path exists but branch is missing: {} ({})",
267 path.display(),
268 branch
269 );
270 }
271 if !worktree_registered(&repo_root, &path)? {
272 bail!(
273 "agent worktree path exists but is not registered in git worktree list: {}",
274 path.display()
275 );
276 }
277 } else {
278 let path_s = path.to_string_lossy().to_string();
279 let add_output = if branch_exists(&repo_root, &branch)? {
280 run_git(
281 &repo_root,
282 ["worktree", "add", path_s.as_str(), branch.as_str()],
283 )?
284 } else {
285 run_git(
286 &repo_root,
287 [
288 "worktree",
289 "add",
290 "-b",
291 branch.as_str(),
292 path_s.as_str(),
293 base_branch.as_str(),
294 ],
295 )?
296 };
297 if !add_output.status.success() {
298 bail!(
299 "git worktree add failed for agent '{}': {}",
300 agent,
301 String::from_utf8_lossy(&add_output.stderr).trim()
302 );
303 }
304 }
305
306 worktrees.push(AgentWorktree { branch, path });
307 }
308
309 Ok(worktrees)
310}
311
312pub fn prepare_integration_worktree(
313 project_root: &Path,
314 prefix: &str,
315 start_ref: &str,
316) -> Result<IntegrationWorktree> {
317 let repo_root = resolve_repo_root(project_root)?;
318 let scratch_root = repo_root.join(".batty").join("integration-worktrees");
319 std::fs::create_dir_all(&scratch_root).with_context(|| {
320 format!(
321 "failed to create integration worktree directory {}",
322 scratch_root.display()
323 )
324 })?;
325
326 let stamp = SystemTime::now()
327 .duration_since(UNIX_EPOCH)
328 .unwrap_or_default()
329 .as_millis();
330 let pid = std::process::id();
331 let path = scratch_root.join(format!("{prefix}{pid}-{stamp}"));
332 let path_s = path.to_string_lossy().into_owned();
333 let add = run_git(
334 &repo_root,
335 ["worktree", "add", "--detach", path_s.as_str(), start_ref],
336 )?;
337 if !add.status.success() {
338 bail!(
339 "failed to add integration worktree at {}: {}",
340 path.display(),
341 String::from_utf8_lossy(&add.stderr).trim()
342 );
343 }
344
345 Ok(IntegrationWorktree { repo_root, path })
346}
347
348fn latest_phase_worktree(project_root: &Path, phase: &str) -> Result<Option<PhaseWorktree>> {
349 let repo_root = resolve_repo_root(project_root)?;
350 let base_branch = current_branch(&repo_root)?;
351 let worktrees_root = repo_root.join(".batty").join("worktrees");
352 if !worktrees_root.is_dir() {
353 return Ok(None);
354 }
355
356 let phase_slug = sanitize_phase_for_branch(phase);
357 let prefix = format!("{phase_slug}-run-");
358 let mut best: Option<(u32, String, PathBuf)> = None;
359
360 for entry in std::fs::read_dir(&worktrees_root)
361 .with_context(|| format!("failed to read {}", worktrees_root.display()))?
362 {
363 let entry = entry?;
364 let path = entry.path();
365 if !path.is_dir() {
366 continue;
367 }
368
369 let branch = entry.file_name().to_string_lossy().to_string();
370 let Some(run) = parse_run_number(&branch, &prefix) else {
371 continue;
372 };
373
374 if !branch_exists(&repo_root, &branch)? {
375 warn!(
376 branch = %branch,
377 path = %path.display(),
378 "skipping stale phase worktree directory without branch"
379 );
380 continue;
381 }
382
383 match &best {
384 Some((best_run, _, _)) if run <= *best_run => {}
385 _ => best = Some((run, branch, path)),
386 }
387 }
388
389 let Some((_, branch, path)) = best else {
390 return Ok(None);
391 };
392
393 let start_commit = current_commit(&repo_root, &branch)?;
394 Ok(Some(PhaseWorktree {
395 repo_root,
396 base_branch,
397 start_commit,
398 branch,
399 path,
400 }))
401}
402
403fn resolve_repo_root(project_root: &Path) -> Result<PathBuf> {
404 let output = Command::new("git")
405 .current_dir(project_root)
406 .args(["rev-parse", "--show-toplevel"])
407 .output()
408 .with_context(|| {
409 format!(
410 "failed while trying to resolve the repository root: could not execute `git rev-parse --show-toplevel` in {}",
411 project_root.display()
412 )
413 })?;
414 if !output.status.success() {
415 bail!(
416 "not a git repository: {}",
417 String::from_utf8_lossy(&output.stderr).trim()
418 );
419 }
420
421 let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
422 if root.is_empty() {
423 bail!("git rev-parse returned empty repository root");
424 }
425 Ok(PathBuf::from(root))
426}
427
428fn current_branch(repo_root: &Path) -> Result<String> {
429 let output = run_git(repo_root, ["branch", "--show-current"])?;
430 if !output.status.success() {
431 bail!(
432 "failed to determine current branch: {}",
433 String::from_utf8_lossy(&output.stderr).trim()
434 );
435 }
436
437 let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
438 if branch.is_empty() {
439 bail!("detached HEAD is not supported for phase worktree runs; checkout a branch first");
440 }
441 Ok(branch)
442}
443
444fn next_run_number(repo_root: &Path, worktrees_root: &Path, prefix: &str) -> Result<u32> {
445 let mut max_run = 0;
446
447 let refs = run_git(
448 repo_root,
449 ["for-each-ref", "--format=%(refname:short)", "refs/heads"],
450 )?;
451 if !refs.status.success() {
452 bail!(
453 "failed to list branches: {}",
454 String::from_utf8_lossy(&refs.stderr).trim()
455 );
456 }
457
458 for branch in String::from_utf8_lossy(&refs.stdout).lines() {
459 if let Some(run) = parse_run_number(branch, prefix) {
460 max_run = max_run.max(run);
461 }
462 }
463
464 if worktrees_root.is_dir() {
465 for entry in std::fs::read_dir(worktrees_root)
466 .with_context(|| format!("failed to read {}", worktrees_root.display()))?
467 {
468 let entry = entry?;
469 let name = entry.file_name();
470 let name = name.to_string_lossy();
471 if let Some(run) = parse_run_number(name.as_ref(), prefix) {
472 max_run = max_run.max(run);
473 }
474 }
475 }
476
477 Ok(max_run + 1)
478}
479
480fn parse_run_number(name: &str, prefix: &str) -> Option<u32> {
481 let suffix = name.strip_prefix(prefix)?;
482 if suffix.len() < 3 || !suffix.chars().all(|c| c.is_ascii_digit()) {
483 return None;
484 }
485 suffix.parse().ok()
486}
487
488fn sanitize_phase_for_branch(phase: &str) -> String {
489 let mut out = String::new();
490 let mut last_dash = false;
491
492 for c in phase.chars() {
493 if c.is_ascii_alphanumeric() {
494 out.push(c.to_ascii_lowercase());
495 last_dash = false;
496 } else if !last_dash {
497 out.push('-');
498 last_dash = true;
499 }
500 }
501
502 let slug = out.trim_matches('-').to_string();
503 if slug.is_empty() {
504 "phase".to_string()
505 } else {
506 slug
507 }
508}
509
510fn run_git<I, S>(repo_root: &Path, args: I) -> Result<Output>
511where
512 I: IntoIterator<Item = S>,
513 S: AsRef<OsStr>,
514{
515 let args = args
516 .into_iter()
517 .map(|arg| arg.as_ref().to_os_string())
518 .collect::<Vec<_>>();
519 let command = {
520 let rendered = args
521 .iter()
522 .map(|arg| arg.to_string_lossy().into_owned())
523 .collect::<Vec<_>>()
524 .join(" ");
525 format!("git {rendered}")
526 };
527 Command::new("git")
528 .current_dir(repo_root)
529 .args(&args)
530 .output()
531 .with_context(|| format!("failed to execute `{command}` in {}", repo_root.display()))
532}
533
534fn branch_exists(repo_root: &Path, branch: &str) -> Result<bool> {
535 let ref_name = format!("refs/heads/{branch}");
536 let output = run_git(
537 repo_root,
538 ["show-ref", "--verify", "--quiet", ref_name.as_str()],
539 )?;
540 match output.status.code() {
541 Some(0) => Ok(true),
542 Some(1) => Ok(false),
543 _ => bail!(
544 "failed to check branch '{}': {}",
545 branch,
546 String::from_utf8_lossy(&output.stderr).trim()
547 ),
548 }
549}
550
551fn worktree_registered(repo_root: &Path, path: &Path) -> Result<bool> {
552 let output = run_git(repo_root, ["worktree", "list", "--porcelain"])?;
553 if !output.status.success() {
554 bail!(
555 "failed to list worktrees: {}",
556 String::from_utf8_lossy(&output.stderr).trim()
557 );
558 }
559
560 let target = path.to_string_lossy().to_string();
561 let listed = String::from_utf8_lossy(&output.stdout);
562 for line in listed.lines() {
563 if let Some(candidate) = line.strip_prefix("worktree ")
564 && candidate.trim() == target
565 {
566 return Ok(true);
567 }
568 }
569 Ok(false)
570}
571
572fn is_merged_into_base(repo_root: &Path, branch: &str, base_branch: &str) -> Result<bool> {
573 let output = run_git(
574 repo_root,
575 ["merge-base", "--is-ancestor", branch, base_branch],
576 )?;
577 match output.status.code() {
578 Some(0) => Ok(true),
579 Some(1) => Ok(false),
580 _ => bail!(
581 "failed to check merge status for '{}' into '{}': {}",
582 branch,
583 base_branch,
584 String::from_utf8_lossy(&output.stderr).trim()
585 ),
586 }
587}
588
589fn current_commit(repo_root: &Path, rev: &str) -> Result<String> {
590 let output = run_git(repo_root, ["rev-parse", rev])?;
591 if !output.status.success() {
592 bail!(
593 "failed to resolve revision '{}': {}",
594 rev,
595 String::from_utf8_lossy(&output.stderr).trim()
596 );
597 }
598
599 let commit = String::from_utf8_lossy(&output.stdout).trim().to_string();
600 if commit.is_empty() {
601 bail!("git rev-parse returned empty commit for '{rev}'");
602 }
603 Ok(commit)
604}
605
606fn remove_worktree(repo_root: &Path, path: &Path) -> Result<()> {
607 if !path.exists() {
608 return Ok(());
609 }
610
611 let path_s = path.to_string_lossy().to_string();
612 let output = run_git(
613 repo_root,
614 ["worktree", "remove", "--force", path_s.as_str()],
615 )?;
616 if !output.status.success() {
617 bail!(
618 "failed to remove worktree '{}': {}",
619 path.display(),
620 String::from_utf8_lossy(&output.stderr).trim()
621 );
622 }
623 Ok(())
624}
625
626fn delete_branch(repo_root: &Path, branch: &str) -> Result<()> {
627 if !branch_exists(repo_root, branch)? {
628 return Ok(());
629 }
630
631 let output = run_git(repo_root, ["branch", "-D", branch])?;
632 if !output.status.success() {
633 bail!(
634 "failed to delete branch '{}': {}",
635 branch,
636 String::from_utf8_lossy(&output.stderr).trim()
637 );
638 }
639 Ok(())
640}
641
642pub fn sync_phase_board_to_worktree(
652 project_root: &Path,
653 worktree_root: &Path,
654 phase: &str,
655) -> Result<()> {
656 let source_phase_dir = crate::paths::resolve_kanban_root(project_root).join(phase);
657 if !source_phase_dir.is_dir() {
658 return Ok(());
659 }
660
661 let dest_kanban_root = crate::paths::resolve_kanban_root(worktree_root);
662 let dest_phase_dir = dest_kanban_root.join(phase);
663
664 if dest_phase_dir.exists() {
666 std::fs::remove_dir_all(&dest_phase_dir).with_context(|| {
667 format!(
668 "failed to remove stale phase board at {}",
669 dest_phase_dir.display()
670 )
671 })?;
672 }
673
674 copy_dir_recursive(&source_phase_dir, &dest_phase_dir).with_context(|| {
675 format!(
676 "failed to sync phase board from {} to {}",
677 source_phase_dir.display(),
678 dest_phase_dir.display()
679 )
680 })?;
681
682 info!(
683 phase = phase,
684 source = %source_phase_dir.display(),
685 dest = %dest_phase_dir.display(),
686 "synced phase board into worktree"
687 );
688 Ok(())
689}
690
691pub fn branch_fully_merged(repo_root: &Path, branch: &str, base: &str) -> Result<bool> {
698 let output = run_git(repo_root, ["cherry", base, branch])?;
699 if !output.status.success() {
700 bail!(
701 "git cherry failed for '{}' against '{}': {}",
702 branch,
703 base,
704 String::from_utf8_lossy(&output.stderr).trim()
705 );
706 }
707
708 let stdout = String::from_utf8_lossy(&output.stdout);
709 for line in stdout.lines() {
710 let trimmed = line.trim();
711 if trimmed.is_empty() {
712 continue;
713 }
714 if trimmed.starts_with('+') {
716 return Ok(false);
717 }
718 }
719 Ok(true)
720}
721
722pub fn commits_ahead(worktree_path: &Path, base: &str) -> Result<usize> {
725 let output = run_git(worktree_path, ["rev-list", &format!("{base}..HEAD")])?;
726 if !output.status.success() {
727 bail!(
728 "git rev-list failed: {}",
729 String::from_utf8_lossy(&output.stderr).trim()
730 );
731 }
732 Ok(String::from_utf8_lossy(&output.stdout)
733 .lines()
734 .filter(|l| !l.trim().is_empty())
735 .count())
736}
737
738pub fn has_uncommitted_changes(worktree_path: &Path) -> Result<bool> {
740 let output = run_git(worktree_path, ["status", "--porcelain"])?;
741 if !output.status.success() {
742 bail!(
743 "git status failed: {}",
744 String::from_utf8_lossy(&output.stderr).trim()
745 );
746 }
747 Ok(!String::from_utf8_lossy(&output.stdout).trim().is_empty())
748}
749
750pub fn git_current_branch(path: &Path) -> Result<String> {
752 let output = run_git(path, ["branch", "--show-current"])?;
753 if !output.status.success() {
754 bail!(
755 "failed to determine current branch in {}: {}",
756 path.display(),
757 String::from_utf8_lossy(&output.stderr).trim()
758 );
759 }
760 let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
761 if branch.is_empty() {
762 bail!(
763 "detached HEAD in {}; cannot determine branch",
764 path.display()
765 );
766 }
767 Ok(branch)
768}
769
770fn ref_exists(path: &Path, ref_name: &str) -> Result<bool> {
771 let output = run_git(path, ["show-ref", "--verify", "--quiet", ref_name])?;
772 match output.status.code() {
773 Some(0) => Ok(true),
774 Some(1) => Ok(false),
775 _ => bail!(
776 "failed to inspect {} in {}: {}",
777 ref_name,
778 path.display(),
779 String::from_utf8_lossy(&output.stderr).trim()
780 ),
781 }
782}
783
784fn rev_list_count(path: &Path, range: &str) -> Result<u32> {
785 let output = run_git(path, ["rev-list", "--count", range])?;
786 if !output.status.success() {
787 bail!(
788 "failed to count commits in {} for {}: {}",
789 path.display(),
790 range,
791 String::from_utf8_lossy(&output.stderr).trim()
792 );
793 }
794 String::from_utf8_lossy(&output.stdout)
795 .trim()
796 .parse::<u32>()
797 .with_context(|| format!("failed to parse rev-list count for {range}"))
798}
799
800fn merge_base_is_ancestor(path: &Path, older: &str, newer: &str) -> Result<bool> {
801 let output = run_git(path, ["merge-base", "--is-ancestor", older, newer])?;
802 match output.status.code() {
803 Some(0) => Ok(true),
804 Some(1) => Ok(false),
805 _ => bail!(
806 "failed to compare {} and {} in {}: {}",
807 older,
808 newer,
809 path.display(),
810 String::from_utf8_lossy(&output.stderr).trim()
811 ),
812 }
813}
814
815fn preferred_main_start_ref(path: &Path) -> Result<MainStartRefSelection> {
816 if !ref_exists(path, "refs/remotes/origin/main")? {
817 return Ok(MainStartRefSelection {
818 ref_name: "main".to_string(),
819 fallback_reason: Some("stale_origin_fallback ahead=0 origin_unreachable".to_string()),
820 });
821 }
822
823 let local_main = current_commit(path, "refs/heads/main")?;
824 let remote_main = current_commit(path, "refs/remotes/origin/main")?;
825 if local_main == remote_main {
826 return Ok(MainStartRefSelection {
827 ref_name: "origin/main".to_string(),
828 fallback_reason: None,
829 });
830 }
831
832 if merge_base_is_ancestor(path, "refs/remotes/origin/main", "refs/heads/main")? {
833 let ahead = rev_list_count(path, "origin/main..main")?;
834 return Ok(MainStartRefSelection {
835 ref_name: "main".to_string(),
836 fallback_reason: Some(format!("stale_origin_fallback ahead={ahead}")),
837 });
838 }
839
840 if merge_base_is_ancestor(path, "refs/heads/main", "refs/remotes/origin/main")? {
841 return Ok(MainStartRefSelection {
842 ref_name: "origin/main".to_string(),
843 fallback_reason: None,
844 });
845 }
846
847 let ahead = rev_list_count(path, "origin/main..main")?;
848 let origin_ahead = rev_list_count(path, "main..origin/main")?;
849 Ok(MainStartRefSelection {
850 ref_name: "main".to_string(),
851 fallback_reason: Some(format!(
852 "stale_origin_fallback ahead={ahead} divergent origin_ahead={origin_ahead}"
853 )),
854 })
855}
856
857pub fn ensure_worktree_branch_for_dispatch(
858 worktree_path: &Path,
859 expected_branch: &str,
860) -> Result<DispatchBranchReset> {
861 let current_branch = git_current_branch(worktree_path)?;
862 if current_branch == expected_branch {
863 return Ok(DispatchBranchReset {
864 changed: false,
865 start_ref: current_branch,
866 fallback_reason: None,
867 reset_reason: None,
868 });
869 }
870
871 let selection = preferred_main_start_ref(worktree_path)?;
872 let commit_message = format!("wip: auto-save before worktree reset [{current_branch}]");
873 let reason = prepare_worktree_for_reset(
874 worktree_path,
875 &commit_message,
876 Duration::from_secs(5),
877 PreserveFailureMode::SkipReset,
878 )?;
879 info!(
880 worktree = %worktree_path.display(),
881 expected_branch,
882 reset_reason = reason.as_str(),
883 "prepared worktree for dispatch branch reset"
884 );
885 if !reason.reset_performed() {
886 return Ok(DispatchBranchReset {
887 changed: false,
888 start_ref: selection.ref_name,
889 fallback_reason: selection.fallback_reason,
890 reset_reason: Some(reason),
891 });
892 }
893
894 let checkout = run_git(
895 worktree_path,
896 [
897 "checkout",
898 "-B",
899 expected_branch,
900 selection.ref_name.as_str(),
901 ],
902 )?;
903 if !checkout.status.success() {
904 bail!(
905 "failed to checkout '{}' from '{}' in {}: {}",
906 expected_branch,
907 selection.ref_name,
908 worktree_path.display(),
909 String::from_utf8_lossy(&checkout.stderr).trim()
910 );
911 }
912
913 Ok(DispatchBranchReset {
914 changed: true,
915 start_ref: selection.ref_name,
916 fallback_reason: selection.fallback_reason,
917 reset_reason: Some(reason),
918 })
919}
920
921pub fn reset_worktree_to_base(worktree_path: &Path, base_branch: &str) -> Result<()> {
924 let branch = git_current_branch(worktree_path).unwrap_or_else(|_| base_branch.to_string());
925 let commit_message = format!("wip: auto-save before worktree reset [{branch}]");
926 reset_worktree_to_base_with_options(
927 worktree_path,
928 base_branch,
929 &commit_message,
930 Duration::from_secs(5),
931 PreserveFailureMode::SkipReset,
932 )?;
933 Ok(())
934}
935
936pub fn reset_worktree_to_base_with_options(
937 worktree_path: &Path,
938 base_branch: &str,
939 commit_message: &str,
940 timeout: Duration,
941 preserve_failure_mode: PreserveFailureMode,
942) -> Result<WorktreeResetReason> {
943 let current_branch =
944 git_current_branch(worktree_path).unwrap_or_else(|_| base_branch.to_string());
945 let reason = prepare_worktree_for_reset(
946 worktree_path,
947 commit_message,
948 timeout,
949 preserve_failure_mode,
950 )?;
951 if !reason.reset_performed() {
952 return Ok(reason);
953 }
954 if reason == WorktreeResetReason::PreservedBeforeReset && current_branch == base_branch {
955 let archived_branch = archive_preserved_base_branch_head(worktree_path, base_branch)?;
956 info!(
957 worktree = %worktree_path.display(),
958 base_branch,
959 archived_branch,
960 "archived preserved base-branch work before reset"
961 );
962 }
963
964 let checkout = run_git(worktree_path, ["checkout", "-B", base_branch, "main"])?;
965 if !checkout.status.success() {
966 bail!(
967 "failed to recreate '{}' from 'main' in {}: {}",
968 base_branch,
969 worktree_path.display(),
970 String::from_utf8_lossy(&checkout.stderr).trim()
971 );
972 }
973 Ok(reason)
974}
975
976pub(crate) fn prepare_worktree_for_reset(
977 worktree_path: &Path,
978 commit_message: &str,
979 timeout: Duration,
980 preserve_failure_mode: PreserveFailureMode,
981) -> Result<WorktreeResetReason> {
982 if !worktree_path.exists() || !crate::team::task_loop::worktree_has_user_changes(worktree_path)?
983 {
984 let _ = run_git(worktree_path, ["merge", "--abort"]);
985 return Ok(WorktreeResetReason::CleanReset);
986 }
987
988 match crate::team::task_loop::preserve_worktree_with_commit(
989 worktree_path,
990 commit_message,
991 timeout,
992 ) {
993 Ok(true) => {
994 let _ = run_git(worktree_path, ["merge", "--abort"]);
995 return Ok(WorktreeResetReason::PreservedBeforeReset);
996 }
997 Ok(false) => {
998 let _ = run_git(worktree_path, ["merge", "--abort"]);
999 return Ok(WorktreeResetReason::CleanReset);
1000 }
1001 Err(error) => {
1002 warn!(
1003 worktree = %worktree_path.display(),
1004 error = %error,
1005 "failed to preserve worktree before reset"
1006 );
1007 }
1008 }
1009
1010 if preserve_failure_mode == PreserveFailureMode::SkipReset {
1011 return Ok(WorktreeResetReason::PreserveFailedResetSkipped);
1012 }
1013
1014 let _ = run_git(worktree_path, ["merge", "--abort"]);
1015 let reset = run_git(worktree_path, ["reset", "--hard"])?;
1016 if !reset.status.success() {
1017 bail!(
1018 "failed to force-reset worktree {}: {}",
1019 worktree_path.display(),
1020 String::from_utf8_lossy(&reset.stderr).trim()
1021 );
1022 }
1023 let clean = run_git(worktree_path, ["clean", "-fd", "--exclude=.batty/"])?;
1024 if !clean.status.success() {
1025 bail!(
1026 "failed to clean worktree {}: {}",
1027 worktree_path.display(),
1028 String::from_utf8_lossy(&clean.stderr).trim()
1029 );
1030 }
1031
1032 Ok(WorktreeResetReason::PreserveFailedForceReset)
1033}
1034
1035fn archive_preserved_base_branch_head(worktree_path: &Path, base_branch: &str) -> Result<String> {
1036 let slug = base_branch.replace('/', "-");
1037 let stamp = SystemTime::now()
1038 .duration_since(UNIX_EPOCH)
1039 .unwrap_or_default()
1040 .as_secs();
1041 let branch = format!("preserved/{slug}-{stamp}");
1042 let create = run_git(worktree_path, ["branch", branch.as_str(), "HEAD"])?;
1043 if !create.status.success() {
1044 bail!(
1045 "failed to archive preserved work on '{}' in {}: {}",
1046 branch,
1047 worktree_path.display(),
1048 String::from_utf8_lossy(&create.stderr).trim()
1049 );
1050 }
1051 Ok(branch)
1052}
1053
1054fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
1055 std::fs::create_dir_all(dst)?;
1056 for entry in std::fs::read_dir(src)? {
1057 let entry = entry?;
1058 let src_path = entry.path();
1059 let dst_path = dst.join(entry.file_name());
1060 if src_path.is_dir() {
1061 copy_dir_recursive(&src_path, &dst_path)?;
1062 } else {
1063 std::fs::copy(&src_path, &dst_path)?;
1064 }
1065 }
1066 Ok(())
1067}
1068
1069#[cfg(test)]
1070mod tests {
1071 use super::*;
1072 use std::fs;
1073
1074 fn git_available() -> bool {
1075 Command::new("git")
1076 .arg("--version")
1077 .output()
1078 .map(|o| o.status.success())
1079 .unwrap_or(false)
1080 }
1081
1082 fn git(repo: &Path, args: &[&str]) {
1083 let output = Command::new("git")
1084 .current_dir(repo)
1085 .args(args)
1086 .output()
1087 .unwrap();
1088 assert!(
1089 output.status.success(),
1090 "git {:?} failed: {}",
1091 args,
1092 String::from_utf8_lossy(&output.stderr)
1093 );
1094 }
1095
1096 fn init_repo() -> Option<tempfile::TempDir> {
1097 if !git_available() {
1098 return None;
1099 }
1100
1101 let tmp = tempfile::tempdir().unwrap();
1102 git(tmp.path(), &["init", "-q", "-b", "main"]);
1103 git(
1104 tmp.path(),
1105 &["config", "user.email", "batty-test@example.com"],
1106 );
1107 git(tmp.path(), &["config", "user.name", "Batty Test"]);
1108
1109 fs::write(tmp.path().join("README.md"), "init\n").unwrap();
1110 git(tmp.path(), &["add", "README.md"]);
1111 git(tmp.path(), &["commit", "-q", "-m", "init"]);
1112
1113 Some(tmp)
1114 }
1115
1116 fn cleanup_worktree(repo_root: &Path, worktree: &PhaseWorktree) {
1117 let _ = remove_worktree(repo_root, &worktree.path);
1118 let _ = delete_branch(repo_root, &worktree.branch);
1119 }
1120
1121 fn cleanup_agent_worktrees(repo_root: &Path, worktrees: &[AgentWorktree]) {
1122 for wt in worktrees {
1123 let _ = remove_worktree(repo_root, &wt.path);
1124 let _ = delete_branch(repo_root, &wt.branch);
1125 }
1126 }
1127
1128 #[test]
1129 fn sanitize_phase_for_branch_normalizes_phase() {
1130 assert_eq!(sanitize_phase_for_branch("phase-2.5"), "phase-2-5");
1131 assert_eq!(sanitize_phase_for_branch("Phase 7"), "phase-7");
1132 assert_eq!(sanitize_phase_for_branch("///"), "phase");
1133 }
1134
1135 #[test]
1136 fn parse_run_number_extracts_suffix() {
1137 assert_eq!(parse_run_number("phase-2-run-001", "phase-2-run-"), Some(1));
1138 assert_eq!(
1139 parse_run_number("phase-2-run-1234", "phase-2-run-"),
1140 Some(1234)
1141 );
1142 assert_eq!(parse_run_number("phase-2-run-aa1", "phase-2-run-"), None);
1143 assert_eq!(parse_run_number("other-001", "phase-2-run-"), None);
1144 }
1145
1146 #[test]
1147 fn prepare_phase_worktree_increments_run_number() {
1148 let Some(tmp) = init_repo() else {
1149 return;
1150 };
1151
1152 let first = prepare_phase_worktree(tmp.path(), "phase-2.5").unwrap();
1153 let second = prepare_phase_worktree(tmp.path(), "phase-2.5").unwrap();
1154
1155 assert!(
1156 first.branch.ends_with("001"),
1157 "first branch: {}",
1158 first.branch
1159 );
1160 assert!(
1161 second.branch.ends_with("002"),
1162 "second branch: {}",
1163 second.branch
1164 );
1165 assert!(first.path.is_dir());
1166 assert!(second.path.is_dir());
1167
1168 cleanup_worktree(tmp.path(), &first);
1169 cleanup_worktree(tmp.path(), &second);
1170 }
1171
1172 #[test]
1173 fn finalize_keeps_unmerged_completed_worktree() {
1174 let Some(tmp) = init_repo() else {
1175 return;
1176 };
1177
1178 let worktree = prepare_phase_worktree(tmp.path(), "phase-2.5").unwrap();
1179 let decision = worktree.finalize(RunOutcome::Completed).unwrap();
1180
1181 assert_eq!(decision, CleanupDecision::KeptForReview);
1182 assert!(worktree.path.exists());
1183 assert!(branch_exists(tmp.path(), &worktree.branch).unwrap());
1184
1185 cleanup_worktree(tmp.path(), &worktree);
1186 }
1187
1188 #[test]
1189 fn finalize_keeps_failed_worktree() {
1190 let Some(tmp) = init_repo() else {
1191 return;
1192 };
1193
1194 let worktree = prepare_phase_worktree(tmp.path(), "phase-2.5").unwrap();
1195 let decision = worktree.finalize(RunOutcome::Failed).unwrap();
1196
1197 assert_eq!(decision, CleanupDecision::KeptForFailure);
1198 assert!(worktree.path.exists());
1199 assert!(branch_exists(tmp.path(), &worktree.branch).unwrap());
1200
1201 cleanup_worktree(tmp.path(), &worktree);
1202 }
1203
1204 #[test]
1205 fn finalize_cleans_when_merged() {
1206 let Some(tmp) = init_repo() else {
1207 return;
1208 };
1209
1210 let worktree = prepare_phase_worktree(tmp.path(), "phase-2.5").unwrap();
1211
1212 fs::write(worktree.path.join("work.txt"), "done\n").unwrap();
1213 git(&worktree.path, &["add", "work.txt"]);
1214 git(&worktree.path, &["commit", "-q", "-m", "worktree change"]);
1215
1216 git(
1217 tmp.path(),
1218 &["merge", "--no-ff", "--no-edit", worktree.branch.as_str()],
1219 );
1220
1221 let decision = worktree.finalize(RunOutcome::Completed).unwrap();
1222 assert_eq!(decision, CleanupDecision::Cleaned);
1223 assert!(!worktree.path.exists());
1224 assert!(!branch_exists(tmp.path(), &worktree.branch).unwrap());
1225 }
1226
1227 #[test]
1228 fn resolve_phase_worktree_resumes_latest_existing_by_default() {
1229 let Some(tmp) = init_repo() else {
1230 return;
1231 };
1232
1233 let first = prepare_phase_worktree(tmp.path(), "phase-2.5").unwrap();
1234 let second = prepare_phase_worktree(tmp.path(), "phase-2.5").unwrap();
1235
1236 let (resolved, resumed) = resolve_phase_worktree(tmp.path(), "phase-2.5", false).unwrap();
1237 assert!(
1238 resumed,
1239 "expected default behavior to resume existing worktree"
1240 );
1241 assert_eq!(
1242 resolved.branch, second.branch,
1243 "should resume latest run branch"
1244 );
1245 assert_eq!(resolved.path, second.path, "should resume latest run path");
1246
1247 cleanup_worktree(tmp.path(), &first);
1248 cleanup_worktree(tmp.path(), &second);
1249 }
1250
1251 #[test]
1252 fn resolve_phase_worktree_force_new_creates_next_run() {
1253 let Some(tmp) = init_repo() else {
1254 return;
1255 };
1256
1257 let first = prepare_phase_worktree(tmp.path(), "phase-2.5").unwrap();
1258 let (resolved, resumed) = resolve_phase_worktree(tmp.path(), "phase-2.5", true).unwrap();
1259
1260 assert!(!resumed, "force-new should never resume prior worktree");
1261 assert_ne!(resolved.branch, first.branch);
1262 assert!(
1263 resolved.branch.ends_with("002"),
1264 "branch: {}",
1265 resolved.branch
1266 );
1267
1268 cleanup_worktree(tmp.path(), &first);
1269 cleanup_worktree(tmp.path(), &resolved);
1270 }
1271
1272 #[test]
1273 fn resolve_phase_worktree_without_existing_creates_new() {
1274 let Some(tmp) = init_repo() else {
1275 return;
1276 };
1277
1278 let (resolved, resumed) = resolve_phase_worktree(tmp.path(), "phase-2.5", false).unwrap();
1279 assert!(!resumed);
1280 assert!(
1281 resolved.branch.ends_with("001"),
1282 "branch: {}",
1283 resolved.branch
1284 );
1285
1286 cleanup_worktree(tmp.path(), &resolved);
1287 }
1288
1289 #[test]
1290 fn prepare_agent_worktrees_creates_layout_and_branches() {
1291 let Some(tmp) = init_repo() else {
1292 return;
1293 };
1294
1295 let names = vec!["agent-1".to_string(), "agent-2".to_string()];
1296 let worktrees = prepare_agent_worktrees(tmp.path(), "phase-4", &names, false).unwrap();
1297
1298 assert_eq!(worktrees.len(), 2);
1299 assert_eq!(
1301 worktrees[0].path.canonicalize().unwrap(),
1302 tmp.path()
1303 .join(".batty")
1304 .join("worktrees")
1305 .join("phase-4")
1306 .join("agent-1")
1307 .canonicalize()
1308 .unwrap()
1309 );
1310 assert_eq!(worktrees[0].branch, "batty/phase-4/agent-1");
1311 assert!(branch_exists(tmp.path(), "batty/phase-4/agent-1").unwrap());
1312 assert!(branch_exists(tmp.path(), "batty/phase-4/agent-2").unwrap());
1313
1314 cleanup_agent_worktrees(tmp.path(), &worktrees);
1315 }
1316
1317 #[test]
1318 fn prepare_agent_worktrees_reuses_existing_agent_paths() {
1319 let Some(tmp) = init_repo() else {
1320 return;
1321 };
1322
1323 let names = vec!["agent-1".to_string(), "agent-2".to_string()];
1324 let first = prepare_agent_worktrees(tmp.path(), "phase-4", &names, false).unwrap();
1325 let second = prepare_agent_worktrees(tmp.path(), "phase-4", &names, false).unwrap();
1326
1327 assert_eq!(first[0].path, second[0].path);
1328 assert_eq!(first[1].path, second[1].path);
1329 assert_eq!(first[0].branch, second[0].branch);
1330 assert_eq!(first[1].branch, second[1].branch);
1331
1332 cleanup_agent_worktrees(tmp.path(), &first);
1333 }
1334
1335 #[test]
1336 fn prepare_agent_worktrees_rejects_duplicate_sanitized_names() {
1337 let Some(tmp) = init_repo() else {
1338 return;
1339 };
1340
1341 let names = vec!["agent 1".to_string(), "agent-1".to_string()];
1342 let err = prepare_agent_worktrees(tmp.path(), "phase-4", &names, false)
1343 .unwrap_err()
1344 .to_string();
1345 assert!(err.contains("duplicate sanitized slug"));
1346 }
1347
1348 #[test]
1349 fn prepare_agent_worktrees_force_new_recreates_worktrees() {
1350 let Some(tmp) = init_repo() else {
1351 return;
1352 };
1353
1354 let names = vec!["agent-1".to_string()];
1355 let first = prepare_agent_worktrees(tmp.path(), "phase-4", &names, false).unwrap();
1356
1357 fs::write(first[0].path.join("agent.txt"), "agent-1\n").unwrap();
1358 git(&first[0].path, &["add", "agent.txt"]);
1359 git(&first[0].path, &["commit", "-q", "-m", "agent work"]);
1360
1361 let second = prepare_agent_worktrees(tmp.path(), "phase-4", &names, true).unwrap();
1362 let listing = run_git(tmp.path(), ["branch", "--list", "batty/phase-4/agent-1"]).unwrap();
1363 assert!(listing.status.success());
1364 assert!(second[0].path.exists());
1365
1366 cleanup_agent_worktrees(tmp.path(), &second);
1367 }
1368
1369 #[test]
1370 fn sync_phase_board_copies_uncommitted_tasks_into_worktree() {
1371 let Some(tmp) = init_repo() else {
1372 return;
1373 };
1374
1375 let kanban = tmp.path().join(".batty").join("kanban");
1377 let phase_dir = kanban.join("my-phase").join("tasks");
1378 fs::create_dir_all(&phase_dir).unwrap();
1379 fs::write(phase_dir.join("001-old.md"), "old task\n").unwrap();
1380 fs::write(
1381 kanban.join("my-phase").join("config.yml"),
1382 "version: 10\nnext_id: 2\n",
1383 )
1384 .unwrap();
1385 git(tmp.path(), &["add", ".batty"]);
1386 git(tmp.path(), &["commit", "-q", "-m", "add phase board"]);
1387
1388 let worktree = prepare_phase_worktree(tmp.path(), "my-phase").unwrap();
1390 let wt_task = worktree
1391 .path
1392 .join(".batty")
1393 .join("kanban")
1394 .join("my-phase")
1395 .join("tasks")
1396 .join("001-old.md");
1397 assert!(wt_task.exists(), "worktree should have committed task");
1398
1399 fs::write(phase_dir.join("002-new.md"), "new task\n").unwrap();
1401
1402 sync_phase_board_to_worktree(tmp.path(), &worktree.path, "my-phase").unwrap();
1404
1405 let wt_tasks_dir = worktree
1407 .path
1408 .join(".batty")
1409 .join("kanban")
1410 .join("my-phase")
1411 .join("tasks");
1412 assert!(wt_tasks_dir.join("001-old.md").exists());
1413 assert!(
1414 wt_tasks_dir.join("002-new.md").exists(),
1415 "uncommitted task should be synced into worktree"
1416 );
1417
1418 let content = fs::read_to_string(wt_tasks_dir.join("002-new.md")).unwrap();
1420 assert_eq!(content, "new task\n");
1421
1422 cleanup_worktree(tmp.path(), &worktree);
1423 }
1424
1425 #[test]
1426 fn sync_phase_board_overwrites_stale_worktree_board() {
1427 let Some(tmp) = init_repo() else {
1428 return;
1429 };
1430
1431 let kanban = tmp.path().join(".batty").join("kanban");
1433 let phase_dir = kanban.join("my-phase").join("tasks");
1434 fs::create_dir_all(&phase_dir).unwrap();
1435 fs::write(phase_dir.join("001-old.md"), "original\n").unwrap();
1436 fs::write(
1437 kanban.join("my-phase").join("config.yml"),
1438 "version: 10\nnext_id: 2\n",
1439 )
1440 .unwrap();
1441 git(tmp.path(), &["add", ".batty"]);
1442 git(tmp.path(), &["commit", "-q", "-m", "add phase board"]);
1443
1444 let worktree = prepare_phase_worktree(tmp.path(), "my-phase").unwrap();
1445
1446 fs::write(phase_dir.join("001-old.md"), "rewritten\n").unwrap();
1448
1449 sync_phase_board_to_worktree(tmp.path(), &worktree.path, "my-phase").unwrap();
1450
1451 let wt_content = fs::read_to_string(
1452 worktree
1453 .path
1454 .join(".batty")
1455 .join("kanban")
1456 .join("my-phase")
1457 .join("tasks")
1458 .join("001-old.md"),
1459 )
1460 .unwrap();
1461 assert_eq!(
1462 wt_content, "rewritten\n",
1463 "worktree board should reflect source tree changes"
1464 );
1465
1466 cleanup_worktree(tmp.path(), &worktree);
1467 }
1468
1469 #[test]
1470 fn sync_phase_board_noop_when_source_missing() {
1471 let Some(tmp) = init_repo() else {
1472 return;
1473 };
1474
1475 let worktree = prepare_phase_worktree(tmp.path(), "nonexistent").unwrap();
1476
1477 sync_phase_board_to_worktree(tmp.path(), &worktree.path, "nonexistent").unwrap();
1479
1480 cleanup_worktree(tmp.path(), &worktree);
1481 }
1482
1483 #[test]
1484 fn branch_fully_merged_true_after_cherry_pick() {
1485 let Some(tmp) = init_repo() else {
1486 return;
1487 };
1488
1489 git(tmp.path(), &["checkout", "-b", "feature"]);
1491 fs::write(tmp.path().join("feature.txt"), "feature work\n").unwrap();
1492 git(tmp.path(), &["add", "feature.txt"]);
1493 git(tmp.path(), &["commit", "-q", "-m", "add feature"]);
1494
1495 git(tmp.path(), &["checkout", "main"]);
1497 git(tmp.path(), &["cherry-pick", "feature"]);
1498
1499 assert!(branch_fully_merged(tmp.path(), "feature", "main").unwrap());
1501 }
1502
1503 #[test]
1504 fn branch_fully_merged_false_with_unique_commits() {
1505 let Some(tmp) = init_repo() else {
1506 return;
1507 };
1508
1509 git(tmp.path(), &["checkout", "-b", "feature"]);
1511 fs::write(tmp.path().join("unique.txt"), "unique work\n").unwrap();
1512 git(tmp.path(), &["add", "unique.txt"]);
1513 git(tmp.path(), &["commit", "-q", "-m", "unique commit"]);
1514 git(tmp.path(), &["checkout", "main"]);
1515
1516 assert!(!branch_fully_merged(tmp.path(), "feature", "main").unwrap());
1517 }
1518
1519 #[test]
1520 fn branch_fully_merged_true_when_same_tip() {
1521 let Some(tmp) = init_repo() else {
1522 return;
1523 };
1524
1525 git(tmp.path(), &["checkout", "-b", "feature"]);
1527 git(tmp.path(), &["checkout", "main"]);
1528
1529 assert!(branch_fully_merged(tmp.path(), "feature", "main").unwrap());
1530 }
1531
1532 #[test]
1533 fn branch_fully_merged_false_partial_merge() {
1534 let Some(tmp) = init_repo() else {
1535 return;
1536 };
1537
1538 git(tmp.path(), &["checkout", "-b", "feature"]);
1540 fs::write(tmp.path().join("a.txt"), "a\n").unwrap();
1541 git(tmp.path(), &["add", "a.txt"]);
1542 git(tmp.path(), &["commit", "-q", "-m", "first"]);
1543
1544 fs::write(tmp.path().join("b.txt"), "b\n").unwrap();
1545 git(tmp.path(), &["add", "b.txt"]);
1546 git(tmp.path(), &["commit", "-q", "-m", "second"]);
1547
1548 git(tmp.path(), &["checkout", "main"]);
1550 git(tmp.path(), &["cherry-pick", "feature~1"]);
1551
1552 assert!(!branch_fully_merged(tmp.path(), "feature", "main").unwrap());
1554 }
1555
1556 #[test]
1557 fn git_current_branch_returns_branch_name() {
1558 let Some(tmp) = init_repo() else {
1559 return;
1560 };
1561
1562 let branch = git_current_branch(tmp.path()).unwrap();
1564 assert!(!branch.is_empty(), "should return a non-empty branch name");
1566 }
1567
1568 #[test]
1569 fn reset_worktree_to_base_switches_branch() {
1570 let Some(tmp) = init_repo() else {
1571 return;
1572 };
1573
1574 let wt_path = tmp.path().join("wt");
1576 git(
1577 tmp.path(),
1578 &[
1579 "worktree",
1580 "add",
1581 "-b",
1582 "feature-reset",
1583 wt_path.to_str().unwrap(),
1584 "main",
1585 ],
1586 );
1587 fs::write(wt_path.join("work.txt"), "work\n").unwrap();
1588 git(&wt_path, &["add", "work.txt"]);
1589 git(&wt_path, &["commit", "-q", "-m", "work on feature"]);
1590
1591 let branch_before = git_current_branch(&wt_path).unwrap();
1593 assert_eq!(branch_before, "feature-reset");
1594
1595 git(tmp.path(), &["branch", "eng-main/test-eng"]);
1597
1598 let reason = reset_worktree_to_base_with_options(
1599 &wt_path,
1600 "eng-main/test-eng",
1601 "wip: auto-save before worktree reset [feature-reset]",
1602 Duration::from_secs(5),
1603 PreserveFailureMode::SkipReset,
1604 )
1605 .unwrap();
1606
1607 let branch_after = git_current_branch(&wt_path).unwrap();
1608 assert_eq!(branch_after, "eng-main/test-eng");
1609 assert_eq!(reason, WorktreeResetReason::CleanReset);
1610
1611 let _ = run_git(
1613 tmp.path(),
1614 ["worktree", "remove", "--force", wt_path.to_str().unwrap()],
1615 );
1616 let _ = run_git(tmp.path(), ["branch", "-D", "feature-reset"]);
1617 let _ = run_git(tmp.path(), ["branch", "-D", "eng-main/test-eng"]);
1618 }
1619
1620 #[test]
1621 fn reset_worktree_to_base_recreates_missing_branch_from_main() {
1622 let Some(tmp) = init_repo() else {
1623 return;
1624 };
1625
1626 let wt_path = tmp.path().join("wt");
1627 git(
1628 tmp.path(),
1629 &[
1630 "worktree",
1631 "add",
1632 "-b",
1633 "feature-reset",
1634 wt_path.to_str().unwrap(),
1635 "main",
1636 ],
1637 );
1638 fs::write(wt_path.join("work.txt"), "work\n").unwrap();
1639 git(&wt_path, &["add", "work.txt"]);
1640 git(&wt_path, &["commit", "-q", "-m", "work on feature"]);
1641
1642 let reason = reset_worktree_to_base_with_options(
1643 &wt_path,
1644 "eng-main/test-eng",
1645 "wip: auto-save before worktree reset [feature-reset]",
1646 Duration::from_secs(5),
1647 PreserveFailureMode::SkipReset,
1648 )
1649 .unwrap();
1650
1651 assert_eq!(reason, WorktreeResetReason::CleanReset);
1652 assert_eq!(git_current_branch(&wt_path).unwrap(), "eng-main/test-eng");
1653 let head = current_commit(&wt_path, "HEAD").unwrap();
1654 let main = current_commit(tmp.path(), "main").unwrap();
1655 assert_eq!(head, main);
1656
1657 let _ = run_git(
1658 tmp.path(),
1659 ["worktree", "remove", "--force", wt_path.to_str().unwrap()],
1660 );
1661 let _ = run_git(tmp.path(), ["branch", "-D", "feature-reset"]);
1662 let _ = run_git(tmp.path(), ["branch", "-D", "eng-main/test-eng"]);
1663 }
1664
1665 #[test]
1666 fn ensure_worktree_branch_for_dispatch_keeps_matching_branch() {
1667 let Some(tmp) = init_repo() else {
1668 return;
1669 };
1670
1671 let wt_path = tmp.path().join("wt");
1672 git(
1673 tmp.path(),
1674 &[
1675 "worktree",
1676 "add",
1677 "-b",
1678 "eng-1-1-502",
1679 wt_path.to_str().unwrap(),
1680 "main",
1681 ],
1682 );
1683
1684 let before = run_git(&wt_path, ["rev-parse", "HEAD"]).unwrap().stdout;
1685 let reset = ensure_worktree_branch_for_dispatch(&wt_path, "eng-1-1-502").unwrap();
1686 let after = run_git(&wt_path, ["rev-parse", "HEAD"]).unwrap().stdout;
1687
1688 assert!(!reset.changed);
1689 assert!(reset.reset_reason.is_none());
1690 assert_eq!(before, after);
1691
1692 let _ = run_git(
1693 tmp.path(),
1694 ["worktree", "remove", "--force", wt_path.to_str().unwrap()],
1695 );
1696 let _ = run_git(tmp.path(), ["branch", "-D", "eng-1-1-502"]);
1697 }
1698
1699 #[test]
1700 fn ensure_worktree_branch_for_dispatch_resets_mismatched_branch() {
1701 let Some(tmp) = init_repo() else {
1702 return;
1703 };
1704
1705 let wt_path = tmp.path().join("wt");
1706 git(
1707 tmp.path(),
1708 &[
1709 "worktree",
1710 "add",
1711 "-b",
1712 "eng-1-1-500",
1713 wt_path.to_str().unwrap(),
1714 "main",
1715 ],
1716 );
1717 fs::write(wt_path.join("work.txt"), "old work\n").unwrap();
1718 git(&wt_path, &["add", "work.txt"]);
1719 git(&wt_path, &["commit", "-q", "-m", "old task"]);
1720
1721 let reset = ensure_worktree_branch_for_dispatch(&wt_path, "eng-1-1-502").unwrap();
1722 let head = run_git(&wt_path, ["rev-parse", "HEAD"]).unwrap().stdout;
1723 let main = run_git(tmp.path(), ["rev-parse", "main"]).unwrap().stdout;
1724
1725 assert!(reset.changed);
1726 assert_eq!(reset.reset_reason, Some(WorktreeResetReason::CleanReset));
1727 assert_eq!(git_current_branch(&wt_path).unwrap(), "eng-1-1-502");
1728 assert_eq!(head, main);
1729
1730 let _ = run_git(
1731 tmp.path(),
1732 ["worktree", "remove", "--force", wt_path.to_str().unwrap()],
1733 );
1734 let _ = run_git(tmp.path(), ["branch", "-D", "eng-1-1-500"]);
1735 let _ = run_git(tmp.path(), ["branch", "-D", "eng-1-1-502"]);
1736 }
1737
1738 #[test]
1739 fn preferred_main_start_ref_uses_origin_when_equal() {
1740 let Some(tmp) = init_repo() else {
1741 return;
1742 };
1743 let main = current_commit(tmp.path(), "main").unwrap();
1744 git(
1745 tmp.path(),
1746 &["update-ref", "refs/remotes/origin/main", main.as_str()],
1747 );
1748
1749 let selection = preferred_main_start_ref(tmp.path()).unwrap();
1750 assert_eq!(selection.ref_name, "origin/main");
1751 assert!(selection.fallback_reason.is_none());
1752 }
1753
1754 #[test]
1755 fn preferred_main_start_ref_falls_back_to_local_main_when_origin_is_behind() {
1756 let Some(tmp) = init_repo() else {
1757 return;
1758 };
1759 let frozen = current_commit(tmp.path(), "main").unwrap();
1760 git(
1761 tmp.path(),
1762 &["update-ref", "refs/remotes/origin/main", frozen.as_str()],
1763 );
1764 fs::write(tmp.path().join("local.txt"), "local\n").unwrap();
1765 git(tmp.path(), &["add", "local.txt"]);
1766 git(tmp.path(), &["commit", "-q", "-m", "local advance"]);
1767
1768 let selection = preferred_main_start_ref(tmp.path()).unwrap();
1769 assert_eq!(selection.ref_name, "main");
1770 assert_eq!(
1771 selection.fallback_reason.as_deref(),
1772 Some("stale_origin_fallback ahead=1")
1773 );
1774 }
1775
1776 #[test]
1777 fn preferred_main_start_ref_falls_back_to_local_main_when_diverged() {
1778 let Some(tmp) = init_repo() else {
1779 return;
1780 };
1781 let base = current_commit(tmp.path(), "main").unwrap();
1782 fs::write(tmp.path().join("local.txt"), "local\n").unwrap();
1783 git(tmp.path(), &["add", "local.txt"]);
1784 git(tmp.path(), &["commit", "-q", "-m", "local advance"]);
1785 git(tmp.path(), &["branch", "origin-side", base.as_str()]);
1786 git(tmp.path(), &["checkout", "origin-side"]);
1787 fs::write(tmp.path().join("remote.txt"), "remote\n").unwrap();
1788 git(tmp.path(), &["add", "remote.txt"]);
1789 git(tmp.path(), &["commit", "-q", "-m", "remote advance"]);
1790 let remote = current_commit(tmp.path(), "HEAD").unwrap();
1791 git(tmp.path(), &["checkout", "main"]);
1792 git(
1793 tmp.path(),
1794 &["update-ref", "refs/remotes/origin/main", remote.as_str()],
1795 );
1796
1797 let selection = preferred_main_start_ref(tmp.path()).unwrap();
1798 assert_eq!(selection.ref_name, "main");
1799 assert_eq!(
1800 selection.fallback_reason.as_deref(),
1801 Some("stale_origin_fallback ahead=1 divergent origin_ahead=1")
1802 );
1803 }
1804
1805 #[test]
1806 fn preferred_main_start_ref_falls_back_to_local_main_when_origin_missing() {
1807 let Some(tmp) = init_repo() else {
1808 return;
1809 };
1810
1811 let selection = preferred_main_start_ref(tmp.path()).unwrap();
1812 assert_eq!(selection.ref_name, "main");
1813 assert_eq!(
1814 selection.fallback_reason.as_deref(),
1815 Some("stale_origin_fallback ahead=0 origin_unreachable")
1816 );
1817 }
1818
1819 #[test]
1820 fn ensure_worktree_branch_for_dispatch_cleans_dirty_worktree_before_reset() {
1821 let Some(tmp) = init_repo() else {
1822 return;
1823 };
1824
1825 let wt_path = tmp.path().join("wt");
1826 git(
1827 tmp.path(),
1828 &[
1829 "worktree",
1830 "add",
1831 "-b",
1832 "eng-1-1-500",
1833 wt_path.to_str().unwrap(),
1834 "main",
1835 ],
1836 );
1837 fs::write(wt_path.join("scratch.txt"), "dirty\n").unwrap();
1838 git(&wt_path, &["add", "scratch.txt"]);
1839
1840 let reset = ensure_worktree_branch_for_dispatch(&wt_path, "eng-1-1-502").unwrap();
1841
1842 assert!(reset.changed);
1843 assert_eq!(
1844 reset.reset_reason,
1845 Some(WorktreeResetReason::PreservedBeforeReset)
1846 );
1847 assert_eq!(git_current_branch(&wt_path).unwrap(), "eng-1-1-502");
1848 assert!(!wt_path.join("scratch.txt").exists());
1849 assert!(
1850 String::from_utf8_lossy(&run_git(&wt_path, ["status", "--porcelain"]).unwrap().stdout)
1851 .trim()
1852 .is_empty()
1853 );
1854 let preserved = run_git(tmp.path(), ["show", "eng-1-1-500:scratch.txt"]).unwrap();
1855 assert!(
1856 preserved.status.success(),
1857 "dirty file should be preserved on the previous branch"
1858 );
1859 assert_eq!(String::from_utf8_lossy(&preserved.stdout), "dirty\n");
1860 let old_branch_log =
1861 run_git(tmp.path(), ["log", "--oneline", "-1", "eng-1-1-500"]).unwrap();
1862 assert!(
1863 String::from_utf8_lossy(&old_branch_log.stdout)
1864 .contains("wip: auto-save before worktree reset"),
1865 "previous branch should record an auto-save commit"
1866 );
1867
1868 let _ = run_git(
1869 tmp.path(),
1870 ["worktree", "remove", "--force", wt_path.to_str().unwrap()],
1871 );
1872 let _ = run_git(tmp.path(), ["branch", "-D", "eng-1-1-500"]);
1873 let _ = run_git(tmp.path(), ["branch", "-D", "eng-1-1-502"]);
1874 }
1875
1876 #[test]
1877 fn reset_worktree_to_base_archives_dirty_base_branch_before_reset() {
1878 let Some(tmp) = init_repo() else {
1879 return;
1880 };
1881
1882 let wt_path = tmp.path().join("wt");
1883 git(
1884 tmp.path(),
1885 &[
1886 "worktree",
1887 "add",
1888 "-b",
1889 "eng-main/test-eng",
1890 wt_path.to_str().unwrap(),
1891 "main",
1892 ],
1893 );
1894 fs::write(wt_path.join("scratch.txt"), "dirty\n").unwrap();
1895 git(&wt_path, &["add", "scratch.txt"]);
1896
1897 let reason = reset_worktree_to_base_with_options(
1898 &wt_path,
1899 "eng-main/test-eng",
1900 "wip: auto-save before worktree reset [eng-main/test-eng]",
1901 Duration::from_secs(5),
1902 PreserveFailureMode::SkipReset,
1903 )
1904 .unwrap();
1905
1906 assert_eq!(reason, WorktreeResetReason::PreservedBeforeReset);
1907 assert_eq!(git_current_branch(&wt_path).unwrap(), "eng-main/test-eng");
1908 assert!(!wt_path.join("scratch.txt").exists());
1909 let preserved_branch = String::from_utf8_lossy(
1910 &run_git(
1911 tmp.path(),
1912 [
1913 "for-each-ref",
1914 "--format=%(refname:short)",
1915 "refs/heads/preserved/",
1916 ],
1917 )
1918 .unwrap()
1919 .stdout,
1920 )
1921 .trim()
1922 .to_string();
1923 assert!(
1924 preserved_branch.starts_with("preserved/eng-main-test-eng-"),
1925 "expected archived preserved branch, got: {preserved_branch}"
1926 );
1927 let preserved_file = run_git(
1928 tmp.path(),
1929 ["show", &format!("{preserved_branch}:scratch.txt")],
1930 )
1931 .unwrap();
1932 assert!(
1933 preserved_file.status.success(),
1934 "dirty file should be preserved on archived branch"
1935 );
1936 assert_eq!(String::from_utf8_lossy(&preserved_file.stdout), "dirty\n");
1937
1938 let _ = run_git(
1939 tmp.path(),
1940 ["worktree", "remove", "--force", wt_path.to_str().unwrap()],
1941 );
1942 let _ = run_git(tmp.path(), ["branch", "-D", "eng-main/test-eng"]);
1943 let _ = run_git(tmp.path(), ["branch", "-D", preserved_branch.as_str()]);
1944 }
1945}