Skip to main content

grex_core/git/
gix_backend.rs

1//! [`GitBackend`] implementation backed by the `gix` crate (pure-Rust git).
2//!
3//! Uses synchronous gix APIs — the `blocking-network-client` feature rewrites
4//! `async fn` on `Remote::connect` / `Prepare::receive` into sync via
5//! `maybe_async`. No tokio runtime is required or spawned here.
6//!
7//! Auth policy: relies on gix defaults (system SSH keys, HTTPS anonymous).
8//! Credential prompting, keyring integration, and SSH-agent wiring are
9//! deferred to a future slice and deliberately absent here.
10//!
11//! # Checkout scope
12//!
13//! [`GixBackend::checkout`] detaches HEAD at the resolved commit and then
14//! materialises that commit's tree into the working directory + index via
15//! `gix_worktree_state::checkout`. That sub-crate is already transitively in
16//! the tree via `gix`, so it adds no net download.
17
18use 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/// Pure-Rust [`GitBackend`] driven by the `gix` crate.
31///
32/// All operations run on the calling thread. The backend holds no state — a
33/// single instance can be shared behind `Arc` across the future walker/exec
34/// pipelines.
35///
36/// Note: `Clone` is intentionally *not* derived; it would conflict with the
37/// [`GitBackend::clone`] method at the method-resolution level. Callers that
38/// need multiple handles should wrap in `Arc` or construct a fresh
39/// [`GixBackend::new`] (the type is zero-sized).
40#[derive(Debug, Default)]
41pub struct GixBackend;
42
43impl GixBackend {
44    /// Construct a new backend. Equivalent to [`GixBackend::default`].
45    #[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        // Per-repo lock: the sidecar lives in the parent dir (keyed by dest's
58        // last component) so the clone can still require `dest` to be empty.
59        // Once clone has happened, subsequent fetch/checkout continue to use
60        // the *same* sidecar — its path is a pure function of `dest`.
61        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        // Per-repo lock held across the whole operation. Cleanliness is
75        // validated AFTER the lock is acquired (a prior caller may have
76        // left the tree dirty between `is_dirty()` at t=0 and us observing
77        // it under the lock) and BEFORE HEAD is moved. Once we hold the
78        // lock the worktree cannot be dirtied by a cooperating caller
79        // before `materialise_tree`, so a single post-lock check is
80        // sufficient to close the TOCTOU window.
81        //
82        // `materialise_tree` calls into gix with `overwrite_existing: true`.
83        // That is safe here because cleanliness is enforced by this
84        // function under the lock; we deliberately do not rely on gix's
85        // `overwrite_existing: false` escape hatch (changing it to `false`
86        // would break legitimate sync-after-stale-files recovery flows).
87        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
102// ---------------------------------------------------------------------------
103// helpers — each kept small so trait methods stay under cyclomatic budget.
104// ---------------------------------------------------------------------------
105
106/// Lock sidecar path for per-repo serialisation. Kept in the parent dir so
107/// the clone path can still require `dest` to be empty, and the sidecar
108/// survives a `rm -rf <dest>` rebuild between retries.
109fn 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
120/// Run `op` while holding the per-repo filesystem lock for `dest`.
121///
122/// Lock is blocking (`fd_lock::RwLock::write`) — per-repo contention is
123/// rare and waiting is the right UX when it happens (e.g. two sync runs
124/// both wanting to `fetch` the same clone cooperate naturally). The
125/// workspace-level lock in [`crate::sync::run`] is the fast-failing guard
126/// that prevents two syncs from ever reaching this point concurrently on
127/// the same workspace.
128fn 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
141/// Fetch body, factored out so the trait method stays a thin lock wrapper.
142fn 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
167/// Error unless `dest` is absent or an empty directory.
168fn 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
180/// Run `gix::prepare_clone` → `fetch_then_checkout` → `main_worktree`.
181fn 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
202/// Open an existing repo or map to [`GitError::NotARepository`].
203fn open_repo(dest: &Path) -> Result<gix::Repository, GitError> {
204    gix::open(dest).map_err(|_| GitError::NotARepository(dest.to_path_buf()))
205}
206
207/// Refuse checkout if the working tree has uncommitted changes.
208fn 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
216/// Resolve a ref string (branch, tag, SHA) to a concrete object id.
217fn 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
223/// Update `HEAD` to point at `target` in detached form, leaving a reflog
224/// entry that credits grex.
225fn 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
248/// Materialise `target`'s tree into the working tree + index, overwriting
249/// whatever is on disk. Precondition: the worktree was clean on entry (the
250/// caller enforced that via [`ensure_clean_worktree`]).
251///
252/// Uses `gix_worktree_state::checkout` with `overwrite_existing = true` so
253/// files from the previous HEAD that are absent in the target tree are
254/// re-materialised atop. This is the equivalent of `git reset --hard` for a
255/// clean working tree.
256fn 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
298/// Peel `commit_id` to its tree object id.
299fn 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
314/// Build a fresh index file from `tree_id`, ready for `gix_worktree_state::checkout`.
315fn 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
327/// Return HEAD as a 40-char lowercase hex SHA.
328fn 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/// Build a `file://` URL from an absolute path, normalising Windows
334/// backslashes to forward slashes as gix/git require.
335///
336/// Exposed for tests; not part of the stable public API.
337#[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}