Skip to main content

chant/
worktree.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/// Commits changes in a worktree.
35///
36/// # Arguments
37///
38/// * `worktree_path` - Path to the worktree
39/// * `message` - Commit message
40///
41/// # Returns
42///
43/// Ok(commit_hash) if commit was successful, Err if failed.
44pub fn commit_in_worktree(worktree_path: &Path, message: &str) -> Result<String> {
45    // Stage all changes
46    let output = Command::new("git")
47        .args(["add", "-A"])
48        .current_dir(worktree_path)
49        .output()
50        .context("Failed to stage changes in worktree")?;
51
52    if !output.status.success() {
53        let stderr = String::from_utf8_lossy(&output.stderr);
54        anyhow::bail!("Failed to stage changes: {}", stderr);
55    }
56
57    // Check if there are any changes to commit
58    let output = Command::new("git")
59        .args(["status", "--porcelain"])
60        .current_dir(worktree_path)
61        .output()
62        .context("Failed to check git status in worktree")?;
63
64    let status_output = String::from_utf8_lossy(&output.stdout);
65    if status_output.trim().is_empty() {
66        // No changes to commit, return the current HEAD
67        let output = Command::new("git")
68            .args(["rev-parse", "HEAD"])
69            .current_dir(worktree_path)
70            .output()
71            .context("Failed to get HEAD commit")?;
72
73        let hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
74        return Ok(hash);
75    }
76
77    // Commit the changes
78    let output = Command::new("git")
79        .args(["commit", "-m", message])
80        .current_dir(worktree_path)
81        .output()
82        .context("Failed to commit changes in worktree")?;
83
84    if !output.status.success() {
85        let stderr = String::from_utf8_lossy(&output.stderr);
86        anyhow::bail!("Failed to commit: {}", stderr);
87    }
88
89    // Get the commit hash
90    let output = Command::new("git")
91        .args(["rev-parse", "HEAD"])
92        .current_dir(worktree_path)
93        .output()
94        .context("Failed to get commit hash")?;
95
96    let hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
97    Ok(hash)
98}
99
100/// Creates a new git worktree for the given spec.
101///
102/// # Arguments
103///
104/// * `spec_id` - The specification ID (used to create unique worktree paths)
105/// * `branch` - The branch name to create in the worktree
106///
107/// # Returns
108///
109/// The absolute path to the created worktree directory.
110///
111/// # Errors
112///
113/// Returns an error if:
114/// - The branch already exists
115/// - Git worktree creation fails (e.g., corrupted repo)
116/// - Directory creation fails
117pub fn create_worktree(spec_id: &str, branch: &str) -> Result<PathBuf> {
118    let worktree_path = PathBuf::from(format!("/tmp/chant-{}", spec_id));
119
120    // Check if branch already exists
121    let output = Command::new("git")
122        .args(["rev-parse", "--verify", branch])
123        .output()
124        .context("Failed to check if branch exists")?;
125
126    if output.status.success() {
127        anyhow::bail!("Branch '{}' already exists", branch);
128    }
129
130    // Create the worktree with the new branch
131    let output = Command::new("git")
132        .args([
133            "worktree",
134            "add",
135            "-b",
136            branch,
137            &worktree_path.to_string_lossy(),
138        ])
139        .output()
140        .context("Failed to create git worktree")?;
141
142    if !output.status.success() {
143        let stderr = String::from_utf8_lossy(&output.stderr);
144        anyhow::bail!("Failed to create worktree: {}", stderr);
145    }
146
147    Ok(worktree_path)
148}
149
150/// Copies the spec file from the main working directory to a worktree.
151///
152/// This ensures the worktree has the current spec state (e.g., in_progress status)
153/// even when the change hasn't been committed to main yet.
154///
155/// # Arguments
156///
157/// * `spec_id` - The specification ID
158/// * `worktree_path` - The path to the worktree
159///
160/// # Returns
161///
162/// Ok(()) if the spec file was successfully copied and committed.
163///
164/// # Errors
165///
166/// Returns an error if:
167/// - The spec file doesn't exist in the main working directory
168/// - The copy operation fails
169/// - The commit fails
170pub fn copy_spec_to_worktree(spec_id: &str, worktree_path: &Path) -> Result<()> {
171    // Use absolute path from git root to avoid issues when current directory changes
172    let git_root = std::env::current_dir().context("Failed to get current directory")?;
173    let main_spec_path = git_root
174        .join(".chant/specs")
175        .join(format!("{}.md", spec_id));
176    let worktree_specs_dir = worktree_path.join(".chant/specs");
177    let worktree_spec_path = worktree_specs_dir.join(format!("{}.md", spec_id));
178
179    // Ensure the .chant/specs directory exists in the worktree
180    std::fs::create_dir_all(&worktree_specs_dir).context(format!(
181        "Failed to create specs directory in worktree: {:?}",
182        worktree_specs_dir
183    ))?;
184
185    // Copy the spec file from main to worktree
186    std::fs::copy(&main_spec_path, &worktree_spec_path).context(format!(
187        "Failed to copy spec file to worktree: {:?}",
188        worktree_spec_path
189    ))?;
190
191    // Commit the updated spec in the worktree
192    commit_in_worktree(
193        worktree_path,
194        &format!("chant({}): update spec status to in_progress", spec_id),
195    )?;
196
197    Ok(())
198}
199
200/// Removes a git worktree and cleans up its directory.
201///
202/// This function is idempotent - it does not error if the worktree is already gone.
203///
204/// # Arguments
205///
206/// * `path` - The path to the worktree to remove
207///
208/// # Returns
209///
210/// Ok(()) if the worktree was successfully removed or didn't exist.
211pub fn remove_worktree(path: &Path) -> Result<()> {
212    // Try to remove the git worktree entry
213    let _output = Command::new("git")
214        .args(["worktree", "remove", &path.to_string_lossy()])
215        .output()
216        .context("Failed to run git worktree remove")?;
217
218    // Even if git worktree remove fails, try to clean up the directory
219    if path.exists() {
220        std::fs::remove_dir_all(path)
221            .context(format!("Failed to remove worktree directory at {:?}", path))?;
222    }
223
224    Ok(())
225}
226
227/// Result of a merge operation
228#[derive(Debug, Clone)]
229pub struct MergeCleanupResult {
230    pub success: bool,
231    pub has_conflict: bool,
232    pub error: Option<String>,
233}
234
235/// Checks if a branch is behind main (main has commits not in branch).
236///
237/// # Arguments
238///
239/// * `branch` - The branch name to check
240/// * `work_dir` - Optional working directory for the git command
241///
242/// # Returns
243///
244/// Ok(true) if main has commits not in branch, Ok(false) otherwise.
245fn branch_is_behind_main(branch: &str, work_dir: Option<&Path>) -> Result<bool> {
246    let mut cmd = Command::new("git");
247    cmd.args(["rev-list", "--count", &format!("{}..main", branch)]);
248    if let Some(dir) = work_dir {
249        cmd.current_dir(dir);
250    }
251    let output = cmd
252        .output()
253        .context("Failed to check if branch is behind main")?;
254
255    if !output.status.success() {
256        let stderr = String::from_utf8_lossy(&output.stderr);
257        anyhow::bail!("Failed to check branch status: {}", stderr);
258    }
259
260    let count_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
261    let count: i32 = count_str
262        .parse()
263        .context(format!("Failed to parse commit count: {}", count_str))?;
264    Ok(count > 0)
265}
266
267/// Rebases a branch onto main.
268///
269/// # Arguments
270///
271/// * `branch` - The branch name to rebase
272/// * `work_dir` - Optional working directory for the git command
273///
274/// # Returns
275///
276/// Ok(()) if rebase succeeded, Err if rebase had conflicts or failed.
277fn rebase_branch_onto_main(branch: &str, work_dir: Option<&Path>) -> Result<()> {
278    // Checkout the branch
279    let mut cmd = Command::new("git");
280    cmd.args(["checkout", branch]);
281    if let Some(dir) = work_dir {
282        cmd.current_dir(dir);
283    }
284    let output = cmd
285        .output()
286        .context("Failed to checkout branch for rebase")?;
287
288    if !output.status.success() {
289        let stderr = String::from_utf8_lossy(&output.stderr);
290        anyhow::bail!("Failed to checkout branch: {}", stderr);
291    }
292
293    // Rebase onto main
294    let mut cmd = Command::new("git");
295    cmd.args(["rebase", "main"]);
296    if let Some(dir) = work_dir {
297        cmd.current_dir(dir);
298    }
299    let output = cmd.output().context("Failed to rebase onto main")?;
300
301    if !output.status.success() {
302        anyhow::bail!("Rebase had conflicts");
303    }
304
305    // Return to main branch
306    let mut cmd = Command::new("git");
307    cmd.args(["checkout", "main"]);
308    if let Some(dir) = work_dir {
309        cmd.current_dir(dir);
310    }
311    let output = cmd
312        .output()
313        .context("Failed to checkout main after rebase")?;
314
315    if !output.status.success() {
316        let stderr = String::from_utf8_lossy(&output.stderr);
317        anyhow::bail!("Failed to checkout main: {}", stderr);
318    }
319
320    Ok(())
321}
322
323/// Aborts a rebase in progress and returns to main branch.
324///
325/// # Arguments
326///
327/// * `work_dir` - Optional working directory for the git command
328///
329/// This function is best-effort and does not return errors.
330fn abort_rebase(work_dir: Option<&Path>) {
331    // Abort the rebase
332    let mut cmd = Command::new("git");
333    cmd.args(["rebase", "--abort"]);
334    if let Some(dir) = work_dir {
335        cmd.current_dir(dir);
336    }
337    let _ = cmd.output();
338
339    // Try to ensure we're on main branch
340    let mut cmd = Command::new("git");
341    cmd.args(["checkout", "main"]);
342    if let Some(dir) = work_dir {
343        cmd.current_dir(dir);
344    }
345    let _ = cmd.output();
346}
347
348/// Merges a branch to main and cleans up.
349///
350/// # Arguments
351///
352/// * `branch` - The branch name to merge
353/// * `no_rebase` - If true, skip automatic rebase even if branch is behind
354///
355/// # Returns
356///
357/// Returns a MergeCleanupResult indicating:
358/// - success: true if merge succeeded and branch was deleted
359/// - has_conflict: true if merge failed due to conflicts
360/// - error: optional error message
361///
362/// If there are merge conflicts, the branch is preserved for manual resolution.
363pub fn merge_and_cleanup(branch: &str, no_rebase: bool) -> MergeCleanupResult {
364    merge_and_cleanup_in_dir(branch, None, no_rebase)
365}
366
367/// Internal function that merges a branch to main with optional working directory.
368fn merge_and_cleanup_in_dir(
369    branch: &str,
370    work_dir: Option<&Path>,
371    no_rebase: bool,
372) -> MergeCleanupResult {
373    // Checkout main branch
374    let mut cmd = Command::new("git");
375    cmd.args(["checkout", "main"]);
376    if let Some(dir) = work_dir {
377        cmd.current_dir(dir);
378    }
379    let output = match cmd.output() {
380        Ok(o) => o,
381        Err(e) => {
382            return MergeCleanupResult {
383                success: false,
384                has_conflict: false,
385                error: Some(format!("Failed to checkout main: {}", e)),
386            };
387        }
388    };
389
390    if !output.status.success() {
391        let stderr = String::from_utf8_lossy(&output.stderr);
392        // Try to ensure we're on main branch before returning error
393        let _ = crate::git::ensure_on_main_branch("main");
394        return MergeCleanupResult {
395            success: false,
396            has_conflict: false,
397            error: Some(format!("Failed to checkout main: {}", stderr)),
398        };
399    }
400
401    // Check if branch needs rebase (is behind main) and attempt rebase if needed
402    if !no_rebase {
403        match branch_is_behind_main(branch, work_dir) {
404            Ok(true) => {
405                // Branch is behind main, attempt automatic rebase
406                println!(
407                    "Branch '{}' is behind main, attempting automatic rebase...",
408                    branch
409                );
410                match rebase_branch_onto_main(branch, work_dir) {
411                    Ok(()) => {
412                        println!("Rebase succeeded, proceeding with merge...");
413                    }
414                    Err(e) => {
415                        // Rebase failed (conflicts), abort and preserve branch
416                        abort_rebase(work_dir);
417                        return MergeCleanupResult {
418                            success: false,
419                            has_conflict: true,
420                            error: Some(format!("Auto-rebase failed due to conflicts: {}", e)),
421                        };
422                    }
423                }
424            }
425            Ok(false) => {
426                // Branch is not behind main, proceed normally
427            }
428            Err(e) => {
429                // Failed to check if branch is behind, log warning and proceed
430                eprintln!("Warning: Failed to check if branch is behind main: {}", e);
431            }
432        }
433    }
434
435    // Perform fast-forward merge
436    let mut cmd = Command::new("git");
437    cmd.args(["merge", "--ff-only", branch]);
438    if let Some(dir) = work_dir {
439        cmd.current_dir(dir);
440    }
441    let output = match cmd.output() {
442        Ok(o) => o,
443        Err(e) => {
444            return MergeCleanupResult {
445                success: false,
446                has_conflict: false,
447                error: Some(format!("Failed to perform merge: {}", e)),
448            };
449        }
450    };
451
452    if !output.status.success() {
453        let stderr = String::from_utf8_lossy(&output.stderr);
454        // Check if this was a conflict
455        let has_conflict = stderr.contains("CONFLICT") || stderr.contains("merge conflict");
456
457        // Abort merge if there was a conflict to preserve the branch
458        if has_conflict {
459            let mut cmd = Command::new("git");
460            cmd.args(["merge", "--abort"]);
461            if let Some(dir) = work_dir {
462                cmd.current_dir(dir);
463            }
464            let _ = cmd.output();
465        }
466
467        // Extract spec_id from branch name (strip "chant/" prefix if present)
468        let spec_id = branch.trim_start_matches("chant/");
469        let error_msg = if has_conflict {
470            crate::merge_errors::merge_conflict(spec_id, branch, "main")
471        } else {
472            crate::merge_errors::fast_forward_conflict(spec_id, branch, "main", &stderr)
473        };
474        // Try to ensure we're on main branch before returning error
475        let _ = crate::git::ensure_on_main_branch("main");
476        return MergeCleanupResult {
477            success: false,
478            has_conflict,
479            error: Some(error_msg),
480        };
481    }
482
483    // Delete the local branch after successful merge
484    let mut cmd = Command::new("git");
485    cmd.args(["branch", "-d", branch]);
486    if let Some(dir) = work_dir {
487        cmd.current_dir(dir);
488    }
489    let output = match cmd.output() {
490        Ok(o) => o,
491        Err(e) => {
492            return MergeCleanupResult {
493                success: false,
494                has_conflict: false,
495                error: Some(format!("Failed to delete branch: {}", e)),
496            };
497        }
498    };
499
500    if !output.status.success() {
501        let stderr = String::from_utf8_lossy(&output.stderr);
502        return MergeCleanupResult {
503            success: false,
504            has_conflict: false,
505            error: Some(format!("Failed to delete branch '{}': {}", branch, stderr)),
506        };
507    }
508
509    // Delete the remote branch (best-effort, don't fail if it doesn't exist)
510    let mut cmd = Command::new("git");
511    cmd.args(["push", "origin", "--delete", branch]);
512    if let Some(dir) = work_dir {
513        cmd.current_dir(dir);
514    }
515    // Ignore errors - remote branch may not exist or remote may be unavailable
516    let _ = cmd.output();
517
518    MergeCleanupResult {
519        success: true,
520        has_conflict: false,
521        error: None,
522    }
523}
524
525#[cfg(test)]
526mod tests {
527    use super::*;
528    use std::fs;
529    use std::process::Command as StdCommand;
530
531    /// Helper to initialize a temporary git repo for testing.
532    fn setup_test_repo(repo_dir: &Path) -> Result<()> {
533        fs::create_dir_all(repo_dir)?;
534
535        let output = StdCommand::new("git")
536            .args(["init", "-b", "main"])
537            .current_dir(repo_dir)
538            .output()
539            .context("Failed to run git init")?;
540        anyhow::ensure!(
541            output.status.success(),
542            "git init failed: {}",
543            String::from_utf8_lossy(&output.stderr)
544        );
545
546        let output = StdCommand::new("git")
547            .args(["config", "user.email", "test@example.com"])
548            .current_dir(repo_dir)
549            .output()
550            .context("Failed to run git config")?;
551        anyhow::ensure!(
552            output.status.success(),
553            "git config email failed: {}",
554            String::from_utf8_lossy(&output.stderr)
555        );
556
557        let output = StdCommand::new("git")
558            .args(["config", "user.name", "Test User"])
559            .current_dir(repo_dir)
560            .output()
561            .context("Failed to run git config")?;
562        anyhow::ensure!(
563            output.status.success(),
564            "git config name failed: {}",
565            String::from_utf8_lossy(&output.stderr)
566        );
567
568        // Create an initial commit
569        fs::write(repo_dir.join("README.md"), "# Test")?;
570
571        let output = StdCommand::new("git")
572            .args(["add", "."])
573            .current_dir(repo_dir)
574            .output()
575            .context("Failed to run git add")?;
576        anyhow::ensure!(
577            output.status.success(),
578            "git add failed: {}",
579            String::from_utf8_lossy(&output.stderr)
580        );
581
582        let output = StdCommand::new("git")
583            .args(["commit", "-m", "Initial commit"])
584            .current_dir(repo_dir)
585            .output()
586            .context("Failed to run git commit")?;
587        anyhow::ensure!(
588            output.status.success(),
589            "git commit failed: {}",
590            String::from_utf8_lossy(&output.stderr)
591        );
592
593        Ok(())
594    }
595
596    /// Helper to clean up test repos.
597    fn cleanup_test_repo(repo_dir: &Path) -> Result<()> {
598        if repo_dir.exists() {
599            fs::remove_dir_all(repo_dir)?;
600        }
601        Ok(())
602    }
603
604    #[test]
605    #[serial_test::serial]
606    fn test_create_worktree_branch_already_exists() -> Result<()> {
607        let repo_dir = PathBuf::from("/tmp/test-chant-repo-branch-exists");
608        cleanup_test_repo(&repo_dir)?;
609        setup_test_repo(&repo_dir)?;
610
611        let original_dir = std::env::current_dir()?;
612
613        let result = {
614            std::env::set_current_dir(&repo_dir).context("Failed to change to repo directory")?;
615
616            let spec_id = "test-spec-branch-exists";
617            let branch = "spec/test-spec-branch-exists";
618
619            // Create the branch first
620            let output = StdCommand::new("git")
621                .args(["branch", branch])
622                .current_dir(&repo_dir)
623                .output()?;
624            anyhow::ensure!(
625                output.status.success(),
626                "git branch failed: {}",
627                String::from_utf8_lossy(&output.stderr)
628            );
629
630            create_worktree(spec_id, branch)
631        };
632
633        // Always restore original directory
634        std::env::set_current_dir(&original_dir).context("Failed to restore original directory")?;
635        cleanup_test_repo(&repo_dir)?;
636
637        assert!(result.is_err());
638        assert!(result.unwrap_err().to_string().contains("already exists"));
639        Ok(())
640    }
641
642    #[test]
643    #[serial_test::serial]
644    fn test_merge_and_cleanup_with_conflict_preserves_branch() -> Result<()> {
645        let repo_dir = PathBuf::from("/tmp/test-chant-repo-conflict-preserve");
646        cleanup_test_repo(&repo_dir)?;
647        setup_test_repo(&repo_dir)?;
648
649        let original_dir = std::env::current_dir()?;
650
651        let result = {
652            std::env::set_current_dir(&repo_dir).context("Failed to change to repo directory")?;
653
654            let branch = "feature/conflict-test";
655
656            // Create a feature branch that conflicts with main
657            let output = StdCommand::new("git")
658                .args(["branch", branch])
659                .current_dir(&repo_dir)
660                .output()?;
661            anyhow::ensure!(
662                output.status.success(),
663                "git branch failed: {}",
664                String::from_utf8_lossy(&output.stderr)
665            );
666
667            let output = StdCommand::new("git")
668                .args(["checkout", branch])
669                .current_dir(&repo_dir)
670                .output()?;
671            anyhow::ensure!(
672                output.status.success(),
673                "git checkout branch failed: {}",
674                String::from_utf8_lossy(&output.stderr)
675            );
676
677            fs::write(repo_dir.join("README.md"), "feature version")?;
678
679            let output = StdCommand::new("git")
680                .args(["add", "."])
681                .current_dir(&repo_dir)
682                .output()?;
683            anyhow::ensure!(
684                output.status.success(),
685                "git add failed: {}",
686                String::from_utf8_lossy(&output.stderr)
687            );
688
689            let output = StdCommand::new("git")
690                .args(["commit", "-m", "Modify README on feature"])
691                .current_dir(&repo_dir)
692                .output()?;
693            anyhow::ensure!(
694                output.status.success(),
695                "git commit feature failed: {}",
696                String::from_utf8_lossy(&output.stderr)
697            );
698
699            // Modify README on main differently
700            let output = StdCommand::new("git")
701                .args(["checkout", "main"])
702                .current_dir(&repo_dir)
703                .output()?;
704            anyhow::ensure!(
705                output.status.success(),
706                "git checkout main failed: {}",
707                String::from_utf8_lossy(&output.stderr)
708            );
709
710            fs::write(repo_dir.join("README.md"), "main version")?;
711
712            let output = StdCommand::new("git")
713                .args(["add", "."])
714                .current_dir(&repo_dir)
715                .output()?;
716            anyhow::ensure!(
717                output.status.success(),
718                "git add main failed: {}",
719                String::from_utf8_lossy(&output.stderr)
720            );
721
722            let output = StdCommand::new("git")
723                .args(["commit", "-m", "Modify README on main"])
724                .current_dir(&repo_dir)
725                .output()?;
726            anyhow::ensure!(
727                output.status.success(),
728                "git commit main failed: {}",
729                String::from_utf8_lossy(&output.stderr)
730            );
731
732            // Now call merge_and_cleanup with explicit repo directory
733            merge_and_cleanup_in_dir(branch, Some(&repo_dir), false)
734        };
735
736        // Always restore original directory
737        std::env::set_current_dir(&original_dir).context("Failed to restore original directory")?;
738
739        // Check that branch still exists (wasn't deleted)
740        let branch_check = StdCommand::new("git")
741            .args(["rev-parse", "--verify", "feature/conflict-test"])
742            .current_dir(&repo_dir)
743            .output()?;
744
745        cleanup_test_repo(&repo_dir)?;
746
747        // Merge should fail (either due to conflict or non-ff situation)
748        assert!(!result.success);
749        // Branch should still exist
750        assert!(branch_check.status.success());
751        Ok(())
752    }
753
754    #[test]
755    #[serial_test::serial]
756    fn test_merge_and_cleanup_successful_merge() -> Result<()> {
757        let repo_dir = PathBuf::from("/tmp/test-chant-repo-merge-success");
758        cleanup_test_repo(&repo_dir)?;
759        setup_test_repo(&repo_dir)?;
760
761        let original_dir = std::env::current_dir()?;
762
763        let result = {
764            std::env::set_current_dir(&repo_dir).context("Failed to change to repo directory")?;
765
766            let branch = "feature/new-feature";
767
768            // Create a fast-forwardable feature branch
769            let output = StdCommand::new("git")
770                .args(["branch", branch])
771                .current_dir(&repo_dir)
772                .output()?;
773            anyhow::ensure!(
774                output.status.success(),
775                "git branch failed: {}",
776                String::from_utf8_lossy(&output.stderr)
777            );
778
779            let output = StdCommand::new("git")
780                .args(["checkout", branch])
781                .current_dir(&repo_dir)
782                .output()?;
783            anyhow::ensure!(
784                output.status.success(),
785                "git checkout failed: {}",
786                String::from_utf8_lossy(&output.stderr)
787            );
788
789            fs::write(repo_dir.join("feature.txt"), "feature content")?;
790
791            let output = StdCommand::new("git")
792                .args(["add", "."])
793                .current_dir(&repo_dir)
794                .output()?;
795            anyhow::ensure!(
796                output.status.success(),
797                "git add failed: {}",
798                String::from_utf8_lossy(&output.stderr)
799            );
800
801            let output = StdCommand::new("git")
802                .args(["commit", "-m", "Add feature"])
803                .current_dir(&repo_dir)
804                .output()?;
805            anyhow::ensure!(
806                output.status.success(),
807                "git commit failed: {}",
808                String::from_utf8_lossy(&output.stderr)
809            );
810
811            // Merge the branch with explicit repo directory
812            merge_and_cleanup_in_dir(branch, Some(&repo_dir), false)
813        };
814
815        // Always restore original directory
816        std::env::set_current_dir(&original_dir).context("Failed to restore original directory")?;
817
818        // Check that branch no longer exists
819        let branch_check = StdCommand::new("git")
820            .args(["rev-parse", "--verify", "feature/new-feature"])
821            .current_dir(&repo_dir)
822            .output()?;
823
824        cleanup_test_repo(&repo_dir)?;
825
826        assert!(
827            result.success && result.error.is_none(),
828            "Merge result: {:?}",
829            result
830        );
831        // Branch should be deleted after merge
832        assert!(!branch_check.status.success());
833        Ok(())
834    }
835
836    #[test]
837    fn test_remove_worktree_idempotent() -> Result<()> {
838        let path = PathBuf::from("/tmp/nonexistent-worktree-12345");
839
840        // Try to remove a non-existent worktree - should succeed
841        let result = remove_worktree(&path);
842
843        assert!(result.is_ok());
844        Ok(())
845    }
846
847    #[test]
848    fn test_worktree_path_for_spec() {
849        let path = worktree_path_for_spec("2026-01-27-001-abc");
850        assert_eq!(path, PathBuf::from("/tmp/chant-2026-01-27-001-abc"));
851    }
852
853    #[test]
854    fn test_get_active_worktree_nonexistent() {
855        // Test with a spec ID that definitely doesn't have a worktree
856        let result = get_active_worktree("nonexistent-spec-12345");
857        assert!(result.is_none());
858    }
859
860    #[test]
861    #[serial_test::serial]
862    fn test_commit_in_worktree() -> Result<()> {
863        let repo_dir = PathBuf::from("/tmp/test-chant-commit-in-worktree");
864        cleanup_test_repo(&repo_dir)?;
865        setup_test_repo(&repo_dir)?;
866
867        // Create a new file
868        fs::write(repo_dir.join("new_file.txt"), "content")?;
869
870        // Commit the changes
871        let result = commit_in_worktree(&repo_dir, "Test commit message");
872
873        cleanup_test_repo(&repo_dir)?;
874
875        assert!(result.is_ok());
876        let hash = result.unwrap();
877        // Commit hash should be a 40-character hex string
878        assert_eq!(hash.len(), 40);
879        assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
880
881        Ok(())
882    }
883
884    #[test]
885    #[serial_test::serial]
886    fn test_commit_in_worktree_no_changes() -> Result<()> {
887        let repo_dir = PathBuf::from("/tmp/test-chant-commit-no-changes");
888        cleanup_test_repo(&repo_dir)?;
889        setup_test_repo(&repo_dir)?;
890
891        // Don't make any changes, just try to commit
892        let result = commit_in_worktree(&repo_dir, "Empty commit");
893
894        cleanup_test_repo(&repo_dir)?;
895
896        // Should still succeed (returns HEAD)
897        assert!(result.is_ok());
898        let hash = result.unwrap();
899        assert_eq!(hash.len(), 40);
900
901        Ok(())
902    }
903}