1use std::path::Path;
4use std::time::SystemTime;
5
6use rskit_errors::{AppError, AppResult};
7
8use crate::error::GitError;
9use crate::options::{BlameOptions, LogOptions};
10use crate::paths::validate_repo_relative_path;
11use crate::read::{Blamer, Differ, IgnoreReader, IndexReader, LogReader, TreeReader};
12use crate::types::{
13 BlameLine, Commit, DiffEntry, DiffStats, EntryKind, EntryState, FileStatus, IndexEntry, Oid,
14 StatusEntry, TreeEntry, TreeHash,
15};
16
17use super::{Git2Repository, commit_from_git2, oid_from_git2, signature_from_git2};
18
19impl Differ for Git2Repository {
20 fn diff(&self, from: &str, to: &str) -> AppResult<Vec<DiffEntry>> {
21 let from_tree = self.tree_for_ref(from)?;
22 let to_tree = self.tree_for_ref(to)?;
23 let diff = self
24 .repo
25 .diff_tree_to_tree(Some(&from_tree), Some(&to_tree), None)
26 .map_err(GitError::Internal)?;
27
28 let mut entries = Vec::new();
29 diff.foreach(
30 &mut |delta, _| {
31 entries.push(diff_entry_from_delta(&delta));
32 true
33 },
34 None,
35 None,
36 None,
37 )
38 .map_err(GitError::Internal)?;
39
40 Ok(entries)
41 }
42
43 fn diff_stats(&self, from: &str, to: &str) -> AppResult<DiffStats> {
44 let from_tree = self.tree_for_ref(from)?;
45 let to_tree = self.tree_for_ref(to)?;
46 let diff = self
47 .repo
48 .diff_tree_to_tree(Some(&from_tree), Some(&to_tree), None)
49 .map_err(GitError::Internal)?;
50 let stats = diff.stats().map_err(GitError::Internal)?;
51
52 Ok(DiffStats {
53 additions: stats.insertions(),
54 deletions: stats.deletions(),
55 files_changed: stats.files_changed(),
56 })
57 }
58
59 fn status(&self) -> AppResult<Vec<StatusEntry>> {
60 let mut opts = git2::StatusOptions::new();
61 opts.include_untracked(true)
62 .include_ignored(false)
63 .recurse_untracked_dirs(true)
64 .renames_head_to_index(true)
65 .renames_index_to_workdir(true);
66
67 let statuses = self
68 .repo
69 .statuses(Some(&mut opts))
70 .map_err(GitError::Internal)?;
71 let mut entries = Vec::new();
72
73 for entry in statuses.iter() {
74 let path = entry.path().map_err(GitError::Internal)?;
75 entries.push(StatusEntry {
76 path: path.to_string(),
77 state: entry_state_from_status(entry.status()),
78 });
79 }
80
81 Ok(entries)
82 }
83}
84
85impl IgnoreReader for Git2Repository {
86 fn is_ignored(&self, path: &str) -> AppResult<bool> {
87 validate_repo_relative_path(path)?;
88 if self.repo.is_bare() {
89 return Err(GitError::NotImplemented {
90 operation: "is_ignored on bare repository",
91 }
92 .into());
93 }
94 self.repo
95 .status_should_ignore(Path::new(path))
96 .map_err(GitError::Internal)
97 .map_err(Into::into)
98 }
99}
100
101impl TreeReader for Git2Repository {
102 fn tree_hash(&self, revision: &str, path: &str) -> AppResult<TreeHash> {
103 let tree = self.resolve_tree(revision, path)?;
104 Ok(oid_from_git2(tree.id()))
105 }
106
107 fn file_at(&self, revision: &str, path: &str) -> AppResult<Vec<u8>> {
108 let obj = self
109 .repo
110 .revparse_single(revision)
111 .map_err(|_| GitError::RefNotFound {
112 refname: revision.to_string(),
113 })?;
114 let commit = obj.peel_to_commit().map_err(|_| GitError::RefNotFound {
115 refname: revision.to_string(),
116 })?;
117 let tree = commit.tree().map_err(GitError::Internal)?;
118 let entry = tree
119 .get_path(Path::new(path))
120 .map_err(|_| GitError::InvalidPath {
121 path: path.to_string(),
122 })?;
123 let blob = self
124 .repo
125 .find_blob(entry.id())
126 .map_err(GitError::Internal)?;
127 Ok(blob.content().to_vec())
128 }
129
130 fn list_entries(&self, revision: &str, path: &str) -> AppResult<Vec<TreeEntry>> {
131 let tree = self.resolve_tree(revision, path)?;
132 Ok(tree
133 .iter()
134 .map(|entry| TreeEntry {
135 name: entry.name().unwrap_or("").to_string(),
136 oid: oid_from_git2(entry.id()),
137 kind: entry_kind_from_filemode(entry.filemode()),
138 filemode: entry.filemode() as u32,
139 })
140 .collect())
141 }
142}
143
144impl IndexReader for Git2Repository {
145 fn index_entry(&self, path: &str) -> AppResult<Option<IndexEntry>> {
146 let index = self.repo.index().map_err(GitError::Internal)?;
147 Ok(index.get_path(Path::new(path), 0).map(|entry| IndexEntry {
148 path: path.to_string(),
149 oid: oid_from_git2(entry.id),
150 kind: entry_kind_from_filemode(entry.mode as i32),
151 filemode: entry.mode,
152 }))
153 }
154}
155
156impl LogReader for Git2Repository {
157 fn log(&self, opts: Option<&LogOptions>) -> AppResult<Vec<Commit>> {
158 let opts = opts.cloned().unwrap_or_default();
159 let mut walk = self.repo.revwalk().map_err(GitError::Internal)?;
160 walk.set_sorting(git2::Sort::TIME | git2::Sort::TOPOLOGICAL)
161 .map_err(GitError::Internal)?;
162 walk.push_head().map_err(GitError::Internal)?;
163
164 let mut commits = Vec::new();
165 for oid in walk {
166 let oid = oid.map_err(GitError::Internal)?;
167 let commit = self.repo.find_commit(oid).map_err(GitError::Internal)?;
168 if self.commit_matches_log_options(&commit, &opts)? {
169 commits.push(commit_from_git2(&commit));
170 if opts.max_count.is_some_and(|max| commits.len() >= max) {
171 break;
172 }
173 }
174 }
175
176 Ok(commits)
177 }
178
179 fn merge_base(&self, a: &str, b: &str) -> AppResult<Oid> {
180 let a_rev = a.to_string();
181 let b_rev = b.to_string();
182 let a = self.resolve_commit(a)?;
183 let b = self.resolve_commit(b)?;
184 self.repo
185 .merge_base(a.id(), b.id())
186 .map(oid_from_git2)
187 .map_err(|e| {
188 if e.code() == git2::ErrorCode::NotFound {
189 GitError::NoMergeBase { a: a_rev, b: b_rev }
190 } else {
191 GitError::Internal(e)
192 }
193 })
194 .map_err(Into::into)
195 }
196
197 fn is_ancestor(&self, ancestor: &str, descendant: &str) -> AppResult<bool> {
198 let ancestor = self.resolve_commit(ancestor)?;
199 let descendant = self.resolve_commit(descendant)?;
200 self.repo
201 .graph_descendant_of(descendant.id(), ancestor.id())
202 .map_err(GitError::Internal)
203 .map_err(Into::into)
204 }
205}
206
207impl Blamer for Git2Repository {
208 fn blame(
209 &self,
210 revision: &str,
211 path: &str,
212 opts: Option<&BlameOptions>,
213 ) -> AppResult<Vec<BlameLine>> {
214 let commit = self.resolve_commit(revision)?;
215 let file = String::from_utf8_lossy(&self.file_at(revision, path)?).into_owned();
216 let lines: Vec<&str> = file.lines().collect();
217
218 let mut blame_opts = git2::BlameOptions::new();
219 blame_opts.newest_commit(commit.id());
220 if let Some(opts) = opts {
221 if let (Some(start), Some(end)) = (opts.start_line, opts.end_line)
222 && start > end
223 {
224 return Err(GitError::InvalidLineRange { start, end }.into());
225 }
226 if let Some(start_line) = opts.start_line {
227 blame_opts.min_line(start_line);
228 }
229 if let Some(end_line) = opts.end_line {
230 blame_opts.max_line(end_line);
231 }
232 blame_opts.ignore_whitespace(opts.ignore_whitespace);
233 }
234
235 let blame = self
236 .repo
237 .blame_file(Path::new(path), Some(&mut blame_opts))
238 .map_err(GitError::Internal)?;
239
240 let mut entries = Vec::new();
241 for hunk in blame.iter() {
242 let signature = hunk
243 .final_signature()
244 .as_ref()
245 .map(signature_from_git2)
246 .ok_or_else(|| AppError::invalid_format("blame signature", "author signature"))?;
247 let commit_oid = oid_from_git2(hunk.final_commit_id());
248 let start = hunk.final_start_line();
249 let end = start + hunk.lines_in_hunk();
250 for line in start..end {
251 let content = lines.get(line - 1).copied().unwrap_or_default().to_string();
252 entries.push(BlameLine {
253 line,
254 commit_oid,
255 author: signature.clone(),
256 content,
257 });
258 }
259 }
260
261 Ok(entries)
262 }
263}
264
265impl Git2Repository {
266 pub(crate) fn tree_for_ref(&self, refname: &str) -> AppResult<git2::Tree<'_>> {
267 let obj = self
268 .repo
269 .revparse_single(refname)
270 .map_err(|_| GitError::RefNotFound {
271 refname: refname.to_string(),
272 })?;
273 if let Ok(tree) = obj.peel_to_tree() {
274 return Ok(tree);
275 }
276 let commit = obj.peel_to_commit().map_err(|_| GitError::RefNotFound {
277 refname: refname.to_string(),
278 })?;
279 commit.tree().map_err(|e| GitError::Internal(e).into())
280 }
281
282 pub(crate) fn resolve_commit(&self, revision: &str) -> AppResult<git2::Commit<'_>> {
283 let obj = self
284 .repo
285 .revparse_single(revision)
286 .map_err(|_| GitError::RefNotFound {
287 refname: revision.to_string(),
288 })?;
289 obj.peel_to_commit()
290 .map_err(|_| GitError::RefNotFound {
291 refname: revision.to_string(),
292 })
293 .map_err(Into::into)
294 }
295
296 fn resolve_tree<'repo>(
297 &'repo self,
298 revision: &str,
299 path: &str,
300 ) -> AppResult<git2::Tree<'repo>> {
301 let obj = self
302 .repo
303 .revparse_single(revision)
304 .map_err(|_| GitError::RefNotFound {
305 refname: revision.to_string(),
306 })?;
307 let tree = if let Ok(tree) = obj.peel_to_tree() {
308 tree
309 } else {
310 let commit = obj.peel_to_commit().map_err(|_| GitError::RefNotFound {
311 refname: revision.to_string(),
312 })?;
313 commit.tree().map_err(GitError::Internal)?
314 };
315
316 if path.is_empty() || path == "." || path == "/" {
317 return Ok(tree);
318 }
319
320 let entry = tree
321 .get_path(Path::new(path))
322 .map_err(|_| GitError::InvalidPath {
323 path: path.to_string(),
324 })?;
325 self.repo
326 .find_tree(entry.id())
327 .map_err(|_| GitError::InvalidPath {
328 path: path.to_string(),
329 })
330 .map_err(Into::into)
331 }
332
333 fn commit_matches_log_options(
334 &self,
335 commit: &git2::Commit<'_>,
336 opts: &LogOptions,
337 ) -> AppResult<bool> {
338 if let Some(author_filter) = &opts.author_filter {
339 let author_filter = author_filter.to_lowercase();
340 let author = commit.author();
341 let name = author.name().unwrap_or_default().to_lowercase();
342 let email = author.email().unwrap_or_default().to_lowercase();
343 if !name.contains(&author_filter) && !email.contains(&author_filter) {
344 return Ok(false);
345 }
346 }
347
348 let commit_time = commit.time();
349 if let Some(since) = opts.since
350 && git2_time_to_system_time(commit_time) < since
351 {
352 return Ok(false);
353 }
354 if let Some(until) = opts.until
355 && git2_time_to_system_time(commit_time) > until
356 {
357 return Ok(false);
358 }
359
360 if let Some(path_filter) = &opts.path_filter {
361 return self.commit_touches_path(commit, Path::new(path_filter));
362 }
363
364 Ok(true)
365 }
366
367 fn commit_touches_path(&self, commit: &git2::Commit<'_>, path: &Path) -> AppResult<bool> {
368 let commit_tree = commit.tree().map_err(GitError::Internal)?;
369
370 if commit.parent_count() == 0 {
371 let mut diff_opts = git2::DiffOptions::new();
372 diff_opts.pathspec(path);
373 let diff = self
374 .repo
375 .diff_tree_to_tree(None, Some(&commit_tree), Some(&mut diff_opts))
376 .map_err(GitError::Internal)?;
377 return Ok(diff.deltas().len() > 0);
378 }
379
380 for parent in commit.parents() {
381 let parent_tree = parent.tree().map_err(GitError::Internal)?;
382 let mut diff_opts = git2::DiffOptions::new();
383 diff_opts.pathspec(path);
384 let diff = self
385 .repo
386 .diff_tree_to_tree(Some(&parent_tree), Some(&commit_tree), Some(&mut diff_opts))
387 .map_err(GitError::Internal)?;
388 if diff.deltas().len() > 0 {
389 return Ok(true);
390 }
391 }
392
393 Ok(false)
394 }
395}
396
397fn diff_entry_from_delta(delta: &git2::DiffDelta<'_>) -> DiffEntry {
398 let status = match delta.status() {
399 git2::Delta::Added => FileStatus::Added,
400 git2::Delta::Deleted => FileStatus::Deleted,
401 git2::Delta::Modified => FileStatus::Modified,
402 git2::Delta::Renamed => FileStatus::Renamed,
403 git2::Delta::Copied => FileStatus::Copied,
404 git2::Delta::Untracked => FileStatus::Untracked,
405 git2::Delta::Ignored => FileStatus::Ignored,
406 git2::Delta::Typechange => FileStatus::TypeChanged,
407 git2::Delta::Conflicted => FileStatus::Conflicted,
408 _ => FileStatus::Modified,
409 };
410
411 let path = delta
412 .new_file()
413 .path()
414 .or_else(|| delta.old_file().path())
415 .map(|path| path.to_string_lossy().into_owned())
416 .unwrap_or_default();
417
418 let old_path = if matches!(status, FileStatus::Renamed | FileStatus::Copied) {
419 delta
420 .old_file()
421 .path()
422 .map(|path| path.to_string_lossy().into_owned())
423 } else {
424 None
425 };
426
427 DiffEntry {
428 path,
429 old_path,
430 old_oid: oid_from_git2(delta.old_file().id()),
431 new_oid: oid_from_git2(delta.new_file().id()),
432 status,
433 }
434}
435
436fn entry_state_from_status(status: git2::Status) -> EntryState {
437 if status.is_conflicted() {
438 EntryState::Conflicted
439 } else if status.intersects(
440 git2::Status::INDEX_NEW
441 | git2::Status::INDEX_MODIFIED
442 | git2::Status::INDEX_DELETED
443 | git2::Status::INDEX_RENAMED
444 | git2::Status::INDEX_TYPECHANGE,
445 ) {
446 EntryState::Staged
447 } else if status.is_wt_new() {
448 EntryState::Untracked
449 } else {
450 EntryState::Unstaged
451 }
452}
453
454fn entry_kind_from_filemode(mode: i32) -> EntryKind {
455 match mode {
456 0o040000 => EntryKind::Tree,
457 0o160000 => EntryKind::Submodule,
458 _ => EntryKind::Blob,
459 }
460}
461
462fn git2_time_to_system_time(time: git2::Time) -> SystemTime {
463 let seconds = time.seconds();
464 if seconds >= 0 {
465 SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(seconds as u64)
466 } else {
467 SystemTime::UNIX_EPOCH - std::time::Duration::from_secs(seconds.unsigned_abs())
468 }
469}
470
471#[cfg(test)]
472mod tests {
473 use std::fs;
474
475 use rskit_errors::ErrorCode;
476
477 use crate::read::{Differ, IgnoreReader};
478
479 #[test]
480 fn status_includes_untracked_files_but_excludes_ignored_paths() {
481 let root = rskit_fs::TempDir::new().expect("temp dir");
482 let repo = super::super::init(root.path()).expect("init repo");
483 fs::write(root.path().join(".gitignore"), "target/\n").expect("write ignore");
484 fs::write(root.path().join("visible.rs"), "fn main() {}\n").expect("write source");
485 fs::create_dir_all(root.path().join("target/debug")).expect("create target");
486 fs::write(root.path().join("target/debug/app"), "binary\n").expect("write ignored file");
487
488 let paths = repo
489 .status()
490 .expect("read status")
491 .into_iter()
492 .map(|entry| entry.path)
493 .collect::<Vec<_>>();
494
495 assert!(paths.iter().any(|path| path == "visible.rs"));
496 assert!(!paths.iter().any(|path| path.starts_with("target/")));
497 }
498
499 #[test]
500 fn ignore_reader_reports_gitignored_paths_without_requiring_files() {
501 let root = rskit_fs::TempDir::new().expect("temp dir");
502 let repo = super::super::init(root.path()).expect("init repo");
503 fs::write(root.path().join(".gitignore"), "target/\n").expect("write ignore");
504 fs::create_dir_all(root.path().join("target/debug")).expect("create target");
505 fs::write(root.path().join("visible.rs"), "fn main() {}\n").expect("write source");
506
507 assert!(
508 repo.is_ignored("target/debug/app")
509 .expect("check ignored nested path")
510 );
511 assert!(
512 repo.is_ignored("target/missing.txt")
513 .expect("check ignored missing path")
514 );
515 assert!(!repo.is_ignored("visible.rs").expect("check visible path"));
516 }
517
518 #[test]
519 fn ignore_reader_rejects_invalid_repository_paths() {
520 let root = rskit_fs::TempDir::new().expect("temp dir");
521 let repo = super::super::init(root.path()).expect("init repo");
522
523 let err = repo
524 .is_ignored("../target/debug/app")
525 .expect_err("reject parent traversal");
526
527 assert_eq!(err.code(), ErrorCode::InvalidInput);
528 assert!(err.message().contains("../target/debug/app"));
529 assert_eq!(
530 err.cause()
531 .expect("preserve path validation cause")
532 .to_string(),
533 "path must not contain '..' segments"
534 );
535 }
536
537 #[test]
538 fn ignore_reader_rejects_bare_repositories() {
539 let root = rskit_fs::TempDir::new().expect("temp dir");
540 let repo = super::super::init_bare(root.path()).expect("init bare repo");
541
542 let err = repo
543 .is_ignored("target/debug/app")
544 .expect_err("reject bare repository");
545
546 assert_eq!(err.code(), ErrorCode::InvalidInput);
547 assert_eq!(
548 err.message(),
549 "git operation not supported: is_ignored on bare repository"
550 );
551 }
552}