gitai/git/
repository.rs

1use crate::config::Config;
2use crate::core::context::{CommitContext, ProjectMetadata, RecentCommit, StagedFile};
3use crate::debug;
4use crate::git::commit::{self, CommitResult};
5use crate::git::files::{RepoFilesInfo, get_file_statuses, get_unstaged_file_statuses};
6use crate::git::metadata;
7use crate::git::utils::is_inside_work_tree;
8use anyhow::{Context as AnyhowContext, Result, anyhow};
9use git2::{Repository, Tree};
10use std::env;
11use std::path::{Path, PathBuf};
12use std::process::{Command, Stdio};
13
14use tempfile::TempDir;
15use url::Url;
16
17/// Represents a Git repository and provides methods for interacting with it.
18pub struct GitRepo {
19    repo_path: PathBuf,
20    /// Optional temporary directory for cloned repositories
21    #[allow(dead_code)] // This field is needed to maintain ownership of temp directories
22    temp_dir: Option<TempDir>,
23    /// Whether this is a remote repository
24    is_remote: bool,
25    /// Original remote URL if this is a cloned repository
26    remote_url: Option<String>,
27}
28
29impl GitRepo {
30    /// Creates a new `GitRepo` instance from a local path.
31    ///
32    /// # Arguments
33    ///
34    /// * `repo_path` - The path to the Git repository.
35    ///
36    /// # Returns
37    ///
38    /// A Result containing the `GitRepo` instance or an error.
39    pub fn new(repo_path: &Path) -> Result<Self> {
40        Ok(Self {
41            repo_path: repo_path.to_path_buf(),
42            temp_dir: None,
43            is_remote: false,
44            remote_url: None,
45        })
46    }
47
48    /// Creates a new `GitRepo` instance, handling both local and remote repositories.
49    ///
50    /// # Arguments
51    ///
52    /// * `repository_url` - Optional URL for a remote repository.
53    ///
54    /// # Returns
55    ///
56    /// A Result containing the `GitRepo` instance or an error.
57    pub fn new_from_url(repository_url: Option<String>) -> Result<Self> {
58        if let Some(url) = repository_url {
59            Self::clone_remote_repository(&url)
60        } else {
61            let current_dir = env::current_dir()?;
62            Self::new(&current_dir)
63        }
64    }
65
66    /// Clones a remote repository and creates a `GitRepo` instance for it.
67    ///
68    /// # Arguments
69    ///
70    /// * `url` - The URL of the remote repository to clone.
71    ///
72    /// # Returns
73    ///
74    /// A Result containing the `GitRepo` instance or an error.
75    pub fn clone_remote_repository(url: &str) -> Result<Self> {
76        debug!("Cloning remote repository from URL: {}", url);
77
78        // Validate URL
79        let _ = Url::parse(url).map_err(|e| anyhow!("Invalid repository URL: {e}"))?;
80
81        // Create a temporary directory for the clone
82        let temp_dir = TempDir::new()?;
83        let temp_path = temp_dir.path();
84
85        debug!("Created temporary directory for clone: {:?}", temp_path);
86
87        // Clone the repository into the temporary directory
88        let repo = match Repository::clone(url, temp_path) {
89            Ok(repo) => repo,
90            Err(e) => return Err(anyhow!("Failed to clone repository: {e}")),
91        };
92
93        debug!("Successfully cloned repository to {:?}", repo.path());
94
95        Ok(Self {
96            repo_path: temp_path.to_path_buf(),
97            temp_dir: Some(temp_dir),
98            is_remote: true,
99            remote_url: Some(url.to_string()),
100        })
101    }
102
103    /// Open the repository at the stored path
104    pub fn open_repo(&self) -> Result<Repository, git2::Error> {
105        Repository::open(&self.repo_path)
106    }
107
108    /// Returns whether this `GitRepo` instance is working with a remote repository
109    pub fn is_remote(&self) -> bool {
110        self.is_remote
111    }
112
113    /// Returns the original remote URL if this is a cloned repository
114    pub fn get_remote_url(&self) -> Option<&str> {
115        self.remote_url.as_deref()
116    }
117
118    /// Returns the repository path
119    pub fn repo_path(&self) -> &PathBuf {
120        &self.repo_path
121    }
122
123    /// Updates the remote repository by fetching the latest changes
124    pub fn update_remote(&self) -> Result<()> {
125        if !self.is_remote {
126            return Err(anyhow!("Not a remote repository"));
127        }
128
129        debug!("Updating remote repository");
130        let repo = self.open_repo()?;
131
132        // Find the default remote (usually "origin")
133        let remotes = repo.remotes()?;
134        let remote_name = remotes
135            .iter()
136            .flatten()
137            .next()
138            .ok_or_else(|| anyhow!("No remote found"))?;
139
140        // Fetch updates from the remote
141        let mut remote = repo.find_remote(remote_name)?;
142        remote.fetch(&["master", "main"], None, None)?;
143
144        debug!("Successfully updated remote repository");
145        Ok(())
146    }
147
148    /// Retrieves the current branch name.
149    ///
150    /// # Returns
151    ///
152    /// A Result containing the branch name as a String or an error.
153    pub fn get_current_branch(&self) -> Result<String> {
154        let repo = self.open_repo()?;
155        let head = repo.head()?;
156        let branch_name = head.shorthand().unwrap_or("HEAD detached").to_string();
157        debug!("Current branch: {}", branch_name);
158        Ok(branch_name)
159    }
160
161    /// Executes a Git hook.
162    ///
163    /// # Arguments
164    ///
165    /// * `hook_name` - The name of the hook to execute.
166    ///
167    /// # Returns
168    ///
169    /// A Result indicating success or an error.
170    pub fn execute_hook(&self, hook_name: &str) -> Result<()> {
171        if self.is_remote {
172            debug!("Skipping hook execution for remote repository");
173            return Ok(());
174        }
175
176        let repo = self.open_repo()?;
177        let hook_path = repo.path().join("hooks").join(hook_name);
178
179        if hook_path.exists() {
180            debug!("Executing hook: {}", hook_name);
181            debug!("Hook path: {:?}", hook_path);
182
183            // Get the repository's working directory (top level)
184            let repo_workdir = repo
185                .workdir()
186                .context("Repository has no working directory")?;
187            debug!("Repository working directory: {:?}", repo_workdir);
188
189            // Create a command with the proper environment and working directory
190            let mut command = Command::new(&hook_path);
191            command
192                .current_dir(repo_workdir) // Use the repository's working directory, not .git
193                .env("GIT_DIR", repo.path()) // Set GIT_DIR to the .git directory
194                .env("GIT_WORK_TREE", repo_workdir) // Set GIT_WORK_TREE to the working directory
195                .stdout(Stdio::piped())
196                .stderr(Stdio::piped());
197
198            debug!("Executing hook command: {:?}", command);
199
200            let mut child = command.spawn()?;
201
202            let stdout = child.stdout.take().context("Could not get stdout")?;
203            let stderr = child.stderr.take().context("Could not get stderr")?;
204
205            std::thread::spawn(move || {
206                std::io::copy(&mut std::io::BufReader::new(stdout), &mut std::io::stdout())
207                    .expect("Failed to copy data to stdout");
208            });
209            std::thread::spawn(move || {
210                std::io::copy(&mut std::io::BufReader::new(stderr), &mut std::io::stderr())
211                    .expect("Failed to copy data to stderr");
212            });
213
214            let status = child.wait()?;
215
216            if !status.success() {
217                return Err(anyhow!(
218                    "Hook '{}' failed with exit code: {:?}",
219                    hook_name,
220                    status.code()
221                ));
222            }
223
224            debug!("Hook '{}' executed successfully", hook_name);
225        } else {
226            debug!("Hook '{}' not found at {:?}", hook_name, hook_path);
227        }
228
229        Ok(())
230    }
231
232    /// Get the root directory of the current git repository
233    pub fn get_repo_root() -> Result<PathBuf> {
234        // Check if we're in a git repository
235        if !is_inside_work_tree()? {
236            return Err(anyhow!(
237                "Not in a Git repository. Please run this command from within a Git repository."
238            ));
239        }
240
241        // Use git2 to find the repository root
242        let repo = Repository::discover(".").context("Failed to discover git repository")?;
243        let workdir = repo
244            .workdir()
245            .context("Repository has no working directory")?;
246        Ok(workdir.to_path_buf())
247    }
248
249    /// Retrieves the README content at a specific commit.
250    ///
251    /// # Arguments
252    ///
253    /// * `commit_ish` - A string that resolves to a commit.
254    ///
255    /// # Returns
256    ///
257    /// A Result containing an `Option<String>` with the README content or an error.
258    pub fn get_readme_at_commit(&self, commit_ish: &str) -> Result<Option<String>> {
259        let repo = self.open_repo()?;
260        let obj = repo.revparse_single(commit_ish)?;
261        let tree = obj.peel_to_tree()?;
262
263        Self::find_readme_in_tree(&repo, &tree)
264            .context("Failed to find and read README at specified commit")
265    }
266
267    /// Finds a README file in the given tree.
268    ///
269    /// # Arguments
270    ///
271    /// * `tree` - A reference to a Git tree.
272    ///
273    /// # Returns
274    ///
275    /// A Result containing an `Option<String>` with the README content or an error.
276    fn find_readme_in_tree(repo: &Repository, tree: &Tree) -> Result<Option<String>> {
277        debug!("Searching for README file in the repository");
278
279        let readme_patterns = [
280            "README.md",
281            "README.markdown",
282            "README.txt",
283            "README",
284            "Readme.md",
285            "readme.md",
286        ];
287
288        for entry in tree {
289            let name = entry.name().unwrap_or("");
290            if readme_patterns
291                .iter()
292                .any(|&pattern| name.eq_ignore_ascii_case(pattern))
293            {
294                let object = entry.to_object(repo)?;
295                if let Some(blob) = object.as_blob()
296                    && let Ok(content) = std::str::from_utf8(blob.content())
297                {
298                    debug!("README file found: {}", name);
299                    return Ok(Some(content.to_string()));
300                }
301            }
302        }
303
304        debug!("No README file found");
305        Ok(None)
306    }
307
308    /// Extract files info without crossing async boundaries
309    pub fn extract_files_info(&self, include_unstaged: bool) -> Result<RepoFilesInfo> {
310        let repo = self.open_repo()?;
311
312        // Get basic repo info
313        let branch = self.get_current_branch()?;
314        let recent_commits = self.get_recent_commits(5)?;
315
316        // Get staged and unstaged files
317        let mut staged_files = get_file_statuses(&repo)?;
318        if include_unstaged {
319            let unstaged_files = self.get_unstaged_files()?;
320            staged_files.extend(unstaged_files);
321            debug!("Combined {} files (staged + unstaged)", staged_files.len());
322        }
323
324        // Extract file paths for metadata
325        let file_paths: Vec<String> = staged_files.iter().map(|file| file.path.clone()).collect();
326
327        Ok(RepoFilesInfo {
328            branch,
329            recent_commits,
330            staged_files,
331            file_paths,
332        })
333    }
334
335    /// Gets unstaged file changes from the repository
336    pub fn get_unstaged_files(&self) -> Result<Vec<StagedFile>> {
337        let repo = self.open_repo()?;
338        get_unstaged_file_statuses(&repo)
339    }
340
341    /// Retrieves project metadata for changed files.
342    ///
343    /// # Arguments
344    ///
345    /// * `changed_files` - A slice of Strings representing the changed file paths.
346    ///
347    /// # Returns
348    ///
349    /// A Result containing the `ProjectMetadata` or an error.
350    pub async fn get_project_metadata(&self, changed_files: &[String]) -> Result<ProjectMetadata> {
351        // Default batch size of 10 files at a time to limit concurrency
352        metadata::extract_project_metadata(changed_files, 10).await
353    }
354
355    /// Helper method for creating `CommitContext`
356    ///
357    /// # Arguments
358    ///
359    /// * `branch` - Branch name
360    /// * `recent_commits` - List of recent commits
361    /// * `staged_files` - List of staged files
362    /// * `project_metadata` - Project metadata
363    ///
364    /// # Returns
365    ///
366    /// A Result containing the `CommitContext` or an error.
367    fn create_commit_context(
368        &self,
369        branch: String,
370        recent_commits: Vec<RecentCommit>,
371        staged_files: Vec<StagedFile>,
372        project_metadata: ProjectMetadata,
373    ) -> Result<CommitContext> {
374        // Get user info
375        let repo = self.open_repo()?;
376        let user_name = repo.config()?.get_string("user.name").unwrap_or_default();
377        let user_email = repo.config()?.get_string("user.email").unwrap_or_default();
378
379        // Create and return the context
380        Ok(CommitContext::new(
381            branch,
382            recent_commits,
383            staged_files,
384            project_metadata,
385            user_name,
386            user_email,
387        ))
388    }
389
390    /// Retrieves Git information for the repository.
391    ///
392    /// # Arguments
393    ///
394    /// * `config` - The configuration object.
395    ///
396    /// # Returns
397    ///
398    /// A Result containing the `CommitContext` or an error.
399    pub async fn get_git_info(&self, _config: &Config) -> Result<CommitContext> {
400        // Get data that doesn't cross async boundaries
401        let repo = self.open_repo()?;
402        debug!("Getting git info for repo path: {:?}", repo.path());
403
404        let branch = self.get_current_branch()?;
405        let recent_commits = self.get_recent_commits(5)?;
406        let staged_files = get_file_statuses(&repo)?;
407
408        let changed_files: Vec<String> =
409            staged_files.iter().map(|file| file.path.clone()).collect();
410
411        debug!("Changed files for metadata extraction: {:?}", changed_files);
412
413        // Get project metadata (async operation)
414        let project_metadata = self.get_project_metadata(&changed_files).await?;
415        debug!("Extracted project metadata: {:?}", project_metadata);
416
417        // Create and return the context
418        self.create_commit_context(branch, recent_commits, staged_files, project_metadata)
419    }
420
421    /// Get Git information including unstaged changes
422    ///
423    /// # Arguments
424    ///
425    /// * `config` - The configuration object
426    /// * `include_unstaged` - Whether to include unstaged changes
427    ///
428    /// # Returns
429    ///
430    /// A Result containing the `CommitContext` or an error.
431    pub async fn get_git_info_with_unstaged(
432        &self,
433        _config: &Config,
434        include_unstaged: bool,
435    ) -> Result<CommitContext> {
436        debug!("Getting git info with unstaged flag: {}", include_unstaged);
437
438        // Extract all git2 data before crossing async boundaries
439        let files_info = self.extract_files_info(include_unstaged)?;
440
441        // Now perform async operations
442        let project_metadata = self.get_project_metadata(&files_info.file_paths).await?;
443
444        // Create and return the context
445        self.create_commit_context(
446            files_info.branch,
447            files_info.recent_commits,
448            files_info.staged_files,
449            project_metadata,
450        )
451    }
452
453    /// Get Git information for comparing two branches
454    ///
455    /// # Arguments
456    ///
457    /// * `config` - The configuration object
458    /// * `base_branch` - The base branch (e.g., "main")
459    /// * `target_branch` - The target branch (e.g., "feature-branch")
460    ///
461    /// # Returns
462    ///
463    /// A Result containing the `CommitContext` for the branch comparison or an error.
464    pub async fn get_git_info_for_branch_diff(
465        &self,
466        _config: &Config,
467        base_branch: &str,
468        target_branch: &str,
469    ) -> Result<CommitContext> {
470        debug!(
471            "Getting git info for branch diff: {} -> {}",
472            base_branch, target_branch
473        );
474        let repo = self.open_repo()?;
475
476        // Extract branch diff info
477        let (display_branch, recent_commits, file_paths) =
478            commit::extract_branch_diff_info(&repo, base_branch, target_branch)?;
479
480        // Get the actual file changes
481        let branch_files = commit::get_branch_diff_files(&repo, base_branch, target_branch)?;
482
483        // Get project metadata with async operations
484        let project_metadata = self.get_project_metadata(&file_paths).await?;
485
486        // Create and return the context
487        self.create_commit_context(
488            display_branch,
489            recent_commits,
490            branch_files,
491            project_metadata,
492        )
493    }
494
495    /// Get Git information for a commit range (for PR descriptions)
496    ///
497    /// # Arguments
498    ///
499    /// * `config` - The configuration object
500    /// * `from` - The starting Git reference (exclusive)
501    /// * `to` - The ending Git reference (inclusive)
502    ///
503    /// # Returns
504    ///
505    /// A Result containing the `CommitContext` for the commit range or an error.
506    pub async fn get_git_info_for_commit_range(
507        &self,
508        _config: &Config,
509        from: &str,
510        to: &str,
511    ) -> Result<CommitContext> {
512        debug!("Getting git info for commit range: {} -> {}", from, to);
513        let repo = self.open_repo()?;
514
515        // Extract commit range info
516        let (display_range, recent_commits, file_paths) =
517            commit::extract_commit_range_info(&repo, from, to)?;
518
519        // Get the actual file changes
520        let range_files = commit::get_commit_range_files(&repo, from, to)?;
521
522        // Get project metadata with async operations
523        let project_metadata = self.get_project_metadata(&file_paths).await?;
524
525        // Create and return the context
526        self.create_commit_context(display_range, recent_commits, range_files, project_metadata)
527    }
528
529    /// Get commits for PR description between two references
530    pub fn get_commits_for_pr(&self, from: &str, to: &str) -> Result<Vec<String>> {
531        let repo = self.open_repo()?;
532        commit::get_commits_for_pr(&repo, from, to)
533    }
534
535    /// Get files changed in a commit range
536    pub fn get_commit_range_files(&self, from: &str, to: &str) -> Result<Vec<StagedFile>> {
537        let repo = self.open_repo()?;
538        commit::get_commit_range_files(&repo, from, to)
539    }
540
541    /// Retrieves recent commits.
542    ///
543    /// # Arguments
544    ///
545    /// * `count` - The number of recent commits to retrieve.
546    ///
547    /// # Returns
548    ///
549    /// A Result containing a Vec of `RecentCommit` objects or an error.
550    pub fn get_recent_commits(&self, count: usize) -> Result<Vec<RecentCommit>> {
551        let repo = self.open_repo()?;
552        debug!("Fetching {} recent commits", count);
553        let mut revwalk = repo.revwalk()?;
554        revwalk.push_head()?;
555
556        let commits = revwalk
557            .take(count)
558            .map(|oid| {
559                let oid = oid?;
560                let commit = repo.find_commit(oid)?;
561                let author = commit.author();
562                Ok(RecentCommit {
563                    hash: oid.to_string(),
564                    message: commit.message().unwrap_or_default().to_string(),
565                    author: author.name().unwrap_or_default().to_string(),
566                    timestamp: commit.time().seconds().to_string(),
567                })
568            })
569            .collect::<Result<Vec<_>>>()?;
570
571        debug!("Retrieved {} recent commits", commits.len());
572        Ok(commits)
573    }
574
575    /// Commits changes and verifies the commit.
576    ///
577    /// # Arguments
578    ///
579    /// * `message` - The commit message.
580    ///
581    /// # Returns
582    ///
583    /// A Result containing the `CommitResult` or an error.
584    pub fn commit_and_verify(&self, message: &str) -> Result<CommitResult> {
585        if self.is_remote {
586            return Err(anyhow!(
587                "Cannot commit to a remote repository in read-only mode"
588            ));
589        }
590
591        let repo = self.open_repo()?;
592        match commit::commit(&repo, message, self.is_remote) {
593            Ok(result) => {
594                if let Err(e) = self.execute_hook("post-commit") {
595                    debug!("Post-commit hook failed: {}", e);
596                }
597                Ok(result)
598            }
599            Err(e) => {
600                debug!("Commit failed: {}", e);
601                Err(e)
602            }
603        }
604    }
605
606    /// Get Git information for a specific commit
607    ///
608    /// # Arguments
609    ///
610    /// * `config` - The configuration object
611    /// * `commit_id` - The ID of the commit to analyze
612    ///
613    /// # Returns
614    ///
615    /// A Result containing the `CommitContext` or an error.
616    pub async fn get_git_info_for_commit(
617        &self,
618        _config: &Config,
619        commit_id: &str,
620    ) -> Result<CommitContext> {
621        debug!("Getting git info for commit: {}", commit_id);
622        let repo = self.open_repo()?;
623
624        // Get branch name
625        let branch = self.get_current_branch()?;
626
627        // Extract commit info
628        let commit_info = commit::extract_commit_info(&repo, commit_id, &branch)?;
629
630        // Now get metadata with async operations
631        let project_metadata = self.get_project_metadata(&commit_info.file_paths).await?;
632
633        // Get the files from commit after async boundary
634        let commit_files = commit::get_commit_files(&repo, commit_id)?;
635
636        // Create and return the context
637        self.create_commit_context(
638            commit_info.branch,
639            vec![commit_info.commit],
640            commit_files,
641            project_metadata,
642        )
643    }
644
645    /// Get the commit date for a reference
646    pub fn get_commit_date(&self, commit_ish: &str) -> Result<String> {
647        let repo = self.open_repo()?;
648        commit::get_commit_date(&repo, commit_ish)
649    }
650
651    /// Get commits between two references with a callback
652    pub fn get_commits_between_with_callback<T, F>(
653        &self,
654        from: &str,
655        to: &str,
656        callback: F,
657    ) -> Result<Vec<T>>
658    where
659        F: FnMut(&RecentCommit) -> Result<T>,
660    {
661        let repo = self.open_repo()?;
662        commit::get_commits_between_with_callback(&repo, from, to, callback)
663    }
664
665    /// Commit changes to the repository
666    pub fn commit(&self, message: &str) -> Result<CommitResult> {
667        let repo = self.open_repo()?;
668        commit::commit(&repo, message, self.is_remote)
669    }
670
671    /// Check if inside a working tree
672    pub fn is_inside_work_tree() -> Result<bool> {
673        is_inside_work_tree()
674    }
675
676    /// Get the files changed in a specific commit
677    pub fn get_commit_files(&self, commit_id: &str) -> Result<Vec<StagedFile>> {
678        let repo = self.open_repo()?;
679        commit::get_commit_files(&repo, commit_id)
680    }
681
682    /// Get just the file paths for a specific commit
683    pub fn get_file_paths_for_commit(&self, commit_id: &str) -> Result<Vec<String>> {
684        let repo = self.open_repo()?;
685        commit::get_file_paths_for_commit(&repo, commit_id)
686    }
687}
688
689impl Drop for GitRepo {
690    fn drop(&mut self) {
691        // The TempDir will be automatically cleaned up when dropped
692        if self.is_remote {
693            debug!("Cleaning up temporary repository at {:?}", self.repo_path);
694        }
695    }
696}