Skip to main content

opensession_git_native/
ops.rs

1use std::path::{Path, PathBuf};
2
3use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog};
4use gix::{ObjectId, Repository};
5
6use crate::error::{GitStorageError, Result};
7
8/// Wrap any gix-compatible error into [`GitStorageError::Gix`].
9pub fn gix_err(e: impl std::error::Error + Send + Sync + 'static) -> GitStorageError {
10    GitStorageError::Gix(Box::new(e))
11}
12
13/// Open a git repository at `repo_path`.
14///
15/// Returns [`GitStorageError::NotARepo`] when `.git` is absent.
16pub fn open_repo(repo_path: &Path) -> Result<Repository> {
17    let repo = gix::open(repo_path).map_err(|e| {
18        if repo_path.join(".git").exists() {
19            gix_err(e)
20        } else {
21            GitStorageError::NotARepo(repo_path.to_path_buf())
22        }
23    })?;
24    Ok(repo)
25}
26
27/// Find the tip commit of a ref, returning `None` if the ref doesn't exist.
28pub fn find_ref_tip<'r>(repo: &'r Repository, ref_name: &str) -> Result<Option<gix::Id<'r>>> {
29    match repo.try_find_reference(ref_name).map_err(gix_err)? {
30        Some(reference) => {
31            let id = reference.into_fully_peeled_id().map_err(gix_err)?;
32            Ok(Some(id))
33        }
34        None => Ok(None),
35    }
36}
37
38/// Get the tree [`ObjectId`] from a commit.
39pub fn commit_tree_id(repo: &Repository, commit_id: ObjectId) -> Result<ObjectId> {
40    let commit = repo
41        .find_object(commit_id)
42        .map_err(gix_err)?
43        .try_into_commit()
44        .map_err(gix_err)?;
45    let tree_id = commit.tree_id().map_err(gix_err)?;
46    Ok(tree_id.detach())
47}
48
49/// Build the default OpenSession committer/author signature.
50pub fn make_signature() -> gix::actor::Signature {
51    gix::actor::Signature {
52        name: "opensession".into(),
53        email: "cli@opensession.io".into(),
54        time: gix::date::Time::now_local_or_utc(),
55    }
56}
57
58/// Create a commit on `ref_name`, optionally with a parent.
59///
60/// The ref is updated atomically — it must either not exist (when `parent` is
61/// `None`) or point to `parent` (when `Some`).
62pub fn create_commit(
63    repo: &Repository,
64    ref_name: &str,
65    tree_id: ObjectId,
66    parent: Option<ObjectId>,
67    message: &str,
68) -> Result<ObjectId> {
69    let sig = make_signature();
70    let parents: Vec<ObjectId> = parent.into_iter().collect();
71
72    let commit = gix::objs::Commit {
73        message: message.into(),
74        tree: tree_id,
75        author: sig.clone(),
76        committer: sig,
77        encoding: None,
78        parents: parents.clone().into(),
79        extra_headers: Default::default(),
80    };
81
82    let commit_id = repo.write_object(&commit).map_err(gix_err)?.detach();
83
84    let expected = match parents.first() {
85        Some(p) => PreviousValue::ExistingMustMatch(gix::refs::Target::Object(*p)),
86        None => PreviousValue::MustNotExist,
87    };
88
89    repo.edit_references([RefEdit {
90        change: Change::Update {
91            log: LogChange {
92                mode: RefLog::AndReference,
93                force_create_reflog: false,
94                message: message.into(),
95            },
96            expected,
97            new: gix::refs::Target::Object(commit_id),
98        },
99        name: ref_name
100            .try_into()
101            .map_err(|e: gix::validate::reference::name::Error| gix_err(e))?,
102        deref: false,
103    }])
104    .map_err(gix_err)?;
105
106    Ok(commit_id)
107}
108
109/// Delete a ref, requiring it currently points to `expected_tip`.
110pub fn delete_ref(repo: &Repository, ref_name: &str, expected_tip: ObjectId) -> Result<()> {
111    repo.edit_references([RefEdit {
112        change: Change::Delete {
113            expected: PreviousValue::ExistingMustMatch(gix::refs::Target::Object(expected_tip)),
114            log: RefLog::AndReference,
115        },
116        name: ref_name
117            .try_into()
118            .map_err(|e: gix::validate::reference::name::Error| gix_err(e))?,
119        deref: false,
120    }])
121    .map_err(gix_err)?;
122    Ok(())
123}
124
125/// Find the git repository root by walking up from `from` looking for `.git`.
126pub fn find_repo_root(from: &Path) -> Option<PathBuf> {
127    let mut dir = from.to_path_buf();
128    loop {
129        if dir.join(".git").exists() {
130            return Some(dir);
131        }
132        if !dir.pop() {
133            return None;
134        }
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use crate::test_utils::init_test_repo;
142
143    #[test]
144    fn test_find_repo_root() {
145        let tmp = tempfile::tempdir().unwrap();
146        let repo = tmp.path().join("myrepo");
147        std::fs::create_dir_all(repo.join(".git")).unwrap();
148        let subdir = repo.join("src").join("deep");
149        std::fs::create_dir_all(&subdir).unwrap();
150
151        assert_eq!(find_repo_root(&subdir), Some(repo.clone()));
152        assert_eq!(find_repo_root(&repo), Some(repo));
153
154        let no_repo = tmp.path().join("norope");
155        std::fs::create_dir_all(&no_repo).unwrap();
156        assert_eq!(find_repo_root(&no_repo), None);
157    }
158
159    #[test]
160    fn test_open_repo_success() {
161        let tmp = tempfile::tempdir().unwrap();
162        init_test_repo(tmp.path());
163
164        let repo = open_repo(tmp.path());
165        assert!(repo.is_ok(), "expected Ok, got: {}", repo.unwrap_err());
166    }
167
168    #[test]
169    fn test_open_repo_not_a_repo() {
170        let tmp = tempfile::tempdir().unwrap();
171        // Don't init git — just a bare directory
172        let err = open_repo(tmp.path()).unwrap_err();
173        assert!(
174            matches!(err, GitStorageError::NotARepo(_)),
175            "expected NotARepo, got: {err}"
176        );
177    }
178
179    #[test]
180    fn test_find_ref_tip_missing() {
181        let tmp = tempfile::tempdir().unwrap();
182        init_test_repo(tmp.path());
183
184        let repo = gix::open(tmp.path()).unwrap();
185        let tip = find_ref_tip(&repo, "refs/heads/nonexistent").unwrap();
186        assert!(tip.is_none());
187    }
188
189    #[test]
190    fn test_find_ref_tip_exists() {
191        let tmp = tempfile::tempdir().unwrap();
192        init_test_repo(tmp.path());
193
194        let repo = gix::open(tmp.path()).unwrap();
195        // init_test_repo creates a commit on "main"
196        let tip = find_ref_tip(&repo, "refs/heads/main").unwrap();
197        assert!(tip.is_some(), "expected Some(id) for refs/heads/main");
198    }
199
200    #[test]
201    fn test_commit_tree_id() {
202        let tmp = tempfile::tempdir().unwrap();
203        init_test_repo(tmp.path());
204
205        let repo = gix::open(tmp.path()).unwrap();
206        let tip = find_ref_tip(&repo, "refs/heads/main")
207            .unwrap()
208            .expect("main should exist");
209
210        let tree_id = commit_tree_id(&repo, tip.detach()).unwrap();
211        // The tree should be a valid object
212        let tree = repo.find_tree(tree_id);
213        assert!(tree.is_ok(), "tree_id should point to a valid tree object");
214    }
215
216    #[test]
217    fn test_create_commit_no_parent() {
218        let tmp = tempfile::tempdir().unwrap();
219        init_test_repo(tmp.path());
220
221        let repo = gix::open(tmp.path()).unwrap();
222        let empty_tree = ObjectId::empty_tree(repo.object_hash());
223
224        let commit_id = create_commit(
225            &repo,
226            "refs/heads/orphan-test",
227            empty_tree,
228            None,
229            "orphan commit",
230        )
231        .unwrap();
232
233        // The ref should now exist and point to our commit
234        let tip = find_ref_tip(&repo, "refs/heads/orphan-test")
235            .unwrap()
236            .expect("orphan-test ref should exist");
237        assert_eq!(tip.detach(), commit_id);
238    }
239
240    #[test]
241    fn test_create_commit_with_parent() {
242        let tmp = tempfile::tempdir().unwrap();
243        init_test_repo(tmp.path());
244
245        let repo = gix::open(tmp.path()).unwrap();
246        let empty_tree = ObjectId::empty_tree(repo.object_hash());
247
248        // Create first (orphan) commit
249        let first_id = create_commit(
250            &repo,
251            "refs/heads/chain-test",
252            empty_tree,
253            None,
254            "first commit",
255        )
256        .unwrap();
257
258        // Create second commit with first as parent
259        let second_id = create_commit(
260            &repo,
261            "refs/heads/chain-test",
262            empty_tree,
263            Some(first_id),
264            "second commit",
265        )
266        .unwrap();
267
268        assert_ne!(first_id, second_id);
269
270        // Ref should now point to the second commit
271        let tip = find_ref_tip(&repo, "refs/heads/chain-test")
272            .unwrap()
273            .expect("chain-test ref should exist");
274        assert_eq!(tip.detach(), second_id);
275    }
276
277    #[test]
278    fn test_delete_ref() {
279        let tmp = tempfile::tempdir().unwrap();
280        init_test_repo(tmp.path());
281
282        let repo = gix::open(tmp.path()).unwrap();
283        let empty_tree = ObjectId::empty_tree(repo.object_hash());
284
285        // Create a ref
286        let commit_id = create_commit(
287            &repo,
288            "refs/heads/to-delete",
289            empty_tree,
290            None,
291            "will be deleted",
292        )
293        .unwrap();
294
295        // Confirm it exists
296        assert!(find_ref_tip(&repo, "refs/heads/to-delete")
297            .unwrap()
298            .is_some());
299
300        // Delete it
301        delete_ref(&repo, "refs/heads/to-delete", commit_id).unwrap();
302
303        // Now it should be gone
304        let tip = find_ref_tip(&repo, "refs/heads/to-delete").unwrap();
305        assert!(tip.is_none(), "ref should be deleted");
306    }
307}