Skip to main content

batty_cli/
worktree.rs

1//! Git worktree lifecycle for isolated phase runs.
2//!
3//! Each `batty work <phase>` run gets a dedicated branch/worktree:
4//! `<phase-slug>-run-<NNN>`.
5//! The executor runs in that worktree. Cleanup is merge-aware:
6//! - merged runs are removed (worktree + branch)
7//! - rejected/failed/unmerged runs are retained for inspection
8#![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
140/// Create an isolated git worktree for a phase run.
141pub 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
196/// Resolve the phase worktree for a run.
197///
198/// Behavior:
199/// - If `force_new` is false, resume the latest existing `<phase>-run-###` worktree if found.
200/// - Otherwise (or if none exists), create a new worktree.
201///
202/// Returns `(worktree, resumed_existing)`.
203pub 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
215/// Prepare (or reuse) one worktree per parallel agent slot for a phase.
216///
217/// Layout:
218/// - path: `.batty/worktrees/<phase>/<agent>/`
219/// - branch: `batty/<phase-slug>/<agent-slug>`
220pub 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
642/// Sync the phase board from the source tree into the worktree.
643///
644/// Worktrees are created from committed state, so any uncommitted kanban
645/// changes (new tasks, reworked boards, etc.) would be lost. This copies
646/// the phase directory from `source_kanban_root/<phase>/` into
647/// `worktree_kanban_root/<phase>/`, overwriting whatever git checked out.
648///
649/// Only syncs when the source directory exists and differs from the
650/// worktree (i.e., the source tree has uncommitted kanban changes).
651pub 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    // Remove stale destination and copy fresh.
665    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
691/// Check if all commits on `branch` since diverging from `base` are already
692/// present on `base` (e.g., via cherry-pick).
693///
694/// Uses `git cherry <base> <branch>` — lines starting with `-` are already on
695/// base. If ALL lines start with `-` (or output is empty), the branch is fully
696/// merged.
697pub 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        // Lines starting with '+' are commits NOT on base.
715        if trimmed.starts_with('+') {
716            return Ok(false);
717        }
718    }
719    Ok(true)
720}
721
722/// Count commits on the current branch that are ahead of `base` (e.g. "main").
723/// Returns 0 if the branch is at or behind base.
724pub 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
738/// Check if a worktree has uncommitted changes (staged or unstaged).
739pub 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
750/// Get the current branch name for a repository/worktree path.
751pub 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
921/// Reset a worktree to point at its base branch. Used to clean up after a
922/// cherry-pick merge has made the task branch redundant.
923pub 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        // Compare canonicalized paths to handle macOS /var vs /private/var symlink
1300        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        // Create a committed phase board with one task.
1376        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        // Create a worktree — it will have the committed (old) board.
1389        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        // Now add a new task to the source tree (uncommitted).
1400        fs::write(phase_dir.join("002-new.md"), "new task\n").unwrap();
1401
1402        // Sync.
1403        sync_phase_board_to_worktree(tmp.path(), &worktree.path, "my-phase").unwrap();
1404
1405        // Worktree should now have both tasks.
1406        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        // The new file content should match.
1419        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        // Create a committed phase board.
1432        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        // Rewrite the source task (uncommitted change).
1447        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        // Should not error when source phase dir doesn't exist.
1478        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        // Create a feature branch with a commit.
1490        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        // Go back to main and cherry-pick the commit.
1496        git(tmp.path(), &["checkout", "main"]);
1497        git(tmp.path(), &["cherry-pick", "feature"]);
1498
1499        // Now all commits on feature are present on main.
1500        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        // Create a feature branch with a commit NOT on main.
1510        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        // Feature branch at the same commit as main — no unique commits.
1526        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        // Create a feature branch with two commits.
1539        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        // Cherry-pick only the first commit onto main.
1549        git(tmp.path(), &["checkout", "main"]);
1550        git(tmp.path(), &["cherry-pick", "feature~1"]);
1551
1552        // One commit is still unique — should be false.
1553        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        // Default branch after init_repo is "main" (or whatever git defaults to).
1563        let branch = git_current_branch(tmp.path()).unwrap();
1564        // The init_repo doesn't specify -b, so branch could be "main" or "master".
1565        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        // Create a worktree on a feature branch.
1575        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        // Verify we're on the feature branch.
1592        let branch_before = git_current_branch(&wt_path).unwrap();
1593        assert_eq!(branch_before, "feature-reset");
1594
1595        // Create a base branch for the worktree to reset to.
1596        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        // Cleanup
1612        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}