Skip to main content

grit_lib/
partial_clone.rs

1//! Partial clone filter specs and on-disk promisor-remote configuration.
2
3use std::path::Path;
4
5use thiserror::Error;
6
7use crate::config::{ConfigFile, ConfigScope, ConfigSet};
8use crate::error::{Error, Result};
9use crate::rev_list::{expand_object_filter_for_protocol, ObjectFilter};
10
11/// Client-side object filter for fetch and clone (partial clone).
12///
13/// Only `blob:none` and `blob:limit=<n>` are supported on the wire today; other
14/// [`ObjectFilter`] kinds remain server-side upload-pack policy only.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
17#[cfg_attr(feature = "serde-models", serde(rename_all = "snake_case"))]
18pub enum ObjectFilterSpec {
19    /// Omit all blob objects (`blob:none`).
20    BlobNone,
21    /// Omit blobs whose inflated size is at or above the limit (`blob:limit=<n>`).
22    BlobLimit(u64),
23}
24
25/// Errors parsing or applying partial-clone configuration.
26#[derive(Debug, Error)]
27pub enum PartialCloneError {
28    /// The filter string is not a supported partial-clone spec.
29    #[error("{0}")]
30    InvalidFilter(String),
31    /// Underlying I/O or config failure.
32    #[error(transparent)]
33    Library(#[from] Error),
34}
35
36impl ObjectFilterSpec {
37    /// Parse `blob:none` or `blob:limit=<size>` (same grammar as Git `--filter=`).
38    ///
39    /// # Errors
40    ///
41    /// Returns [`PartialCloneError::InvalidFilter`] for unsupported filter kinds.
42    pub fn parse(spec: &str) -> std::result::Result<Self, PartialCloneError> {
43        let filter = ObjectFilter::parse(spec.trim()).map_err(PartialCloneError::InvalidFilter)?;
44        Self::from_object_filter(&filter)
45    }
46
47    /// Narrow a general [`ObjectFilter`] to a partial-clone wire spec when possible.
48    ///
49    /// # Errors
50    ///
51    /// Returns [`PartialCloneError::InvalidFilter`] when `filter` is not `blob:none` or `blob:limit`.
52    pub fn from_object_filter(
53        filter: &ObjectFilter,
54    ) -> std::result::Result<Self, PartialCloneError> {
55        match filter {
56            ObjectFilter::BlobNone => Ok(Self::BlobNone),
57            ObjectFilter::BlobLimit(n) => Ok(Self::BlobLimit(*n)),
58            other => Err(PartialCloneError::InvalidFilter(format!(
59                "partial clone filter must be blob:none or blob:limit, not {other:?}"
60            ))),
61        }
62    }
63
64    /// Serialize for a `filter <spec>` wire line (limits expanded to byte counts).
65    #[must_use]
66    pub fn to_wire_spec(&self) -> String {
67        match self {
68            Self::BlobNone => "blob:none".to_owned(),
69            Self::BlobLimit(n) => format!("blob:limit={n}"),
70        }
71    }
72
73    /// Value stored in `remote.<name>.partialclonefilter` (Git-compatible).
74    #[must_use]
75    pub fn to_config_value(&self) -> String {
76        self.to_wire_spec()
77    }
78
79    /// Expand for protocol (e.g. `blob:limit=1k` → `blob:limit=1024`).
80    ///
81    /// # Errors
82    ///
83    /// Returns [`PartialCloneError::InvalidFilter`] when expansion fails.
84    pub fn expand_for_protocol(&self) -> std::result::Result<String, PartialCloneError> {
85        expand_object_filter_for_protocol(&self.to_wire_spec())
86            .map_err(PartialCloneError::InvalidFilter)
87    }
88
89    /// Parse `grit clone --blobs` values: `none` or a size with optional `k`/`m`/`g` suffix.
90    ///
91    /// Sizes map to `blob:limit=<n>` (same grammar as Git `--filter=blob:limit=…`).
92    ///
93    /// # Errors
94    ///
95    /// Returns [`PartialCloneError::InvalidFilter`] when `value` is not `none` or a valid limit.
96    pub fn from_blobs_cli(value: &str) -> std::result::Result<Self, PartialCloneError> {
97        let trimmed = value.trim();
98        if trimmed.eq_ignore_ascii_case("none") {
99            return Ok(Self::BlobNone);
100        }
101        Self::parse(&format!("blob:limit={trimmed}"))
102    }
103
104    /// Byte limit when this spec is [`Self::BlobLimit`]; `None` for [`Self::BlobNone`].
105    #[must_use]
106    pub const fn blob_limit_bytes(&self) -> Option<u64> {
107        match self {
108            Self::BlobNone => None,
109            Self::BlobLimit(n) => Some(*n),
110        }
111    }
112}
113
114/// Writes Git partial-clone configuration for `remote` after a filtered fetch/clone.
115pub struct PartialCloneConfig;
116
117impl PartialCloneConfig {
118    /// Set promisor-remote keys matching `git clone --filter=…`.
119    ///
120    /// Writes `remote.<remote>.promisor=true`, `remote.<remote>.partialclonefilter`,
121    /// `extensions.partialclone=<remote>`, and `core.repositoryformatversion=1`.
122    ///
123    /// # Errors
124    ///
125    /// Returns [`crate::error::Error`] on config I/O failure.
126    pub fn write(git_dir: &Path, remote: &str, spec: &ObjectFilterSpec) -> Result<()> {
127        let path = git_dir.join("config");
128        let content = std::fs::read_to_string(&path).map_err(Error::Io)?;
129        let mut config = ConfigFile::parse(&path, &content, ConfigScope::Local)?;
130        let filter = spec.to_config_value();
131        config.set(&format!("remote.{remote}.promisor"), "true")?;
132        config.set(
133            &format!("remote.{remote}.partialclonefilter"),
134            filter.as_str(),
135        )?;
136        config.set("extensions.partialclone", remote)?;
137        config.set("core.repositoryformatversion", "1")?;
138        config.write()?;
139        Ok(())
140    }
141
142    /// Set the same keys as [`Self::write`] with the filter left to be chosen on each fetch
143    /// (`remote.<remote>.partialclonefilter=auto`, Git's `clone --filter=auto`).
144    ///
145    /// Each later fetch from `remote` then picks its filter from the promisor remotes the
146    /// server advertises and the client accepts (see
147    /// [`PromisorDecision::combined_filter`](crate::promisor_remote::PromisorDecision::combined_filter)).
148    /// Git older than 2.54 does not understand the `auto` value.
149    ///
150    /// # Errors
151    ///
152    /// Returns [`crate::error::Error`] on config I/O failure.
153    pub fn write_auto(git_dir: &Path, remote: &str) -> Result<()> {
154        let path = git_dir.join("config");
155        let content = std::fs::read_to_string(&path).map_err(Error::Io)?;
156        let mut config = ConfigFile::parse(&path, &content, ConfigScope::Local)?;
157        config.set(&format!("remote.{remote}.promisor"), "true")?;
158        config.set(
159            &format!("remote.{remote}.partialclonefilter"),
160            AUTO_FILTER_VALUE,
161        )?;
162        config.set("extensions.partialclone", remote)?;
163        config.set("core.repositoryformatversion", "1")?;
164        config.write()?;
165        Ok(())
166    }
167}
168
169/// The `remote.<name>.partialclonefilter` value that makes each fetch choose its filter from
170/// the accepted promisor remotes (Git's `--filter=auto`).
171pub const AUTO_FILTER_VALUE: &str = "auto";
172
173/// Whether `remote.<remote>.partialclonefilter` is [`AUTO_FILTER_VALUE`].
174///
175/// # Parameters
176///
177/// - `config`: merged config.
178/// - `remote`: remote name.
179#[must_use]
180pub fn stored_remote_filter_is_auto(config: &ConfigSet, remote: &str) -> bool {
181    config
182        .get(&format!("remote.{remote}.partialclonefilter"))
183        .is_some_and(|s| s.trim().eq_ignore_ascii_case(AUTO_FILTER_VALUE))
184}
185
186/// Stored `remote.<name>.partialclonefilter` when present and valid.
187#[must_use]
188pub fn stored_remote_filter(config: &ConfigSet, remote: &str) -> Option<ObjectFilterSpec> {
189    config
190        .get(&format!("remote.{remote}.partialclonefilter"))
191        .and_then(|s| ObjectFilterSpec::parse(&s).ok())
192}
193
194/// Whether `remote` is configured as a promisor partial-clone source.
195#[must_use]
196pub fn remote_is_promisor(config: &ConfigSet, remote: &str) -> bool {
197    config
198        .get(&format!("remote.{remote}.promisor"))
199        .is_some_and(|v| v.eq_ignore_ascii_case("true"))
200        || config
201            .get(&format!("remote.{remote}.partialclonefilter"))
202            .is_some()
203}
204
205/// Decide whether to send `filter` on the wire and return the expanded spec string.
206///
207/// When the server lacks filter support, returns `(None, false)` so the client
208/// fetches unfiltered, matching Git's `fetch-pack.c` behavior.
209#[must_use]
210pub fn wire_filter_for_capabilities(
211    spec: Option<&ObjectFilterSpec>,
212    v2_fetch_caps: Option<&[String]>,
213    v0_v1_caps: Option<&str>,
214) -> (Option<String>, bool) {
215    let Some(spec) = spec else {
216        return (None, false);
217    };
218    let Ok(wire) = spec.expand_for_protocol() else {
219        return (None, false);
220    };
221    let supported = match v2_fetch_caps {
222        Some(caps) => crate::protocol_v2::fetch_supports_filter(caps),
223        None => {
224            v0_v1_caps.is_some_and(|caps| caps.split_whitespace().any(|token| token == "filter"))
225        }
226    };
227    if supported {
228        (Some(wire), true)
229    } else {
230        (None, false)
231    }
232}
233
234/// Fetch/clone must use upload-pack on the wire (not local ODB copy) when a filter
235/// is requested or the remote is already a promisor partial-clone source.
236#[must_use]
237pub fn fetch_requires_upload_pack(
238    opts_filter: Option<&ObjectFilterSpec>,
239    config: &ConfigSet,
240    remote_name: Option<&str>,
241) -> bool {
242    if opts_filter.is_some() {
243        return true;
244    }
245    remote_name.is_some_and(|name| remote_is_promisor(config, name))
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    #[test]
253    fn from_blobs_cli_rejects_overflowing_suffix() {
254        assert!(ObjectFilterSpec::from_blobs_cli("18446744073709551615g").is_err());
255    }
256
257    #[test]
258    fn from_blobs_cli_none_and_suffixes() {
259        assert_eq!(
260            ObjectFilterSpec::from_blobs_cli("none").expect("none"),
261            ObjectFilterSpec::BlobNone
262        );
263        assert_eq!(
264            ObjectFilterSpec::from_blobs_cli("1k").expect("1k"),
265            ObjectFilterSpec::BlobLimit(1024)
266        );
267        assert!(ObjectFilterSpec::from_blobs_cli("not-a-size").is_err());
268    }
269
270    #[test]
271    fn parse_blob_none_and_limit() {
272        assert_eq!(
273            ObjectFilterSpec::parse("blob:none").expect("none"),
274            ObjectFilterSpec::BlobNone
275        );
276        assert_eq!(
277            ObjectFilterSpec::parse("blob:limit=512").expect("limit"),
278            ObjectFilterSpec::BlobLimit(512)
279        );
280        assert!(ObjectFilterSpec::parse("tree:0").is_err());
281    }
282
283    #[test]
284    fn auto_filter_is_written_and_detected() {
285        let dir = tempfile::tempdir().expect("tempdir");
286        std::fs::write(dir.path().join("config"), "[core]\n\tbare = true\n").expect("config");
287        PartialCloneConfig::write_auto(dir.path(), "origin").expect("write");
288        let cfg = ConfigSet::load_repo_local_only(dir.path()).expect("load");
289        assert!(stored_remote_filter_is_auto(&cfg, "origin"));
290        assert!(!stored_remote_filter_is_auto(&cfg, "other"));
291        assert_eq!(stored_remote_filter(&cfg, "origin"), None);
292        assert!(remote_is_promisor(&cfg, "origin"));
293        assert_eq!(
294            cfg.get("extensions.partialclone").as_deref(),
295            Some("origin")
296        );
297    }
298
299    #[test]
300    fn wire_filter_dropped_without_server_cap() {
301        let spec = ObjectFilterSpec::BlobNone;
302        let (wire, send) = wire_filter_for_capabilities(Some(&spec), Some(&[]), None);
303        assert!(!send);
304        assert!(wire.is_none());
305
306        let (wire, send) =
307            wire_filter_for_capabilities(Some(&spec), None, Some("multi_ack side-band-64k"));
308        assert!(!send);
309        assert!(wire.is_none());
310
311        let (wire, send) =
312            wire_filter_for_capabilities(Some(&spec), Some(&["fetch=filter".to_owned()]), None);
313        assert!(send);
314        assert_eq!(wire.as_deref(), Some("blob:none"));
315    }
316}