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