Skip to main content

chant/worktree/
git_ops.rs

1//! Low-level git worktree operations.
2//!
3//! Provides utilities for creating, managing, and removing git worktrees.
4//! These functions handle the mechanics of worktree lifecycle management.
5//!
6//! # Doc Audit
7//! - audited: 2026-01-25
8//! - docs: scale/isolation.md
9//! - ignore: false
10
11use anyhow::{Context, Result};
12use std::path::{Path, PathBuf};
13use std::process::Command;
14use std::sync::Mutex;
15use std::time::Duration;
16
17/// Run a git command with arguments in a specific directory and return stdout on success.
18///
19/// # Errors
20///
21/// Returns an error if the command fails to execute or exits with non-zero status.
22fn run_git_in_dir(args: &[&str], work_dir: Option<&Path>) -> Result<String> {
23    let mut cmd = Command::new("git");
24    cmd.args(args);
25    if let Some(dir) = work_dir {
26        cmd.current_dir(dir);
27    }
28
29    let output = cmd
30        .output()
31        .context(format!("Failed to run git {}", args.join(" ")))?;
32
33    if !output.status.success() {
34        let stderr = String::from_utf8_lossy(&output.stderr);
35        anyhow::bail!("git {} failed: {}", args.join(" "), stderr);
36    }
37
38    Ok(String::from_utf8_lossy(&output.stdout).to_string())
39}
40
41/// Global mutex to serialize worktree creation operations.
42///
43/// This prevents race conditions when multiple concurrent `git worktree add` calls
44/// attempt to access git's internal locks simultaneously.
45static WORKTREE_CREATION_LOCK: Mutex<()> = Mutex::new(());
46
47/// Returns the worktree path for a given spec ID.
48///
49/// This does not check whether the worktree exists.
50pub fn worktree_path_for_spec(spec_id: &str, project_name: Option<&str>) -> PathBuf {
51    match project_name.filter(|n| !n.is_empty()) {
52        Some(name) => PathBuf::from(format!("/tmp/chant-{}-{}", name, spec_id)),
53        None => PathBuf::from(format!("/tmp/chant-{}", spec_id)),
54    }
55}
56
57/// Returns the worktree path for a spec if an active worktree exists.
58///
59/// Returns Some(path) if the worktree directory exists, None otherwise.
60pub fn get_active_worktree(spec_id: &str, project_name: Option<&str>) -> Option<PathBuf> {
61    let path = worktree_path_for_spec(spec_id, project_name);
62    if path.exists() && path.is_dir() {
63        Some(path)
64    } else {
65        None
66    }
67}
68
69/// Checks if a worktree has uncommitted changes.
70///
71/// # Arguments
72///
73/// * `worktree_path` - Path to the worktree
74///
75/// # Returns
76///
77/// Ok(true) if there are uncommitted changes (staged or unstaged), Ok(false) if clean.
78pub fn has_uncommitted_changes(worktree_path: &Path) -> Result<bool> {
79    let status_output = run_git_in_dir(&["status", "--porcelain"], Some(worktree_path))?;
80    Ok(!status_output.trim().is_empty())
81}
82
83/// Commits changes in a worktree.
84///
85/// # Arguments
86///
87/// * `worktree_path` - Path to the worktree
88/// * `message` - Commit message
89///
90/// # Returns
91///
92/// Ok(commit_hash) if commit was successful, Err if failed.
93pub fn commit_in_worktree(worktree_path: &Path, message: &str) -> Result<String> {
94    // Stage all changes
95    run_git_in_dir(&["add", "-A"], Some(worktree_path))
96        .context("Failed to stage changes in worktree")?;
97
98    // Check if there are any changes to commit
99    let status_output = run_git_in_dir(&["status", "--porcelain"], Some(worktree_path))?;
100    if status_output.trim().is_empty() {
101        // No changes to commit, return the current HEAD
102        let hash = run_git_in_dir(&["rev-parse", "HEAD"], Some(worktree_path))?;
103        return Ok(hash.trim().to_string());
104    }
105
106    // Commit the changes
107    run_git_in_dir(&["commit", "-m", message], Some(worktree_path))
108        .context("Failed to commit changes in worktree")?;
109
110    // Get the commit hash
111    let hash = run_git_in_dir(&["rev-parse", "HEAD"], Some(worktree_path))?;
112    Ok(hash.trim().to_string())
113}
114
115/// Creates a new git worktree for the given spec.
116///
117/// This function is serialized to prevent concurrent `git worktree add` calls from
118/// racing on git's internal locks. If worktree creation fails due to a lock error,
119/// it will retry once after a short delay.
120///
121/// # Arguments
122///
123/// * `spec_id` - The specification ID (used to create unique worktree paths)
124/// * `branch` - The branch name to create in the worktree
125/// * `project_name` - Optional project name to namespace the worktree path
126///
127/// # Returns
128///
129/// The absolute path to the created worktree directory.
130///
131/// # Errors
132///
133/// Returns an error if:
134/// - Git worktree creation fails after retry
135/// - Directory cleanup/creation fails
136pub fn create_worktree(spec_id: &str, branch: &str, project_name: Option<&str>) -> Result<PathBuf> {
137    // Acquire the global lock to serialize worktree creation
138    let _lock = WORKTREE_CREATION_LOCK
139        .lock()
140        .map_err(|e| anyhow::anyhow!("Failed to acquire worktree creation lock: {}", e))?;
141
142    let worktree_path = worktree_path_for_spec(spec_id, project_name);
143
144    // Check if worktree already exists
145    if worktree_path.exists() {
146        // Clean up existing worktree
147        let _ = Command::new("git")
148            .args([
149                "worktree",
150                "remove",
151                "--force",
152                &worktree_path.to_string_lossy(),
153            ])
154            .output();
155
156        // Force remove directory if still present
157        if worktree_path.exists() {
158            let _ = std::fs::remove_dir_all(&worktree_path);
159        }
160    }
161
162    // Check if branch already exists
163    let output = Command::new("git")
164        .args(["rev-parse", "--verify", branch])
165        .output()
166        .context("Failed to check if branch exists")?;
167
168    if output.status.success() {
169        // Branch exists - delete it forcefully
170        let _ = Command::new("git").args(["branch", "-D", branch]).output();
171    }
172
173    // Attempt to create the worktree with retry on lock errors
174    let max_attempts = 2;
175    let mut last_error = None;
176
177    for attempt in 1..=max_attempts {
178        let output = Command::new("git")
179            .args([
180                "worktree",
181                "add",
182                "-b",
183                branch,
184                &worktree_path.to_string_lossy(),
185            ])
186            .output()
187            .context("Failed to execute git worktree add")?;
188
189        if output.status.success() {
190            return Ok(worktree_path);
191        }
192
193        let stderr = String::from_utf8_lossy(&output.stderr);
194        let error_msg = stderr.trim().to_string();
195
196        // Check if this is a transient lock error
197        let is_lock_error = error_msg.contains("lock")
198            || error_msg.contains("unable to create")
199            || error_msg.contains("already exists");
200
201        last_error = Some(error_msg.clone());
202
203        if is_lock_error && attempt < max_attempts {
204            // Retry after a short delay
205            eprintln!(
206                "⚠️  [{}] Worktree creation failed (attempt {}/{}): {}. Retrying...",
207                spec_id, attempt, max_attempts, error_msg
208            );
209            std::thread::sleep(Duration::from_millis(250));
210        } else {
211            // Not a lock error or out of retries
212            break;
213        }
214    }
215
216    // All attempts failed
217    let error_msg = last_error.unwrap_or_else(|| "Unknown error".to_string());
218    anyhow::bail!(
219        "Failed to create worktree after {} attempts: {}",
220        max_attempts,
221        error_msg
222    )
223}
224
225/// Copies the spec file from the main working directory to a worktree.
226///
227/// This ensures the worktree has the current spec state (e.g., in_progress status)
228/// even when the change hasn't been committed to main yet.
229///
230/// # Arguments
231///
232/// * `spec_id` - The specification ID
233/// * `worktree_path` - The path to the worktree
234///
235/// # Returns
236///
237/// Ok(()) if the spec file was successfully copied and committed.
238///
239/// # Errors
240///
241/// Returns an error if:
242/// - The spec file doesn't exist in the main working directory
243/// - The copy operation fails
244/// - The commit fails
245pub fn copy_spec_to_worktree(spec_id: &str, worktree_path: &Path) -> Result<()> {
246    // Use absolute path from git root to avoid issues when current directory changes
247    let git_root = std::env::current_dir().context("Failed to get current directory")?;
248    let main_spec_path = git_root
249        .join(".chant/specs")
250        .join(format!("{}.md", spec_id));
251    let worktree_specs_dir = worktree_path.join(".chant/specs");
252    let worktree_spec_path = worktree_specs_dir.join(format!("{}.md", spec_id));
253
254    // Ensure the .chant/specs directory exists in the worktree
255    std::fs::create_dir_all(&worktree_specs_dir).context(format!(
256        "Failed to create specs directory in worktree: {:?}",
257        worktree_specs_dir
258    ))?;
259
260    // Copy the spec file from main to worktree
261    std::fs::copy(&main_spec_path, &worktree_spec_path).context(format!(
262        "Failed to copy spec file to worktree: {:?}",
263        worktree_spec_path
264    ))?;
265
266    // Commit the updated spec in the worktree
267    commit_in_worktree(
268        worktree_path,
269        &format!("chant({}): update spec status to in_progress", spec_id),
270    )?;
271
272    Ok(())
273}
274
275/// Isolates the worktree environment by removing all specs except the working spec.
276///
277/// This prevents agents from seeing sibling specs and getting confused about their task,
278/// especially in group scenarios where many related specs exist.
279///
280/// # Arguments
281///
282/// * `spec_id` - The specification ID (the only spec to keep)
283/// * `worktree_path` - The path to the worktree
284///
285/// # Returns
286///
287/// Ok(()) if the isolation was successful.
288///
289/// # Errors
290///
291/// Returns an error if:
292/// - Directory operations fail
293/// - The working spec doesn't exist in the worktree
294pub fn isolate_worktree_specs(spec_id: &str, worktree_path: &Path) -> Result<()> {
295    let worktree_specs_dir = worktree_path.join(".chant/specs");
296    let worktree_archive_dir = worktree_path.join(".chant/archive");
297    let working_spec_filename = format!("{}.md", spec_id);
298
299    // Remove the archive directory entirely (agents don't need it)
300    if worktree_archive_dir.exists() {
301        std::fs::remove_dir_all(&worktree_archive_dir).context(format!(
302            "Failed to remove archive directory in worktree: {:?}",
303            worktree_archive_dir
304        ))?;
305    }
306
307    // Remove all specs except the working spec
308    if worktree_specs_dir.exists() {
309        let entries = std::fs::read_dir(&worktree_specs_dir).context(format!(
310            "Failed to read specs directory in worktree: {:?}",
311            worktree_specs_dir
312        ))?;
313
314        for entry in entries {
315            let entry = entry.context("Failed to read directory entry")?;
316            let path = entry.path();
317
318            // Skip if it's not a file
319            if !path.is_file() {
320                continue;
321            }
322
323            // Skip the working spec
324            if let Some(filename) = path.file_name() {
325                if filename == working_spec_filename.as_str() {
326                    continue;
327                }
328            }
329
330            // Remove all other spec files
331            std::fs::remove_file(&path).context(format!(
332                "Failed to remove spec file in worktree: {:?}",
333                path
334            ))?;
335        }
336    }
337
338    Ok(())
339}
340
341/// Removes a git worktree and cleans up its directory.
342///
343/// This function is idempotent - it does not error if the worktree is already gone.
344///
345/// # Arguments
346///
347/// * `path` - The path to the worktree to remove
348///
349/// # Returns
350///
351/// Ok(()) if the worktree was successfully removed or didn't exist.
352pub fn remove_worktree(path: &Path) -> Result<()> {
353    // Try to remove the git worktree entry
354    let _output = Command::new("git")
355        .args(["worktree", "remove", &path.to_string_lossy()])
356        .output()
357        .context("Failed to run git worktree remove")?;
358
359    // Even if git worktree remove fails, try to clean up the directory
360    if path.exists() {
361        std::fs::remove_dir_all(path)
362            .context(format!("Failed to remove worktree directory at {:?}", path))?;
363    }
364
365    Ok(())
366}
367
368/// Result of a merge operation
369#[derive(Debug, Clone)]
370pub struct MergeCleanupResult {
371    pub success: bool,
372    pub has_conflict: bool,
373    pub error: Option<String>,
374}
375
376/// Checks if a branch is behind the main branch (main has commits not in branch).
377///
378/// # Arguments
379///
380/// * `branch` - The branch name to check
381/// * `main_branch` - The name of the main branch
382/// * `work_dir` - Optional working directory for the git command
383///
384/// # Returns
385///
386/// Ok(true) if main has commits not in branch, Ok(false) otherwise.
387fn branch_is_behind_main(branch: &str, main_branch: &str, work_dir: Option<&Path>) -> Result<bool> {
388    let mut cmd = Command::new("git");
389    cmd.args([
390        "rev-list",
391        "--count",
392        &format!("{}..{}", branch, main_branch),
393    ]);
394    if let Some(dir) = work_dir {
395        cmd.current_dir(dir);
396    }
397    let output = cmd
398        .output()
399        .context("Failed to check if branch is behind main")?;
400
401    if !output.status.success() {
402        let stderr = String::from_utf8_lossy(&output.stderr);
403        anyhow::bail!("Failed to check branch status: {}", stderr);
404    }
405
406    let count_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
407    let count: i32 = count_str
408        .parse()
409        .context(format!("Failed to parse commit count: {}", count_str))?;
410    Ok(count > 0)
411}
412
413/// Rebases a branch onto the main branch.
414///
415/// # Arguments
416///
417/// * `branch` - The branch name to rebase
418/// * `main_branch` - The name of the main branch
419/// * `work_dir` - Optional working directory for the git command
420///
421/// # Returns
422///
423/// Ok(()) if rebase succeeded, Err if rebase had conflicts or failed.
424fn rebase_branch_onto_main(branch: &str, main_branch: &str, work_dir: Option<&Path>) -> Result<()> {
425    // Checkout the branch
426    let mut cmd = Command::new("git");
427    cmd.args(["checkout", branch]);
428    if let Some(dir) = work_dir {
429        cmd.current_dir(dir);
430    }
431    let output = cmd
432        .output()
433        .context("Failed to checkout branch for rebase")?;
434
435    if !output.status.success() {
436        let stderr = String::from_utf8_lossy(&output.stderr);
437        anyhow::bail!("Failed to checkout branch: {}", stderr);
438    }
439
440    // Rebase onto main
441    let mut cmd = Command::new("git");
442    cmd.args(["rebase", main_branch]);
443    if let Some(dir) = work_dir {
444        cmd.current_dir(dir);
445    }
446    let output = cmd.output().context("Failed to rebase onto main")?;
447
448    if !output.status.success() {
449        anyhow::bail!("Rebase had conflicts");
450    }
451
452    // Return to main branch
453    let mut cmd = Command::new("git");
454    cmd.args(["checkout", main_branch]);
455    if let Some(dir) = work_dir {
456        cmd.current_dir(dir);
457    }
458    let output = cmd
459        .output()
460        .context("Failed to checkout main after rebase")?;
461
462    if !output.status.success() {
463        let stderr = String::from_utf8_lossy(&output.stderr);
464        anyhow::bail!("Failed to checkout main: {}", stderr);
465    }
466
467    Ok(())
468}
469
470/// Aborts a rebase in progress and returns to main branch.
471///
472/// # Arguments
473///
474/// * `main_branch` - The name of the main branch
475/// * `work_dir` - Optional working directory for the git command
476///
477/// This function is best-effort and does not return errors.
478fn abort_rebase(main_branch: &str, work_dir: Option<&Path>) {
479    // Abort the rebase
480    let mut cmd = Command::new("git");
481    cmd.args(["rebase", "--abort"]);
482    if let Some(dir) = work_dir {
483        cmd.current_dir(dir);
484    }
485    let _ = cmd.output();
486
487    // Try to ensure we're on main branch
488    let mut cmd = Command::new("git");
489    cmd.args(["checkout", main_branch]);
490    if let Some(dir) = work_dir {
491        cmd.current_dir(dir);
492    }
493    let _ = cmd.output();
494}
495
496/// Merges a branch to main and cleans up.
497///
498/// # Arguments
499///
500/// * `branch` - The branch name to merge
501/// * `main_branch` - The name of the main branch
502/// * `no_rebase` - If true, skip automatic rebase even if branch is behind
503///
504/// # Returns
505///
506/// Returns a MergeCleanupResult indicating:
507/// - success: true if merge succeeded and branch was deleted
508/// - has_conflict: true if merge failed due to conflicts
509/// - error: optional error message
510///
511/// If there are merge conflicts, the branch is preserved for manual resolution.
512pub fn merge_and_cleanup(branch: &str, main_branch: &str, no_rebase: bool) -> MergeCleanupResult {
513    merge_and_cleanup_in_dir(branch, main_branch, None, no_rebase)
514}
515
516/// Internal function that merges a branch to main with optional working directory.
517fn merge_and_cleanup_in_dir(
518    branch: &str,
519    main_branch: &str,
520    work_dir: Option<&Path>,
521    no_rebase: bool,
522) -> MergeCleanupResult {
523    // Checkout main branch
524    let mut cmd = Command::new("git");
525    cmd.args(["checkout", main_branch]);
526    if let Some(dir) = work_dir {
527        cmd.current_dir(dir);
528    }
529    let output = match cmd.output() {
530        Ok(o) => o,
531        Err(e) => {
532            return MergeCleanupResult {
533                success: false,
534                has_conflict: false,
535                error: Some(format!("Failed to checkout {}: {}", main_branch, e)),
536            };
537        }
538    };
539
540    if !output.status.success() {
541        let stderr = String::from_utf8_lossy(&output.stderr);
542        // Try to ensure we're on main branch before returning error
543        let _ = crate::git::ensure_on_main_branch(main_branch);
544        return MergeCleanupResult {
545            success: false,
546            has_conflict: false,
547            error: Some(format!("Failed to checkout {}: {}", main_branch, stderr)),
548        };
549    }
550
551    // Check if branch needs rebase (is behind main) and attempt rebase if needed
552    if !no_rebase {
553        match branch_is_behind_main(branch, main_branch, work_dir) {
554            Ok(true) => {
555                // Branch is behind main, attempt automatic rebase
556                println!(
557                    "Branch '{}' is behind {}, attempting automatic rebase...",
558                    branch, main_branch
559                );
560                match rebase_branch_onto_main(branch, main_branch, work_dir) {
561                    Ok(()) => {
562                        println!("Rebase succeeded, proceeding with merge...");
563                    }
564                    Err(e) => {
565                        // Rebase failed (conflicts), abort and preserve branch
566                        abort_rebase(main_branch, work_dir);
567                        return MergeCleanupResult {
568                            success: false,
569                            has_conflict: true,
570                            error: Some(format!("Auto-rebase failed due to conflicts: {}", e)),
571                        };
572                    }
573                }
574            }
575            Ok(false) => {
576                // Branch is not behind main, proceed normally
577            }
578            Err(e) => {
579                // Failed to check if branch is behind, log warning and proceed
580                eprintln!(
581                    "Warning: Failed to check if branch is behind {}: {}",
582                    main_branch, e
583                );
584            }
585        }
586    }
587
588    // Perform fast-forward merge
589    let mut cmd = Command::new("git");
590    cmd.args(["merge", "--ff-only", branch]);
591    if let Some(dir) = work_dir {
592        cmd.current_dir(dir);
593    }
594    let output = match cmd.output() {
595        Ok(o) => o,
596        Err(e) => {
597            return MergeCleanupResult {
598                success: false,
599                has_conflict: false,
600                error: Some(format!("Failed to perform merge: {}", e)),
601            };
602        }
603    };
604
605    if !output.status.success() {
606        let stderr = String::from_utf8_lossy(&output.stderr);
607        // Check if this was a conflict
608        let has_conflict = stderr.contains("CONFLICT") || stderr.contains("merge conflict");
609
610        // Abort merge if there was a conflict to preserve the branch
611        if has_conflict {
612            let mut cmd = Command::new("git");
613            cmd.args(["merge", "--abort"]);
614            if let Some(dir) = work_dir {
615                cmd.current_dir(dir);
616            }
617            let _ = cmd.output();
618        }
619
620        // Extract spec_id from branch name (strip prefix like "chant/" or "chant/frontend/")
621        let spec_id = branch.rsplit('/').next().unwrap_or(branch);
622        let error_msg = if has_conflict {
623            crate::merge_errors::merge_conflict(spec_id, branch, main_branch)
624        } else {
625            crate::merge_errors::fast_forward_conflict(spec_id, branch, main_branch, &stderr)
626        };
627        // Try to ensure we're on main branch before returning error
628        let _ = crate::git::ensure_on_main_branch(main_branch);
629        return MergeCleanupResult {
630            success: false,
631            has_conflict,
632            error: Some(error_msg),
633        };
634    }
635
636    // Delete the local branch after successful merge
637    let mut cmd = Command::new("git");
638    cmd.args(["branch", "-d", branch]);
639    if let Some(dir) = work_dir {
640        cmd.current_dir(dir);
641    }
642    let output = match cmd.output() {
643        Ok(o) => o,
644        Err(e) => {
645            return MergeCleanupResult {
646                success: false,
647                has_conflict: false,
648                error: Some(format!("Failed to delete branch: {}", e)),
649            };
650        }
651    };
652
653    if !output.status.success() {
654        let stderr = String::from_utf8_lossy(&output.stderr);
655        return MergeCleanupResult {
656            success: false,
657            has_conflict: false,
658            error: Some(format!("Failed to delete branch '{}': {}", branch, stderr)),
659        };
660    }
661
662    // Delete the remote branch (best-effort, don't fail if it doesn't exist)
663    let mut cmd = Command::new("git");
664    cmd.args(["push", "origin", "--delete", branch]);
665    if let Some(dir) = work_dir {
666        cmd.current_dir(dir);
667    }
668    // Ignore errors - remote branch may not exist or remote may be unavailable
669    let _ = cmd.output();
670
671    MergeCleanupResult {
672        success: true,
673        has_conflict: false,
674        error: None,
675    }
676}
677
678#[cfg(test)]
679mod tests {
680    use super::*;
681    use std::fs;
682    use std::process::Command as StdCommand;
683
684    /// Helper to initialize a temporary git repo for testing.
685    fn setup_test_repo(repo_dir: &Path) -> Result<()> {
686        fs::create_dir_all(repo_dir)?;
687
688        let output = StdCommand::new("git")
689            .args(["init", "-b", "main"])
690            .current_dir(repo_dir)
691            .output()
692            .context("Failed to run git init")?;
693        anyhow::ensure!(
694            output.status.success(),
695            "git init failed: {}",
696            String::from_utf8_lossy(&output.stderr)
697        );
698
699        let output = StdCommand::new("git")
700            .args(["config", "user.email", "test@example.com"])
701            .current_dir(repo_dir)
702            .output()
703            .context("Failed to run git config")?;
704        anyhow::ensure!(
705            output.status.success(),
706            "git config email failed: {}",
707            String::from_utf8_lossy(&output.stderr)
708        );
709
710        let output = StdCommand::new("git")
711            .args(["config", "user.name", "Test User"])
712            .current_dir(repo_dir)
713            .output()
714            .context("Failed to run git config")?;
715        anyhow::ensure!(
716            output.status.success(),
717            "git config name failed: {}",
718            String::from_utf8_lossy(&output.stderr)
719        );
720
721        // Create an initial commit
722        fs::write(repo_dir.join("README.md"), "# Test")?;
723
724        let output = StdCommand::new("git")
725            .args(["add", "."])
726            .current_dir(repo_dir)
727            .output()
728            .context("Failed to run git add")?;
729        anyhow::ensure!(
730            output.status.success(),
731            "git add failed: {}",
732            String::from_utf8_lossy(&output.stderr)
733        );
734
735        let output = StdCommand::new("git")
736            .args(["commit", "-m", "Initial commit"])
737            .current_dir(repo_dir)
738            .output()
739            .context("Failed to run git commit")?;
740        anyhow::ensure!(
741            output.status.success(),
742            "git commit failed: {}",
743            String::from_utf8_lossy(&output.stderr)
744        );
745
746        Ok(())
747    }
748
749    /// Helper to clean up test repos.
750    fn cleanup_test_repo(repo_dir: &Path) -> Result<()> {
751        if repo_dir.exists() {
752            fs::remove_dir_all(repo_dir)?;
753        }
754        Ok(())
755    }
756
757    #[test]
758    #[serial_test::serial]
759    fn test_create_worktree_branch_already_exists() -> Result<()> {
760        let repo_dir = PathBuf::from("/tmp/test-chant-repo-branch-exists");
761        cleanup_test_repo(&repo_dir)?;
762        setup_test_repo(&repo_dir)?;
763
764        let original_dir = std::env::current_dir()?;
765
766        let result = {
767            std::env::set_current_dir(&repo_dir).context("Failed to change to repo directory")?;
768
769            let spec_id = "test-spec-branch-exists";
770            let branch = "spec/test-spec-branch-exists";
771
772            // Create the branch first
773            let output = StdCommand::new("git")
774                .args(["branch", branch])
775                .current_dir(&repo_dir)
776                .output()?;
777            anyhow::ensure!(
778                output.status.success(),
779                "git branch failed: {}",
780                String::from_utf8_lossy(&output.stderr)
781            );
782
783            create_worktree(spec_id, branch, None)
784        };
785
786        // Always restore original directory
787        std::env::set_current_dir(&original_dir).context("Failed to restore original directory")?;
788        cleanup_test_repo(&repo_dir)?;
789
790        // Should now succeed - auto-clean existing branch and create fresh worktree
791        assert!(
792            result.is_ok(),
793            "create_worktree should auto-clean and succeed"
794        );
795        Ok(())
796    }
797
798    #[test]
799    #[serial_test::serial]
800    fn test_merge_and_cleanup_with_conflict_preserves_branch() -> Result<()> {
801        let repo_dir = PathBuf::from("/tmp/test-chant-repo-conflict-preserve");
802        cleanup_test_repo(&repo_dir)?;
803        setup_test_repo(&repo_dir)?;
804
805        let original_dir = std::env::current_dir()?;
806
807        let result = {
808            std::env::set_current_dir(&repo_dir).context("Failed to change to repo directory")?;
809
810            let branch = "feature/conflict-test";
811
812            // Create a feature branch that conflicts with main
813            let output = StdCommand::new("git")
814                .args(["branch", branch])
815                .current_dir(&repo_dir)
816                .output()?;
817            anyhow::ensure!(
818                output.status.success(),
819                "git branch failed: {}",
820                String::from_utf8_lossy(&output.stderr)
821            );
822
823            let output = StdCommand::new("git")
824                .args(["checkout", branch])
825                .current_dir(&repo_dir)
826                .output()?;
827            anyhow::ensure!(
828                output.status.success(),
829                "git checkout branch failed: {}",
830                String::from_utf8_lossy(&output.stderr)
831            );
832
833            fs::write(repo_dir.join("README.md"), "feature version")?;
834
835            let output = StdCommand::new("git")
836                .args(["add", "."])
837                .current_dir(&repo_dir)
838                .output()?;
839            anyhow::ensure!(
840                output.status.success(),
841                "git add failed: {}",
842                String::from_utf8_lossy(&output.stderr)
843            );
844
845            let output = StdCommand::new("git")
846                .args(["commit", "-m", "Modify README on feature"])
847                .current_dir(&repo_dir)
848                .output()?;
849            anyhow::ensure!(
850                output.status.success(),
851                "git commit feature failed: {}",
852                String::from_utf8_lossy(&output.stderr)
853            );
854
855            // Modify README on main differently
856            let output = StdCommand::new("git")
857                .args(["checkout", "main"])
858                .current_dir(&repo_dir)
859                .output()?;
860            anyhow::ensure!(
861                output.status.success(),
862                "git checkout main failed: {}",
863                String::from_utf8_lossy(&output.stderr)
864            );
865
866            fs::write(repo_dir.join("README.md"), "main version")?;
867
868            let output = StdCommand::new("git")
869                .args(["add", "."])
870                .current_dir(&repo_dir)
871                .output()?;
872            anyhow::ensure!(
873                output.status.success(),
874                "git add main failed: {}",
875                String::from_utf8_lossy(&output.stderr)
876            );
877
878            let output = StdCommand::new("git")
879                .args(["commit", "-m", "Modify README on main"])
880                .current_dir(&repo_dir)
881                .output()?;
882            anyhow::ensure!(
883                output.status.success(),
884                "git commit main failed: {}",
885                String::from_utf8_lossy(&output.stderr)
886            );
887
888            // Now call merge_and_cleanup with explicit repo directory
889            merge_and_cleanup_in_dir(branch, "main", Some(&repo_dir), false)
890        };
891
892        // Always restore original directory
893        std::env::set_current_dir(&original_dir).context("Failed to restore original directory")?;
894
895        // Check that branch still exists (wasn't deleted)
896        let branch_check = StdCommand::new("git")
897            .args(["rev-parse", "--verify", "feature/conflict-test"])
898            .current_dir(&repo_dir)
899            .output()?;
900
901        cleanup_test_repo(&repo_dir)?;
902
903        // Merge should fail (either due to conflict or non-ff situation)
904        assert!(!result.success);
905        // Branch should still exist
906        assert!(branch_check.status.success());
907        Ok(())
908    }
909
910    #[test]
911    #[serial_test::serial]
912    fn test_merge_and_cleanup_successful_merge() -> Result<()> {
913        let repo_dir = PathBuf::from("/tmp/test-chant-repo-merge-success");
914        cleanup_test_repo(&repo_dir)?;
915        setup_test_repo(&repo_dir)?;
916
917        let original_dir = std::env::current_dir()?;
918
919        let result = {
920            std::env::set_current_dir(&repo_dir).context("Failed to change to repo directory")?;
921
922            let branch = "feature/new-feature";
923
924            // Create a fast-forwardable feature branch
925            let output = StdCommand::new("git")
926                .args(["branch", branch])
927                .current_dir(&repo_dir)
928                .output()?;
929            anyhow::ensure!(
930                output.status.success(),
931                "git branch failed: {}",
932                String::from_utf8_lossy(&output.stderr)
933            );
934
935            let output = StdCommand::new("git")
936                .args(["checkout", branch])
937                .current_dir(&repo_dir)
938                .output()?;
939            anyhow::ensure!(
940                output.status.success(),
941                "git checkout failed: {}",
942                String::from_utf8_lossy(&output.stderr)
943            );
944
945            fs::write(repo_dir.join("feature.txt"), "feature content")?;
946
947            let output = StdCommand::new("git")
948                .args(["add", "."])
949                .current_dir(&repo_dir)
950                .output()?;
951            anyhow::ensure!(
952                output.status.success(),
953                "git add failed: {}",
954                String::from_utf8_lossy(&output.stderr)
955            );
956
957            let output = StdCommand::new("git")
958                .args(["commit", "-m", "Add feature"])
959                .current_dir(&repo_dir)
960                .output()?;
961            anyhow::ensure!(
962                output.status.success(),
963                "git commit failed: {}",
964                String::from_utf8_lossy(&output.stderr)
965            );
966
967            // Merge the branch with explicit repo directory
968            merge_and_cleanup_in_dir(branch, "main", Some(&repo_dir), false)
969        };
970
971        // Always restore original directory
972        std::env::set_current_dir(&original_dir).context("Failed to restore original directory")?;
973
974        // Check that branch no longer exists
975        let branch_check = StdCommand::new("git")
976            .args(["rev-parse", "--verify", "feature/new-feature"])
977            .current_dir(&repo_dir)
978            .output()?;
979
980        cleanup_test_repo(&repo_dir)?;
981
982        assert!(
983            result.success && result.error.is_none(),
984            "Merge result: {:?}",
985            result
986        );
987        // Branch should be deleted after merge
988        assert!(!branch_check.status.success());
989        Ok(())
990    }
991
992    #[test]
993    #[serial_test::serial]
994    fn test_create_worktree_success() -> Result<()> {
995        let repo_dir = PathBuf::from("/tmp/test-chant-repo-create-success");
996        cleanup_test_repo(&repo_dir)?;
997        setup_test_repo(&repo_dir)?;
998
999        let original_dir = std::env::current_dir()?;
1000
1001        let result = {
1002            std::env::set_current_dir(&repo_dir).context("Failed to change to repo directory")?;
1003
1004            let spec_id = "test-spec-create-success";
1005            let branch = "spec/test-spec-create-success";
1006
1007            create_worktree(spec_id, branch, None)
1008        };
1009
1010        // Always restore original directory
1011        std::env::set_current_dir(&original_dir).context("Failed to restore original directory")?;
1012
1013        // Verify worktree was created successfully
1014        assert!(result.is_ok(), "create_worktree should succeed");
1015        let worktree_path = result.unwrap();
1016        assert!(worktree_path.exists(), "Worktree directory should exist");
1017        assert_eq!(
1018            worktree_path,
1019            PathBuf::from("/tmp/chant-test-spec-create-success"),
1020            "Worktree path should match expected format"
1021        );
1022
1023        // Verify branch was created
1024        let branch_check = StdCommand::new("git")
1025            .args(["rev-parse", "--verify", "spec/test-spec-create-success"])
1026            .current_dir(&repo_dir)
1027            .output()?;
1028        assert!(branch_check.status.success(), "Branch should exist");
1029
1030        // Cleanup
1031        let _ = StdCommand::new("git")
1032            .args(["worktree", "remove", worktree_path.to_str().unwrap()])
1033            .current_dir(&repo_dir)
1034            .output();
1035        let _ = fs::remove_dir_all(&worktree_path);
1036        cleanup_test_repo(&repo_dir)?;
1037
1038        Ok(())
1039    }
1040
1041    #[test]
1042    #[serial_test::serial]
1043    fn test_copy_spec_to_worktree_success() -> Result<()> {
1044        let repo_dir = PathBuf::from("/tmp/test-chant-repo-copy-spec");
1045        cleanup_test_repo(&repo_dir)?;
1046        setup_test_repo(&repo_dir)?;
1047
1048        let original_dir = std::env::current_dir()?;
1049
1050        let result: Result<PathBuf> = {
1051            std::env::set_current_dir(&repo_dir).context("Failed to change to repo directory")?;
1052
1053            let spec_id = "test-spec-copy";
1054            let branch = "spec/test-spec-copy";
1055
1056            // Create .chant/specs directory in main repo
1057            let specs_dir = repo_dir.join(".chant/specs");
1058            fs::create_dir_all(&specs_dir)?;
1059
1060            // Create a test spec file in main repo
1061            let spec_path = specs_dir.join(format!("{}.md", spec_id));
1062            fs::write(
1063                &spec_path,
1064                "---\ntype: code\nstatus: in_progress\n---\n# Test Spec\n",
1065            )?;
1066
1067            // Create worktree
1068            let worktree_path = create_worktree(spec_id, branch, None)?;
1069
1070            // Copy spec to worktree
1071            copy_spec_to_worktree(spec_id, &worktree_path)?;
1072
1073            // Verify spec was copied to worktree
1074            let worktree_spec_path = worktree_path
1075                .join(".chant/specs")
1076                .join(format!("{}.md", spec_id));
1077            assert!(
1078                worktree_spec_path.exists(),
1079                "Spec file should exist in worktree"
1080            );
1081
1082            let worktree_spec_content = fs::read_to_string(&worktree_spec_path)?;
1083            assert!(
1084                worktree_spec_content.contains("in_progress"),
1085                "Spec should contain in_progress status"
1086            );
1087
1088            // Verify the copy was committed
1089            let log_output = StdCommand::new("git")
1090                .args(["log", "--oneline", "-n", "1"])
1091                .current_dir(&worktree_path)
1092                .output()?;
1093            let log = String::from_utf8_lossy(&log_output.stdout);
1094            assert!(
1095                log.contains("update spec status"),
1096                "Commit message should mention spec update"
1097            );
1098
1099            Ok(worktree_path)
1100        };
1101
1102        // Always restore original directory
1103        std::env::set_current_dir(&original_dir).context("Failed to restore original directory")?;
1104
1105        // Cleanup
1106        if let Ok(worktree_path) = result {
1107            let _ = StdCommand::new("git")
1108                .args(["worktree", "remove", worktree_path.to_str().unwrap()])
1109                .current_dir(&repo_dir)
1110                .output();
1111            let _ = fs::remove_dir_all(&worktree_path);
1112        }
1113        cleanup_test_repo(&repo_dir)?;
1114
1115        Ok(())
1116    }
1117
1118    #[test]
1119    #[serial_test::serial]
1120    fn test_remove_worktree_success() -> Result<()> {
1121        let repo_dir = PathBuf::from("/tmp/test-chant-repo-remove-success");
1122        cleanup_test_repo(&repo_dir)?;
1123        setup_test_repo(&repo_dir)?;
1124
1125        let original_dir = std::env::current_dir()?;
1126
1127        let worktree_path = {
1128            std::env::set_current_dir(&repo_dir).context("Failed to change to repo directory")?;
1129
1130            let spec_id = "test-spec-remove";
1131            let branch = "spec/test-spec-remove";
1132
1133            // Create worktree
1134            let path = create_worktree(spec_id, branch, None)?;
1135            assert!(path.exists(), "Worktree should exist after creation");
1136            path
1137        };
1138
1139        // Always restore original directory before cleanup
1140        std::env::set_current_dir(&original_dir).context("Failed to restore original directory")?;
1141
1142        // Remove the worktree
1143        let result = remove_worktree(&worktree_path);
1144        assert!(result.is_ok(), "remove_worktree should succeed");
1145
1146        // Verify worktree directory no longer exists
1147        assert!(
1148            !worktree_path.exists(),
1149            "Worktree directory should be removed"
1150        );
1151
1152        cleanup_test_repo(&repo_dir)?;
1153        Ok(())
1154    }
1155
1156    #[test]
1157    fn test_remove_worktree_idempotent() -> Result<()> {
1158        let path = PathBuf::from("/tmp/nonexistent-worktree-12345");
1159
1160        // Try to remove a non-existent worktree - should succeed
1161        let result = remove_worktree(&path);
1162
1163        assert!(result.is_ok());
1164        Ok(())
1165    }
1166
1167    #[test]
1168    fn test_worktree_path_for_spec() {
1169        let path = worktree_path_for_spec("2026-01-27-001-abc", None);
1170        assert_eq!(path, PathBuf::from("/tmp/chant-2026-01-27-001-abc"));
1171
1172        let path = worktree_path_for_spec("2026-01-27-001-abc", Some("myproject"));
1173        assert_eq!(
1174            path,
1175            PathBuf::from("/tmp/chant-myproject-2026-01-27-001-abc")
1176        );
1177
1178        let path = worktree_path_for_spec("2026-01-27-001-abc", Some(""));
1179        assert_eq!(path, PathBuf::from("/tmp/chant-2026-01-27-001-abc"));
1180    }
1181
1182    #[test]
1183    fn test_get_active_worktree_nonexistent() {
1184        // Test with a spec ID that definitely doesn't have a worktree
1185        let result = get_active_worktree("nonexistent-spec-12345", None);
1186        assert!(result.is_none());
1187    }
1188
1189    #[test]
1190    #[serial_test::serial]
1191    fn test_commit_in_worktree() -> Result<()> {
1192        let repo_dir = PathBuf::from("/tmp/test-chant-commit-in-worktree");
1193        cleanup_test_repo(&repo_dir)?;
1194        setup_test_repo(&repo_dir)?;
1195
1196        // Create a new file
1197        fs::write(repo_dir.join("new_file.txt"), "content")?;
1198
1199        // Commit the changes
1200        let result = commit_in_worktree(&repo_dir, "Test commit message");
1201
1202        cleanup_test_repo(&repo_dir)?;
1203
1204        assert!(result.is_ok());
1205        let hash = result.unwrap();
1206        // Commit hash should be a 40-character hex string
1207        assert_eq!(hash.len(), 40);
1208        assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
1209
1210        Ok(())
1211    }
1212
1213    #[test]
1214    #[serial_test::serial]
1215    fn test_commit_in_worktree_no_changes() -> Result<()> {
1216        let repo_dir = PathBuf::from("/tmp/test-chant-commit-no-changes");
1217        cleanup_test_repo(&repo_dir)?;
1218        setup_test_repo(&repo_dir)?;
1219
1220        // Don't make any changes, just try to commit
1221        let result = commit_in_worktree(&repo_dir, "Empty commit");
1222
1223        cleanup_test_repo(&repo_dir)?;
1224
1225        // Should still succeed (returns HEAD)
1226        assert!(result.is_ok());
1227        let hash = result.unwrap();
1228        assert_eq!(hash.len(), 40);
1229
1230        Ok(())
1231    }
1232
1233    #[test]
1234    #[serial_test::serial]
1235    fn test_has_uncommitted_changes_clean() -> Result<()> {
1236        let repo_dir = PathBuf::from("/tmp/test-chant-uncommitted-clean");
1237        cleanup_test_repo(&repo_dir)?;
1238        setup_test_repo(&repo_dir)?;
1239
1240        // Check for uncommitted changes on a clean repo
1241        let has_changes = has_uncommitted_changes(&repo_dir)?;
1242
1243        cleanup_test_repo(&repo_dir)?;
1244
1245        assert!(
1246            !has_changes,
1247            "Clean repo should have no uncommitted changes"
1248        );
1249
1250        Ok(())
1251    }
1252
1253    #[test]
1254    #[serial_test::serial]
1255    fn test_has_uncommitted_changes_with_unstaged() -> Result<()> {
1256        let repo_dir = PathBuf::from("/tmp/test-chant-uncommitted-unstaged");
1257        cleanup_test_repo(&repo_dir)?;
1258        setup_test_repo(&repo_dir)?;
1259
1260        // Create a new file (unstaged)
1261        fs::write(repo_dir.join("newfile.txt"), "content")?;
1262
1263        // Check for uncommitted changes
1264        let has_changes = has_uncommitted_changes(&repo_dir)?;
1265
1266        cleanup_test_repo(&repo_dir)?;
1267
1268        assert!(has_changes, "Repo with unstaged changes should return true");
1269
1270        Ok(())
1271    }
1272
1273    #[test]
1274    #[serial_test::serial]
1275    fn test_has_uncommitted_changes_with_staged() -> Result<()> {
1276        let repo_dir = PathBuf::from("/tmp/test-chant-uncommitted-staged");
1277        cleanup_test_repo(&repo_dir)?;
1278        setup_test_repo(&repo_dir)?;
1279
1280        // Create a new file and stage it
1281        fs::write(repo_dir.join("newfile.txt"), "content")?;
1282        let output = StdCommand::new("git")
1283            .args(["add", "newfile.txt"])
1284            .current_dir(&repo_dir)
1285            .output()?;
1286        assert!(output.status.success());
1287
1288        // Check for uncommitted changes
1289        let has_changes = has_uncommitted_changes(&repo_dir)?;
1290
1291        cleanup_test_repo(&repo_dir)?;
1292
1293        assert!(has_changes, "Repo with staged changes should return true");
1294
1295        Ok(())
1296    }
1297}