Skip to main content

rskit_git/embedded/
mod.rs

1//! Embedded libgit2 repository implementation.
2
3pub mod auth;
4mod manage;
5mod read;
6mod write;
7
8use std::path::{Path, PathBuf};
9use std::time::{Duration, SystemTime, UNIX_EPOCH};
10
11use rskit_errors::AppResult;
12
13use crate::core::Repository as RepositoryTrait;
14use crate::error::GitError;
15use crate::types::{Commit, Oid, Reference, Signature};
16
17/// Repository implementation backed by libgit2.
18pub struct Git2Repository {
19    pub(crate) repo: git2::Repository,
20    root: PathBuf,
21}
22
23impl Git2Repository {
24    /// Returns the repository root path.
25    pub fn root(&self) -> &Path {
26        &self.root
27    }
28}
29
30/// Opens a git repository at the given path (canonicalized).
31pub fn open(path: impl AsRef<Path>) -> AppResult<Git2Repository> {
32    let path = path.as_ref();
33    let abs = std::fs::canonicalize(path).map_err(|err| match err.kind() {
34        std::io::ErrorKind::NotFound => GitError::NotFound {
35            path: path.to_path_buf(),
36        },
37        _ => GitError::Internal(git2::Error::from_str(&err.to_string())),
38    })?;
39    let repo = git2::Repository::open(&abs).map_err(|err| {
40        if err.code() == git2::ErrorCode::NotFound {
41            GitError::NotFound { path: abs.clone() }
42        } else {
43            GitError::Internal(err)
44        }
45    })?;
46    // workdir() is None for bare repos; fall back to the .git dir path
47    let root = repo.workdir().unwrap_or_else(|| repo.path()).to_path_buf();
48    Ok(Git2Repository { repo, root })
49}
50
51/// Discovers a git repository by walking up from the given path.
52pub fn discover(path: impl AsRef<Path>) -> AppResult<Git2Repository> {
53    let path = path.as_ref();
54    let repo = git2::Repository::discover(path).map_err(|err| {
55        if err.code() == git2::ErrorCode::NotFound {
56            GitError::NotFound {
57                path: path.to_path_buf(),
58            }
59        } else {
60            GitError::Internal(err)
61        }
62    })?;
63    // workdir() is None for bare repos; fall back to the .git dir path
64    let root = repo.workdir().unwrap_or_else(|| repo.path()).to_path_buf();
65    Ok(Git2Repository { repo, root })
66}
67
68/// Clones a git repository into the given path.
69pub fn clone(url: &str, path: impl AsRef<Path>) -> AppResult<Git2Repository> {
70    let path = path.as_ref();
71    let repo = git2::Repository::clone(url, path).map_err(GitError::Internal)?;
72    let root = repo
73        .workdir()
74        .map(Path::to_path_buf)
75        .unwrap_or_else(|| path.to_path_buf());
76    Ok(Git2Repository { repo, root })
77}
78
79/// Creates a new git repository at the given path.
80pub fn init(path: impl AsRef<Path>) -> AppResult<Git2Repository> {
81    let path = path.as_ref();
82    let repo = git2::Repository::init(path).map_err(GitError::Internal)?;
83    let root = repo
84        .workdir()
85        .map(Path::to_path_buf)
86        .unwrap_or_else(|| path.to_path_buf());
87    Ok(Git2Repository { repo, root })
88}
89
90/// Creates a new bare git repository at the given path.
91pub fn init_bare(path: impl AsRef<Path>) -> AppResult<Git2Repository> {
92    let path = path.as_ref();
93    let repo = git2::Repository::init_bare(path).map_err(GitError::Internal)?;
94    Ok(Git2Repository {
95        repo,
96        root: path.to_path_buf(),
97    })
98}
99
100impl RepositoryTrait for Git2Repository {
101    fn root(&self) -> &Path {
102        &self.root
103    }
104
105    fn head(&self) -> AppResult<Reference> {
106        let head = self.repo.head().map_err(map_head_error)?;
107        Ok(reference_from_git2(&head))
108    }
109
110    fn resolve_ref(&self, refname: &str) -> AppResult<Oid> {
111        let obj = self
112            .repo
113            .revparse_single(refname)
114            .map_err(|_| GitError::RefNotFound {
115                refname: refname.to_string(),
116            })?;
117        Ok(oid_from_git2(obj.id()))
118    }
119
120    fn is_dirty(&self) -> AppResult<bool> {
121        let statuses = self.repo.statuses(None).map_err(GitError::Internal)?;
122        Ok(!statuses.is_empty())
123    }
124}
125
126pub(crate) fn oid_from_git2(oid: git2::Oid) -> Oid {
127    let mut bytes = [0u8; 20];
128    bytes.copy_from_slice(oid.as_bytes());
129    Oid::from_bytes(bytes)
130}
131
132pub(crate) fn signature_from_git2(signature: &git2::Signature<'_>) -> Signature {
133    Signature {
134        name: signature.name().unwrap_or_default().to_string(),
135        email: signature.email().unwrap_or_default().to_string(),
136        when: system_time_from_git2(signature.when()),
137    }
138}
139
140pub(crate) fn commit_from_git2(commit: &git2::Commit<'_>) -> Commit {
141    Commit {
142        oid: oid_from_git2(commit.id()),
143        author: signature_from_git2(&commit.author()),
144        committer: signature_from_git2(&commit.committer()),
145        message: commit
146            .message()
147            .unwrap_or_default()
148            .trim_end_matches('\n')
149            .trim_end_matches('\0')
150            .to_string(),
151        parents: commit.parent_ids().map(oid_from_git2).collect(),
152    }
153}
154
155fn reference_from_git2(reference: &git2::Reference<'_>) -> Reference {
156    let name = reference.name().unwrap_or("HEAD").to_string();
157    let target = reference
158        .resolve()
159        .ok()
160        .and_then(|resolved| resolved.target())
161        .or_else(|| reference.target())
162        .map(oid_from_git2)
163        .unwrap_or_else(|| Oid::from_bytes([0; 20]));
164    Reference {
165        name,
166        target,
167        is_branch: reference.is_branch(),
168        is_tag: reference.is_tag(),
169    }
170}
171
172fn map_head_error(err: git2::Error) -> GitError {
173    if err.code() == git2::ErrorCode::UnbornBranch || err.code() == git2::ErrorCode::NotFound {
174        GitError::RefNotFound {
175            refname: "HEAD".to_string(),
176        }
177    } else {
178        GitError::Internal(err)
179    }
180}
181
182pub(crate) fn map_remote_error(err: git2::Error) -> GitError {
183    if err.class() == git2::ErrorClass::Net {
184        GitError::Network(err.message().to_string())
185    } else {
186        GitError::Internal(err)
187    }
188}
189
190fn system_time_from_git2(time: git2::Time) -> SystemTime {
191    let seconds = time.seconds();
192    if seconds >= 0 {
193        UNIX_EPOCH + Duration::from_secs(seconds as u64)
194    } else {
195        UNIX_EPOCH - Duration::from_secs(seconds.unsigned_abs())
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use crate::core::Repository;
202
203    use super::*;
204
205    #[test]
206    fn head_on_unborn_repository_maps_to_ref_not_found() {
207        let root = rskit_testutil::test_workspace!("git2-unborn-head");
208        let repo = init(root.path()).expect("init repo");
209
210        let err = repo.head().expect_err("HEAD is unborn");
211
212        assert_eq!(err.code(), rskit_errors::ErrorCode::NotFound);
213    }
214
215    #[test]
216    fn system_time_from_git2_handles_negative_offsets() {
217        let before_epoch = system_time_from_git2(git2::Time::new(-5, 0));
218
219        assert_eq!(before_epoch, UNIX_EPOCH - Duration::from_secs(5));
220    }
221
222    #[test]
223    fn map_remote_error_distinguishes_network_from_internal_errors() {
224        let net = git2::Error::new(git2::ErrorCode::GenericError, git2::ErrorClass::Net, "down");
225        assert!(matches!(map_remote_error(net), GitError::Network(_)));
226
227        let other = git2::Error::from_str("other");
228        assert!(matches!(map_remote_error(other), GitError::Internal(_)));
229    }
230}