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 replace_ref_tip(
112 repo: &Repository,
113 ref_name: &str,
114 expected_tip: ObjectId,
115 new_tip: ObjectId,
116 message: &str,
117) -> Result<()> {
118 repo.edit_references([RefEdit {
119 change: Change::Update {
120 log: LogChange {
121 mode: RefLog::AndReference,
122 force_create_reflog: false,
123 message: message.into(),
124 },
125 expected: PreviousValue::ExistingMustMatch(gix::refs::Target::Object(expected_tip)),
126 new: gix::refs::Target::Object(new_tip),
127 },
128 name: ref_name
129 .try_into()
130 .map_err(|e: gix::validate::reference::name::Error| gix_err(e))?,
131 deref: false,
132 }])
133 .map_err(gix_err)?;
134 Ok(())
135}
136
137pub fn delete_ref(repo: &Repository, ref_name: &str, expected_tip: ObjectId) -> Result<()> {
139 repo.edit_references([RefEdit {
140 change: Change::Delete {
141 expected: PreviousValue::ExistingMustMatch(gix::refs::Target::Object(expected_tip)),
142 log: RefLog::AndReference,
143 },
144 name: ref_name
145 .try_into()
146 .map_err(|e: gix::validate::reference::name::Error| gix_err(e))?,
147 deref: false,
148 }])
149 .map_err(gix_err)?;
150 Ok(())
151}
152
153pub fn find_repo_root(from: &Path) -> Option<PathBuf> {
155 let mut dir = from.to_path_buf();
156 loop {
157 if dir.join(".git").exists() {
158 return Some(dir);
159 }
160 if !dir.pop() {
161 return None;
162 }
163 }
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169 use crate::test_utils::init_test_repo;
170
171 #[test]
172 fn test_find_repo_root() {
173 let tmp = tempfile::tempdir().unwrap();
174 let repo = tmp.path().join("myrepo");
175 std::fs::create_dir_all(repo.join(".git")).unwrap();
176 let subdir = repo.join("src").join("deep");
177 std::fs::create_dir_all(&subdir).unwrap();
178
179 assert_eq!(find_repo_root(&subdir), Some(repo.clone()));
180 assert_eq!(find_repo_root(&repo), Some(repo));
181
182 let no_repo = tmp.path().join("norope");
183 std::fs::create_dir_all(&no_repo).unwrap();
184 assert_eq!(find_repo_root(&no_repo), None);
185 }
186
187 #[test]
188 fn test_open_repo_success() {
189 let tmp = tempfile::tempdir().unwrap();
190 init_test_repo(tmp.path());
191
192 let repo = open_repo(tmp.path());
193 assert!(repo.is_ok(), "expected Ok, got: {}", repo.unwrap_err());
194 }
195
196 #[test]
197 fn test_open_repo_not_a_repo() {
198 let tmp = tempfile::tempdir().unwrap();
199 let err = open_repo(tmp.path()).unwrap_err();
201 assert!(
202 matches!(err, GitStorageError::NotARepo(_)),
203 "expected NotARepo, got: {err}"
204 );
205 }
206
207 #[test]
208 fn test_find_ref_tip_missing() {
209 let tmp = tempfile::tempdir().unwrap();
210 init_test_repo(tmp.path());
211
212 let repo = gix::open(tmp.path()).unwrap();
213 let tip = find_ref_tip(&repo, "refs/heads/nonexistent").unwrap();
214 assert!(tip.is_none());
215 }
216
217 #[test]
218 fn test_find_ref_tip_exists() {
219 let tmp = tempfile::tempdir().unwrap();
220 init_test_repo(tmp.path());
221
222 let repo = gix::open(tmp.path()).unwrap();
223 let tip = find_ref_tip(&repo, "refs/heads/main").unwrap();
225 assert!(tip.is_some(), "expected Some(id) for refs/heads/main");
226 }
227
228 #[test]
229 fn test_commit_tree_id() {
230 let tmp = tempfile::tempdir().unwrap();
231 init_test_repo(tmp.path());
232
233 let repo = gix::open(tmp.path()).unwrap();
234 let tip = find_ref_tip(&repo, "refs/heads/main")
235 .unwrap()
236 .expect("main should exist");
237
238 let tree_id = commit_tree_id(&repo, tip.detach()).unwrap();
239 let tree = repo.find_tree(tree_id);
241 assert!(tree.is_ok(), "tree_id should point to a valid tree object");
242 }
243
244 #[test]
245 fn test_create_commit_no_parent() {
246 let tmp = tempfile::tempdir().unwrap();
247 init_test_repo(tmp.path());
248
249 let repo = gix::open(tmp.path()).unwrap();
250 let empty_tree = ObjectId::empty_tree(repo.object_hash());
251
252 let commit_id = create_commit(
253 &repo,
254 "refs/heads/orphan-test",
255 empty_tree,
256 None,
257 "orphan commit",
258 )
259 .unwrap();
260
261 let tip = find_ref_tip(&repo, "refs/heads/orphan-test")
263 .unwrap()
264 .expect("orphan-test ref should exist");
265 assert_eq!(tip.detach(), commit_id);
266 }
267
268 #[test]
269 fn test_create_commit_with_parent() {
270 let tmp = tempfile::tempdir().unwrap();
271 init_test_repo(tmp.path());
272
273 let repo = gix::open(tmp.path()).unwrap();
274 let empty_tree = ObjectId::empty_tree(repo.object_hash());
275
276 let first_id = create_commit(
278 &repo,
279 "refs/heads/chain-test",
280 empty_tree,
281 None,
282 "first commit",
283 )
284 .unwrap();
285
286 let second_id = create_commit(
288 &repo,
289 "refs/heads/chain-test",
290 empty_tree,
291 Some(first_id),
292 "second commit",
293 )
294 .unwrap();
295
296 assert_ne!(first_id, second_id);
297
298 let tip = find_ref_tip(&repo, "refs/heads/chain-test")
300 .unwrap()
301 .expect("chain-test ref should exist");
302 assert_eq!(tip.detach(), second_id);
303 }
304
305 #[test]
306 fn test_delete_ref() {
307 let tmp = tempfile::tempdir().unwrap();
308 init_test_repo(tmp.path());
309
310 let repo = gix::open(tmp.path()).unwrap();
311 let empty_tree = ObjectId::empty_tree(repo.object_hash());
312
313 let commit_id = create_commit(
315 &repo,
316 "refs/heads/to-delete",
317 empty_tree,
318 None,
319 "will be deleted",
320 )
321 .unwrap();
322
323 assert!(find_ref_tip(&repo, "refs/heads/to-delete")
325 .unwrap()
326 .is_some());
327
328 delete_ref(&repo, "refs/heads/to-delete", commit_id).unwrap();
330
331 let tip = find_ref_tip(&repo, "refs/heads/to-delete").unwrap();
333 assert!(tip.is_none(), "ref should be deleted");
334 }
335
336 #[test]
337 fn test_replace_ref_tip() {
338 let tmp = tempfile::tempdir().unwrap();
339 init_test_repo(tmp.path());
340
341 let repo = gix::open(tmp.path()).unwrap();
342 let empty_tree = ObjectId::empty_tree(repo.object_hash());
343
344 let first_id = create_commit(
345 &repo,
346 "refs/heads/replace-test",
347 empty_tree,
348 None,
349 "first commit",
350 )
351 .unwrap();
352
353 let second_id = create_commit(
354 &repo,
355 "refs/heads/replace-test-next",
356 empty_tree,
357 None,
358 "second commit",
359 )
360 .unwrap();
361
362 replace_ref_tip(
363 &repo,
364 "refs/heads/replace-test",
365 first_id,
366 second_id,
367 "replace tip",
368 )
369 .unwrap();
370
371 let tip = find_ref_tip(&repo, "refs/heads/replace-test")
372 .unwrap()
373 .expect("replace-test should exist");
374 assert_eq!(tip.detach(), second_id);
375 }
376}