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
16pub struct GitRepo {
18 repo_path: PathBuf,
19 #[allow(dead_code)] temp_dir: Option<TempDir>,
22 is_remote: bool,
24 remote_url: Option<String>,
26}
27
28impl GitRepo {
29 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 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(¤t_dir)
62 }
63 }
64
65 pub fn clone_remote_repository(url: &str) -> Result<Self> {
75 log_debug!("Cloning remote repository from URL: {}", url);
76
77 let _ = Url::parse(url).map_err(|e| anyhow!("Invalid repository URL: {}", e))?;
79
80 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 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 pub fn open_repo(&self) -> Result<Repository, git2::Error> {
104 Repository::open(&self.repo_path)
105 }
106
107 pub fn is_remote(&self) -> bool {
109 self.is_remote
110 }
111
112 pub fn get_remote_url(&self) -> Option<&str> {
114 self.remote_url.as_deref()
115 }
116
117 pub fn repo_path(&self) -> &PathBuf {
119 &self.repo_path
120 }
121
122 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 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 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 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 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 let repo_workdir = repo
184 .workdir()
185 .context("Repository has no working directory")?;
186 log_debug!("Repository working directory: {:?}", repo_workdir);
187
188 let mut command = Command::new(&hook_path);
190 command
191 .current_dir(repo_workdir) .env("GIT_DIR", repo.path()) .env("GIT_WORK_TREE", repo_workdir) .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 pub fn get_repo_root() -> Result<PathBuf> {
233 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 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 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 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 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 pub fn extract_files_info(&self, include_unstaged: bool) -> Result<RepoFilesInfo> {
323 let repo = self.open_repo()?;
324
325 let branch = self.get_current_branch()?;
327 let recent_commits = self.get_recent_commits(5)?;
328
329 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 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 pub fn get_unstaged_files(&self) -> Result<Vec<StagedFile>> {
350 let repo = self.open_repo()?;
351 get_unstaged_file_statuses(&repo)
352 }
353
354 pub async fn get_project_metadata(&self, changed_files: &[String]) -> Result<ProjectMetadata> {
364 metadata::extract_project_metadata(changed_files, 10).await
366 }
367
368 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 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 Ok(CommitContext::new(
394 branch,
395 recent_commits,
396 staged_files,
397 project_metadata,
398 user_name,
399 user_email,
400 ))
401 }
402
403 pub async fn get_git_info(&self, _config: &Config) -> Result<CommitContext> {
413 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 let project_metadata = self.get_project_metadata(&changed_files).await?;
428 log_debug!("Extracted project metadata: {:?}", project_metadata);
429
430 self.create_commit_context(branch, recent_commits, staged_files, project_metadata)
432 }
433
434 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 let files_info = self.extract_files_info(include_unstaged)?;
453
454 let project_metadata = self.get_project_metadata(&files_info.file_paths).await?;
456
457 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 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 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 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 let branch = self.get_current_branch()?;
551
552 let commit_info = commit::extract_commit_info(&repo, commit_id, &branch)?;
554
555 let project_metadata = self.get_project_metadata(&commit_info.file_paths).await?;
557
558 let commit_files = commit::get_commit_files(&repo, commit_id)?;
560
561 self.create_commit_context(
563 commit_info.branch,
564 vec![commit_info.commit],
565 commit_files,
566 project_metadata,
567 )
568 }
569
570 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 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 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 pub fn is_inside_work_tree() -> Result<bool> {
598 is_inside_work_tree()
599 }
600
601 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 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 if self.is_remote {
618 log_debug!("Cleaning up temporary repository at {:?}", self.repo_path);
619 }
620 }
621}