1use crate::config::Config;
2use crate::context::{CommitContext, RecentCommit, StagedFile};
3use crate::git::commit::{self, CommitResult};
4use crate::git::files::{
5 RepoFilesInfo, get_ahead_behind, get_all_tracked_files, get_file_statuses,
6 get_unstaged_file_statuses, get_untracked_files,
7};
8use crate::git::utils::is_inside_work_tree;
9use crate::log_debug;
10use anyhow::{Context as AnyhowContext, Result, anyhow};
11use git2::{Repository, Tree};
12use std::env;
13use std::path::{Path, PathBuf};
14use std::process::{Command, Stdio};
15use tempfile::TempDir;
16use url::Url;
17
18#[derive(Debug)]
20pub struct GitRepo {
21 repo_path: PathBuf,
22 #[allow(dead_code)] temp_dir: Option<TempDir>,
25 is_remote: bool,
27 remote_url: Option<String>,
29}
30
31impl GitRepo {
32 pub fn new(repo_path: &Path) -> Result<Self> {
42 Ok(Self {
43 repo_path: repo_path.to_path_buf(),
44 temp_dir: None,
45 is_remote: false,
46 remote_url: None,
47 })
48 }
49
50 pub fn new_from_url(repository_url: Option<String>) -> Result<Self> {
60 if let Some(url) = repository_url {
61 Self::clone_remote_repository(&url)
62 } else {
63 let current_dir = env::current_dir()?;
64 Self::new(¤t_dir)
65 }
66 }
67
68 pub fn clone_remote_repository(url: &str) -> Result<Self> {
78 log_debug!("Cloning remote repository from URL: {}", url);
79
80 let _ = Url::parse(url).map_err(|e| anyhow!("Invalid repository URL: {}", e))?;
82
83 let temp_dir = TempDir::new()?;
85 let temp_path = temp_dir.path();
86
87 log_debug!("Created temporary directory for clone: {:?}", temp_path);
88
89 let repo = match Repository::clone(url, temp_path) {
91 Ok(repo) => repo,
92 Err(e) => return Err(anyhow!("Failed to clone repository: {}", e)),
93 };
94
95 log_debug!("Successfully cloned repository to {:?}", repo.path());
96
97 Ok(Self {
98 repo_path: temp_path.to_path_buf(),
99 temp_dir: Some(temp_dir),
100 is_remote: true,
101 remote_url: Some(url.to_string()),
102 })
103 }
104
105 pub fn open_repo(&self) -> Result<Repository, git2::Error> {
107 Repository::open(&self.repo_path)
108 }
109
110 pub fn is_remote(&self) -> bool {
112 self.is_remote
113 }
114
115 pub fn get_remote_url(&self) -> Option<&str> {
117 self.remote_url.as_deref()
118 }
119
120 pub fn repo_path(&self) -> &PathBuf {
122 &self.repo_path
123 }
124
125 pub fn update_remote(&self) -> Result<()> {
127 if !self.is_remote {
128 return Err(anyhow!("Not a remote repository"));
129 }
130
131 log_debug!("Updating remote repository");
132 let repo = self.open_repo()?;
133
134 let remotes = repo.remotes()?;
136 let remote_name = remotes
137 .iter()
138 .flatten()
139 .next()
140 .ok_or_else(|| anyhow!("No remote found"))?;
141
142 let mut remote = repo.find_remote(remote_name)?;
144 remote.fetch(&["master", "main"], None, None)?;
145
146 log_debug!("Successfully updated remote repository");
147 Ok(())
148 }
149
150 pub fn get_current_branch(&self) -> Result<String> {
156 let repo = self.open_repo()?;
157 let head = repo.head()?;
158 let branch_name = head.shorthand().unwrap_or("HEAD detached").to_string();
159 log_debug!("Current branch: {}", branch_name);
160 Ok(branch_name)
161 }
162
163 pub fn execute_hook(&self, hook_name: &str) -> Result<()> {
173 if self.is_remote {
174 log_debug!("Skipping hook execution for remote repository");
175 return Ok(());
176 }
177
178 let repo = self.open_repo()?;
179 let hook_path = repo.path().join("hooks").join(hook_name);
180
181 if hook_path.exists() {
182 log_debug!("Executing hook: {}", hook_name);
183 log_debug!("Hook path: {:?}", hook_path);
184
185 let repo_workdir = repo
187 .workdir()
188 .context("Repository has no working directory")?;
189 log_debug!("Repository working directory: {:?}", repo_workdir);
190
191 let mut command = Command::new(&hook_path);
193 command
194 .current_dir(repo_workdir) .env("GIT_DIR", repo.path()) .env("GIT_WORK_TREE", repo_workdir) .stdout(Stdio::piped())
198 .stderr(Stdio::piped());
199
200 log_debug!("Executing hook command: {:?}", command);
201
202 let mut child = command.spawn()?;
203
204 let stdout = child.stdout.take().context("Could not get stdout")?;
205 let stderr = child.stderr.take().context("Could not get stderr")?;
206
207 std::thread::spawn(move || {
208 if let Err(e) =
209 std::io::copy(&mut std::io::BufReader::new(stdout), &mut std::io::stdout())
210 {
211 tracing::debug!("Failed to copy hook stdout: {e}");
212 }
213 });
214 std::thread::spawn(move || {
215 if let Err(e) =
216 std::io::copy(&mut std::io::BufReader::new(stderr), &mut std::io::stderr())
217 {
218 tracing::debug!("Failed to copy hook stderr: {e}");
219 }
220 });
221
222 let status = child.wait()?;
223
224 if !status.success() {
225 return Err(anyhow!(
226 "Hook '{}' failed with exit code: {:?}",
227 hook_name,
228 status.code()
229 ));
230 }
231
232 log_debug!("Hook '{}' executed successfully", hook_name);
233 } else {
234 log_debug!("Hook '{}' not found at {:?}", hook_name, hook_path);
235 }
236
237 Ok(())
238 }
239
240 pub fn get_repo_root() -> Result<PathBuf> {
242 if !is_inside_work_tree()? {
244 return Err(anyhow!(
245 "Not in a Git repository. Please run this command from within a Git repository."
246 ));
247 }
248
249 let output = Command::new("git")
251 .args(["rev-parse", "--show-toplevel"])
252 .output()
253 .context("Failed to execute git command")?;
254
255 if !output.status.success() {
256 return Err(anyhow!(
257 "Failed to get repository root: {}",
258 String::from_utf8_lossy(&output.stderr)
259 ));
260 }
261
262 let root = String::from_utf8(output.stdout)
264 .context("Invalid UTF-8 output from git command")?
265 .trim()
266 .to_string();
267
268 Ok(PathBuf::from(root))
269 }
270
271 pub fn get_readme_at_commit(&self, commit_ish: &str) -> Result<Option<String>> {
281 let repo = self.open_repo()?;
282 let obj = repo.revparse_single(commit_ish)?;
283 let tree = obj.peel_to_tree()?;
284
285 Self::find_readme_in_tree(&repo, &tree)
286 .context("Failed to find and read README at specified commit")
287 }
288
289 fn find_readme_in_tree(repo: &Repository, tree: &Tree) -> Result<Option<String>> {
299 log_debug!("Searching for README file in the repository");
300
301 let readme_patterns = [
302 "README.md",
303 "README.markdown",
304 "README.txt",
305 "README",
306 "Readme.md",
307 "readme.md",
308 ];
309
310 for entry in tree {
311 let name = entry.name().unwrap_or("");
312 if readme_patterns
313 .iter()
314 .any(|&pattern| name.eq_ignore_ascii_case(pattern))
315 {
316 let object = entry.to_object(repo)?;
317 if let Some(blob) = object.as_blob()
318 && let Ok(content) = std::str::from_utf8(blob.content())
319 {
320 log_debug!("README file found: {}", name);
321 return Ok(Some(content.to_string()));
322 }
323 }
324 }
325
326 log_debug!("No README file found");
327 Ok(None)
328 }
329
330 pub fn extract_files_info(&self, include_unstaged: bool) -> Result<RepoFilesInfo> {
332 let repo = self.open_repo()?;
333
334 let branch = self.get_current_branch()?;
336 let recent_commits = self.get_recent_commits(5)?;
337
338 let mut staged_files = get_file_statuses(&repo)?;
340 if include_unstaged {
341 let unstaged_files = self.get_unstaged_files()?;
342 staged_files.extend(unstaged_files);
343 log_debug!("Combined {} files (staged + unstaged)", staged_files.len());
344 }
345
346 let file_paths: Vec<String> = staged_files.iter().map(|file| file.path.clone()).collect();
348
349 Ok(RepoFilesInfo {
350 branch,
351 recent_commits,
352 staged_files,
353 file_paths,
354 })
355 }
356
357 pub fn get_unstaged_files(&self) -> Result<Vec<StagedFile>> {
359 let repo = self.open_repo()?;
360 get_unstaged_file_statuses(&repo)
361 }
362
363 pub fn get_ref_diff_full(&self, from: &str, to: &str) -> Result<String> {
371 let repo = self.open_repo()?;
372
373 let from_commit = repo.revparse_single(from)?.peel_to_commit()?;
375 let to_commit = repo.revparse_single(to)?.peel_to_commit()?;
376
377 let from_tree = from_commit.tree()?;
378 let to_tree = to_commit.tree()?;
379
380 let diff = repo.diff_tree_to_tree(Some(&from_tree), Some(&to_tree), None)?;
382
383 let mut diff_string = String::new();
385 diff.print(git2::DiffFormat::Patch, |delta, _hunk, line| {
386 if matches!(line.origin(), '+' | '-' | ' ') {
388 diff_string.push(line.origin());
389 }
390 diff_string.push_str(&String::from_utf8_lossy(line.content()));
392
393 if line.origin() == 'F'
394 && !diff_string.contains("diff --git")
395 && let Some(new_file) = delta.new_file().path()
396 {
397 let header = format!("diff --git a/{0} b/{0}\n", new_file.display());
398 if !diff_string.ends_with(&header) {
399 diff_string.insert_str(
400 diff_string.rfind("---").unwrap_or(diff_string.len()),
401 &header,
402 );
403 }
404 }
405 true
406 })?;
407
408 Ok(diff_string)
409 }
410
411 pub fn get_staged_diff_full(&self) -> Result<String> {
419 let repo = self.open_repo()?;
420
421 let head = repo.head()?;
423 let head_tree = head.peel_to_tree()?;
424
425 let diff = repo.diff_tree_to_index(Some(&head_tree), None, None)?;
427
428 let mut diff_string = String::new();
430 diff.print(git2::DiffFormat::Patch, |delta, _hunk, line| {
431 match line.origin() {
433 'H' => {
434 diff_string.push_str(&String::from_utf8_lossy(line.content()));
436 }
437 'F' => {
438 diff_string.push_str(&String::from_utf8_lossy(line.content()));
440 }
441 '+' | '-' | ' ' => {
442 diff_string.push(line.origin());
443 diff_string.push_str(&String::from_utf8_lossy(line.content()));
444 }
445 '>' | '<' | '=' => {
446 diff_string.push_str(&String::from_utf8_lossy(line.content()));
448 }
449 _ => {
450 diff_string.push_str(&String::from_utf8_lossy(line.content()));
452 }
453 }
454
455 if line.origin() == 'F'
457 && !diff_string.contains("diff --git")
458 && let Some(new_file) = delta.new_file().path()
459 {
460 let header = format!("diff --git a/{0} b/{0}\n", new_file.display());
461 if !diff_string.ends_with(&header) {
462 diff_string.insert_str(
463 diff_string.rfind("---").unwrap_or(diff_string.len()),
464 &header,
465 );
466 }
467 }
468 true
469 })?;
470
471 Ok(diff_string)
472 }
473
474 fn create_commit_context(
487 &self,
488 branch: String,
489 recent_commits: Vec<RecentCommit>,
490 staged_files: Vec<StagedFile>,
491 ) -> Result<CommitContext> {
492 let repo = self.open_repo()?;
494 let user_name = repo.config()?.get_string("user.name").unwrap_or_default();
495 let user_email = repo.config()?.get_string("user.email").unwrap_or_default();
496
497 Ok(CommitContext::new(
499 branch,
500 recent_commits,
501 staged_files,
502 user_name,
503 user_email,
504 ))
505 }
506
507 pub fn get_git_info(&self, _config: &Config) -> Result<CommitContext> {
517 let repo = self.open_repo()?;
519 log_debug!("Getting git info for repo path: {:?}", repo.path());
520
521 let branch = self.get_current_branch()?;
522 let recent_commits = self.get_recent_commits(5)?;
523 let staged_files = get_file_statuses(&repo)?;
524
525 self.create_commit_context(branch, recent_commits, staged_files)
527 }
528
529 pub fn get_git_info_with_unstaged(
540 &self,
541 _config: &Config,
542 include_unstaged: bool,
543 ) -> Result<CommitContext> {
544 log_debug!("Getting git info with unstaged flag: {}", include_unstaged);
545
546 let files_info = self.extract_files_info(include_unstaged)?;
548
549 self.create_commit_context(
551 files_info.branch,
552 files_info.recent_commits,
553 files_info.staged_files,
554 )
555 }
556
557 pub fn get_git_info_for_branch_diff(
569 &self,
570 _config: &Config,
571 base_branch: &str,
572 target_branch: &str,
573 ) -> Result<CommitContext> {
574 log_debug!(
575 "Getting git info for branch diff: {} -> {}",
576 base_branch,
577 target_branch
578 );
579 let repo = self.open_repo()?;
580
581 let (display_branch, recent_commits, _file_paths) =
583 commit::extract_branch_diff_info(&repo, base_branch, target_branch)?;
584
585 let branch_files = commit::get_branch_diff_files(&repo, base_branch, target_branch)?;
587
588 self.create_commit_context(display_branch, recent_commits, branch_files)
590 }
591
592 pub fn get_git_info_for_commit_range(
604 &self,
605 _config: &Config,
606 from: &str,
607 to: &str,
608 ) -> Result<CommitContext> {
609 log_debug!("Getting git info for commit range: {} -> {}", from, to);
610 let repo = self.open_repo()?;
611
612 let (display_range, recent_commits, _file_paths) =
614 commit::extract_commit_range_info(&repo, from, to)?;
615
616 let range_files = commit::get_commit_range_files(&repo, from, to)?;
618
619 self.create_commit_context(display_range, recent_commits, range_files)
621 }
622
623 pub fn get_commits_for_pr(&self, from: &str, to: &str) -> Result<Vec<String>> {
625 let repo = self.open_repo()?;
626 commit::get_commits_for_pr(&repo, from, to)
627 }
628
629 pub fn get_commit_range_files(&self, from: &str, to: &str) -> Result<Vec<StagedFile>> {
631 let repo = self.open_repo()?;
632 commit::get_commit_range_files(&repo, from, to)
633 }
634
635 pub fn get_recent_commits(&self, count: usize) -> Result<Vec<RecentCommit>> {
645 let repo = self.open_repo()?;
646 log_debug!("Fetching {} recent commits", count);
647 let mut revwalk = repo.revwalk()?;
648 revwalk.push_head()?;
649
650 let commits = revwalk
651 .take(count)
652 .map(|oid| {
653 let oid = oid?;
654 let commit = repo.find_commit(oid)?;
655 let author = commit.author();
656 Ok(RecentCommit {
657 hash: oid.to_string(),
658 message: commit.message().unwrap_or_default().to_string(),
659 author: author.name().unwrap_or_default().to_string(),
660 timestamp: commit.time().seconds().to_string(),
661 })
662 })
663 .collect::<Result<Vec<_>>>()?;
664
665 log_debug!("Retrieved {} recent commits", commits.len());
666 Ok(commits)
667 }
668
669 pub fn commit_and_verify(&self, message: &str) -> Result<CommitResult> {
679 if self.is_remote {
680 return Err(anyhow!(
681 "Cannot commit to a remote repository in read-only mode"
682 ));
683 }
684
685 let repo = self.open_repo()?;
686 match commit::commit(&repo, message, self.is_remote) {
687 Ok(result) => {
688 if let Err(e) = self.execute_hook("post-commit") {
689 log_debug!("Post-commit hook failed: {}", e);
690 }
691 Ok(result)
692 }
693 Err(e) => {
694 log_debug!("Commit failed: {}", e);
695 Err(e)
696 }
697 }
698 }
699
700 pub fn get_git_info_for_commit(
711 &self,
712 _config: &Config,
713 commit_id: &str,
714 ) -> Result<CommitContext> {
715 log_debug!("Getting git info for commit: {}", commit_id);
716 let repo = self.open_repo()?;
717
718 let branch = self.get_current_branch()?;
720
721 let commit_info = commit::extract_commit_info(&repo, commit_id, &branch)?;
723
724 let commit_files = commit::get_commit_files(&repo, commit_id)?;
726
727 self.create_commit_context(commit_info.branch, vec![commit_info.commit], commit_files)
729 }
730
731 pub fn get_commit_date(&self, commit_ish: &str) -> Result<String> {
733 let repo = self.open_repo()?;
734 commit::get_commit_date(&repo, commit_ish)
735 }
736
737 pub fn get_commits_between_with_callback<T, F>(
739 &self,
740 from: &str,
741 to: &str,
742 callback: F,
743 ) -> Result<Vec<T>>
744 where
745 F: FnMut(&RecentCommit) -> Result<T>,
746 {
747 let repo = self.open_repo()?;
748 commit::get_commits_between_with_callback(&repo, from, to, callback)
749 }
750
751 pub fn commit(&self, message: &str) -> Result<CommitResult> {
753 let repo = self.open_repo()?;
754 commit::commit(&repo, message, self.is_remote)
755 }
756
757 pub fn amend_commit(&self, message: &str) -> Result<CommitResult> {
759 let repo = self.open_repo()?;
760 commit::amend_commit(&repo, message, self.is_remote)
761 }
762
763 pub fn get_head_commit_message(&self) -> Result<String> {
765 let repo = self.open_repo()?;
766 commit::get_head_commit_message(&repo)
767 }
768
769 pub fn is_inside_work_tree() -> Result<bool> {
771 is_inside_work_tree()
772 }
773
774 pub fn get_commit_files(&self, commit_id: &str) -> Result<Vec<StagedFile>> {
776 let repo = self.open_repo()?;
777 commit::get_commit_files(&repo, commit_id)
778 }
779
780 pub fn get_file_paths_for_commit(&self, commit_id: &str) -> Result<Vec<String>> {
782 let repo = self.open_repo()?;
783 commit::get_file_paths_for_commit(&repo, commit_id)
784 }
785
786 pub fn stage_file(&self, path: &Path) -> Result<()> {
788 let repo = self.open_repo()?;
789 let mut index = repo.index()?;
790
791 let full_path = self.repo_path.join(path);
793 if full_path.exists() {
794 index.add_path(path)?;
795 } else {
796 index.remove_path(path)?;
798 }
799
800 index.write()?;
801 Ok(())
802 }
803
804 pub fn unstage_file(&self, path: &Path) -> Result<()> {
806 let repo = self.open_repo()?;
807
808 let head = repo.head()?;
810 let head_commit = head.peel_to_commit()?;
811 let head_tree = head_commit.tree()?;
812
813 let mut index = repo.index()?;
814
815 if let Ok(entry) = head_tree.get_path(path) {
817 let blob = repo.find_blob(entry.id())?;
819 #[allow(
820 clippy::cast_sign_loss,
821 clippy::cast_possible_truncation,
822 clippy::as_conversions
823 )]
824 let file_mode = entry.filemode() as u32;
825 #[allow(clippy::cast_possible_truncation, clippy::as_conversions)]
826 let file_size = blob.content().len() as u32;
827 index.add_frombuffer(
828 &git2::IndexEntry {
829 ctime: git2::IndexTime::new(0, 0),
830 mtime: git2::IndexTime::new(0, 0),
831 dev: 0,
832 ino: 0,
833 mode: file_mode,
834 uid: 0,
835 gid: 0,
836 file_size,
837 id: entry.id(),
838 flags: 0,
839 flags_extended: 0,
840 path: path.to_string_lossy().as_bytes().to_vec(),
841 },
842 blob.content(),
843 )?;
844 } else {
845 index.remove_path(path)?;
847 }
848
849 index.write()?;
850 Ok(())
851 }
852
853 pub fn stage_all(&self) -> Result<()> {
855 let repo = self.open_repo()?;
856 let mut index = repo.index()?;
857 index.add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)?;
858 index.write()?;
859 Ok(())
860 }
861
862 pub fn unstage_all(&self) -> Result<()> {
864 let repo = self.open_repo()?;
865 let head = repo.head()?;
866 let head_commit = head.peel_to_commit()?;
867 repo.reset(head_commit.as_object(), git2::ResetType::Mixed, None)?;
868 Ok(())
869 }
870
871 pub fn get_untracked_files(&self) -> Result<Vec<String>> {
873 let repo = self.open_repo()?;
874 get_untracked_files(&repo)
875 }
876
877 pub fn get_all_tracked_files(&self) -> Result<Vec<String>> {
879 let repo = self.open_repo()?;
880 get_all_tracked_files(&repo)
881 }
882
883 pub fn get_ahead_behind(&self) -> (usize, usize) {
887 let Ok(repo) = self.open_repo() else {
888 return (0, 0);
889 };
890 get_ahead_behind(&repo)
891 }
892}
893
894impl Drop for GitRepo {
895 fn drop(&mut self) {
896 if self.is_remote {
898 log_debug!("Cleaning up temporary repository at {:?}", self.repo_path);
899 }
900 }
901}