Skip to main content

git_iris/git/
repository.rs

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