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::{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(&self, url: &str, dest: &Path, r#ref: Option<&str>) -> Result<ClonedRepo, GitError> {
57 with_repo_lock(dest, || {
62 ensure_dest_empty(dest)?;
63 let repo = run_clone(url, dest, r#ref)?;
64 let head_sha = read_head_sha(&repo)?;
65 Ok(ClonedRepo { path: dest.to_path_buf(), head_sha })
66 })
67 }
68
69 fn fetch(&self, dest: &Path) -> Result<(), GitError> {
70 with_repo_lock(dest, || fetch_locked(dest))
71 }
72
73 fn checkout(&self, dest: &Path, r#ref: &str) -> Result<(), GitError> {
74 with_repo_lock(dest, || {
88 let repo = open_repo(dest)?;
89 ensure_clean_worktree(&repo, dest)?;
90 let target = resolve_ref(&repo, r#ref)?;
91 update_head_detached(&repo, r#ref, target)?;
92 materialise_tree(&repo, r#ref, target)
93 })
94 }
95
96 fn head_sha(&self, dest: &Path) -> Result<String, GitError> {
97 let repo = open_repo(dest)?;
98 read_head_sha(&repo)
99 }
100}
101
102fn repo_lock_path(dest: &Path) -> PathBuf {
110 let parent = dest.parent().map_or_else(|| PathBuf::from("."), Path::to_path_buf);
111 let stem = dest
112 .file_name()
113 .map_or_else(|| std::ffi::OsString::from("repo"), std::ffi::OsStr::to_os_string);
114 let mut name = std::ffi::OsString::from(".grex-backend-");
115 name.push(&stem);
116 name.push(".lock");
117 parent.join(name)
118}
119
120fn with_repo_lock<T, F>(dest: &Path, op: F) -> Result<T, GitError>
129where
130 F: FnOnce() -> Result<T, GitError>,
131{
132 let lock_path = repo_lock_path(dest);
133 let mut lock = ScopedLock::open(&lock_path)
134 .map_err(|e| GitError::Internal(format!("open repo lock {}: {e}", lock_path.display())))?;
135 let _guard = lock.acquire().map_err(|e| {
136 GitError::Internal(format!("acquire repo lock {}: {e}", lock_path.display()))
137 })?;
138 op()
139}
140
141fn fetch_locked(dest: &Path) -> Result<(), GitError> {
143 let repo = open_repo(dest)?;
144 let remote = repo
145 .find_default_remote(Direction::Fetch)
146 .ok_or_else(|| {
147 GitError::FetchFailed(dest.to_path_buf(), "no default remote configured".into())
148 })?
149 .map_err(|e| GitError::FetchFailed(dest.to_path_buf(), e.to_string()))?;
150
151 let connection = remote
152 .connect(Direction::Fetch)
153 .map_err(|e| GitError::FetchFailed(dest.to_path_buf(), e.to_string()))?;
154
155 let interrupt = AtomicBool::new(false);
156 let prepare = connection
157 .prepare_fetch(Discard, gix::remote::ref_map::Options::default())
158 .map_err(|e| GitError::FetchFailed(dest.to_path_buf(), e.to_string()))?;
159
160 prepare
161 .receive(Discard, &interrupt)
162 .map_err(|e| GitError::FetchFailed(dest.to_path_buf(), e.to_string()))?;
163
164 Ok(())
165}
166
167fn ensure_dest_empty(dest: &Path) -> Result<(), GitError> {
169 if !dest.exists() {
170 return Ok(());
171 }
172 let mut iter = std::fs::read_dir(dest)
173 .map_err(|e| GitError::Internal(format!("read_dir({}): {e}", dest.display())))?;
174 if iter.next().is_some() {
175 return Err(GitError::DestinationNotEmpty(dest.to_path_buf()));
176 }
177 Ok(())
178}
179
180fn run_clone(url: &str, dest: &Path, r#ref: Option<&str>) -> Result<gix::Repository, GitError> {
182 let mut prepare = gix::prepare_clone(url, dest)
183 .map_err(|e| GitError::CloneFailed { url: url.to_string(), detail: e.to_string() })?;
184
185 if let Some(name) = r#ref {
186 prepare = prepare
187 .with_ref_name(Some(name))
188 .map_err(|e| GitError::CloneFailed { url: url.to_string(), detail: e.to_string() })?;
189 }
190
191 let interrupt = AtomicBool::new(false);
192 let (mut checkout, _) = prepare
193 .fetch_then_checkout(Discard, &interrupt)
194 .map_err(|e| GitError::CloneFailed { url: url.to_string(), detail: e.to_string() })?;
195
196 let (repo, _) = checkout
197 .main_worktree(Discard, &interrupt)
198 .map_err(|e| GitError::CloneFailed { url: url.to_string(), detail: e.to_string() })?;
199 Ok(repo)
200}
201
202fn open_repo(dest: &Path) -> Result<gix::Repository, GitError> {
204 gix::open(dest).map_err(|_| GitError::NotARepository(dest.to_path_buf()))
205}
206
207fn ensure_clean_worktree(repo: &gix::Repository, dest: &Path) -> Result<(), GitError> {
209 match repo.is_dirty() {
210 Ok(false) => Ok(()),
211 Ok(true) => Err(GitError::DirtyWorkingTree(dest.to_path_buf())),
212 Err(e) => Err(GitError::Internal(format!("is_dirty({}): {e}", dest.display()))),
213 }
214}
215
216fn resolve_ref(repo: &gix::Repository, r#ref: &str) -> Result<gix::ObjectId, GitError> {
218 repo.rev_parse_single(r#ref)
219 .map(|id| id.detach())
220 .map_err(|_| GitError::RefNotFound(r#ref.to_string()))
221}
222
223fn update_head_detached(
226 repo: &gix::Repository,
227 r#ref: &str,
228 target: gix::ObjectId,
229) -> Result<(), GitError> {
230 let edit = RefEdit {
231 change: Change::Update {
232 log: LogChange {
233 mode: RefLog::AndReference,
234 force_create_reflog: false,
235 message: format!("grex: checkout {ref_name}", ref_name = r#ref).into(),
236 },
237 expected: PreviousValue::Any,
238 new: Target::Object(target),
239 },
240 name: "HEAD".try_into().expect("HEAD is a valid ref name"),
241 deref: false,
242 };
243 repo.edit_reference(edit)
244 .map(|_| ())
245 .map_err(|e| GitError::CheckoutFailed { r#ref: r#ref.to_string(), detail: e.to_string() })
246}
247
248fn materialise_tree(
257 repo: &gix::Repository,
258 r#ref: &str,
259 target: gix::ObjectId,
260) -> Result<(), GitError> {
261 let workdir = repo.work_dir().ok_or_else(|| GitError::CheckoutFailed {
262 r#ref: r#ref.to_string(),
263 detail: "bare repository has no working tree".into(),
264 })?;
265
266 let tree_id = tree_of_commit(repo, r#ref, target)?;
267 let mut index = build_index_from_tree(repo, r#ref, tree_id)?;
268
269 let objects = repo.objects.clone().into_arc().map_err(|e: std::io::Error| {
270 GitError::CheckoutFailed { r#ref: r#ref.to_string(), detail: e.to_string() }
271 })?;
272 let interrupt = AtomicBool::new(false);
273
274 let opts = gix_worktree_state::checkout::Options {
275 overwrite_existing: true,
276 destination_is_initially_empty: false,
277 ..Default::default()
278 };
279
280 gix_worktree_state::checkout(
281 &mut index,
282 workdir.to_path_buf(),
283 objects,
284 &Discard,
285 &Discard,
286 &interrupt,
287 opts,
288 )
289 .map_err(|e| GitError::CheckoutFailed { r#ref: r#ref.to_string(), detail: e.to_string() })?;
290
291 index.write(Default::default()).map_err(|e| GitError::CheckoutFailed {
292 r#ref: r#ref.to_string(),
293 detail: e.to_string(),
294 })?;
295 Ok(())
296}
297
298fn tree_of_commit(
300 repo: &gix::Repository,
301 r#ref: &str,
302 commit_id: gix::ObjectId,
303) -> Result<gix::ObjectId, GitError> {
304 let object = repo.find_object(commit_id).map_err(|e| GitError::CheckoutFailed {
305 r#ref: r#ref.to_string(),
306 detail: e.to_string(),
307 })?;
308 let tree = object.peel_to_kind(gix::object::Kind::Tree).map_err(|e| {
309 GitError::CheckoutFailed { r#ref: r#ref.to_string(), detail: e.to_string() }
310 })?;
311 Ok(tree.id)
312}
313
314fn build_index_from_tree(
316 repo: &gix::Repository,
317 r#ref: &str,
318 tree_id: gix::ObjectId,
319) -> Result<gix::index::File, GitError> {
320 let validate = gix::validate::path::component::Options::default();
321 let state = gix::index::State::from_tree(&tree_id, &repo.objects, validate).map_err(|e| {
322 GitError::CheckoutFailed { r#ref: r#ref.to_string(), detail: e.to_string() }
323 })?;
324 Ok(gix::index::File::from_state(state, repo.index_path()))
325}
326
327fn read_head_sha(repo: &gix::Repository) -> Result<String, GitError> {
329 let id = repo.head_id().map_err(|e| GitError::Internal(format!("head_id: {e}")))?;
330 Ok(id.detach().to_hex().to_string())
331}
332
333#[doc(hidden)]
338#[must_use]
339pub fn file_url_from_path(path: &Path) -> String {
340 let s = path.to_string_lossy().replace('\\', "/");
341 if s.starts_with('/') {
342 format!("file://{s}")
343 } else {
344 format!("file:///{s}")
345 }
346}