git_iris/git/
repository.rs

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