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)]
16pub enum ObjectFilterSpec {
17    /// Omit all blob objects (`blob:none`).
18    BlobNone,
19    /// Omit blobs whose inflated size is at or above the limit (`blob:limit=<n>`).
20    BlobLimit(u64),
21}
22
23/// Errors parsing or applying partial-clone configuration.
24#[derive(Debug, Error)]
25pub enum PartialCloneError {
26    /// The filter string is not a supported partial-clone spec.
27    #[error("{0}")]
28    InvalidFilter(String),
29    /// Underlying I/O or config failure.
30    #[error(transparent)]
31    Library(#[from] Error),
32}
33
34impl ObjectFilterSpec {
35    /// Parse `blob:none` or `blob:limit=<size>` (same grammar as Git `--filter=`).
36    ///
37    /// # Errors
38    ///
39    /// Returns [`PartialCloneError::InvalidFilter`] for unsupported filter kinds.
40    pub fn parse(spec: &str) -> std::result::Result<Self, PartialCloneError> {
41        let filter = ObjectFilter::parse(spec.trim()).map_err(PartialCloneError::InvalidFilter)?;
42        Self::from_object_filter(&filter)
43    }
44
45    /// Narrow a general [`ObjectFilter`] to a partial-clone wire spec when possible.
46    ///
47    /// # Errors
48    ///
49    /// Returns [`PartialCloneError::InvalidFilter`] when `filter` is not `blob:none` or `blob:limit`.
50    pub fn from_object_filter(
51        filter: &ObjectFilter,
52    ) -> std::result::Result<Self, PartialCloneError> {
53        match filter {
54            ObjectFilter::BlobNone => Ok(Self::BlobNone),
55            ObjectFilter::BlobLimit(n) => Ok(Self::BlobLimit(*n)),
56            other => Err(PartialCloneError::InvalidFilter(format!(
57                "partial clone filter must be blob:none or blob:limit, not {other:?}"
58            ))),
59        }
60    }
61
62    /// Serialize for a `filter <spec>` wire line (limits expanded to byte counts).
63    #[must_use]
64    pub fn to_wire_spec(&self) -> String {
65        match self {
66            Self::BlobNone => "blob:none".to_owned(),
67            Self::BlobLimit(n) => format!("blob:limit={n}"),
68        }
69    }
70
71    /// Value stored in `remote.<name>.partialclonefilter` (Git-compatible).
72    #[must_use]
73    pub fn to_config_value(&self) -> String {
74        self.to_wire_spec()
75    }
76
77    /// Expand for protocol (e.g. `blob:limit=1k` → `blob:limit=1024`).
78    ///
79    /// # Errors
80    ///
81    /// Returns [`PartialCloneError::InvalidFilter`] when expansion fails.
82    pub fn expand_for_protocol(&self) -> std::result::Result<String, PartialCloneError> {
83        expand_object_filter_for_protocol(&self.to_wire_spec())
84            .map_err(PartialCloneError::InvalidFilter)
85    }
86
87    /// Parse `grit clone --blobs` values: `none` or a size with optional `k`/`m`/`g` suffix.
88    ///
89    /// Sizes map to `blob:limit=<n>` (same grammar as Git `--filter=blob:limit=…`).
90    ///
91    /// # Errors
92    ///
93    /// Returns [`PartialCloneError::InvalidFilter`] when `value` is not `none` or a valid limit.
94    pub fn from_blobs_cli(value: &str) -> std::result::Result<Self, PartialCloneError> {
95        let trimmed = value.trim();
96        if trimmed.eq_ignore_ascii_case("none") {
97            return Ok(Self::BlobNone);
98        }
99        Self::parse(&format!("blob:limit={trimmed}"))
100    }
101
102    /// Byte limit when this spec is [`Self::BlobLimit`]; `None` for [`Self::BlobNone`].
103    #[must_use]
104    pub const fn blob_limit_bytes(&self) -> Option<u64> {
105        match self {
106            Self::BlobNone => None,
107            Self::BlobLimit(n) => Some(*n),
108        }
109    }
110}
111
112/// Writes Git partial-clone configuration for `remote` after a filtered fetch/clone.
113pub struct PartialCloneConfig;
114
115impl PartialCloneConfig {
116    /// Set promisor-remote keys matching `git clone --filter=…`.
117    ///
118    /// Writes `remote.<remote>.promisor=true`, `remote.<remote>.partialclonefilter`,
119    /// `extensions.partialclone=<remote>`, and `core.repositoryformatversion=1`.
120    ///
121    /// # Errors
122    ///
123    /// Returns [`crate::error::Error`] on config I/O failure.
124    pub fn write(git_dir: &Path, remote: &str, spec: &ObjectFilterSpec) -> Result<()> {
125        let path = git_dir.join("config");
126        let content = std::fs::read_to_string(&path).map_err(Error::Io)?;
127        let mut config = ConfigFile::parse(&path, &content, ConfigScope::Local)?;
128        let filter = spec.to_config_value();
129        config.set(&format!("remote.{remote}.promisor"), "true")?;
130        config.set(
131            &format!("remote.{remote}.partialclonefilter"),
132            filter.as_str(),
133        )?;
134        config.set("extensions.partialclone", remote)?;
135        config.set("core.repositoryformatversion", "1")?;
136        config.write()?;
137        Ok(())
138    }
139}
140
141/// Stored `remote.<name>.partialclonefilter` when present and valid.
142#[must_use]
143pub fn stored_remote_filter(config: &ConfigSet, remote: &str) -> Option<ObjectFilterSpec> {
144    config
145        .get(&format!("remote.{remote}.partialclonefilter"))
146        .and_then(|s| ObjectFilterSpec::parse(&s).ok())
147}
148
149/// Whether `remote` is configured as a promisor partial-clone source.
150#[must_use]
151pub fn remote_is_promisor(config: &ConfigSet, remote: &str) -> bool {
152    config
153        .get(&format!("remote.{remote}.promisor"))
154        .is_some_and(|v| v.eq_ignore_ascii_case("true"))
155        || config
156            .get(&format!("remote.{remote}.partialclonefilter"))
157            .is_some()
158}
159
160/// Decide whether to send `filter` on the wire and return the expanded spec string.
161///
162/// When the server lacks filter support, returns `(None, false)` so the client
163/// fetches unfiltered, matching Git's `fetch-pack.c` behavior.
164#[must_use]
165pub fn wire_filter_for_capabilities(
166    spec: Option<&ObjectFilterSpec>,
167    v2_fetch_caps: Option<&[String]>,
168    v0_v1_caps: Option<&str>,
169) -> (Option<String>, bool) {
170    let Some(spec) = spec else {
171        return (None, false);
172    };
173    let Ok(wire) = spec.expand_for_protocol() else {
174        return (None, false);
175    };
176    let supported = match v2_fetch_caps {
177        Some(caps) => crate::protocol_v2::fetch_supports_filter(caps),
178        None => {
179            v0_v1_caps.is_some_and(|caps| caps.split_whitespace().any(|token| token == "filter"))
180        }
181    };
182    if supported {
183        (Some(wire), true)
184    } else {
185        (None, false)
186    }
187}
188
189/// Fetch/clone must use upload-pack on the wire (not local ODB copy) when a filter
190/// is requested or the remote is already a promisor partial-clone source.
191#[must_use]
192pub fn fetch_requires_upload_pack(
193    opts_filter: Option<&ObjectFilterSpec>,
194    config: &ConfigSet,
195    remote_name: Option<&str>,
196) -> bool {
197    if opts_filter.is_some() {
198        return true;
199    }
200    remote_name.is_some_and(|name| remote_is_promisor(config, name))
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    #[test]
208    fn from_blobs_cli_rejects_overflowing_suffix() {
209        assert!(ObjectFilterSpec::from_blobs_cli("18446744073709551615g").is_err());
210    }
211
212    #[test]
213    fn from_blobs_cli_none_and_suffixes() {
214        assert_eq!(
215            ObjectFilterSpec::from_blobs_cli("none").expect("none"),
216            ObjectFilterSpec::BlobNone
217        );
218        assert_eq!(
219            ObjectFilterSpec::from_blobs_cli("1k").expect("1k"),
220            ObjectFilterSpec::BlobLimit(1024)
221        );
222        assert!(ObjectFilterSpec::from_blobs_cli("not-a-size").is_err());
223    }
224
225    #[test]
226    fn parse_blob_none_and_limit() {
227        assert_eq!(
228            ObjectFilterSpec::parse("blob:none").expect("none"),
229            ObjectFilterSpec::BlobNone
230        );
231        assert_eq!(
232            ObjectFilterSpec::parse("blob:limit=512").expect("limit"),
233            ObjectFilterSpec::BlobLimit(512)
234        );
235        assert!(ObjectFilterSpec::parse("tree:0").is_err());
236    }
237
238    #[test]
239    fn wire_filter_dropped_without_server_cap() {
240        let spec = ObjectFilterSpec::BlobNone;
241        let (wire, send) = wire_filter_for_capabilities(Some(&spec), Some(&[]), None);
242        assert!(!send);
243        assert!(wire.is_none());
244
245        let (wire, send) =
246            wire_filter_for_capabilities(Some(&spec), None, Some("multi_ack side-band-64k"));
247        assert!(!send);
248        assert!(wire.is_none());
249
250        let (wire, send) =
251            wire_filter_for_capabilities(Some(&spec), Some(&["fetch=filter".to_owned()]), None);
252        assert!(send);
253        assert_eq!(wire.as_deref(), Some("blob:none"));
254    }
255}