grit_lib/
partial_clone.rs1use 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#[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 BlobNone,
21 BlobLimit(u64),
23}
24
25#[derive(Debug, Error)]
27pub enum PartialCloneError {
28 #[error("{0}")]
30 InvalidFilter(String),
31 #[error(transparent)]
33 Library(#[from] Error),
34}
35
36impl ObjectFilterSpec {
37 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 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 #[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 #[must_use]
75 pub fn to_config_value(&self) -> String {
76 self.to_wire_spec()
77 }
78
79 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 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 #[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
114pub struct PartialCloneConfig;
116
117impl PartialCloneConfig {
118 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 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
169pub const AUTO_FILTER_VALUE: &str = "auto";
172
173#[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#[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#[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#[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#[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}