grex_core/git/mod.rs
1//! Decoupled git backend surface used by the pack walker and exec path.
2//!
3//! Pack children reference git remotes (`url`, `path`, `ref` — see
4//! `inst/pack-spec.md` §children). The walker needs to clone, fetch, and
5//! checkout these remotes; the exec path pins commits. Every one of those
6//! callers goes through the [`GitBackend`] trait rather than the `gix` crate
7//! directly, so:
8//!
9//! - tests can substitute an in-memory mock
10//! - a future IPC or CLI-shell backend can plug in without rewriting callers
11//! - backend-specific error types stay out of the public API (see
12//! `error::GitError` which uses `String` detail fields)
13//!
14//! The default implementation, [`GixBackend`], wraps the pure-Rust `gix`
15//! crate. Auth is the gix default: system SSH keys and anonymous HTTPS.
16//! Credential prompting, SSH-agent integration, shallow clones, submodules,
17//! and concurrent-fetch coordination are all **out of scope** for this slice
18//! and will land in later M3 slices.
19
20pub mod error;
21pub mod gix_backend;
22
23use std::path::{Path, PathBuf};
24
25pub use self::error::GitError;
26pub use self::gix_backend::GixBackend;
27// `BackendLockCtx` is part of the `GitBackend` trait surface (v1.3.2 B11);
28// re-export at module root so consumers don't need to thread the inline
29// `git::BackendLockCtx` import alongside the trait import.
30
31/// Result of a successful clone.
32///
33/// `head_sha` is always the 40-char lowercase hex SHA of the commit HEAD was
34/// left pointing at after checkout.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct ClonedRepo {
37 /// Filesystem path of the cloned working tree.
38 pub path: PathBuf,
39 /// HEAD commit SHA, 40-char lowercase hex.
40 pub head_sha: String,
41}
42
43/// Per-call backend-lock context (v1.3.2 B11).
44///
45/// The per-repo backend lock lives at
46/// `<parent_meta>/.grex/locks/<child_path>.backend.lock` (parent-owned) so
47/// it (a) survives a `rm -rf <dest>` rebuild and (b) keeps every lock
48/// artifact under one `.grex/` namespace per meta. This struct threads the
49/// `(parent_meta, child_path)` pair through the [`GitBackend`] surface so
50/// the backend can compute the lock path without re-deriving it from
51/// `dest` (which would lose intermediate `/` segments on slash-paths).
52///
53/// `child_path` is the LITERAL manifest-declared path (e.g. `tools/foo`)
54/// — POSIX forward slashes, no slug encoding. Intermediate directories
55/// under `<parent_meta>/.grex/locks/` are auto-created on first acquire.
56#[derive(Debug, Clone, Copy)]
57pub struct BackendLockCtx<'a> {
58 /// Workdir of the meta-pack that owns the child clone (`meta_dir` in
59 /// the walker terminology). Lock files live under
60 /// `<parent_meta>/.grex/locks/`.
61 pub parent_meta: &'a Path,
62 /// Manifest-declared child path (e.g. `"tools/foo"`). Used verbatim
63 /// as the suffix of the backend-lock filename.
64 pub child_path: &'a str,
65}
66
67impl<'a> BackendLockCtx<'a> {
68 /// Construct a new lock context.
69 #[must_use]
70 pub const fn new(parent_meta: &'a Path, child_path: &'a str) -> Self {
71 Self { parent_meta, child_path }
72 }
73}
74
75/// Owned counterpart of [`BackendLockCtx`]. Provides a convenient way for
76/// tests and callers that don't otherwise carry a parent-meta + child-path
77/// pair to derive one from a flat `dest` path: `parent_meta = dest.parent()`,
78/// `child_path = dest.file_name()`. Borrow with [`BackendLockCtxOwned::as_ctx`]
79/// at the trait-method call site.
80///
81/// v1.3.2 B11 — added so the per-repo backend lock lands under
82/// `<parent>/.grex/locks/<name>.backend.lock` for callers that have only a
83/// `dest` path on hand. Production code paths (walker phase1) construct
84/// [`BackendLockCtx`] directly with the manifest-declared `child.path()`.
85#[derive(Debug, Clone)]
86pub struct BackendLockCtxOwned {
87 /// Owned parent-meta path.
88 pub parent_meta: PathBuf,
89 /// Owned child path (POSIX-style, may contain `/`).
90 pub child_path: String,
91}
92
93impl BackendLockCtxOwned {
94 /// Derive a lock context from a flat `dest` path. Used by tests that
95 /// don't otherwise carry a parent-meta — `dest.parent()` plays the
96 /// role of `parent_meta` and `dest.file_name()` the role of
97 /// `child_path`. Falls back to `"."` when the parent component is
98 /// absent. A missing filename becomes an empty `child_path`, which the
99 /// backend lock path validator rejects instead of silently routing to a
100 /// generic `"repo"` lock.
101 #[must_use]
102 pub fn from_dest(dest: &Path) -> Self {
103 let parent_meta =
104 dest.parent().map(Path::to_path_buf).unwrap_or_else(|| PathBuf::from("."));
105 let child_path =
106 dest.file_name().and_then(|s| s.to_str()).map_or_else(String::new, str::to_string);
107 Self { parent_meta, child_path }
108 }
109
110 /// Borrow as a [`BackendLockCtx`] for trait-method call sites.
111 #[must_use]
112 pub fn as_ctx(&self) -> BackendLockCtx<'_> {
113 BackendLockCtx::new(&self.parent_meta, &self.child_path)
114 }
115}
116
117/// Stable surface for all git operations grex needs.
118///
119/// Implementors must be `Send + Sync` so a single instance can be handed to
120/// the scheduler as `Arc<dyn GitBackend>`. All methods are synchronous — the
121/// slice 3 design deliberately avoids async to keep the trait object-safe and
122/// the default backend runtime-free.
123///
124/// Errors are carried as [`GitError`]; the enum is `#[non_exhaustive]` so
125/// future variants (credentials, submodules, …) won't break implementors.
126pub trait GitBackend: Send + Sync {
127 /// Short human-readable name of the backend, e.g. `"gix"`. Used in logs
128 /// and diagnostics; not parsed programmatically.
129 fn name(&self) -> &'static str;
130
131 /// Clone `url` into `dest`.
132 ///
133 /// # Contract
134 ///
135 /// - If `dest` exists and is non-empty → [`GitError::DestinationNotEmpty`].
136 /// - If `r#ref` is `Some`, check out that ref after the clone finishes.
137 /// - If `r#ref` is `None`, leave the working tree on the remote's default
138 /// HEAD.
139 ///
140 /// `lock_ctx` carries `(parent_meta, child_path)` so the backend can
141 /// compute the per-repo lock path under `<parent_meta>/.grex/locks/`
142 /// (v1.3.2 B11). Implementations that do not lock may ignore the
143 /// argument.
144 ///
145 /// # Errors
146 ///
147 /// Any clone-, network-, or checkout-layer failure maps to a
148 /// [`GitError`] variant — see that enum for the taxonomy.
149 fn clone(
150 &self,
151 url: &str,
152 dest: &Path,
153 r#ref: Option<&str>,
154 lock_ctx: BackendLockCtx<'_>,
155 ) -> Result<ClonedRepo, GitError>;
156
157 /// Fetch from the default remote (`origin`) into an existing repo at
158 /// `dest`. Leaves the working tree untouched.
159 ///
160 /// `lock_ctx` carries the parent-meta + child-path so the per-repo
161 /// lock can be computed under `<parent_meta>/.grex/locks/` (v1.3.2 B11).
162 ///
163 /// # Errors
164 ///
165 /// Returns [`GitError::NotARepository`] when `dest` is not a git repo,
166 /// or [`GitError::FetchFailed`] on any network- or ref-update failure.
167 fn fetch(&self, dest: &Path, lock_ctx: BackendLockCtx<'_>) -> Result<(), GitError>;
168
169 /// Resolve `r#ref` (branch, tag, or SHA) and update the working tree at
170 /// `dest` to match. Refuses to run if the working tree has uncommitted
171 /// changes.
172 ///
173 /// `lock_ctx` carries the parent-meta + child-path so the per-repo
174 /// lock can be computed under `<parent_meta>/.grex/locks/` (v1.3.2 B11).
175 ///
176 /// # Errors
177 ///
178 /// - [`GitError::NotARepository`] when `dest` is not a git repo.
179 /// - [`GitError::DirtyWorkingTree`] when there are uncommitted changes.
180 /// - [`GitError::RefNotFound`] when the ref cannot be resolved.
181 /// - [`GitError::CheckoutFailed`] for any other checkout-layer failure.
182 fn checkout(
183 &self,
184 dest: &Path,
185 r#ref: &str,
186 lock_ctx: BackendLockCtx<'_>,
187 ) -> Result<(), GitError>;
188
189 /// Return HEAD at `dest` as a 40-char lowercase hex SHA.
190 ///
191 /// # Errors
192 ///
193 /// [`GitError::NotARepository`] when `dest` is not a git repo;
194 /// [`GitError::Internal`] wraps any unexpected head-resolution failure.
195 fn head_sha(&self, dest: &Path) -> Result<String, GitError>;
196}
197
198#[cfg(test)]
199mod tests {
200 use super::*;
201
202 #[test]
203 fn backend_lock_ctx_owned_from_dest_does_not_fallback_to_repo() {
204 let ctx = BackendLockCtxOwned::from_dest(Path::new("/"));
205
206 assert_eq!(ctx.child_path, "");
207 }
208}