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
8pub fn gix_err(e: impl std::error::Error + Send + Sync + 'static) -> GitStorageError {
10 GitStorageError::Gix(Box::new(e))
11}
12
13pub 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
27pub 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
38pub 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
49pub 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
58pub 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
109pub 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
125pub 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 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 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 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 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 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 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 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 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 assert!(find_ref_tip(&repo, "refs/heads/to-delete")
297 .unwrap()
298 .is_some());
299
300 delete_ref(&repo, "refs/heads/to-delete", commit_id).unwrap();
302
303 let tip = find_ref_tip(&repo, "refs/heads/to-delete").unwrap();
305 assert!(tip.is_none(), "ref should be deleted");
306 }
307}