Skip to main content

gba_core/
git.rs

1//! Centralized Git and GitHub CLI operations.
2//!
3//! This module provides a clean API for git and GitHub CLI operations,
4//! centralizing the scattered `std::process::Command` calls from across
5//! the codebase.
6//!
7//! # Overview
8//!
9//! The module provides two main types:
10//!
11//! - [`GitRepo`] - Wrapper for git repository operations (branch, worktree, commit)
12//! - [`GitHub`] - Wrapper for GitHub CLI operations (PR status)
13//!
14//! # Example
15//!
16//! ```no_run
17//! use gba_core::git::{GitRepo, GitHub, PrStatus};
18//!
19//! let repo = GitRepo::new("/path/to/repo");
20//!
21//! // Get current branch
22//! let branch = repo.current_branch().unwrap();
23//!
24//! // Stage and commit
25//! repo.add(".").unwrap();
26//! repo.commit("feat: add new feature").unwrap();
27//! repo.push().unwrap();
28//!
29//! // Check PR status
30//! let gh = GitHub::new("/path/to/repo");
31//! if let Some(status) = gh.pr_status(&branch).unwrap() {
32//!     match status {
33//!         PrStatus::Open => println!("PR is open"),
34//!         PrStatus::Merged => println!("PR was merged"),
35//!         PrStatus::Closed => println!("PR was closed"),
36//!     }
37//! }
38//! ```
39
40use std::path::{Path, PathBuf};
41use std::process::Command;
42
43use crate::{EngineError, Result};
44
45/// PR status from GitHub CLI.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum PrStatus {
48    /// PR is open.
49    Open,
50    /// PR has been merged.
51    Merged,
52    /// PR has been closed without merging.
53    Closed,
54}
55
56/// Git repository operations wrapper.
57///
58/// This struct provides a clean API for common git operations,
59/// executing them via `std::process::Command` internally.
60#[derive(Debug)]
61pub struct GitRepo {
62    workdir: PathBuf,
63}
64
65impl GitRepo {
66    /// Create a new `GitRepo` for the given working directory.
67    ///
68    /// # Arguments
69    ///
70    /// * `workdir` - Path to the git repository working directory
71    ///
72    /// # Example
73    ///
74    /// ```
75    /// use gba_core::git::GitRepo;
76    ///
77    /// let repo = GitRepo::new("/path/to/repo");
78    /// ```
79    pub fn new(workdir: impl Into<PathBuf>) -> Self {
80        Self {
81            workdir: workdir.into(),
82        }
83    }
84
85    /// Get the working directory path.
86    pub fn workdir(&self) -> &Path {
87        &self.workdir
88    }
89
90    /// Create a git command configured for this repository.
91    ///
92    /// Sets `current_dir` to the workdir and configures the environment for proper
93    /// isolation. This clears git environment variables (like `GIT_INDEX_FILE`,
94    /// `GIT_DIR`) that might be set by parent processes (e.g., during pre-commit hooks),
95    /// and sets `GIT_CEILING_DIRECTORIES` to prevent discovering parent repositories.
96    fn git_cmd(&self) -> Command {
97        let mut cmd = Command::new("git");
98        cmd.current_dir(&self.workdir);
99        // Clear git environment variables that could interfere with our operations.
100        // This is crucial when running inside git hooks where these are set.
101        cmd.env_remove("GIT_DIR");
102        cmd.env_remove("GIT_WORK_TREE");
103        cmd.env_remove("GIT_INDEX_FILE");
104        cmd.env_remove("GIT_OBJECT_DIRECTORY");
105        cmd.env_remove("GIT_ALTERNATE_OBJECT_DIRECTORIES");
106        // Prevent git from walking up to find parent repos
107        if let Some(parent) = self.workdir.parent() {
108            cmd.env("GIT_CEILING_DIRECTORIES", parent);
109        }
110        cmd
111    }
112
113    // === Branch Operations ===
114
115    /// Get the current branch name.
116    ///
117    /// # Errors
118    ///
119    /// Returns an error if the git command fails or if HEAD is detached.
120    ///
121    /// # Example
122    ///
123    /// ```no_run
124    /// use gba_core::git::GitRepo;
125    ///
126    /// let repo = GitRepo::new(".");
127    /// let branch = repo.current_branch()?;
128    /// println!("Current branch: {}", branch);
129    /// # Ok::<(), gba_core::EngineError>(())
130    /// ```
131    pub fn current_branch(&self) -> Result<String> {
132        let output = self
133            .git_cmd()
134            .args(["rev-parse", "--abbrev-ref", "HEAD"])
135            .output()
136            .map_err(|e| EngineError::git_error(format!("failed to execute git: {e}")))?;
137
138        if !output.status.success() {
139            let stderr = String::from_utf8_lossy(&output.stderr);
140            return Err(EngineError::git_error(format!(
141                "failed to get current branch: {stderr}"
142            )));
143        }
144
145        Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
146    }
147
148    /// Delete a local branch.
149    ///
150    /// # Arguments
151    ///
152    /// * `name` - The branch name to delete
153    /// * `force` - If true, use `-D` instead of `-d` for force deletion
154    ///
155    /// # Errors
156    ///
157    /// Returns an error if the git command fails.
158    ///
159    /// # Example
160    ///
161    /// ```no_run
162    /// use gba_core::git::GitRepo;
163    ///
164    /// let repo = GitRepo::new(".");
165    /// repo.delete_branch("feature/old-branch", false)?;
166    /// # Ok::<(), gba_core::EngineError>(())
167    /// ```
168    pub fn delete_branch(&self, name: &str, force: bool) -> Result<()> {
169        let flag = if force { "-D" } else { "-d" };
170        let output = self
171            .git_cmd()
172            .args(["branch", flag, name])
173            .output()
174            .map_err(|e| EngineError::git_error(format!("failed to execute git: {e}")))?;
175
176        if !output.status.success() {
177            let stderr = String::from_utf8_lossy(&output.stderr);
178            return Err(EngineError::git_error(format!(
179                "failed to delete branch '{name}': {stderr}"
180            )));
181        }
182
183        Ok(())
184    }
185
186    /// Detect the default branch (main/master) of the repository.
187    ///
188    /// Queries the remote origin to determine the default branch.
189    /// Falls back to "main" if detection fails.
190    ///
191    /// # Example
192    ///
193    /// ```no_run
194    /// use gba_core::git::GitRepo;
195    ///
196    /// let repo = GitRepo::new(".");
197    /// let default = repo.detect_default_branch();
198    /// println!("Default branch: {}", default);
199    /// ```
200    pub fn detect_default_branch(&self) -> String {
201        // Try to detect from remote
202        let output = self.git_cmd().args(["remote", "show", "origin"]).output();
203
204        if let Ok(output) = output
205            && output.status.success()
206        {
207            let stdout = String::from_utf8_lossy(&output.stdout);
208            for line in stdout.lines() {
209                if line.contains("HEAD branch:")
210                    && let Some(branch) = line.split(':').nth(1)
211                {
212                    return branch.trim().to_string();
213                }
214            }
215        }
216
217        // Fallback: check if main or master exists
218        let branches = self
219            .git_cmd()
220            .args(["branch", "--list", "main", "master"])
221            .output();
222
223        if let Ok(output) = branches {
224            let stdout = String::from_utf8_lossy(&output.stdout);
225            if stdout.contains("main") {
226                return "main".to_string();
227            }
228            if stdout.contains("master") {
229                return "master".to_string();
230            }
231        }
232
233        // Default fallback
234        "main".to_string()
235    }
236
237    // === Worktree Operations ===
238
239    /// Create a new worktree with a new branch.
240    ///
241    /// # Arguments
242    ///
243    /// * `path` - Path where the worktree should be created
244    /// * `branch` - Name of the new branch to create
245    ///
246    /// # Errors
247    ///
248    /// Returns an error if the git command fails.
249    ///
250    /// # Example
251    ///
252    /// ```no_run
253    /// use std::path::Path;
254    /// use gba_core::git::GitRepo;
255    ///
256    /// let repo = GitRepo::new(".");
257    /// repo.create_worktree(Path::new(".trees/feature"), "feature/new-feature")?;
258    /// # Ok::<(), gba_core::EngineError>(())
259    /// ```
260    pub fn create_worktree(&self, path: &Path, branch: &str) -> Result<()> {
261        let output = self
262            .git_cmd()
263            .args(["worktree", "add", "-b", branch])
264            .arg(path)
265            .output()
266            .map_err(|e| EngineError::git_error(format!("failed to execute git: {e}")))?;
267
268        if !output.status.success() {
269            let stderr = String::from_utf8_lossy(&output.stderr);
270            return Err(EngineError::git_error(format!(
271                "failed to create worktree at '{}': {stderr}",
272                path.display()
273            )));
274        }
275
276        Ok(())
277    }
278
279    /// Remove a worktree.
280    ///
281    /// # Arguments
282    ///
283    /// * `path` - Path of the worktree to remove
284    /// * `force` - If true, force removal even with uncommitted changes
285    ///
286    /// # Errors
287    ///
288    /// Returns an error if the git command fails.
289    ///
290    /// # Example
291    ///
292    /// ```no_run
293    /// use gba_core::git::GitRepo;
294    ///
295    /// let repo = GitRepo::new(".");
296    /// repo.remove_worktree(".trees/feature", false)?;
297    /// # Ok::<(), gba_core::EngineError>(())
298    /// ```
299    pub fn remove_worktree(&self, path: &str, force: bool) -> Result<()> {
300        let mut args = vec!["worktree", "remove"];
301        if force {
302            args.push("--force");
303        }
304        args.push(path);
305
306        let output = self
307            .git_cmd()
308            .args(&args)
309            .output()
310            .map_err(|e| EngineError::git_error(format!("failed to execute git: {e}")))?;
311
312        if !output.status.success() {
313            let stderr = String::from_utf8_lossy(&output.stderr);
314            return Err(EngineError::git_error(format!(
315                "failed to remove worktree '{path}': {stderr}"
316            )));
317        }
318
319        Ok(())
320    }
321
322    // === Commit Operations ===
323
324    /// Get the short SHA of HEAD.
325    ///
326    /// Returns `None` if the repository has no commits yet.
327    ///
328    /// # Errors
329    ///
330    /// Returns an error if the git command fails for reasons other than
331    /// an empty repository.
332    ///
333    /// # Example
334    ///
335    /// ```no_run
336    /// use gba_core::git::GitRepo;
337    ///
338    /// let repo = GitRepo::new(".");
339    /// if let Some(sha) = repo.head_short_sha()? {
340    ///     println!("Current commit: {}", sha);
341    /// }
342    /// # Ok::<(), gba_core::EngineError>(())
343    /// ```
344    pub fn head_short_sha(&self) -> Result<Option<String>> {
345        let output = self
346            .git_cmd()
347            .args(["rev-parse", "--short", "HEAD"])
348            .output()
349            .map_err(|e| EngineError::git_error(format!("failed to execute git: {e}")))?;
350
351        if !output.status.success() {
352            let stderr = String::from_utf8_lossy(&output.stderr);
353            // Check if this is just an empty repo (no commits)
354            if stderr.contains("unknown revision")
355                || stderr.contains("bad revision")
356                || stderr.contains("Needed a single revision")
357            {
358                return Ok(None);
359            }
360            return Err(EngineError::git_error(format!(
361                "failed to get HEAD SHA: {stderr}"
362            )));
363        }
364
365        let sha = String::from_utf8_lossy(&output.stdout).trim().to_string();
366        if sha.is_empty() {
367            Ok(None)
368        } else {
369            Ok(Some(sha))
370        }
371    }
372
373    /// Stage a file or path pattern.
374    ///
375    /// # Arguments
376    ///
377    /// * `path` - The file or path pattern to stage (e.g., ".", "src/", "*.rs")
378    ///
379    /// # Errors
380    ///
381    /// Returns an error if the git command fails.
382    ///
383    /// # Example
384    ///
385    /// ```no_run
386    /// use gba_core::git::GitRepo;
387    ///
388    /// let repo = GitRepo::new(".");
389    /// repo.add("src/main.rs")?;
390    /// # Ok::<(), gba_core::EngineError>(())
391    /// ```
392    pub fn add(&self, path: &str) -> Result<()> {
393        let output = self
394            .git_cmd()
395            .args(["add", path])
396            .output()
397            .map_err(|e| EngineError::git_error(format!("failed to execute git: {e}")))?;
398
399        if !output.status.success() {
400            let stderr = String::from_utf8_lossy(&output.stderr);
401            return Err(EngineError::git_error(format!(
402                "failed to stage '{path}': {stderr}"
403            )));
404        }
405
406        Ok(())
407    }
408
409    /// Create a commit with the given message.
410    ///
411    /// # Arguments
412    ///
413    /// * `message` - The commit message
414    ///
415    /// # Errors
416    ///
417    /// Returns an error if the git command fails.
418    ///
419    /// # Example
420    ///
421    /// ```no_run
422    /// use gba_core::git::GitRepo;
423    ///
424    /// let repo = GitRepo::new(".");
425    /// repo.add(".")?;
426    /// repo.commit("feat: add new feature")?;
427    /// # Ok::<(), gba_core::EngineError>(())
428    /// ```
429    pub fn commit(&self, message: &str) -> Result<()> {
430        let output = self
431            .git_cmd()
432            .args(["commit", "-m", message])
433            .output()
434            .map_err(|e| EngineError::git_error(format!("failed to execute git: {e}")))?;
435
436        if !output.status.success() {
437            let stderr = String::from_utf8_lossy(&output.stderr);
438            return Err(EngineError::git_error(format!(
439                "failed to commit: {stderr}"
440            )));
441        }
442
443        Ok(())
444    }
445
446    /// Push to origin.
447    ///
448    /// # Errors
449    ///
450    /// Returns an error if the git command fails.
451    ///
452    /// # Example
453    ///
454    /// ```no_run
455    /// use gba_core::git::GitRepo;
456    ///
457    /// let repo = GitRepo::new(".");
458    /// repo.push()?;
459    /// # Ok::<(), gba_core::EngineError>(())
460    /// ```
461    pub fn push(&self) -> Result<()> {
462        let output = self
463            .git_cmd()
464            .args(["push"])
465            .output()
466            .map_err(|e| EngineError::git_error(format!("failed to execute git: {e}")))?;
467
468        if !output.status.success() {
469            let stderr = String::from_utf8_lossy(&output.stderr);
470            return Err(EngineError::git_error(format!("failed to push: {stderr}")));
471        }
472
473        Ok(())
474    }
475}
476
477/// GitHub CLI operations wrapper.
478///
479/// This struct provides a clean API for GitHub CLI operations.
480#[derive(Debug)]
481pub struct GitHub {
482    workdir: PathBuf,
483}
484
485impl GitHub {
486    /// Create a new GitHub CLI wrapper for the given working directory.
487    ///
488    /// # Arguments
489    ///
490    /// * `workdir` - Path to the git repository working directory
491    ///
492    /// # Example
493    ///
494    /// ```
495    /// use gba_core::git::GitHub;
496    ///
497    /// let gh = GitHub::new("/path/to/repo");
498    /// ```
499    pub fn new(workdir: impl Into<PathBuf>) -> Self {
500        Self {
501            workdir: workdir.into(),
502        }
503    }
504
505    /// Get PR status for a branch.
506    ///
507    /// Returns `None` if no PR exists for the branch.
508    ///
509    /// # Arguments
510    ///
511    /// * `branch` - The branch name to check
512    ///
513    /// # Errors
514    ///
515    /// Returns an error if the gh command fails for reasons other than
516    /// no PR existing.
517    ///
518    /// # Example
519    ///
520    /// ```no_run
521    /// use gba_core::git::{GitHub, PrStatus};
522    ///
523    /// let gh = GitHub::new(".");
524    /// if let Some(status) = gh.pr_status("feature/my-branch")? {
525    ///     println!("PR status: {:?}", status);
526    /// } else {
527    ///     println!("No PR for this branch");
528    /// }
529    /// # Ok::<(), gba_core::EngineError>(())
530    /// ```
531    pub fn pr_status(&self, branch: &str) -> Result<Option<PrStatus>> {
532        let output = Command::new("gh")
533            .current_dir(&self.workdir)
534            .args(["pr", "view", branch, "--json", "state", "-q", ".state"])
535            .output()
536            .map_err(|e| EngineError::github_error(format!("failed to execute gh: {e}")))?;
537
538        if !output.status.success() {
539            let stderr = String::from_utf8_lossy(&output.stderr);
540            // Check if this is just "no PR exists"
541            if stderr.contains("no pull requests found")
542                || stderr.contains("Could not resolve")
543                || stderr.contains("no open pull requests")
544            {
545                return Ok(None);
546            }
547            return Err(EngineError::github_error(format!(
548                "failed to get PR status for '{branch}': {stderr}"
549            )));
550        }
551
552        let state = String::from_utf8_lossy(&output.stdout)
553            .trim()
554            .to_uppercase();
555
556        match state.as_str() {
557            "OPEN" => Ok(Some(PrStatus::Open)),
558            "MERGED" => Ok(Some(PrStatus::Merged)),
559            "CLOSED" => Ok(Some(PrStatus::Closed)),
560            _ => Ok(None),
561        }
562    }
563}
564
565#[cfg(test)]
566mod tests {
567    use std::fs;
568    use std::process::Command;
569
570    use tempfile::TempDir;
571
572    use super::*;
573
574    /// Helper to run a git command in an isolated temp repo.
575    ///
576    /// Clears git environment variables that might be set by parent processes
577    /// (e.g., during pre-commit hooks) and sets `GIT_CEILING_DIRECTORIES`
578    /// to prevent discovery of parent repos.
579    fn git_cmd(temp_dir: &TempDir) -> Command {
580        let mut cmd = Command::new("git");
581        cmd.current_dir(temp_dir.path());
582        // Clear git environment variables that could interfere with test isolation.
583        // This is crucial when tests run inside git hooks.
584        cmd.env_remove("GIT_DIR");
585        cmd.env_remove("GIT_WORK_TREE");
586        cmd.env_remove("GIT_INDEX_FILE");
587        cmd.env_remove("GIT_OBJECT_DIRECTORY");
588        cmd.env_remove("GIT_ALTERNATE_OBJECT_DIRECTORIES");
589        // Prevent git from walking up to find parent repos
590        if let Some(parent) = temp_dir.path().parent() {
591            cmd.env("GIT_CEILING_DIRECTORIES", parent);
592        }
593        cmd
594    }
595
596    /// Helper to create an initialized git repo in a temp directory.
597    /// The repo is completely isolated from any parent repositories.
598    fn create_test_repo() -> (TempDir, GitRepo) {
599        let temp_dir = TempDir::new().expect("failed to create temp dir");
600
601        // Initialize git repo with ceiling directories to prevent parent discovery
602        git_cmd(&temp_dir)
603            .args(["init"])
604            .output()
605            .expect("failed to init git repo");
606
607        // Configure user for commits
608        git_cmd(&temp_dir)
609            .args(["config", "user.email", "test@example.com"])
610            .output()
611            .expect("failed to set user email");
612
613        git_cmd(&temp_dir)
614            .args(["config", "user.name", "Test User"])
615            .output()
616            .expect("failed to set user name");
617
618        // Disable hooks to prevent interference from parent repo's pre-commit
619        git_cmd(&temp_dir)
620            .args(["config", "core.hooksPath", "/dev/null"])
621            .output()
622            .expect("failed to disable hooks");
623
624        let repo = GitRepo::new(temp_dir.path());
625        (temp_dir, repo)
626    }
627
628    /// Helper to create a git repo with an initial commit.
629    fn create_test_repo_with_commit() -> (TempDir, GitRepo) {
630        let (temp_dir, repo) = create_test_repo();
631
632        // Create a file and make initial commit
633        let file_path = temp_dir.path().join("README.md");
634        fs::write(&file_path, "# Test Repository").expect("failed to write file");
635
636        repo.add(".").expect("failed to stage file");
637        repo.commit("Initial commit").expect("failed to commit");
638
639        (temp_dir, repo)
640    }
641
642    // === GitRepo Construction Tests ===
643
644    #[test]
645    fn test_should_create_git_repo_with_path() {
646        let repo = GitRepo::new("/path/to/repo");
647        assert_eq!(repo.workdir(), Path::new("/path/to/repo"));
648    }
649
650    #[test]
651    fn test_should_create_git_repo_from_pathbuf() {
652        let path = PathBuf::from("/another/path");
653        let repo = GitRepo::new(path);
654        assert_eq!(repo.workdir(), Path::new("/another/path"));
655    }
656
657    // === Branch Operations Tests ===
658
659    #[test]
660    fn test_should_get_current_branch_on_main() {
661        let (_temp_dir, repo) = create_test_repo_with_commit();
662
663        // Default branch after init could be 'main' or 'master' depending on git config
664        let branch = repo.current_branch().expect("failed to get current branch");
665        assert!(
666            branch == "main" || branch == "master",
667            "Expected 'main' or 'master', got '{branch}'"
668        );
669    }
670
671    #[test]
672    fn test_should_fail_get_branch_in_non_git_directory() {
673        let temp_dir = TempDir::new().expect("failed to create temp dir");
674        let repo = GitRepo::new(temp_dir.path());
675
676        let result = repo.current_branch();
677        assert!(result.is_err());
678    }
679
680    #[test]
681    fn test_should_delete_branch() {
682        let (temp_dir, repo) = create_test_repo_with_commit();
683
684        // Create a new branch using isolated command
685        git_cmd(&temp_dir)
686            .args(["branch", "feature/test-branch"])
687            .output()
688            .expect("failed to create branch");
689
690        // Delete the branch
691        let result = repo.delete_branch("feature/test-branch", false);
692        assert!(result.is_ok());
693
694        // Verify branch is gone
695        let output = git_cmd(&temp_dir)
696            .args(["branch", "--list", "feature/test-branch"])
697            .output()
698            .expect("failed to list branches");
699        let stdout = String::from_utf8_lossy(&output.stdout);
700        assert!(!stdout.contains("feature/test-branch"));
701    }
702
703    #[test]
704    fn test_should_force_delete_unmerged_branch() {
705        let (temp_dir, repo) = create_test_repo_with_commit();
706
707        // Create and checkout a new branch
708        git_cmd(&temp_dir)
709            .args(["checkout", "-b", "feature/unmerged"])
710            .output()
711            .expect("failed to create branch");
712
713        // Make a commit on the new branch
714        let file_path = temp_dir.path().join("new_file.txt");
715        fs::write(&file_path, "content").expect("failed to write file");
716        repo.add("new_file.txt").expect("failed to stage file");
717        repo.commit("Unmerged commit").expect("failed to commit");
718
719        // Go back to main/master
720        let main_branch = repo.detect_default_branch();
721        git_cmd(&temp_dir)
722            .args(["checkout", &main_branch])
723            .output()
724            .expect("failed to checkout main");
725
726        // Try normal delete - should fail
727        let result = repo.delete_branch("feature/unmerged", false);
728        assert!(result.is_err());
729
730        // Force delete - should succeed
731        let result = repo.delete_branch("feature/unmerged", true);
732        assert!(result.is_ok());
733    }
734
735    #[test]
736    fn test_should_fail_delete_nonexistent_branch() {
737        let (_temp_dir, repo) = create_test_repo_with_commit();
738
739        let result = repo.delete_branch("nonexistent-branch", false);
740        assert!(result.is_err());
741    }
742
743    #[test]
744    fn test_should_detect_default_branch_as_main_or_master() {
745        let (_temp_dir, repo) = create_test_repo_with_commit();
746
747        let default_branch = repo.detect_default_branch();
748        assert!(
749            default_branch == "main" || default_branch == "master",
750            "Expected 'main' or 'master', got '{default_branch}'"
751        );
752    }
753
754    // === Worktree Operations Tests ===
755
756    #[test]
757    fn test_should_create_and_remove_worktree() {
758        let (temp_dir, repo) = create_test_repo_with_commit();
759
760        // Create worktree
761        let worktree_path = temp_dir.path().join("worktree-test");
762        let result = repo.create_worktree(&worktree_path, "feature/worktree-test");
763        assert!(result.is_ok(), "Failed to create worktree: {result:?}");
764
765        // Verify worktree exists
766        assert!(worktree_path.exists());
767        assert!(worktree_path.join(".git").exists());
768
769        // Remove worktree
770        let result = repo.remove_worktree(worktree_path.to_str().unwrap(), false);
771        assert!(result.is_ok(), "Failed to remove worktree: {result:?}");
772    }
773
774    #[test]
775    fn test_should_fail_create_worktree_with_existing_branch() {
776        let (temp_dir, repo) = create_test_repo_with_commit();
777
778        // Create a branch first
779        git_cmd(&temp_dir)
780            .args(["branch", "existing-branch"])
781            .output()
782            .expect("failed to create branch");
783
784        // Try to create worktree with existing branch name
785        let worktree_path = temp_dir.path().join("worktree-fail");
786        let result = repo.create_worktree(&worktree_path, "existing-branch");
787        assert!(result.is_err());
788    }
789
790    #[test]
791    fn test_should_force_remove_worktree_with_changes() {
792        let (temp_dir, repo) = create_test_repo_with_commit();
793
794        // Create worktree
795        let worktree_path = temp_dir.path().join("worktree-dirty");
796        repo.create_worktree(&worktree_path, "feature/dirty-worktree")
797            .expect("failed to create worktree");
798
799        // Make uncommitted changes in worktree
800        let file_path = worktree_path.join("dirty-file.txt");
801        fs::write(&file_path, "uncommitted content").expect("failed to write file");
802
803        // Stage the file in the worktree (need to set ceiling for worktree too)
804        let mut cmd = Command::new("git");
805        cmd.current_dir(&worktree_path);
806        if let Some(parent) = worktree_path.parent() {
807            cmd.env("GIT_CEILING_DIRECTORIES", parent);
808        }
809        cmd.args(["add", "dirty-file.txt"])
810            .output()
811            .expect("failed to stage file");
812
813        // Normal remove should fail
814        let result = repo.remove_worktree(worktree_path.to_str().unwrap(), false);
815        assert!(result.is_err());
816
817        // Force remove should succeed
818        let result = repo.remove_worktree(worktree_path.to_str().unwrap(), true);
819        assert!(result.is_ok());
820    }
821
822    // === Commit Operations Tests ===
823
824    #[test]
825    fn test_should_return_none_for_head_sha_in_empty_repo() {
826        let (_temp_dir, repo) = create_test_repo();
827
828        let result = repo.head_short_sha().expect("unexpected error");
829        assert!(result.is_none());
830    }
831
832    #[test]
833    fn test_should_return_sha_after_commit() {
834        let (_temp_dir, repo) = create_test_repo_with_commit();
835
836        let sha = repo
837            .head_short_sha()
838            .expect("failed to get sha")
839            .expect("expected some sha");
840        assert!(!sha.is_empty());
841        // Short SHA is typically 7 characters
842        assert!(sha.len() >= 7);
843    }
844
845    #[test]
846    fn test_should_stage_and_commit_file() {
847        let (temp_dir, repo) = create_test_repo_with_commit();
848
849        // Create a new file
850        let file_path = temp_dir.path().join("new_feature.rs");
851        fs::write(&file_path, "fn main() {}").expect("failed to write file");
852
853        // Stage and commit
854        repo.add("new_feature.rs").expect("failed to stage");
855        repo.commit("Add new feature").expect("failed to commit");
856
857        // Verify commit was made by checking log
858        let output = git_cmd(&temp_dir)
859            .args(["log", "--oneline", "-1"])
860            .output()
861            .expect("failed to run git log");
862        let log = String::from_utf8_lossy(&output.stdout);
863        assert!(log.contains("Add new feature"));
864    }
865
866    #[test]
867    fn test_should_stage_multiple_files_with_pattern() {
868        let (temp_dir, repo) = create_test_repo_with_commit();
869
870        // Create multiple files
871        fs::write(temp_dir.path().join("file1.txt"), "content1").expect("failed to write file1");
872        fs::write(temp_dir.path().join("file2.txt"), "content2").expect("failed to write file2");
873
874        // Stage all with "."
875        repo.add(".").expect("failed to stage");
876        repo.commit("Add multiple files").expect("failed to commit");
877
878        // Verify both files are in the repo
879        let output = git_cmd(&temp_dir)
880            .args(["ls-files"])
881            .output()
882            .expect("failed to list files");
883        let files = String::from_utf8_lossy(&output.stdout);
884        assert!(files.contains("file1.txt"));
885        assert!(files.contains("file2.txt"));
886    }
887
888    #[test]
889    fn test_should_fail_commit_with_nothing_staged() {
890        let (_temp_dir, repo) = create_test_repo_with_commit();
891
892        // Try to commit with nothing staged
893        let result = repo.commit("Empty commit");
894        assert!(result.is_err());
895    }
896
897    #[test]
898    fn test_should_fail_add_nonexistent_file() {
899        let (_temp_dir, repo) = create_test_repo_with_commit();
900
901        // Try to stage a file that doesn't exist
902        let result = repo.add("nonexistent-file.txt");
903        assert!(result.is_err());
904    }
905
906    // === Error Handling Tests ===
907
908    #[test]
909    fn test_should_fail_operations_on_non_git_directory() {
910        let temp_dir = TempDir::new().expect("failed to create temp dir");
911        let repo = GitRepo::new(temp_dir.path());
912
913        // All operations should fail gracefully
914        assert!(repo.current_branch().is_err());
915        assert!(repo.head_short_sha().is_err());
916        assert!(repo.add(".").is_err());
917        assert!(repo.commit("test").is_err());
918    }
919}