git_iris/git/
repository.rs

1use crate::config::Config;
2use crate::context::{CommitContext, RecentCommit, StagedFile};
3use crate::git::commit::{self, CommitResult};
4use crate::git::files::{
5    RepoFilesInfo, get_ahead_behind, get_all_tracked_files, get_file_statuses,
6    get_unstaged_file_statuses, get_untracked_files,
7};
8use crate::git::utils::is_inside_work_tree;
9use crate::log_debug;
10use anyhow::{Context as AnyhowContext, Result, anyhow};
11use git2::{Repository, Tree};
12use std::env;
13use std::path::{Path, PathBuf};
14use std::process::{Command, Stdio};
15use tempfile::TempDir;
16use url::Url;
17
18/// Represents a Git repository and provides methods for interacting with it.
19#[derive(Debug)]
20pub struct GitRepo {
21    repo_path: PathBuf,
22    /// Optional temporary directory for cloned repositories
23    #[allow(dead_code)] // This field is needed to maintain ownership of temp directories
24    temp_dir: Option<TempDir>,
25    /// Whether this is a remote repository
26    is_remote: bool,
27    /// Original remote URL if this is a cloned repository
28    remote_url: Option<String>,
29}
30
31impl GitRepo {
32    /// Creates a new `GitRepo` instance from a local path.
33    ///
34    /// # Arguments
35    ///
36    /// * `repo_path` - The path to the Git repository.
37    ///
38    /// # Returns
39    ///
40    /// A Result containing the `GitRepo` instance or an error.
41    pub fn new(repo_path: &Path) -> Result<Self> {
42        Ok(Self {
43            repo_path: repo_path.to_path_buf(),
44            temp_dir: None,
45            is_remote: false,
46            remote_url: None,
47        })
48    }
49
50    /// Creates a new `GitRepo` instance, handling both local and remote repositories.
51    ///
52    /// # Arguments
53    ///
54    /// * `repository_url` - Optional URL for a remote repository.
55    ///
56    /// # Returns
57    ///
58    /// A Result containing the `GitRepo` instance or an error.
59    pub fn new_from_url(repository_url: Option<String>) -> Result<Self> {
60        if let Some(url) = repository_url {
61            Self::clone_remote_repository(&url)
62        } else {
63            let current_dir = env::current_dir()?;
64            Self::new(&current_dir)
65        }
66    }
67
68    /// Clones a remote repository and creates a `GitRepo` instance for it.
69    ///
70    /// # Arguments
71    ///
72    /// * `url` - The URL of the remote repository to clone.
73    ///
74    /// # Returns
75    ///
76    /// A Result containing the `GitRepo` instance or an error.
77    pub fn clone_remote_repository(url: &str) -> Result<Self> {
78        log_debug!("Cloning remote repository from URL: {}", url);
79
80        // Validate URL
81        let _ = Url::parse(url).map_err(|e| anyhow!("Invalid repository URL: {}", e))?;
82
83        // Create a temporary directory for the clone
84        let temp_dir = TempDir::new()?;
85        let temp_path = temp_dir.path();
86
87        log_debug!("Created temporary directory for clone: {:?}", temp_path);
88
89        // Clone the repository into the temporary directory
90        let repo = match Repository::clone(url, temp_path) {
91            Ok(repo) => repo,
92            Err(e) => return Err(anyhow!("Failed to clone repository: {}", e)),
93        };
94
95        log_debug!("Successfully cloned repository to {:?}", repo.path());
96
97        Ok(Self {
98            repo_path: temp_path.to_path_buf(),
99            temp_dir: Some(temp_dir),
100            is_remote: true,
101            remote_url: Some(url.to_string()),
102        })
103    }
104
105    /// Open the repository at the stored path
106    pub fn open_repo(&self) -> Result<Repository, git2::Error> {
107        Repository::open(&self.repo_path)
108    }
109
110    /// Returns whether this `GitRepo` instance is working with a remote repository
111    pub fn is_remote(&self) -> bool {
112        self.is_remote
113    }
114
115    /// Returns the original remote URL if this is a cloned repository
116    pub fn get_remote_url(&self) -> Option<&str> {
117        self.remote_url.as_deref()
118    }
119
120    /// Returns the repository path
121    pub fn repo_path(&self) -> &PathBuf {
122        &self.repo_path
123    }
124
125    /// Updates the remote repository by fetching the latest changes
126    pub fn update_remote(&self) -> Result<()> {
127        if !self.is_remote {
128            return Err(anyhow!("Not a remote repository"));
129        }
130
131        log_debug!("Updating remote repository");
132        let repo = self.open_repo()?;
133
134        // Find the default remote (usually "origin")
135        let remotes = repo.remotes()?;
136        let remote_name = remotes
137            .iter()
138            .flatten()
139            .next()
140            .ok_or_else(|| anyhow!("No remote found"))?;
141
142        // Fetch updates from the remote
143        let mut remote = repo.find_remote(remote_name)?;
144        remote.fetch(&["master", "main"], None, None)?;
145
146        log_debug!("Successfully updated remote repository");
147        Ok(())
148    }
149
150    /// Retrieves the current branch name.
151    ///
152    /// # Returns
153    ///
154    /// A Result containing the branch name as a String or an error.
155    pub fn get_current_branch(&self) -> Result<String> {
156        let repo = self.open_repo()?;
157        let head = repo.head()?;
158        let branch_name = head.shorthand().unwrap_or("HEAD detached").to_string();
159        log_debug!("Current branch: {}", branch_name);
160        Ok(branch_name)
161    }
162
163    /// Executes a Git hook.
164    ///
165    /// # Arguments
166    ///
167    /// * `hook_name` - The name of the hook to execute.
168    ///
169    /// # Returns
170    ///
171    /// A Result indicating success or an error.
172    pub fn execute_hook(&self, hook_name: &str) -> Result<()> {
173        if self.is_remote {
174            log_debug!("Skipping hook execution for remote repository");
175            return Ok(());
176        }
177
178        let repo = self.open_repo()?;
179        let hook_path = repo.path().join("hooks").join(hook_name);
180
181        if hook_path.exists() {
182            log_debug!("Executing hook: {}", hook_name);
183            log_debug!("Hook path: {:?}", hook_path);
184
185            // Get the repository's working directory (top level)
186            let repo_workdir = repo
187                .workdir()
188                .context("Repository has no working directory")?;
189            log_debug!("Repository working directory: {:?}", repo_workdir);
190
191            // Create a command with the proper environment and working directory
192            let mut command = Command::new(&hook_path);
193            command
194                .current_dir(repo_workdir) // Use the repository's working directory, not .git
195                .env("GIT_DIR", repo.path()) // Set GIT_DIR to the .git directory
196                .env("GIT_WORK_TREE", repo_workdir) // Set GIT_WORK_TREE to the working directory
197                .stdout(Stdio::piped())
198                .stderr(Stdio::piped());
199
200            log_debug!("Executing hook command: {:?}", command);
201
202            let mut child = command.spawn()?;
203
204            let stdout = child.stdout.take().context("Could not get stdout")?;
205            let stderr = child.stderr.take().context("Could not get stderr")?;
206
207            std::thread::spawn(move || {
208                if let Err(e) =
209                    std::io::copy(&mut std::io::BufReader::new(stdout), &mut std::io::stdout())
210                {
211                    tracing::debug!("Failed to copy hook stdout: {e}");
212                }
213            });
214            std::thread::spawn(move || {
215                if let Err(e) =
216                    std::io::copy(&mut std::io::BufReader::new(stderr), &mut std::io::stderr())
217                {
218                    tracing::debug!("Failed to copy hook stderr: {e}");
219                }
220            });
221
222            let status = child.wait()?;
223
224            if !status.success() {
225                return Err(anyhow!(
226                    "Hook '{}' failed with exit code: {:?}",
227                    hook_name,
228                    status.code()
229                ));
230            }
231
232            log_debug!("Hook '{}' executed successfully", hook_name);
233        } else {
234            log_debug!("Hook '{}' not found at {:?}", hook_name, hook_path);
235        }
236
237        Ok(())
238    }
239
240    /// Get the root directory of the current git repository
241    pub fn get_repo_root() -> Result<PathBuf> {
242        // Check if we're in a git repository
243        if !is_inside_work_tree()? {
244            return Err(anyhow!(
245                "Not in a Git repository. Please run this command from within a Git repository."
246            ));
247        }
248
249        // Use git rev-parse to find the repository root
250        let output = Command::new("git")
251            .args(["rev-parse", "--show-toplevel"])
252            .output()
253            .context("Failed to execute git command")?;
254
255        if !output.status.success() {
256            return Err(anyhow!(
257                "Failed to get repository root: {}",
258                String::from_utf8_lossy(&output.stderr)
259            ));
260        }
261
262        // Convert the output to a path
263        let root = String::from_utf8(output.stdout)
264            .context("Invalid UTF-8 output from git command")?
265            .trim()
266            .to_string();
267
268        Ok(PathBuf::from(root))
269    }
270
271    /// Retrieves the README content at a specific commit.
272    ///
273    /// # Arguments
274    ///
275    /// * `commit_ish` - A string that resolves to a commit.
276    ///
277    /// # Returns
278    ///
279    /// A Result containing an Option<String> with the README content or an error.
280    pub fn get_readme_at_commit(&self, commit_ish: &str) -> Result<Option<String>> {
281        let repo = self.open_repo()?;
282        let obj = repo.revparse_single(commit_ish)?;
283        let tree = obj.peel_to_tree()?;
284
285        Self::find_readme_in_tree(&repo, &tree)
286            .context("Failed to find and read README at specified commit")
287    }
288
289    /// Finds a README file in the given tree.
290    ///
291    /// # Arguments
292    ///
293    /// * `tree` - A reference to a Git tree.
294    ///
295    /// # Returns
296    ///
297    /// A Result containing an Option<String> with the README content or an error.
298    fn find_readme_in_tree(repo: &Repository, tree: &Tree) -> Result<Option<String>> {
299        log_debug!("Searching for README file in the repository");
300
301        let readme_patterns = [
302            "README.md",
303            "README.markdown",
304            "README.txt",
305            "README",
306            "Readme.md",
307            "readme.md",
308        ];
309
310        for entry in tree {
311            let name = entry.name().unwrap_or("");
312            if readme_patterns
313                .iter()
314                .any(|&pattern| name.eq_ignore_ascii_case(pattern))
315            {
316                let object = entry.to_object(repo)?;
317                if let Some(blob) = object.as_blob()
318                    && let Ok(content) = std::str::from_utf8(blob.content())
319                {
320                    log_debug!("README file found: {}", name);
321                    return Ok(Some(content.to_string()));
322                }
323            }
324        }
325
326        log_debug!("No README file found");
327        Ok(None)
328    }
329
330    /// Extract files info without crossing async boundaries
331    pub fn extract_files_info(&self, include_unstaged: bool) -> Result<RepoFilesInfo> {
332        let repo = self.open_repo()?;
333
334        // Get basic repo info
335        let branch = self.get_current_branch()?;
336        let recent_commits = self.get_recent_commits(5)?;
337
338        // Get staged and unstaged files
339        let mut staged_files = get_file_statuses(&repo)?;
340        if include_unstaged {
341            let unstaged_files = self.get_unstaged_files()?;
342            staged_files.extend(unstaged_files);
343            log_debug!("Combined {} files (staged + unstaged)", staged_files.len());
344        }
345
346        // Extract file paths for metadata
347        let file_paths: Vec<String> = staged_files.iter().map(|file| file.path.clone()).collect();
348
349        Ok(RepoFilesInfo {
350            branch,
351            recent_commits,
352            staged_files,
353            file_paths,
354        })
355    }
356
357    /// Gets unstaged file changes from the repository
358    pub fn get_unstaged_files(&self) -> Result<Vec<StagedFile>> {
359        let repo = self.open_repo()?;
360        get_unstaged_file_statuses(&repo)
361    }
362
363    /// Get diff between two refs as a full unified diff string with headers
364    ///
365    /// Returns a complete diff suitable for parsing, including:
366    /// - diff --git headers
367    /// - --- and +++ file headers
368    /// - @@ hunk headers
369    /// - +/- content lines
370    pub fn get_ref_diff_full(&self, from: &str, to: &str) -> Result<String> {
371        let repo = self.open_repo()?;
372
373        // Resolve the from and to refs
374        let from_commit = repo.revparse_single(from)?.peel_to_commit()?;
375        let to_commit = repo.revparse_single(to)?.peel_to_commit()?;
376
377        let from_tree = from_commit.tree()?;
378        let to_tree = to_commit.tree()?;
379
380        // Get diff between the two trees
381        let diff = repo.diff_tree_to_tree(Some(&from_tree), Some(&to_tree), None)?;
382
383        // Format as unified diff
384        let mut diff_string = String::new();
385        diff.print(git2::DiffFormat::Patch, |delta, _hunk, line| {
386            // For diff content lines (+/-/context), prefix with origin char
387            if matches!(line.origin(), '+' | '-' | ' ') {
388                diff_string.push(line.origin());
389            }
390            // All line types get their content appended
391            diff_string.push_str(&String::from_utf8_lossy(line.content()));
392
393            if line.origin() == 'F'
394                && !diff_string.contains("diff --git")
395                && let Some(new_file) = delta.new_file().path()
396            {
397                let header = format!("diff --git a/{0} b/{0}\n", new_file.display());
398                if !diff_string.ends_with(&header) {
399                    diff_string.insert_str(
400                        diff_string.rfind("---").unwrap_or(diff_string.len()),
401                        &header,
402                    );
403                }
404            }
405            true
406        })?;
407
408        Ok(diff_string)
409    }
410
411    /// Get staged diff as a full unified diff string with headers
412    ///
413    /// Returns a complete diff suitable for parsing, including:
414    /// - diff --git headers
415    /// - --- and +++ file headers
416    /// - @@ hunk headers
417    /// - +/- content lines
418    pub fn get_staged_diff_full(&self) -> Result<String> {
419        let repo = self.open_repo()?;
420
421        // Get the HEAD tree to diff against
422        let head = repo.head()?;
423        let head_tree = head.peel_to_tree()?;
424
425        // Get staged changes (index vs HEAD)
426        let diff = repo.diff_tree_to_index(Some(&head_tree), None, None)?;
427
428        // Format as unified diff
429        let mut diff_string = String::new();
430        diff.print(git2::DiffFormat::Patch, |delta, _hunk, line| {
431            // Include all line types for a complete diff
432            match line.origin() {
433                'H' => {
434                    // Hunk header - just the content
435                    diff_string.push_str(&String::from_utf8_lossy(line.content()));
436                }
437                'F' => {
438                    // File header
439                    diff_string.push_str(&String::from_utf8_lossy(line.content()));
440                }
441                '+' | '-' | ' ' => {
442                    diff_string.push(line.origin());
443                    diff_string.push_str(&String::from_utf8_lossy(line.content()));
444                }
445                '>' | '<' | '=' => {
446                    // Binary file markers
447                    diff_string.push_str(&String::from_utf8_lossy(line.content()));
448                }
449                _ => {
450                    // Any other content (context info, etc.)
451                    diff_string.push_str(&String::from_utf8_lossy(line.content()));
452                }
453            }
454
455            // Add diff --git header before each file if not present
456            if line.origin() == 'F'
457                && !diff_string.contains("diff --git")
458                && let Some(new_file) = delta.new_file().path()
459            {
460                let header = format!("diff --git a/{0} b/{0}\n", new_file.display());
461                if !diff_string.ends_with(&header) {
462                    diff_string.insert_str(
463                        diff_string.rfind("---").unwrap_or(diff_string.len()),
464                        &header,
465                    );
466                }
467            }
468            true
469        })?;
470
471        Ok(diff_string)
472    }
473
474    /// Retrieves project metadata for changed files.
475    /// Helper method for creating `CommitContext`
476    ///
477    /// # Arguments
478    ///
479    /// * `branch` - Branch name
480    /// * `recent_commits` - List of recent commits
481    /// * `staged_files` - List of staged files
482    ///
483    /// # Returns
484    ///
485    /// A Result containing the `CommitContext` or an error.
486    fn create_commit_context(
487        &self,
488        branch: String,
489        recent_commits: Vec<RecentCommit>,
490        staged_files: Vec<StagedFile>,
491    ) -> Result<CommitContext> {
492        // Get user info
493        let repo = self.open_repo()?;
494        let user_name = repo.config()?.get_string("user.name").unwrap_or_default();
495        let user_email = repo.config()?.get_string("user.email").unwrap_or_default();
496
497        // Create and return the context
498        Ok(CommitContext::new(
499            branch,
500            recent_commits,
501            staged_files,
502            user_name,
503            user_email,
504        ))
505    }
506
507    /// Retrieves Git information for the repository.
508    ///
509    /// # Arguments
510    ///
511    /// * `config` - The configuration object.
512    ///
513    /// # Returns
514    ///
515    /// A Result containing the `CommitContext` or an error.
516    pub fn get_git_info(&self, _config: &Config) -> Result<CommitContext> {
517        // Get data that doesn't cross async boundaries
518        let repo = self.open_repo()?;
519        log_debug!("Getting git info for repo path: {:?}", repo.path());
520
521        let branch = self.get_current_branch()?;
522        let recent_commits = self.get_recent_commits(5)?;
523        let staged_files = get_file_statuses(&repo)?;
524
525        // Create and return the context
526        self.create_commit_context(branch, recent_commits, staged_files)
527    }
528
529    /// Get Git information including unstaged changes
530    ///
531    /// # Arguments
532    ///
533    /// * `config` - The configuration object
534    /// * `include_unstaged` - Whether to include unstaged changes
535    ///
536    /// # Returns
537    ///
538    /// A Result containing the `CommitContext` or an error.
539    pub fn get_git_info_with_unstaged(
540        &self,
541        _config: &Config,
542        include_unstaged: bool,
543    ) -> Result<CommitContext> {
544        log_debug!("Getting git info with unstaged flag: {}", include_unstaged);
545
546        // Extract all git2 data before crossing async boundaries
547        let files_info = self.extract_files_info(include_unstaged)?;
548
549        // Create and return the context
550        self.create_commit_context(
551            files_info.branch,
552            files_info.recent_commits,
553            files_info.staged_files,
554        )
555    }
556
557    /// Get Git information for comparing two branches
558    ///
559    /// # Arguments
560    ///
561    /// * `config` - The configuration object
562    /// * `base_branch` - The base branch (e.g., "main")
563    /// * `target_branch` - The target branch (e.g., "feature-branch")
564    ///
565    /// # Returns
566    ///
567    /// A Result containing the `CommitContext` for the branch comparison or an error.
568    pub fn get_git_info_for_branch_diff(
569        &self,
570        _config: &Config,
571        base_branch: &str,
572        target_branch: &str,
573    ) -> Result<CommitContext> {
574        log_debug!(
575            "Getting git info for branch diff: {} -> {}",
576            base_branch,
577            target_branch
578        );
579        let repo = self.open_repo()?;
580
581        // Extract branch diff info
582        let (display_branch, recent_commits, _file_paths) =
583            commit::extract_branch_diff_info(&repo, base_branch, target_branch)?;
584
585        // Get the actual file changes
586        let branch_files = commit::get_branch_diff_files(&repo, base_branch, target_branch)?;
587
588        // Create and return the context
589        self.create_commit_context(display_branch, recent_commits, branch_files)
590    }
591
592    /// Get Git information for a commit range (for PR descriptions)
593    ///
594    /// # Arguments
595    ///
596    /// * `config` - The configuration object
597    /// * `from` - The starting Git reference (exclusive)
598    /// * `to` - The ending Git reference (inclusive)
599    ///
600    /// # Returns
601    ///
602    /// A Result containing the `CommitContext` for the commit range or an error.
603    pub fn get_git_info_for_commit_range(
604        &self,
605        _config: &Config,
606        from: &str,
607        to: &str,
608    ) -> Result<CommitContext> {
609        log_debug!("Getting git info for commit range: {} -> {}", from, to);
610        let repo = self.open_repo()?;
611
612        // Extract commit range info
613        let (display_range, recent_commits, _file_paths) =
614            commit::extract_commit_range_info(&repo, from, to)?;
615
616        // Get the actual file changes
617        let range_files = commit::get_commit_range_files(&repo, from, to)?;
618
619        // Create and return the context
620        self.create_commit_context(display_range, recent_commits, range_files)
621    }
622
623    /// Get commits for PR description between two references
624    pub fn get_commits_for_pr(&self, from: &str, to: &str) -> Result<Vec<String>> {
625        let repo = self.open_repo()?;
626        commit::get_commits_for_pr(&repo, from, to)
627    }
628
629    /// Get files changed in a commit range  
630    pub fn get_commit_range_files(&self, from: &str, to: &str) -> Result<Vec<StagedFile>> {
631        let repo = self.open_repo()?;
632        commit::get_commit_range_files(&repo, from, to)
633    }
634
635    /// Retrieves recent commits.
636    ///
637    /// # Arguments
638    ///
639    /// * `count` - The number of recent commits to retrieve.
640    ///
641    /// # Returns
642    ///
643    /// A Result containing a Vec of `RecentCommit` objects or an error.
644    pub fn get_recent_commits(&self, count: usize) -> Result<Vec<RecentCommit>> {
645        let repo = self.open_repo()?;
646        log_debug!("Fetching {} recent commits", count);
647        let mut revwalk = repo.revwalk()?;
648        revwalk.push_head()?;
649
650        let commits = revwalk
651            .take(count)
652            .map(|oid| {
653                let oid = oid?;
654                let commit = repo.find_commit(oid)?;
655                let author = commit.author();
656                Ok(RecentCommit {
657                    hash: oid.to_string(),
658                    message: commit.message().unwrap_or_default().to_string(),
659                    author: author.name().unwrap_or_default().to_string(),
660                    timestamp: commit.time().seconds().to_string(),
661                })
662            })
663            .collect::<Result<Vec<_>>>()?;
664
665        log_debug!("Retrieved {} recent commits", commits.len());
666        Ok(commits)
667    }
668
669    /// Commits changes and verifies the commit.
670    ///
671    /// # Arguments
672    ///
673    /// * `message` - The commit message.
674    ///
675    /// # Returns
676    ///
677    /// A Result containing the `CommitResult` or an error.
678    pub fn commit_and_verify(&self, message: &str) -> Result<CommitResult> {
679        if self.is_remote {
680            return Err(anyhow!(
681                "Cannot commit to a remote repository in read-only mode"
682            ));
683        }
684
685        let repo = self.open_repo()?;
686        match commit::commit(&repo, message, self.is_remote) {
687            Ok(result) => {
688                if let Err(e) = self.execute_hook("post-commit") {
689                    log_debug!("Post-commit hook failed: {}", e);
690                }
691                Ok(result)
692            }
693            Err(e) => {
694                log_debug!("Commit failed: {}", e);
695                Err(e)
696            }
697        }
698    }
699
700    /// Get Git information for a specific commit
701    ///
702    /// # Arguments
703    ///
704    /// * `config` - The configuration object
705    /// * `commit_id` - The ID of the commit to analyze
706    ///
707    /// # Returns
708    ///
709    /// A Result containing the `CommitContext` or an error.
710    pub fn get_git_info_for_commit(
711        &self,
712        _config: &Config,
713        commit_id: &str,
714    ) -> Result<CommitContext> {
715        log_debug!("Getting git info for commit: {}", commit_id);
716        let repo = self.open_repo()?;
717
718        // Get branch name
719        let branch = self.get_current_branch()?;
720
721        // Extract commit info
722        let commit_info = commit::extract_commit_info(&repo, commit_id, &branch)?;
723
724        // Get the files from commit
725        let commit_files = commit::get_commit_files(&repo, commit_id)?;
726
727        // Create and return the context
728        self.create_commit_context(commit_info.branch, vec![commit_info.commit], commit_files)
729    }
730
731    /// Get the commit date for a reference
732    pub fn get_commit_date(&self, commit_ish: &str) -> Result<String> {
733        let repo = self.open_repo()?;
734        commit::get_commit_date(&repo, commit_ish)
735    }
736
737    /// Get commits between two references with a callback
738    pub fn get_commits_between_with_callback<T, F>(
739        &self,
740        from: &str,
741        to: &str,
742        callback: F,
743    ) -> Result<Vec<T>>
744    where
745        F: FnMut(&RecentCommit) -> Result<T>,
746    {
747        let repo = self.open_repo()?;
748        commit::get_commits_between_with_callback(&repo, from, to, callback)
749    }
750
751    /// Commit changes to the repository
752    pub fn commit(&self, message: &str) -> Result<CommitResult> {
753        let repo = self.open_repo()?;
754        commit::commit(&repo, message, self.is_remote)
755    }
756
757    /// Amend the previous commit with staged changes and a new message
758    pub fn amend_commit(&self, message: &str) -> Result<CommitResult> {
759        let repo = self.open_repo()?;
760        commit::amend_commit(&repo, message, self.is_remote)
761    }
762
763    /// Get the message of the HEAD commit
764    pub fn get_head_commit_message(&self) -> Result<String> {
765        let repo = self.open_repo()?;
766        commit::get_head_commit_message(&repo)
767    }
768
769    /// Check if inside a working tree
770    pub fn is_inside_work_tree() -> Result<bool> {
771        is_inside_work_tree()
772    }
773
774    /// Get the files changed in a specific commit
775    pub fn get_commit_files(&self, commit_id: &str) -> Result<Vec<StagedFile>> {
776        let repo = self.open_repo()?;
777        commit::get_commit_files(&repo, commit_id)
778    }
779
780    /// Get just the file paths for a specific commit
781    pub fn get_file_paths_for_commit(&self, commit_id: &str) -> Result<Vec<String>> {
782        let repo = self.open_repo()?;
783        commit::get_file_paths_for_commit(&repo, commit_id)
784    }
785
786    /// Stage a file (add to index)
787    pub fn stage_file(&self, path: &Path) -> Result<()> {
788        let repo = self.open_repo()?;
789        let mut index = repo.index()?;
790
791        // Check if file exists - if not, it might be a deletion
792        let full_path = self.repo_path.join(path);
793        if full_path.exists() {
794            index.add_path(path)?;
795        } else {
796            // File was deleted, remove from index
797            index.remove_path(path)?;
798        }
799
800        index.write()?;
801        Ok(())
802    }
803
804    /// Unstage a file (remove from index, keep working tree changes)
805    pub fn unstage_file(&self, path: &Path) -> Result<()> {
806        let repo = self.open_repo()?;
807
808        // Get HEAD tree to reset index entry
809        let head = repo.head()?;
810        let head_commit = head.peel_to_commit()?;
811        let head_tree = head_commit.tree()?;
812
813        let mut index = repo.index()?;
814
815        // Try to get the entry from HEAD
816        if let Ok(entry) = head_tree.get_path(path) {
817            // File exists in HEAD, reset to that state
818            let blob = repo.find_blob(entry.id())?;
819            #[allow(
820                clippy::cast_sign_loss,
821                clippy::cast_possible_truncation,
822                clippy::as_conversions
823            )]
824            let file_mode = entry.filemode() as u32;
825            #[allow(clippy::cast_possible_truncation, clippy::as_conversions)]
826            let file_size = blob.content().len() as u32;
827            index.add_frombuffer(
828                &git2::IndexEntry {
829                    ctime: git2::IndexTime::new(0, 0),
830                    mtime: git2::IndexTime::new(0, 0),
831                    dev: 0,
832                    ino: 0,
833                    mode: file_mode,
834                    uid: 0,
835                    gid: 0,
836                    file_size,
837                    id: entry.id(),
838                    flags: 0,
839                    flags_extended: 0,
840                    path: path.to_string_lossy().as_bytes().to_vec(),
841                },
842                blob.content(),
843            )?;
844        } else {
845            // File doesn't exist in HEAD (new file), remove from index
846            index.remove_path(path)?;
847        }
848
849        index.write()?;
850        Ok(())
851    }
852
853    /// Stage all modified/new/deleted files
854    pub fn stage_all(&self) -> Result<()> {
855        let repo = self.open_repo()?;
856        let mut index = repo.index()?;
857        index.add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)?;
858        index.write()?;
859        Ok(())
860    }
861
862    /// Unstage all files (reset index to HEAD)
863    pub fn unstage_all(&self) -> Result<()> {
864        let repo = self.open_repo()?;
865        let head = repo.head()?;
866        let head_commit = head.peel_to_commit()?;
867        repo.reset(head_commit.as_object(), git2::ResetType::Mixed, None)?;
868        Ok(())
869    }
870
871    /// Get list of untracked files (new files not in the index)
872    pub fn get_untracked_files(&self) -> Result<Vec<String>> {
873        let repo = self.open_repo()?;
874        get_untracked_files(&repo)
875    }
876
877    /// Get all tracked files in the repository (from HEAD + index)
878    pub fn get_all_tracked_files(&self) -> Result<Vec<String>> {
879        let repo = self.open_repo()?;
880        get_all_tracked_files(&repo)
881    }
882
883    /// Get ahead/behind counts relative to upstream tracking branch
884    ///
885    /// Returns (ahead, behind) tuple, or (0, 0) if no upstream is configured
886    pub fn get_ahead_behind(&self) -> (usize, usize) {
887        let Ok(repo) = self.open_repo() else {
888            return (0, 0);
889        };
890        get_ahead_behind(&repo)
891    }
892}
893
894impl Drop for GitRepo {
895    fn drop(&mut self) {
896        // The TempDir will be automatically cleaned up when dropped
897        if self.is_remote {
898            log_debug!("Cleaning up temporary repository at {:?}", self.repo_path);
899        }
900    }
901}