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