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::{BackendLockCtx, 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(
57        &self,
58        url: &str,
59        dest: &Path,
60        r#ref: Option<&str>,
61        lock_ctx: BackendLockCtx<'_>,
62    ) -> Result<ClonedRepo, GitError> {
63        // Per-repo lock: v1.3.2 B11 — the sidecar lives at
64        // `<parent_meta>/.grex/locks/<child_path>.backend.lock`
65        // (parent-owned). It survives a `rm -rf <dest>` rebuild and
66        // pre-exists the clone (`.grex/` is created on first acquire).
67        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        // Per-repo lock held across the whole operation. Cleanliness is
86        // validated AFTER the lock is acquired (a prior caller may have
87        // left the tree dirty between `is_dirty()` at t=0 and us observing
88        // it under the lock) and BEFORE HEAD is moved. Once we hold the
89        // lock the worktree cannot be dirtied by a cooperating caller
90        // before `materialise_tree`, so a single post-lock check is
91        // sufficient to close the TOCTOU window.
92        //
93        // `materialise_tree` calls into gix with `overwrite_existing: true`.
94        // That is safe here because cleanliness is enforced by this
95        // function under the lock; we deliberately do not rely on gix's
96        // `overwrite_existing: false` escape hatch (changing it to `false`
97        // would break legitimate sync-after-stale-files recovery flows).
98        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
113// ---------------------------------------------------------------------------
114// helpers — each kept small so trait methods stay under cyclomatic budget.
115// ---------------------------------------------------------------------------
116
117/// Lock sidecar path for per-repo serialisation.
118///
119/// v1.3.2 B11: the sidecar lives under the PARENT meta-pack's `.grex/locks/`
120/// namespace at `<parent_meta>/.grex/locks/<child_path>.backend.lock`.
121/// `child_path` is the literal manifest-declared path verbatim (e.g.
122/// `tools/foo` → `<parent>/.grex/locks/tools/foo.backend.lock`); intermediate
123/// directories are auto-created on first acquire by [`with_repo_lock`].
124///
125/// Properties (per `inst/lockfile.md` §"File location"):
126/// - Survives `rm -rf <dest>` — lock is parent-owned, not dest-adjacent.
127/// - Pre-clone safe — parent's `.grex/` exists before any child clone.
128/// - Path-keyed identity — no collision when two children share `name:`
129///   at distinct paths.
130fn 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    // Use a relative `Path::new(child_path)` so forward-slash separators
143    // in the literal manifest path translate to OS-native separators
144    // when joined. `child_path` is validated by the manifest loader
145    // before reaching here; `..` and absolute components are rejected
146    // upstream.
147    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
155/// Run `op` while holding the per-repo filesystem lock for `(parent_meta, child_path)`.
156///
157/// Lock is blocking (`fd_lock::RwLock::write`) — per-repo contention is
158/// rare and waiting is the right UX when it happens (e.g. two sync runs
159/// both wanting to `fetch` the same clone cooperate naturally). The
160/// workspace-level lock in [`crate::sync::run`] is the fast-failing guard
161/// that prevents two syncs from ever reaching this point concurrently on
162/// the same workspace.
163///
164/// v1.3.2 B11: intermediate directories under `<parent_meta>/.grex/locks/`
165/// are auto-created via `fs::create_dir_all` so a slash-path child like
166/// `tools/foo` finds its lock dir on first invocation.
167fn 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
185/// Fetch body, factored out so the trait method stays a thin lock wrapper.
186fn 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
211/// Error unless `dest` is absent or an empty directory.
212fn 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
224/// Run `gix::prepare_clone` → `fetch_then_checkout` → `main_worktree`.
225fn 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
246/// Open an existing repo or map to [`GitError::NotARepository`].
247fn open_repo(dest: &Path) -> Result<gix::Repository, GitError> {
248    gix::open(dest).map_err(|_| GitError::NotARepository(dest.to_path_buf()))
249}
250
251/// Refuse checkout if the working tree has uncommitted changes.
252fn 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
260/// Resolve a ref string (branch, tag, SHA) to a concrete object id.
261fn 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
267/// Update `HEAD` to point at `target` in detached form, leaving a reflog
268/// entry that credits grex.
269fn 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
292/// Materialise `target`'s tree into the working tree + index, overwriting
293/// whatever is on disk. Precondition: the worktree was clean on entry (the
294/// caller enforced that via [`ensure_clean_worktree`]).
295///
296/// Uses `gix_worktree_state::checkout` with `overwrite_existing = true` so
297/// files from the previous HEAD that are absent in the target tree are
298/// re-materialised atop. This is the equivalent of `git reset --hard` for a
299/// clean working tree.
300fn 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
342/// Peel `commit_id` to its tree object id.
343fn 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
358/// Build a fresh index file from `tree_id`, ready for `gix_worktree_state::checkout`.
359fn 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
371/// Return HEAD as a 40-char lowercase hex SHA.
372fn 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/// Build a `file://` URL from an absolute path, normalising Windows
378/// backslashes to forward slashes as gix/git require.
379///
380/// Exposed for tests; not part of the stable public API.
381#[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}