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
17pub struct GitRepo {
19 repo_path: PathBuf,
20 #[allow(dead_code)] temp_dir: Option<TempDir>,
23 is_remote: bool,
25 remote_url: Option<String>,
27}
28
29impl GitRepo {
30 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 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(¤t_dir)
63 }
64 }
65
66 pub fn clone_remote_repository(url: &str) -> Result<Self> {
76 debug!("Cloning remote repository from URL: {}", url);
77
78 let _ = Url::parse(url).map_err(|e| anyhow!("Invalid repository URL: {e}"))?;
80
81 let temp_dir = TempDir::new()?;
83 let temp_path = temp_dir.path();
84
85 debug!("Created temporary directory for clone: {:?}", temp_path);
86
87 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 pub fn open_repo(&self) -> Result<Repository, git2::Error> {
105 Repository::open(&self.repo_path)
106 }
107
108 pub fn is_remote(&self) -> bool {
110 self.is_remote
111 }
112
113 pub fn get_remote_url(&self) -> Option<&str> {
115 self.remote_url.as_deref()
116 }
117
118 pub fn repo_path(&self) -> &PathBuf {
120 &self.repo_path
121 }
122
123 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 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 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 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 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 let repo_workdir = repo
185 .workdir()
186 .context("Repository has no working directory")?;
187 debug!("Repository working directory: {:?}", repo_workdir);
188
189 let mut command = Command::new(&hook_path);
191 command
192 .current_dir(repo_workdir) .env("GIT_DIR", repo.path()) .env("GIT_WORK_TREE", repo_workdir) .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 pub fn get_repo_root() -> Result<PathBuf> {
234 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 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 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 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 pub fn extract_files_info(&self, include_unstaged: bool) -> Result<RepoFilesInfo> {
310 let repo = self.open_repo()?;
311
312 let branch = self.get_current_branch()?;
314 let recent_commits = self.get_recent_commits(5)?;
315
316 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 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 pub fn get_unstaged_files(&self) -> Result<Vec<StagedFile>> {
337 let repo = self.open_repo()?;
338 get_unstaged_file_statuses(&repo)
339 }
340
341 pub async fn get_project_metadata(&self, changed_files: &[String]) -> Result<ProjectMetadata> {
351 metadata::extract_project_metadata(changed_files, 10).await
353 }
354
355 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 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 Ok(CommitContext::new(
381 branch,
382 recent_commits,
383 staged_files,
384 project_metadata,
385 user_name,
386 user_email,
387 ))
388 }
389
390 pub async fn get_git_info(&self, _config: &Config) -> Result<CommitContext> {
400 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 let project_metadata = self.get_project_metadata(&changed_files).await?;
415 debug!("Extracted project metadata: {:?}", project_metadata);
416
417 self.create_commit_context(branch, recent_commits, staged_files, project_metadata)
419 }
420
421 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 let files_info = self.extract_files_info(include_unstaged)?;
440
441 let project_metadata = self.get_project_metadata(&files_info.file_paths).await?;
443
444 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 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 let (display_branch, recent_commits, file_paths) =
478 commit::extract_branch_diff_info(&repo, base_branch, target_branch)?;
479
480 let branch_files = commit::get_branch_diff_files(&repo, base_branch, target_branch)?;
482
483 let project_metadata = self.get_project_metadata(&file_paths).await?;
485
486 self.create_commit_context(
488 display_branch,
489 recent_commits,
490 branch_files,
491 project_metadata,
492 )
493 }
494
495 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 let (display_range, recent_commits, file_paths) =
517 commit::extract_commit_range_info(&repo, from, to)?;
518
519 let range_files = commit::get_commit_range_files(&repo, from, to)?;
521
522 let project_metadata = self.get_project_metadata(&file_paths).await?;
524
525 self.create_commit_context(display_range, recent_commits, range_files, project_metadata)
527 }
528
529 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 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 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 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 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 let branch = self.get_current_branch()?;
626
627 let commit_info = commit::extract_commit_info(&repo, commit_id, &branch)?;
629
630 let project_metadata = self.get_project_metadata(&commit_info.file_paths).await?;
632
633 let commit_files = commit::get_commit_files(&repo, commit_id)?;
635
636 self.create_commit_context(
638 commit_info.branch,
639 vec![commit_info.commit],
640 commit_files,
641 project_metadata,
642 )
643 }
644
645 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 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 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 pub fn is_inside_work_tree() -> Result<bool> {
673 is_inside_work_tree()
674 }
675
676 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 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 if self.is_remote {
693 debug!("Cleaning up temporary repository at {:?}", self.repo_path);
694 }
695 }
696}