Skip to main content

rskit_git/embedded/
read.rs

1//! Read trait implementations for the libgit2 repository.
2
3use 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}