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