1use std::path::{Path, PathBuf};
19use std::sync::atomic::AtomicBool;
20
21use gix::progress::Discard;
22use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog};
23use gix::refs::Target;
24use gix::remote::Direction;
25
26use super::error::GitError;
27use super::{BackendLockCtx, ClonedRepo, GitBackend};
28use crate::fs::ScopedLock;
29
30#[derive(Debug, Default)]
41pub struct GixBackend;
42
43impl GixBackend {
44 #[must_use]
46 pub const fn new() -> Self {
47 Self
48 }
49}
50
51impl GitBackend for GixBackend {
52 fn name(&self) -> &'static str {
53 "gix"
54 }
55
56 fn clone(
57 &self,
58 url: &str,
59 dest: &Path,
60 r#ref: Option<&str>,
61 lock_ctx: BackendLockCtx<'_>,
62 ) -> Result<ClonedRepo, GitError> {
63 with_repo_lock(lock_ctx, || {
68 ensure_dest_empty(dest)?;
69 let repo = run_clone(url, dest, r#ref)?;
70 let head_sha = read_head_sha(&repo)?;
71 Ok(ClonedRepo { path: dest.to_path_buf(), head_sha })
72 })
73 }
74
75 fn fetch(&self, dest: &Path, lock_ctx: BackendLockCtx<'_>) -> Result<(), GitError> {
76 with_repo_lock(lock_ctx, || fetch_locked(dest))
77 }
78
79 fn checkout(
80 &self,
81 dest: &Path,
82 r#ref: &str,
83 lock_ctx: BackendLockCtx<'_>,
84 ) -> Result<(), GitError> {
85 with_repo_lock(lock_ctx, || {
99 let repo = open_repo(dest)?;
100 ensure_clean_worktree(&repo, dest)?;
101 let target = resolve_ref(&repo, r#ref)?;
102 update_head_detached(&repo, r#ref, target)?;
103 materialise_tree(&repo, r#ref, target)
104 })
105 }
106
107 fn head_sha(&self, dest: &Path) -> Result<String, GitError> {
108 let repo = open_repo(dest)?;
109 read_head_sha(&repo)
110 }
111}
112
113fn repo_lock_path(lock_ctx: BackendLockCtx<'_>) -> Result<PathBuf, GitError> {
131 if lock_ctx.child_path.is_empty()
132 || lock_ctx.child_path.ends_with('/')
133 || lock_ctx.child_path.ends_with('\\')
134 {
135 return Err(GitError::Internal(
136 "repo_lock_path: child_path must be non-empty and must not end with a separator"
137 .to_string(),
138 ));
139 }
140
141 let mut p = lock_ctx.parent_meta.join(".grex").join("locks");
142 p.push(Path::new(lock_ctx.child_path));
148 let mut filename =
149 p.file_name().map_or_else(std::ffi::OsString::new, std::ffi::OsStr::to_os_string);
150 filename.push(".backend.lock");
151 p.set_file_name(filename);
152 Ok(p)
153}
154
155fn with_repo_lock<T, F>(lock_ctx: BackendLockCtx<'_>, op: F) -> Result<T, GitError>
168where
169 F: FnOnce() -> Result<T, GitError>,
170{
171 let lock_path = repo_lock_path(lock_ctx)?;
172 if let Some(parent) = lock_path.parent() {
173 std::fs::create_dir_all(parent).map_err(|e| {
174 GitError::Internal(format!("create lock dir {}: {e}", parent.display()))
175 })?;
176 }
177 let mut lock = ScopedLock::open(&lock_path)
178 .map_err(|e| GitError::Internal(format!("open repo lock {}: {e}", lock_path.display())))?;
179 let _guard = lock.acquire().map_err(|e| {
180 GitError::Internal(format!("acquire repo lock {}: {e}", lock_path.display()))
181 })?;
182 op()
183}
184
185fn fetch_locked(dest: &Path) -> Result<(), GitError> {
187 let repo = open_repo(dest)?;
188 let remote = repo
189 .find_default_remote(Direction::Fetch)
190 .ok_or_else(|| {
191 GitError::FetchFailed(dest.to_path_buf(), "no default remote configured".into())
192 })?
193 .map_err(|e| GitError::FetchFailed(dest.to_path_buf(), e.to_string()))?;
194
195 let connection = remote
196 .connect(Direction::Fetch)
197 .map_err(|e| GitError::FetchFailed(dest.to_path_buf(), e.to_string()))?;
198
199 let interrupt = AtomicBool::new(false);
200 let prepare = connection
201 .prepare_fetch(Discard, gix::remote::ref_map::Options::default())
202 .map_err(|e| GitError::FetchFailed(dest.to_path_buf(), e.to_string()))?;
203
204 prepare
205 .receive(Discard, &interrupt)
206 .map_err(|e| GitError::FetchFailed(dest.to_path_buf(), e.to_string()))?;
207
208 Ok(())
209}
210
211fn ensure_dest_empty(dest: &Path) -> Result<(), GitError> {
213 if !dest.exists() {
214 return Ok(());
215 }
216 let mut iter = std::fs::read_dir(dest)
217 .map_err(|e| GitError::Internal(format!("read_dir({}): {e}", dest.display())))?;
218 if iter.next().is_some() {
219 return Err(GitError::DestinationNotEmpty(dest.to_path_buf()));
220 }
221 Ok(())
222}
223
224fn run_clone(url: &str, dest: &Path, r#ref: Option<&str>) -> Result<gix::Repository, GitError> {
226 let mut prepare = gix::prepare_clone(url, dest)
227 .map_err(|e| GitError::CloneFailed { url: url.to_string(), detail: e.to_string() })?;
228
229 if let Some(name) = r#ref {
230 prepare = prepare
231 .with_ref_name(Some(name))
232 .map_err(|e| GitError::CloneFailed { url: url.to_string(), detail: e.to_string() })?;
233 }
234
235 let interrupt = AtomicBool::new(false);
236 let (mut checkout, _) = prepare
237 .fetch_then_checkout(Discard, &interrupt)
238 .map_err(|e| GitError::CloneFailed { url: url.to_string(), detail: e.to_string() })?;
239
240 let (repo, _) = checkout
241 .main_worktree(Discard, &interrupt)
242 .map_err(|e| GitError::CloneFailed { url: url.to_string(), detail: e.to_string() })?;
243 Ok(repo)
244}
245
246fn open_repo(dest: &Path) -> Result<gix::Repository, GitError> {
248 gix::open(dest).map_err(|_| GitError::NotARepository(dest.to_path_buf()))
249}
250
251fn ensure_clean_worktree(repo: &gix::Repository, dest: &Path) -> Result<(), GitError> {
253 match repo.is_dirty() {
254 Ok(false) => Ok(()),
255 Ok(true) => Err(GitError::DirtyWorkingTree(dest.to_path_buf())),
256 Err(e) => Err(GitError::Internal(format!("is_dirty({}): {e}", dest.display()))),
257 }
258}
259
260fn resolve_ref(repo: &gix::Repository, r#ref: &str) -> Result<gix::ObjectId, GitError> {
262 repo.rev_parse_single(r#ref)
263 .map(|id| id.detach())
264 .map_err(|_| GitError::RefNotFound(r#ref.to_string()))
265}
266
267fn update_head_detached(
270 repo: &gix::Repository,
271 r#ref: &str,
272 target: gix::ObjectId,
273) -> Result<(), GitError> {
274 let edit = RefEdit {
275 change: Change::Update {
276 log: LogChange {
277 mode: RefLog::AndReference,
278 force_create_reflog: false,
279 message: format!("grex: checkout {ref_name}", ref_name = r#ref).into(),
280 },
281 expected: PreviousValue::Any,
282 new: Target::Object(target),
283 },
284 name: "HEAD".try_into().expect("HEAD is a valid ref name"),
285 deref: false,
286 };
287 repo.edit_reference(edit)
288 .map(|_| ())
289 .map_err(|e| GitError::CheckoutFailed { r#ref: r#ref.to_string(), detail: e.to_string() })
290}
291
292fn materialise_tree(
301 repo: &gix::Repository,
302 r#ref: &str,
303 target: gix::ObjectId,
304) -> Result<(), GitError> {
305 let workdir = repo.work_dir().ok_or_else(|| GitError::CheckoutFailed {
306 r#ref: r#ref.to_string(),
307 detail: "bare repository has no working tree".into(),
308 })?;
309
310 let tree_id = tree_of_commit(repo, r#ref, target)?;
311 let mut index = build_index_from_tree(repo, r#ref, tree_id)?;
312
313 let objects = repo.objects.clone().into_arc().map_err(|e: std::io::Error| {
314 GitError::CheckoutFailed { r#ref: r#ref.to_string(), detail: e.to_string() }
315 })?;
316 let interrupt = AtomicBool::new(false);
317
318 let opts = gix_worktree_state::checkout::Options {
319 overwrite_existing: true,
320 destination_is_initially_empty: false,
321 ..Default::default()
322 };
323
324 gix_worktree_state::checkout(
325 &mut index,
326 workdir.to_path_buf(),
327 objects,
328 &Discard,
329 &Discard,
330 &interrupt,
331 opts,
332 )
333 .map_err(|e| GitError::CheckoutFailed { r#ref: r#ref.to_string(), detail: e.to_string() })?;
334
335 index.write(Default::default()).map_err(|e| GitError::CheckoutFailed {
336 r#ref: r#ref.to_string(),
337 detail: e.to_string(),
338 })?;
339 Ok(())
340}
341
342fn tree_of_commit(
344 repo: &gix::Repository,
345 r#ref: &str,
346 commit_id: gix::ObjectId,
347) -> Result<gix::ObjectId, GitError> {
348 let object = repo.find_object(commit_id).map_err(|e| GitError::CheckoutFailed {
349 r#ref: r#ref.to_string(),
350 detail: e.to_string(),
351 })?;
352 let tree = object.peel_to_kind(gix::object::Kind::Tree).map_err(|e| {
353 GitError::CheckoutFailed { r#ref: r#ref.to_string(), detail: e.to_string() }
354 })?;
355 Ok(tree.id)
356}
357
358fn build_index_from_tree(
360 repo: &gix::Repository,
361 r#ref: &str,
362 tree_id: gix::ObjectId,
363) -> Result<gix::index::File, GitError> {
364 let validate = gix::validate::path::component::Options::default();
365 let state = gix::index::State::from_tree(&tree_id, &repo.objects, validate).map_err(|e| {
366 GitError::CheckoutFailed { r#ref: r#ref.to_string(), detail: e.to_string() }
367 })?;
368 Ok(gix::index::File::from_state(state, repo.index_path()))
369}
370
371fn read_head_sha(repo: &gix::Repository) -> Result<String, GitError> {
373 let id = repo.head_id().map_err(|e| GitError::Internal(format!("head_id: {e}")))?;
374 Ok(id.detach().to_hex().to_string())
375}
376
377#[doc(hidden)]
382#[must_use]
383pub fn file_url_from_path(path: &Path) -> String {
384 let s = path.to_string_lossy().replace('\\', "/");
385 if s.starts_with('/') {
386 format!("file://{s}")
387 } else {
388 format!("file:///{s}")
389 }
390}
391
392#[cfg(test)]
393mod tests {
394 use super::*;
395
396 #[test]
397 fn repo_lock_path_rejects_empty_child_path() {
398 let err = repo_lock_path(BackendLockCtx::new(Path::new("parent"), ""))
399 .expect_err("empty child_path should be rejected");
400
401 assert!(matches!(
402 err,
403 GitError::Internal(ref msg)
404 if msg == "repo_lock_path: child_path must be non-empty and must not end with a separator"
405 ));
406 }
407
408 #[test]
409 fn repo_lock_path_rejects_trailing_slash() {
410 let err = repo_lock_path(BackendLockCtx::new(Path::new("parent"), "tools/foo/"))
411 .expect_err("trailing slash child_path should be rejected");
412
413 assert!(matches!(
414 err,
415 GitError::Internal(ref msg)
416 if msg == "repo_lock_path: child_path must be non-empty and must not end with a separator"
417 ));
418 }
419}