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/// Removes a git worktree and cleans up its directory.
297///
298/// This function is idempotent - it does not error if the worktree is already gone.
299///
300/// # Arguments
301///
302/// * `path` - The path to the worktree to remove
303///
304/// # Returns
305///
306/// Ok(()) if the worktree was successfully removed or didn't exist.
307pub fn remove_worktree(path: &Path) -> Result<()> {
308    // Try to remove the git worktree entry
309    let _output = Command::new("git")
310        .args(["worktree", "remove", &path.to_string_lossy()])
311        .output()
312        .context("Failed to run git worktree remove")?;
313
314    // Even if git worktree remove fails, try to clean up the directory
315    if path.exists() {
316        std::fs::remove_dir_all(path)
317            .context(format!("Failed to remove worktree directory at {:?}", path))?;
318    }
319
320    Ok(())
321}
322
323/// Result of a merge operation
324#[derive(Debug, Clone)]
325pub struct MergeCleanupResult {
326    pub success: bool,
327    pub has_conflict: bool,
328    pub error: Option<String>,
329}
330
331/// Checks if a branch is behind the main branch (main has commits not in branch).
332///
333/// # Arguments
334///
335/// * `branch` - The branch name to check
336/// * `main_branch` - The name of the main branch
337/// * `work_dir` - Optional working directory for the git command
338///
339/// # Returns
340///
341/// Ok(true) if main has commits not in branch, Ok(false) otherwise.
342fn branch_is_behind_main(branch: &str, main_branch: &str, work_dir: Option<&Path>) -> Result<bool> {
343    let mut cmd = Command::new("git");
344    cmd.args([
345        "rev-list",
346        "--count",
347        &format!("{}..{}", branch, main_branch),
348    ]);
349    if let Some(dir) = work_dir {
350        cmd.current_dir(dir);
351    }
352    let output = cmd
353        .output()
354        .context("Failed to check if branch is behind main")?;
355
356    if !output.status.success() {
357        let stderr = String::from_utf8_lossy(&output.stderr);
358        anyhow::bail!("Failed to check branch status: {}", stderr);
359    }
360
361    let count_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
362    let count: i32 = count_str
363        .parse()
364        .context(format!("Failed to parse commit count: {}", count_str))?;
365    Ok(count > 0)
366}
367
368/// Rebases a branch onto the main branch.
369///
370/// # Arguments
371///
372/// * `branch` - The branch name to rebase
373/// * `main_branch` - The name of the main branch
374/// * `work_dir` - Optional working directory for the git command
375///
376/// # Returns
377///
378/// Ok(()) if rebase succeeded, Err if rebase had conflicts or failed.
379fn rebase_branch_onto_main(branch: &str, main_branch: &str, work_dir: Option<&Path>) -> Result<()> {
380    // Checkout the branch
381    let mut cmd = Command::new("git");
382    cmd.args(["checkout", branch]);
383    if let Some(dir) = work_dir {
384        cmd.current_dir(dir);
385    }
386    let output = cmd
387        .output()
388        .context("Failed to checkout branch for rebase")?;
389
390    if !output.status.success() {
391        let stderr = String::from_utf8_lossy(&output.stderr);
392        anyhow::bail!("Failed to checkout branch: {}", stderr);
393    }
394
395    // Rebase onto main
396    let mut cmd = Command::new("git");
397    cmd.args(["rebase", main_branch]);
398    if let Some(dir) = work_dir {
399        cmd.current_dir(dir);
400    }
401    let output = cmd.output().context("Failed to rebase onto main")?;
402
403    if !output.status.success() {
404        anyhow::bail!("Rebase had conflicts");
405    }
406
407    // Return to main branch
408    let mut cmd = Command::new("git");
409    cmd.args(["checkout", main_branch]);
410    if let Some(dir) = work_dir {
411        cmd.current_dir(dir);
412    }
413    let output = cmd
414        .output()
415        .context("Failed to checkout main after rebase")?;
416
417    if !output.status.success() {
418        let stderr = String::from_utf8_lossy(&output.stderr);
419        anyhow::bail!("Failed to checkout main: {}", stderr);
420    }
421
422    Ok(())
423}
424
425/// Aborts a rebase in progress and returns to main branch.
426///
427/// # Arguments
428///
429/// * `main_branch` - The name of the main branch
430/// * `work_dir` - Optional working directory for the git command
431///
432/// This function is best-effort and does not return errors.
433fn abort_rebase(main_branch: &str, work_dir: Option<&Path>) {
434    // Abort the rebase
435    let mut cmd = Command::new("git");
436    cmd.args(["rebase", "--abort"]);
437    if let Some(dir) = work_dir {
438        cmd.current_dir(dir);
439    }
440    let _ = cmd.output();
441
442    // Try to ensure we're on main branch
443    let mut cmd = Command::new("git");
444    cmd.args(["checkout", main_branch]);
445    if let Some(dir) = work_dir {
446        cmd.current_dir(dir);
447    }
448    let _ = cmd.output();
449}
450
451/// Merges a branch to main and cleans up.
452///
453/// # Arguments
454///
455/// * `branch` - The branch name to merge
456/// * `main_branch` - The name of the main branch
457/// * `no_rebase` - If true, skip automatic rebase even if branch is behind
458///
459/// # Returns
460///
461/// Returns a MergeCleanupResult indicating:
462/// - success: true if merge succeeded and branch was deleted
463/// - has_conflict: true if merge failed due to conflicts
464/// - error: optional error message
465///
466/// If there are merge conflicts, the branch is preserved for manual resolution.
467pub fn merge_and_cleanup(branch: &str, main_branch: &str, no_rebase: bool) -> MergeCleanupResult {
468    merge_and_cleanup_in_dir(branch, main_branch, None, no_rebase)
469}
470
471/// Internal function that merges a branch to main with optional working directory.
472fn merge_and_cleanup_in_dir(
473    branch: &str,
474    main_branch: &str,
475    work_dir: Option<&Path>,
476    no_rebase: bool,
477) -> MergeCleanupResult {
478    // Checkout main branch
479    let mut cmd = Command::new("git");
480    cmd.args(["checkout", main_branch]);
481    if let Some(dir) = work_dir {
482        cmd.current_dir(dir);
483    }
484    let output = match cmd.output() {
485        Ok(o) => o,
486        Err(e) => {
487            return MergeCleanupResult {
488                success: false,
489                has_conflict: false,
490                error: Some(format!("Failed to checkout {}: {}", main_branch, e)),
491            };
492        }
493    };
494
495    if !output.status.success() {
496        let stderr = String::from_utf8_lossy(&output.stderr);
497        // Try to ensure we're on main branch before returning error
498        let _ = crate::git::ensure_on_main_branch(main_branch);
499        return MergeCleanupResult {
500            success: false,
501            has_conflict: false,
502            error: Some(format!("Failed to checkout {}: {}", main_branch, stderr)),
503        };
504    }
505
506    // Check if branch needs rebase (is behind main) and attempt rebase if needed
507    if !no_rebase {
508        match branch_is_behind_main(branch, main_branch, work_dir) {
509            Ok(true) => {
510                // Branch is behind main, attempt automatic rebase
511                println!(
512                    "Branch '{}' is behind {}, attempting automatic rebase...",
513                    branch, main_branch
514                );
515                match rebase_branch_onto_main(branch, main_branch, work_dir) {
516                    Ok(()) => {
517                        println!("Rebase succeeded, proceeding with merge...");
518                    }
519                    Err(e) => {
520                        // Rebase failed (conflicts), abort and preserve branch
521                        abort_rebase(main_branch, work_dir);
522                        return MergeCleanupResult {
523                            success: false,
524                            has_conflict: true,
525                            error: Some(format!("Auto-rebase failed due to conflicts: {}", e)),
526                        };
527                    }
528                }
529            }
530            Ok(false) => {
531                // Branch is not behind main, proceed normally
532            }
533            Err(e) => {
534                // Failed to check if branch is behind, log warning and proceed
535                eprintln!(
536                    "Warning: Failed to check if branch is behind {}: {}",
537                    main_branch, e
538                );
539            }
540        }
541    }
542
543    // Perform fast-forward merge
544    let mut cmd = Command::new("git");
545    cmd.args(["merge", "--ff-only", branch]);
546    if let Some(dir) = work_dir {
547        cmd.current_dir(dir);
548    }
549    let output = match cmd.output() {
550        Ok(o) => o,
551        Err(e) => {
552            return MergeCleanupResult {
553                success: false,
554                has_conflict: false,
555                error: Some(format!("Failed to perform merge: {}", e)),
556            };
557        }
558    };
559
560    if !output.status.success() {
561        let stderr = String::from_utf8_lossy(&output.stderr);
562        // Check if this was a conflict
563        let has_conflict = stderr.contains("CONFLICT") || stderr.contains("merge conflict");
564
565        // Abort merge if there was a conflict to preserve the branch
566        if has_conflict {
567            let mut cmd = Command::new("git");
568            cmd.args(["merge", "--abort"]);
569            if let Some(dir) = work_dir {
570                cmd.current_dir(dir);
571            }
572            let _ = cmd.output();
573        }
574
575        // Extract spec_id from branch name (strip prefix like "chant/" or "chant/frontend/")
576        let spec_id = branch.rsplit('/').next().unwrap_or(branch);
577        let error_msg = if has_conflict {
578            crate::merge_errors::merge_conflict(spec_id, branch, main_branch)
579        } else {
580            crate::merge_errors::fast_forward_conflict(spec_id, branch, main_branch, &stderr)
581        };
582        // Try to ensure we're on main branch before returning error
583        let _ = crate::git::ensure_on_main_branch(main_branch);
584        return MergeCleanupResult {
585            success: false,
586            has_conflict,
587            error: Some(error_msg),
588        };
589    }
590
591    // Delete the local branch after successful merge
592    let mut cmd = Command::new("git");
593    cmd.args(["branch", "-d", branch]);
594    if let Some(dir) = work_dir {
595        cmd.current_dir(dir);
596    }
597    let output = match cmd.output() {
598        Ok(o) => o,
599        Err(e) => {
600            return MergeCleanupResult {
601                success: false,
602                has_conflict: false,
603                error: Some(format!("Failed to delete branch: {}", e)),
604            };
605        }
606    };
607
608    if !output.status.success() {
609        let stderr = String::from_utf8_lossy(&output.stderr);
610        return MergeCleanupResult {
611            success: false,
612            has_conflict: false,
613            error: Some(format!("Failed to delete branch '{}': {}", branch, stderr)),
614        };
615    }
616
617    // Delete the remote branch (best-effort, don't fail if it doesn't exist)
618    let mut cmd = Command::new("git");
619    cmd.args(["push", "origin", "--delete", branch]);
620    if let Some(dir) = work_dir {
621        cmd.current_dir(dir);
622    }
623    // Ignore errors - remote branch may not exist or remote may be unavailable
624    let _ = cmd.output();
625
626    MergeCleanupResult {
627        success: true,
628        has_conflict: false,
629        error: None,
630    }
631}
632
633#[cfg(test)]
634mod tests {
635    use super::*;
636    use std::fs;
637    use std::process::Command as StdCommand;
638
639    /// Helper to initialize a temporary git repo for testing.
640    fn setup_test_repo(repo_dir: &Path) -> Result<()> {
641        fs::create_dir_all(repo_dir)?;
642
643        let output = StdCommand::new("git")
644            .args(["init", "-b", "main"])
645            .current_dir(repo_dir)
646            .output()
647            .context("Failed to run git init")?;
648        anyhow::ensure!(
649            output.status.success(),
650            "git init failed: {}",
651            String::from_utf8_lossy(&output.stderr)
652        );
653
654        let output = StdCommand::new("git")
655            .args(["config", "user.email", "test@example.com"])
656            .current_dir(repo_dir)
657            .output()
658            .context("Failed to run git config")?;
659        anyhow::ensure!(
660            output.status.success(),
661            "git config email failed: {}",
662            String::from_utf8_lossy(&output.stderr)
663        );
664
665        let output = StdCommand::new("git")
666            .args(["config", "user.name", "Test User"])
667            .current_dir(repo_dir)
668            .output()
669            .context("Failed to run git config")?;
670        anyhow::ensure!(
671            output.status.success(),
672            "git config name failed: {}",
673            String::from_utf8_lossy(&output.stderr)
674        );
675
676        // Create an initial commit
677        fs::write(repo_dir.join("README.md"), "# Test")?;
678
679        let output = StdCommand::new("git")
680            .args(["add", "."])
681            .current_dir(repo_dir)
682            .output()
683            .context("Failed to run git add")?;
684        anyhow::ensure!(
685            output.status.success(),
686            "git add failed: {}",
687            String::from_utf8_lossy(&output.stderr)
688        );
689
690        let output = StdCommand::new("git")
691            .args(["commit", "-m", "Initial commit"])
692            .current_dir(repo_dir)
693            .output()
694            .context("Failed to run git commit")?;
695        anyhow::ensure!(
696            output.status.success(),
697            "git commit failed: {}",
698            String::from_utf8_lossy(&output.stderr)
699        );
700
701        Ok(())
702    }
703
704    /// Helper to clean up test repos.
705    fn cleanup_test_repo(repo_dir: &Path) -> Result<()> {
706        if repo_dir.exists() {
707            fs::remove_dir_all(repo_dir)?;
708        }
709        Ok(())
710    }
711
712    #[test]
713    #[serial_test::serial]
714    fn test_create_worktree_branch_already_exists() -> Result<()> {
715        let repo_dir = PathBuf::from("/tmp/test-chant-repo-branch-exists");
716        cleanup_test_repo(&repo_dir)?;
717        setup_test_repo(&repo_dir)?;
718
719        let original_dir = std::env::current_dir()?;
720
721        let result = {
722            std::env::set_current_dir(&repo_dir).context("Failed to change to repo directory")?;
723
724            let spec_id = "test-spec-branch-exists";
725            let branch = "spec/test-spec-branch-exists";
726
727            // Create the branch first
728            let output = StdCommand::new("git")
729                .args(["branch", branch])
730                .current_dir(&repo_dir)
731                .output()?;
732            anyhow::ensure!(
733                output.status.success(),
734                "git branch failed: {}",
735                String::from_utf8_lossy(&output.stderr)
736            );
737
738            create_worktree(spec_id, branch, None)
739        };
740
741        // Always restore original directory
742        std::env::set_current_dir(&original_dir).context("Failed to restore original directory")?;
743        cleanup_test_repo(&repo_dir)?;
744
745        // Should now succeed - auto-clean existing branch and create fresh worktree
746        assert!(
747            result.is_ok(),
748            "create_worktree should auto-clean and succeed"
749        );
750        Ok(())
751    }
752
753    #[test]
754    #[serial_test::serial]
755    fn test_merge_and_cleanup_with_conflict_preserves_branch() -> Result<()> {
756        let repo_dir = PathBuf::from("/tmp/test-chant-repo-conflict-preserve");
757        cleanup_test_repo(&repo_dir)?;
758        setup_test_repo(&repo_dir)?;
759
760        let original_dir = std::env::current_dir()?;
761
762        let result = {
763            std::env::set_current_dir(&repo_dir).context("Failed to change to repo directory")?;
764
765            let branch = "feature/conflict-test";
766
767            // Create a feature branch that conflicts with main
768            let output = StdCommand::new("git")
769                .args(["branch", branch])
770                .current_dir(&repo_dir)
771                .output()?;
772            anyhow::ensure!(
773                output.status.success(),
774                "git branch failed: {}",
775                String::from_utf8_lossy(&output.stderr)
776            );
777
778            let output = StdCommand::new("git")
779                .args(["checkout", branch])
780                .current_dir(&repo_dir)
781                .output()?;
782            anyhow::ensure!(
783                output.status.success(),
784                "git checkout branch failed: {}",
785                String::from_utf8_lossy(&output.stderr)
786            );
787
788            fs::write(repo_dir.join("README.md"), "feature version")?;
789
790            let output = StdCommand::new("git")
791                .args(["add", "."])
792                .current_dir(&repo_dir)
793                .output()?;
794            anyhow::ensure!(
795                output.status.success(),
796                "git add failed: {}",
797                String::from_utf8_lossy(&output.stderr)
798            );
799
800            let output = StdCommand::new("git")
801                .args(["commit", "-m", "Modify README on feature"])
802                .current_dir(&repo_dir)
803                .output()?;
804            anyhow::ensure!(
805                output.status.success(),
806                "git commit feature failed: {}",
807                String::from_utf8_lossy(&output.stderr)
808            );
809
810            // Modify README on main differently
811            let output = StdCommand::new("git")
812                .args(["checkout", "main"])
813                .current_dir(&repo_dir)
814                .output()?;
815            anyhow::ensure!(
816                output.status.success(),
817                "git checkout main failed: {}",
818                String::from_utf8_lossy(&output.stderr)
819            );
820
821            fs::write(repo_dir.join("README.md"), "main version")?;
822
823            let output = StdCommand::new("git")
824                .args(["add", "."])
825                .current_dir(&repo_dir)
826                .output()?;
827            anyhow::ensure!(
828                output.status.success(),
829                "git add main failed: {}",
830                String::from_utf8_lossy(&output.stderr)
831            );
832
833            let output = StdCommand::new("git")
834                .args(["commit", "-m", "Modify README on main"])
835                .current_dir(&repo_dir)
836                .output()?;
837            anyhow::ensure!(
838                output.status.success(),
839                "git commit main failed: {}",
840                String::from_utf8_lossy(&output.stderr)
841            );
842
843            // Now call merge_and_cleanup with explicit repo directory
844            merge_and_cleanup_in_dir(branch, "main", Some(&repo_dir), false)
845        };
846
847        // Always restore original directory
848        std::env::set_current_dir(&original_dir).context("Failed to restore original directory")?;
849
850        // Check that branch still exists (wasn't deleted)
851        let branch_check = StdCommand::new("git")
852            .args(["rev-parse", "--verify", "feature/conflict-test"])
853            .current_dir(&repo_dir)
854            .output()?;
855
856        cleanup_test_repo(&repo_dir)?;
857
858        // Merge should fail (either due to conflict or non-ff situation)
859        assert!(!result.success);
860        // Branch should still exist
861        assert!(branch_check.status.success());
862        Ok(())
863    }
864
865    #[test]
866    #[serial_test::serial]
867    fn test_merge_and_cleanup_successful_merge() -> Result<()> {
868        let repo_dir = PathBuf::from("/tmp/test-chant-repo-merge-success");
869        cleanup_test_repo(&repo_dir)?;
870        setup_test_repo(&repo_dir)?;
871
872        let original_dir = std::env::current_dir()?;
873
874        let result = {
875            std::env::set_current_dir(&repo_dir).context("Failed to change to repo directory")?;
876
877            let branch = "feature/new-feature";
878
879            // Create a fast-forwardable feature branch
880            let output = StdCommand::new("git")
881                .args(["branch", branch])
882                .current_dir(&repo_dir)
883                .output()?;
884            anyhow::ensure!(
885                output.status.success(),
886                "git branch failed: {}",
887                String::from_utf8_lossy(&output.stderr)
888            );
889
890            let output = StdCommand::new("git")
891                .args(["checkout", branch])
892                .current_dir(&repo_dir)
893                .output()?;
894            anyhow::ensure!(
895                output.status.success(),
896                "git checkout failed: {}",
897                String::from_utf8_lossy(&output.stderr)
898            );
899
900            fs::write(repo_dir.join("feature.txt"), "feature content")?;
901
902            let output = StdCommand::new("git")
903                .args(["add", "."])
904                .current_dir(&repo_dir)
905                .output()?;
906            anyhow::ensure!(
907                output.status.success(),
908                "git add failed: {}",
909                String::from_utf8_lossy(&output.stderr)
910            );
911
912            let output = StdCommand::new("git")
913                .args(["commit", "-m", "Add feature"])
914                .current_dir(&repo_dir)
915                .output()?;
916            anyhow::ensure!(
917                output.status.success(),
918                "git commit failed: {}",
919                String::from_utf8_lossy(&output.stderr)
920            );
921
922            // Merge the branch with explicit repo directory
923            merge_and_cleanup_in_dir(branch, "main", Some(&repo_dir), false)
924        };
925
926        // Always restore original directory
927        std::env::set_current_dir(&original_dir).context("Failed to restore original directory")?;
928
929        // Check that branch no longer exists
930        let branch_check = StdCommand::new("git")
931            .args(["rev-parse", "--verify", "feature/new-feature"])
932            .current_dir(&repo_dir)
933            .output()?;
934
935        cleanup_test_repo(&repo_dir)?;
936
937        assert!(
938            result.success && result.error.is_none(),
939            "Merge result: {:?}",
940            result
941        );
942        // Branch should be deleted after merge
943        assert!(!branch_check.status.success());
944        Ok(())
945    }
946
947    #[test]
948    #[serial_test::serial]
949    fn test_create_worktree_success() -> Result<()> {
950        let repo_dir = PathBuf::from("/tmp/test-chant-repo-create-success");
951        cleanup_test_repo(&repo_dir)?;
952        setup_test_repo(&repo_dir)?;
953
954        let original_dir = std::env::current_dir()?;
955
956        let result = {
957            std::env::set_current_dir(&repo_dir).context("Failed to change to repo directory")?;
958
959            let spec_id = "test-spec-create-success";
960            let branch = "spec/test-spec-create-success";
961
962            create_worktree(spec_id, branch, None)
963        };
964
965        // Always restore original directory
966        std::env::set_current_dir(&original_dir).context("Failed to restore original directory")?;
967
968        // Verify worktree was created successfully
969        assert!(result.is_ok(), "create_worktree should succeed");
970        let worktree_path = result.unwrap();
971        assert!(worktree_path.exists(), "Worktree directory should exist");
972        assert_eq!(
973            worktree_path,
974            PathBuf::from("/tmp/chant-test-spec-create-success"),
975            "Worktree path should match expected format"
976        );
977
978        // Verify branch was created
979        let branch_check = StdCommand::new("git")
980            .args(["rev-parse", "--verify", "spec/test-spec-create-success"])
981            .current_dir(&repo_dir)
982            .output()?;
983        assert!(branch_check.status.success(), "Branch should exist");
984
985        // Cleanup
986        let _ = StdCommand::new("git")
987            .args(["worktree", "remove", worktree_path.to_str().unwrap()])
988            .current_dir(&repo_dir)
989            .output();
990        let _ = fs::remove_dir_all(&worktree_path);
991        cleanup_test_repo(&repo_dir)?;
992
993        Ok(())
994    }
995
996    #[test]
997    #[serial_test::serial]
998    fn test_copy_spec_to_worktree_success() -> Result<()> {
999        let repo_dir = PathBuf::from("/tmp/test-chant-repo-copy-spec");
1000        cleanup_test_repo(&repo_dir)?;
1001        setup_test_repo(&repo_dir)?;
1002
1003        let original_dir = std::env::current_dir()?;
1004
1005        let result: Result<PathBuf> = {
1006            std::env::set_current_dir(&repo_dir).context("Failed to change to repo directory")?;
1007
1008            let spec_id = "test-spec-copy";
1009            let branch = "spec/test-spec-copy";
1010
1011            // Create .chant/specs directory in main repo
1012            let specs_dir = repo_dir.join(".chant/specs");
1013            fs::create_dir_all(&specs_dir)?;
1014
1015            // Create a test spec file in main repo
1016            let spec_path = specs_dir.join(format!("{}.md", spec_id));
1017            fs::write(
1018                &spec_path,
1019                "---\ntype: code\nstatus: in_progress\n---\n# Test Spec\n",
1020            )?;
1021
1022            // Create worktree
1023            let worktree_path = create_worktree(spec_id, branch, None)?;
1024
1025            // Copy spec to worktree
1026            copy_spec_to_worktree(spec_id, &worktree_path)?;
1027
1028            // Verify spec was copied to worktree
1029            let worktree_spec_path = worktree_path
1030                .join(".chant/specs")
1031                .join(format!("{}.md", spec_id));
1032            assert!(
1033                worktree_spec_path.exists(),
1034                "Spec file should exist in worktree"
1035            );
1036
1037            let worktree_spec_content = fs::read_to_string(&worktree_spec_path)?;
1038            assert!(
1039                worktree_spec_content.contains("in_progress"),
1040                "Spec should contain in_progress status"
1041            );
1042
1043            // Verify the copy was committed
1044            let log_output = StdCommand::new("git")
1045                .args(["log", "--oneline", "-n", "1"])
1046                .current_dir(&worktree_path)
1047                .output()?;
1048            let log = String::from_utf8_lossy(&log_output.stdout);
1049            assert!(
1050                log.contains("update spec status"),
1051                "Commit message should mention spec update"
1052            );
1053
1054            Ok(worktree_path)
1055        };
1056
1057        // Always restore original directory
1058        std::env::set_current_dir(&original_dir).context("Failed to restore original directory")?;
1059
1060        // Cleanup
1061        if let Ok(worktree_path) = result {
1062            let _ = StdCommand::new("git")
1063                .args(["worktree", "remove", worktree_path.to_str().unwrap()])
1064                .current_dir(&repo_dir)
1065                .output();
1066            let _ = fs::remove_dir_all(&worktree_path);
1067        }
1068        cleanup_test_repo(&repo_dir)?;
1069
1070        Ok(())
1071    }
1072
1073    #[test]
1074    #[serial_test::serial]
1075    fn test_remove_worktree_success() -> Result<()> {
1076        let repo_dir = PathBuf::from("/tmp/test-chant-repo-remove-success");
1077        cleanup_test_repo(&repo_dir)?;
1078        setup_test_repo(&repo_dir)?;
1079
1080        let original_dir = std::env::current_dir()?;
1081
1082        let worktree_path = {
1083            std::env::set_current_dir(&repo_dir).context("Failed to change to repo directory")?;
1084
1085            let spec_id = "test-spec-remove";
1086            let branch = "spec/test-spec-remove";
1087
1088            // Create worktree
1089            let path = create_worktree(spec_id, branch, None)?;
1090            assert!(path.exists(), "Worktree should exist after creation");
1091            path
1092        };
1093
1094        // Always restore original directory before cleanup
1095        std::env::set_current_dir(&original_dir).context("Failed to restore original directory")?;
1096
1097        // Remove the worktree
1098        let result = remove_worktree(&worktree_path);
1099        assert!(result.is_ok(), "remove_worktree should succeed");
1100
1101        // Verify worktree directory no longer exists
1102        assert!(
1103            !worktree_path.exists(),
1104            "Worktree directory should be removed"
1105        );
1106
1107        cleanup_test_repo(&repo_dir)?;
1108        Ok(())
1109    }
1110
1111    #[test]
1112    fn test_remove_worktree_idempotent() -> Result<()> {
1113        let path = PathBuf::from("/tmp/nonexistent-worktree-12345");
1114
1115        // Try to remove a non-existent worktree - should succeed
1116        let result = remove_worktree(&path);
1117
1118        assert!(result.is_ok());
1119        Ok(())
1120    }
1121
1122    #[test]
1123    fn test_worktree_path_for_spec() {
1124        let path = worktree_path_for_spec("2026-01-27-001-abc", None);
1125        assert_eq!(path, PathBuf::from("/tmp/chant-2026-01-27-001-abc"));
1126
1127        let path = worktree_path_for_spec("2026-01-27-001-abc", Some("myproject"));
1128        assert_eq!(
1129            path,
1130            PathBuf::from("/tmp/chant-myproject-2026-01-27-001-abc")
1131        );
1132
1133        let path = worktree_path_for_spec("2026-01-27-001-abc", Some(""));
1134        assert_eq!(path, PathBuf::from("/tmp/chant-2026-01-27-001-abc"));
1135    }
1136
1137    #[test]
1138    fn test_get_active_worktree_nonexistent() {
1139        // Test with a spec ID that definitely doesn't have a worktree
1140        let result = get_active_worktree("nonexistent-spec-12345", None);
1141        assert!(result.is_none());
1142    }
1143
1144    #[test]
1145    #[serial_test::serial]
1146    fn test_commit_in_worktree() -> Result<()> {
1147        let repo_dir = PathBuf::from("/tmp/test-chant-commit-in-worktree");
1148        cleanup_test_repo(&repo_dir)?;
1149        setup_test_repo(&repo_dir)?;
1150
1151        // Create a new file
1152        fs::write(repo_dir.join("new_file.txt"), "content")?;
1153
1154        // Commit the changes
1155        let result = commit_in_worktree(&repo_dir, "Test commit message");
1156
1157        cleanup_test_repo(&repo_dir)?;
1158
1159        assert!(result.is_ok());
1160        let hash = result.unwrap();
1161        // Commit hash should be a 40-character hex string
1162        assert_eq!(hash.len(), 40);
1163        assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
1164
1165        Ok(())
1166    }
1167
1168    #[test]
1169    #[serial_test::serial]
1170    fn test_commit_in_worktree_no_changes() -> Result<()> {
1171        let repo_dir = PathBuf::from("/tmp/test-chant-commit-no-changes");
1172        cleanup_test_repo(&repo_dir)?;
1173        setup_test_repo(&repo_dir)?;
1174
1175        // Don't make any changes, just try to commit
1176        let result = commit_in_worktree(&repo_dir, "Empty commit");
1177
1178        cleanup_test_repo(&repo_dir)?;
1179
1180        // Should still succeed (returns HEAD)
1181        assert!(result.is_ok());
1182        let hash = result.unwrap();
1183        assert_eq!(hash.len(), 40);
1184
1185        Ok(())
1186    }
1187
1188    #[test]
1189    #[serial_test::serial]
1190    fn test_has_uncommitted_changes_clean() -> Result<()> {
1191        let repo_dir = PathBuf::from("/tmp/test-chant-uncommitted-clean");
1192        cleanup_test_repo(&repo_dir)?;
1193        setup_test_repo(&repo_dir)?;
1194
1195        // Check for uncommitted changes on a clean repo
1196        let has_changes = has_uncommitted_changes(&repo_dir)?;
1197
1198        cleanup_test_repo(&repo_dir)?;
1199
1200        assert!(
1201            !has_changes,
1202            "Clean repo should have no uncommitted changes"
1203        );
1204
1205        Ok(())
1206    }
1207
1208    #[test]
1209    #[serial_test::serial]
1210    fn test_has_uncommitted_changes_with_unstaged() -> Result<()> {
1211        let repo_dir = PathBuf::from("/tmp/test-chant-uncommitted-unstaged");
1212        cleanup_test_repo(&repo_dir)?;
1213        setup_test_repo(&repo_dir)?;
1214
1215        // Create a new file (unstaged)
1216        fs::write(repo_dir.join("newfile.txt"), "content")?;
1217
1218        // Check for uncommitted changes
1219        let has_changes = has_uncommitted_changes(&repo_dir)?;
1220
1221        cleanup_test_repo(&repo_dir)?;
1222
1223        assert!(has_changes, "Repo with unstaged changes should return true");
1224
1225        Ok(())
1226    }
1227
1228    #[test]
1229    #[serial_test::serial]
1230    fn test_has_uncommitted_changes_with_staged() -> Result<()> {
1231        let repo_dir = PathBuf::from("/tmp/test-chant-uncommitted-staged");
1232        cleanup_test_repo(&repo_dir)?;
1233        setup_test_repo(&repo_dir)?;
1234
1235        // Create a new file and stage it
1236        fs::write(repo_dir.join("newfile.txt"), "content")?;
1237        let output = StdCommand::new("git")
1238            .args(["add", "newfile.txt"])
1239            .current_dir(&repo_dir)
1240            .output()?;
1241        assert!(output.status.success());
1242
1243        // Check for uncommitted changes
1244        let has_changes = has_uncommitted_changes(&repo_dir)?;
1245
1246        cleanup_test_repo(&repo_dir)?;
1247
1248        assert!(has_changes, "Repo with staged changes should return true");
1249
1250        Ok(())
1251    }
1252}