1use std::env;
4use std::fs;
5use std::io::Write;
6use std::path::Path;
7
8use sley_config::GitConfig;
9use sley_config::raw_edit::{ConfigFileWriteOptions, RawConfigEditor, write_config_file_locked};
10use sley_config::remotes::remote_names;
11use sley_core::{Capability, GitError, Result};
12use sley_protocol::{
13 PromisorRemoteAdvertisement, encode_promisor_remote_advertisement,
14 encode_promisor_remote_reply, parse_promisor_remote_advertisement,
15};
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
18pub enum PromisorAcceptPolicy {
19 #[default]
20 None,
21 KnownUrl,
22 KnownName,
23 All,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq, Default)]
27pub struct PromisorRemoteDecision {
28 pub accepted: Vec<PromisorRemoteAdvertisement>,
30 pub reply: Option<String>,
32 pub stored_fields: Vec<PromisorRemoteFieldUpdate>,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum PromisorRemoteField {
39 PartialCloneFilter,
40 Token,
41}
42
43impl PromisorRemoteField {
44 pub const fn config_key(self) -> &'static str {
45 match self {
46 Self::PartialCloneFilter => "partialCloneFilter",
47 Self::Token => "token",
48 }
49 }
50
51 pub const fn display_name(self) -> &'static str {
52 match self {
53 Self::PartialCloneFilter => "filter",
54 Self::Token => "token",
55 }
56 }
57}
58
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct PromisorRemoteFieldUpdate {
61 pub remote_name: String,
62 pub field: PromisorRemoteField,
63 pub previous: Option<String>,
64 pub value: String,
65}
66
67pub fn promisor_remote_server_capability(config: &GitConfig) -> Result<Option<Capability>> {
69 if !config
70 .get_bool("promisor", None, "advertise")
71 .unwrap_or(false)
72 {
73 return Ok(None);
74 }
75 let send_fields = configured_fields(config, "sendFields");
76 let remotes = configured_promisor_remotes(config, &send_fields);
77 if remotes.is_empty() {
78 return Ok(None);
79 }
80 Ok(Some(Capability {
81 name: "promisor-remote".into(),
82 value: Some(encode_promisor_remote_advertisement(&remotes)?),
83 }))
84}
85
86pub fn decide_promisor_remote_reply(
88 config: &GitConfig,
89 capability: &Capability,
90) -> Result<PromisorRemoteDecision> {
91 if capability.name != "promisor-remote" {
92 return Ok(PromisorRemoteDecision::default());
93 }
94 let Some(value) = capability.value.as_deref() else {
95 return Ok(PromisorRemoteDecision::default());
96 };
97 let policy = promisor_accept_policy(config);
98 if policy == PromisorAcceptPolicy::None {
99 return Ok(PromisorRemoteDecision::default());
100 }
101 let checked_fields = configured_fields(config, "checkFields");
102 let configured = configured_promisor_remotes(config, &checked_fields);
103 let advertised = parse_promisor_remote_advertisement(value)?;
104 let accepted = advertised
105 .into_iter()
106 .filter(|remote| should_accept(policy, remote, &configured, &checked_fields))
107 .collect::<Vec<_>>();
108 let names = accepted
109 .iter()
110 .map(|remote| remote.name.clone())
111 .collect::<Vec<_>>();
112 let stored_fields = planned_stored_fields(config, &accepted);
113 Ok(PromisorRemoteDecision {
114 accepted,
115 reply: encode_promisor_remote_reply(&names)?,
116 stored_fields,
117 })
118}
119
120pub fn apply_promisor_remote_field_updates(
122 git_dir: &Path,
123 updates: &[PromisorRemoteFieldUpdate],
124) -> Result<()> {
125 if updates.is_empty() {
126 return Ok(());
127 }
128 let path = git_dir.join("config");
129 let mut contents = match fs::read(&path) {
130 Ok(contents) => contents,
131 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Vec::new(),
132 Err(err) => return Err(GitError::Io(err.to_string())),
133 };
134 for update in updates {
135 let mut editor = RawConfigEditor::new(
136 contents,
137 "remote",
138 Some(&update.remote_name),
139 update.field.config_key(),
140 );
141 editor.set_multivar(Some(&update.value), None, None, true);
142 contents = editor.into_bytes();
143 }
144 write_config_file_locked(&path, &contents, ConfigFileWriteOptions::default())
145 .map_err(|err| GitError::Io(err.to_string()))
146}
147
148pub fn promisor_remote_auto_filter(
151 decision: &PromisorRemoteDecision,
152) -> Option<sley_odb::PackObjectFilter> {
153 decision
154 .accepted
155 .iter()
156 .filter_map(|remote| remote.partial_clone_filter.as_deref())
157 .filter_map(crate::pack_filter_from_spec)
158 .reduce(crate::filter::combine_pack_filters)
159}
160
161pub fn promisor_accept_policy(config: &GitConfig) -> PromisorAcceptPolicy {
162 match config.get("promisor", None, "acceptFromServer") {
163 Some(value) if value.eq_ignore_ascii_case("All") => PromisorAcceptPolicy::All,
164 Some(value) if value.eq_ignore_ascii_case("KnownName") => PromisorAcceptPolicy::KnownName,
165 Some(value) if value.eq_ignore_ascii_case("KnownUrl") => PromisorAcceptPolicy::KnownUrl,
166 _ => PromisorAcceptPolicy::None,
167 }
168}
169
170pub fn config_has_promisor_remote(config: &GitConfig) -> bool {
172 config
173 .get("extensions", None, "partialclone")
174 .is_some_and(|value| !value.is_empty())
175 || remote_names(config).into_iter().any(|name| {
176 config
177 .get_bool("remote", Some(&name), "promisor")
178 .unwrap_or(false)
179 || config
180 .get("remote", Some(&name), "partialCloneFilter")
181 .is_some_and(|value| !value.is_empty())
182 })
183}
184
185pub fn configured_promisor_remote_names(config: &GitConfig) -> Vec<String> {
192 let mut config_order = Vec::new();
193 for section in &config.sections {
194 if section.name.eq_ignore_ascii_case("remote")
195 && let Some(name) = section.subsection.as_deref()
196 && !config_order.iter().any(|existing| existing == name)
197 {
198 config_order.push(name.to_string());
199 }
200 }
201 let mut names = config_order
202 .into_iter()
203 .filter(|name| {
204 config
205 .get_bool("remote", Some(name), "promisor")
206 .unwrap_or(false)
207 || config
208 .get("remote", Some(name), "partialCloneFilter")
209 .is_some_and(|value| !value.is_empty())
210 })
211 .collect::<Vec<_>>();
212 if let Some(default) = config
213 .get("extensions", None, "partialClone")
214 .filter(|value| !value.is_empty())
215 {
216 if let Some(position) = names.iter().position(|name| name == default) {
217 let default = names.remove(position);
218 names.push(default);
219 } else {
220 names.push(default.to_string());
221 }
222 }
223 names
224}
225
226pub(crate) fn trace_promisor_remote_contact(remote_name: &str) {
231 let Some(target) = env::var_os("GIT_TRACE") else {
232 return;
233 };
234 let value = target.to_string_lossy();
235 if matches!(value.to_ascii_lowercase().as_str(), "" | "0" | "false") {
236 return;
237 }
238 let line = promisor_remote_contact_trace_line(remote_name);
239 if matches!(value.to_ascii_lowercase().as_str(), "1" | "2" | "true") {
240 eprintln!("{line}");
241 } else if Path::new(target.as_os_str()).is_absolute()
242 && let Ok(mut file) = fs::OpenOptions::new()
243 .create(true)
244 .append(true)
245 .open(target)
246 {
247 let _ = writeln!(file, "{line}");
248 }
249}
250
251fn promisor_remote_contact_trace_line(remote_name: &str) -> String {
252 format!(
253 "trace: run_command: git -c fetch.negotiationAlgorithm=noop fetch {} --no-tags --no-write-fetch-head --recurse-submodules=no --filter=blob:none --stdin",
254 trace_quote_argument(remote_name)
255 )
256}
257
258fn trace_quote_argument(value: &str) -> String {
259 const SAFE: &[u8] = b"+,-./:=@_^";
260 if !value.is_empty()
261 && value
262 .bytes()
263 .all(|byte| byte.is_ascii_alphanumeric() || SAFE.contains(&byte))
264 {
265 return value.to_string();
266 }
267 format!("'{}'", value.replace('\'', "'\\''"))
268}
269
270fn configured_fields(config: &GitConfig, key: &str) -> Vec<String> {
271 config
272 .get("promisor", None, key)
273 .unwrap_or("")
274 .split(',')
275 .map(str::trim)
276 .filter(|field| {
277 field.eq_ignore_ascii_case("partialCloneFilter") || field.eq_ignore_ascii_case("token")
278 })
279 .map(str::to_string)
280 .collect()
281}
282
283fn configured_promisor_remotes(
284 config: &GitConfig,
285 fields: &[String],
286) -> Vec<PromisorRemoteAdvertisement> {
287 remote_names(config)
288 .into_iter()
289 .filter(|name| {
290 config
291 .get_bool("remote", Some(name), "promisor")
292 .unwrap_or(false)
293 || config
294 .get("remote", Some(name), "partialCloneFilter")
295 .is_some_and(|value| !value.is_empty())
296 })
297 .filter_map(|name| {
298 let url = config.get("remote", Some(&name), "url")?.to_string();
299 if url.is_empty() {
300 return None;
301 }
302 let includes = |field: &str| fields.iter().any(|item| item.eq_ignore_ascii_case(field));
303 Some(PromisorRemoteAdvertisement {
304 partial_clone_filter: includes("partialCloneFilter")
305 .then(|| {
306 config
307 .get("remote", Some(&name), "partialCloneFilter")
308 .map(str::to_string)
309 })
310 .flatten(),
311 token: includes("token")
312 .then(|| {
313 config
314 .get("remote", Some(&name), "token")
315 .map(str::to_string)
316 })
317 .flatten(),
318 name,
319 url,
320 })
321 })
322 .collect()
323}
324
325fn planned_stored_fields(
326 config: &GitConfig,
327 accepted: &[PromisorRemoteAdvertisement],
328) -> Vec<PromisorRemoteFieldUpdate> {
329 let fields = configured_fields(config, "storeFields");
330 if fields.is_empty() {
331 return Vec::new();
332 }
333 let configured = configured_promisor_remotes(config, &fields);
334 let mut updates = Vec::new();
335 for advertised in accepted {
336 let Some(current) = configured
337 .iter()
338 .find(|remote| remote.name == advertised.name)
339 else {
340 continue;
341 };
342 for field_name in &fields {
343 let (field, advertised_value, previous) =
344 if field_name.eq_ignore_ascii_case("partialCloneFilter") {
345 (
346 PromisorRemoteField::PartialCloneFilter,
347 advertised.partial_clone_filter.as_deref(),
348 current.partial_clone_filter.as_deref(),
349 )
350 } else {
351 (
352 PromisorRemoteField::Token,
353 advertised.token.as_deref(),
354 current.token.as_deref(),
355 )
356 };
357 let Some(value) = advertised_value else {
358 continue;
359 };
360 let valid = match field {
361 PromisorRemoteField::PartialCloneFilter => {
362 crate::pack_filter_from_spec(value).is_some()
363 }
364 PromisorRemoteField::Token => !value.chars().any(char::is_control),
365 };
366 if valid && previous != Some(value) {
367 updates.push(PromisorRemoteFieldUpdate {
368 remote_name: advertised.name.clone(),
369 field,
370 previous: previous.map(str::to_string),
371 value: value.to_string(),
372 });
373 }
374 }
375 }
376 updates
377}
378
379fn should_accept(
380 policy: PromisorAcceptPolicy,
381 advertised: &PromisorRemoteAdvertisement,
382 configured: &[PromisorRemoteAdvertisement],
383 checked_fields: &[String],
384) -> bool {
385 let named = configured
386 .iter()
387 .find(|remote| remote.name == advertised.name);
388 let candidate = match policy {
389 PromisorAcceptPolicy::None => return false,
390 PromisorAcceptPolicy::All => None,
391 PromisorAcceptPolicy::KnownName => match named {
392 Some(remote) => Some(remote),
393 None => return false,
394 },
395 PromisorAcceptPolicy::KnownUrl => match named {
396 Some(remote) if remote.url == advertised.url => Some(remote),
397 _ => return false,
398 },
399 };
400 checked_fields.iter().all(|field| {
401 let advertised_value = field_value(advertised, field);
402 match candidate {
403 Some(configured) => advertised_value == field_value(configured, field),
404 None => advertised_value.is_some_and(|value| {
405 configured
406 .iter()
407 .any(|remote| field_value(remote, field) == Some(value))
408 }),
409 }
410 })
411}
412
413fn field_value<'a>(remote: &'a PromisorRemoteAdvertisement, field: &str) -> Option<&'a str> {
414 if field.eq_ignore_ascii_case("partialCloneFilter") {
415 remote.partial_clone_filter.as_deref()
416 } else if field.eq_ignore_ascii_case("token") {
417 remote.token.as_deref()
418 } else {
419 None
420 }
421}
422
423#[cfg(test)]
424mod tests {
425 use super::*;
426
427 #[test]
428 fn advertises_promisors_in_config_order_with_selected_fields() {
429 let config = GitConfig::parse(
430 b"[promisor]\n\tadvertise = true\n\tsendFields = partialCloneFilter, token\n\
431 [remote \"first\"]\n\tpromisor = true\n\turl = file:///one\n\tpartialCloneFilter = blob:none\n\
432 [remote \"ignored\"]\n\turl = file:///ignored\n\
433 [remote \"second\"]\n\tpromisor = true\n\turl = file:///two\n\ttoken = secret\n",
434 )
435 .expect("config");
436 let capability = promisor_remote_server_capability(&config)
437 .expect("capability")
438 .expect("advertised");
439 assert_eq!(
440 capability.value.as_deref(),
441 Some(
442 "name=first,url=file:///one,partialCloneFilter=blob:none;name=second,url=file:///two,token=secret"
443 )
444 );
445 }
446
447 #[test]
448 fn acceptance_preserves_server_order_and_honors_known_name() {
449 let config = GitConfig::parse(
450 b"[promisor]\n\tacceptFromServer = KnownName\n\
451 [remote \"second\"]\n\tpromisor = true\n\turl = file:///local-two\n\
452 [remote \"first\"]\n\tpromisor = true\n\turl = file:///local-one\n",
453 )
454 .expect("config");
455 let capability = Capability {
456 name: "promisor-remote".into(),
457 value: Some(
458 "name=first,url=file:///server-one;name=second,url=file:///server-two".into(),
459 ),
460 };
461 let decision = decide_promisor_remote_reply(&config, &capability).expect("decision");
462 assert_eq!(decision.reply.as_deref(), Some("first;second"));
463 }
464
465 #[test]
466 fn accepted_fields_plan_storage_and_resolve_auto_filter() {
467 let config = GitConfig::parse(
468 b"[promisor]\n\tacceptFromServer = All\n\tstoreFields = partialCloneFilter\n\
469 [remote \"lop\"]\n\tpromisor = true\n\turl = file:///lop\n\tpartialCloneFilter = blob:none\n",
470 )
471 .expect("config");
472 let capability = Capability {
473 name: "promisor-remote".into(),
474 value: Some(
475 "name=lop,url=file:///lop,partialCloneFilter=blob:limit=9500,token=secret".into(),
476 ),
477 };
478 let decision = decide_promisor_remote_reply(&config, &capability).expect("decision");
479 assert_eq!(
480 decision.stored_fields,
481 vec![PromisorRemoteFieldUpdate {
482 remote_name: "lop".into(),
483 field: PromisorRemoteField::PartialCloneFilter,
484 previous: Some("blob:none".into()),
485 value: "blob:limit=9500".into(),
486 }]
487 );
488 assert_eq!(
489 promisor_remote_auto_filter(&decision),
490 Some(sley_odb::PackObjectFilter::BlobLimit(9500))
491 );
492 }
493
494 #[test]
495 fn lazy_fetch_order_moves_default_partial_clone_remote_to_tail() {
496 let config = GitConfig::parse(
497 b"[extensions]\n\tpartialClone = origin\n\
498 [remote \"origin\"]\n\tpromisor = true\n\turl = file:///origin\n\
499 [remote \"lop\"]\n\tpromisor = true\n\turl = file:///lop\n\
500 [remote \"archive\"]\n\tpartialCloneFilter = blob:none\n\turl = file:///archive\n",
501 )
502 .expect("config");
503 assert_eq!(
504 configured_promisor_remote_names(&config),
505 vec!["lop", "archive", "origin"]
506 );
507 }
508
509 #[test]
510 fn native_promisor_contact_trace_matches_git_run_command_shape() {
511 assert_eq!(
512 promisor_remote_contact_trace_line("lop"),
513 "trace: run_command: git -c fetch.negotiationAlgorithm=noop fetch lop --no-tags --no-write-fetch-head --recurse-submodules=no --filter=blob:none --stdin"
514 );
515 assert!(
516 !promisor_remote_contact_trace_line("lop").contains("unused_lop"),
517 "the trace names only the remote actually selected"
518 );
519 }
520}