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