1use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5use std::process::Command;
6use std::sync::{Mutex, OnceLock};
7
8use crate::Error;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum ConfigScope {
13 Global,
16 Local,
18 System,
20}
21
22impl ConfigScope {
23 fn flag(self) -> &'static str {
24 match self {
25 Self::Global => "--global",
26 Self::Local => "--local",
27 Self::System => "--system",
28 }
29 }
30}
31
32pub fn get(cwd: &Path, scope: ConfigScope, key: &str) -> Result<Option<String>, Error> {
42 let out = Command::new("git")
43 .arg("-C")
44 .arg(cwd)
45 .args(["config", "--includes", scope.flag(), "--get", key])
46 .output()?;
47 match out.status.code() {
48 Some(0) => Ok(Some(String::from_utf8_lossy(&out.stdout).trim().to_owned())),
49 Some(1) | Some(128) | Some(129) => Ok(None),
54 _ => Err(Error::Failed(
55 String::from_utf8_lossy(&out.stderr).trim().to_owned(),
56 )),
57 }
58}
59
60fn get_any_scope(cwd: &Path, key: &str) -> Result<Option<String>, Error> {
66 let out = Command::new("git")
67 .arg("-C")
68 .arg(cwd)
69 .args(["config", "--includes", "--get", key])
70 .output()?;
71 match out.status.code() {
72 Some(0) => Ok(Some(String::from_utf8_lossy(&out.stdout).trim().to_owned())),
73 Some(1) | Some(128) => Ok(None),
74 _ => Err(Error::Failed(
75 String::from_utf8_lossy(&out.stderr).trim().to_owned(),
76 )),
77 }
78}
79
80pub fn get_from_file(cwd: &Path, file: &Path, key: &str) -> Result<Option<String>, Error> {
83 if !cwd.join(file).is_file() {
84 return Ok(None);
87 }
88 let file_arg = format!("--file={}", file.display());
89 let out = Command::new("git")
90 .arg("-C")
91 .arg(cwd)
92 .args(["config", "--includes", &file_arg, "--get", key])
93 .output()?;
94 match out.status.code() {
95 Some(0) => Ok(Some(String::from_utf8_lossy(&out.stdout).trim().to_owned())),
96 Some(1) => Ok(None),
97 _ => Err(Error::Failed(
98 String::from_utf8_lossy(&out.stderr).trim().to_owned(),
99 )),
100 }
101}
102
103pub fn get_effective(cwd: &Path, key: &str) -> Result<Option<String>, Error> {
113 if let Some(v) = get_any_scope(cwd, key)? {
114 return Ok(Some(v));
115 }
116 get_from_lfsconfig(cwd, key)
117}
118
119pub fn get_from_lfsconfig(cwd: &Path, key: &str) -> Result<Option<String>, Error> {
126 let entries = load_lfsconfig(cwd)?;
127 Ok(entries
128 .get(&fold_key(key))
129 .and_then(|vs| vs.last().cloned()))
130}
131
132pub fn set(cwd: &Path, scope: ConfigScope, key: &str, value: &str) -> Result<(), Error> {
134 let out = Command::new("git")
135 .arg("-C")
136 .arg(cwd)
137 .args(["config", scope.flag(), key, value])
138 .output()?;
139 if out.status.success() {
140 Ok(())
141 } else {
142 Err(Error::Failed(
143 String::from_utf8_lossy(&out.stderr).trim().to_owned(),
144 ))
145 }
146}
147
148pub fn unset(cwd: &Path, scope: ConfigScope, key: &str) -> Result<(), Error> {
151 let out = Command::new("git")
152 .arg("-C")
153 .arg(cwd)
154 .args(["config", scope.flag(), "--unset", key])
155 .output()?;
156 match out.status.code() {
157 Some(0) => Ok(()),
158 Some(5) => Ok(()),
160 _ => Err(Error::Failed(
161 String::from_utf8_lossy(&out.stderr).trim().to_owned(),
162 )),
163 }
164}
165
166const SAFE_KEYS: &[&str] = &[
172 "lfs.allowincompletepush",
173 "lfs.fetchexclude",
174 "lfs.fetchinclude",
175 "lfs.gitprotocol",
176 "lfs.locksverify",
177 "lfs.pushurl",
178 "lfs.skipdownloaderrors",
179 "lfs.url",
180];
181
182fn is_safe_key(key: &str) -> bool {
186 let parts: Vec<&str> = key.split('.').collect();
187
188 if parts.len() == 4 && parts[0] == "lfs" && parts[1] == "extension" && parts[3] == "priority" {
192 return true;
193 }
194
195 if parts.len() >= 3 && parts[0] == "remote" && *parts.last().unwrap() == "lfsurl" {
197 return true;
198 }
199
200 if parts.len() >= 3 && *parts.last().unwrap() == "access" {
204 return true;
205 }
206
207 SAFE_KEYS.contains(&key)
208}
209
210fn fold_key(key: &str) -> String {
215 let parts: Vec<&str> = key.split('.').collect();
216 if parts.len() < 3 {
217 return key.to_lowercase();
218 }
219 let last = parts.len() - 1;
220 let middle = parts[1..last].join(".");
221 format!(
222 "{}.{}.{}",
223 parts[0].to_lowercase(),
224 middle,
225 parts[last].to_lowercase(),
226 )
227}
228
229type LfsConfigEntries = HashMap<String, Vec<String>>;
230
231static LFSCONFIG_CACHE: OnceLock<Mutex<HashMap<PathBuf, LfsConfigEntries>>> = OnceLock::new();
236
237fn lfsconfig_cache() -> &'static Mutex<HashMap<PathBuf, LfsConfigEntries>> {
238 LFSCONFIG_CACHE.get_or_init(|| Mutex::new(HashMap::new()))
239}
240
241fn load_lfsconfig(cwd: &Path) -> Result<LfsConfigEntries, Error> {
242 let root = repo_root(cwd).unwrap_or_else(|| cwd.to_path_buf());
247 let canon = root.canonicalize().unwrap_or_else(|_| root.clone());
248 if let Some(cached) = lfsconfig_cache().lock().unwrap().get(&canon) {
249 return Ok(cached.clone());
250 }
251
252 let bare = is_bare(cwd);
259 let mut entries = None;
260 if !bare && root.join(".lfsconfig").is_file() {
261 entries = Some(read_lfsconfig_file(&root)?);
262 }
263 if entries.is_none() && !bare {
264 entries = read_lfsconfig_blob(cwd, ":.lfsconfig")?;
265 }
266 if entries.is_none() {
267 entries = read_lfsconfig_blob(cwd, "HEAD:.lfsconfig")?;
268 }
269 let entries = entries.unwrap_or_default();
270
271 let (safe, ignored) = filter_safe(entries);
272
273 if !ignored.is_empty() {
274 eprintln!("warning: These unsafe '.lfsconfig' keys were ignored:");
278 eprintln!();
279 for key in &ignored {
280 eprintln!(" {key}");
281 }
282 }
283
284 lfsconfig_cache()
285 .lock()
286 .unwrap()
287 .insert(canon, safe.clone());
288 Ok(safe)
289}
290
291fn read_lfsconfig_file(root: &Path) -> Result<LfsConfigEntries, Error> {
293 let out = Command::new("git")
294 .arg("-C")
295 .arg(root)
296 .args(["config", "--includes", "--file=.lfsconfig", "--list"])
297 .output()?;
298 if !out.status.success() {
299 return Err(Error::Failed(format!(
300 "git config --file=.lfsconfig --list failed: {}",
301 String::from_utf8_lossy(&out.stderr).trim()
302 )));
303 }
304 Ok(parse_list_output(&out.stdout))
305}
306
307fn read_lfsconfig_blob(cwd: &Path, revision: &str) -> Result<Option<LfsConfigEntries>, Error> {
312 let blob_arg = format!("--blob={revision}");
313 let out = Command::new("git")
314 .arg("-C")
315 .arg(cwd)
316 .args(["config", "--includes", &blob_arg, "--list"])
317 .output()?;
318 match out.status.code() {
319 Some(0) => Ok(Some(parse_list_output(&out.stdout))),
320 _ => Ok(None),
324 }
325}
326
327fn is_bare(cwd: &Path) -> bool {
331 Command::new("git")
332 .arg("-C")
333 .arg(cwd)
334 .args(["rev-parse", "--is-bare-repository"])
335 .output()
336 .ok()
337 .filter(|o| o.status.success())
338 .is_some_and(|o| String::from_utf8_lossy(&o.stdout).trim() == "true")
339}
340
341fn repo_root(cwd: &Path) -> Option<PathBuf> {
346 let out = Command::new("git")
347 .arg("-C")
348 .arg(cwd)
349 .args(["rev-parse", "--show-toplevel"])
350 .output()
351 .ok()?;
352 if !out.status.success() {
353 return None;
354 }
355 let s = String::from_utf8_lossy(&out.stdout).trim().to_owned();
356 if s.is_empty() {
357 return None;
358 }
359 Some(PathBuf::from(s))
360}
361
362fn parse_list_output(bytes: &[u8]) -> LfsConfigEntries {
366 let s = String::from_utf8_lossy(bytes);
367 let mut entries: LfsConfigEntries = HashMap::new();
368 for line in s.lines() {
369 if let Some((k, v)) = line.split_once('=') {
370 entries.entry(k.to_owned()).or_default().push(v.to_owned());
371 }
372 }
373 entries
374}
375
376fn filter_safe(entries: LfsConfigEntries) -> (LfsConfigEntries, Vec<String>) {
379 let mut safe = LfsConfigEntries::new();
380 let mut ignored = Vec::new();
381 let mut keys: Vec<String> = entries.keys().cloned().collect();
382 keys.sort();
383 for k in keys {
384 let values = entries.get(&k).cloned().unwrap_or_default();
385 if is_safe_key(&k) {
386 safe.insert(k, values);
387 } else {
388 ignored.push(k);
389 }
390 }
391 (safe, ignored)
392}
393
394#[cfg(test)]
395mod tests {
396 use super::*;
397 use tempfile::TempDir;
398
399 fn init_repo() -> TempDir {
400 let tmp = TempDir::new().unwrap();
401 let status = Command::new("git")
402 .args(["init", "--quiet"])
403 .arg(tmp.path())
404 .status()
405 .unwrap();
406 assert!(status.success());
407 tmp
408 }
409
410 #[test]
411 fn get_unset_key_returns_none() {
412 let tmp = init_repo();
413 let v = get(tmp.path(), ConfigScope::Local, "filter.lfs.clean").unwrap();
414 assert_eq!(v, None);
415 }
416
417 #[test]
418 fn set_then_get_round_trips() {
419 let tmp = init_repo();
420 set(
421 tmp.path(),
422 ConfigScope::Local,
423 "filter.lfs.clean",
424 "git-lfs clean -- %f",
425 )
426 .unwrap();
427 let v = get(tmp.path(), ConfigScope::Local, "filter.lfs.clean").unwrap();
428 assert_eq!(v.as_deref(), Some("git-lfs clean -- %f"));
429 }
430
431 #[test]
432 fn unset_removes_key() {
433 let tmp = init_repo();
434 set(
435 tmp.path(),
436 ConfigScope::Local,
437 "filter.lfs.required",
438 "true",
439 )
440 .unwrap();
441 unset(tmp.path(), ConfigScope::Local, "filter.lfs.required").unwrap();
442 let v = get(tmp.path(), ConfigScope::Local, "filter.lfs.required").unwrap();
443 assert_eq!(v, None);
444 }
445
446 #[test]
447 fn unset_missing_key_is_ok() {
448 let tmp = init_repo();
449 unset(tmp.path(), ConfigScope::Local, "never.was.set").unwrap();
450 }
451
452 #[test]
453 fn safe_key_classification() {
454 assert!(is_safe_key("lfs.url"));
456 assert!(is_safe_key("lfs.fetchinclude"));
457 assert!(is_safe_key("lfs.locksverify"));
458
459 assert!(is_safe_key("lfs.http://example.com/repo.git.access"));
461 assert!(is_safe_key("lfs.https://host.access"));
462
463 assert!(is_safe_key("remote.origin.lfsurl"));
465 assert!(!is_safe_key("remote.origin.url"));
466 assert!(!is_safe_key("remote.origin.pushurl"));
467
468 assert!(is_safe_key("lfs.extension.foo.priority"));
470 assert!(!is_safe_key("lfs.extension.foo.clean"));
471 assert!(!is_safe_key("lfs.extension.foo.smudge"));
472
473 assert!(!is_safe_key("core.askpass"));
475 assert!(!is_safe_key("credential.helper"));
476 assert!(!is_safe_key("lfs.concurrenttransfers"));
477 }
478
479 #[test]
480 fn fold_key_lowercases_first_and_last_only() {
481 assert_eq!(fold_key("LFS.URL"), "lfs.url");
482 assert_eq!(
483 fold_key("LFS.http://Example.com.ACCESS"),
484 "lfs.http://Example.com.access"
485 );
486 assert_eq!(fold_key("Section.Key"), "section.key");
487 }
488
489 #[test]
490 fn parse_list_handles_values_with_equals() {
491 let raw = b"lfs.url=http://example.com/path?x=1\nremote.origin.lfsurl=http://a\n";
492 let parsed = parse_list_output(raw);
493 assert_eq!(
494 parsed["lfs.url"],
495 vec!["http://example.com/path?x=1".to_owned()]
496 );
497 assert_eq!(parsed["remote.origin.lfsurl"], vec!["http://a".to_owned()]);
498 }
499
500 #[test]
501 fn parse_list_collects_repeated_keys_in_order() {
502 let raw = b"url.http://a/.insteadof=alias\nurl.http://b/.insteadof=alias\n";
503 let parsed = parse_list_output(raw);
504 assert_eq!(parsed["url.http://a/.insteadof"], vec!["alias".to_owned()]);
505 assert_eq!(parsed["url.http://b/.insteadof"], vec!["alias".to_owned()]);
506 }
507
508 #[test]
509 fn lfsconfig_falls_back_to_head_blob_when_no_working_tree_file() {
510 let tmp = init_repo();
513 let path = tmp.path();
514 let _ = Command::new("git")
516 .arg("-C")
517 .arg(path)
518 .args(["config", "user.name", "test"])
519 .status();
520 let _ = Command::new("git")
521 .arg("-C")
522 .arg(path)
523 .args(["config", "user.email", "test@example.com"])
524 .status();
525 std::fs::write(
526 path.join(".lfsconfig"),
527 "[lfs]\n\turl = http://from-head/\n",
528 )
529 .unwrap();
530 let _ = Command::new("git")
531 .arg("-C")
532 .arg(path)
533 .args(["add", ".lfsconfig"])
534 .status();
535 let _ = Command::new("git")
536 .arg("-C")
537 .arg(path)
538 .args(["-c", "commit.gpgsign=false", "commit", "-m", "init"])
539 .status();
540 std::fs::remove_file(path.join(".lfsconfig")).unwrap();
542
543 let entries = read_lfsconfig_blob(path, "HEAD:.lfsconfig")
544 .unwrap()
545 .unwrap();
546 assert_eq!(
547 entries.get("lfs.url").and_then(|v| v.last().cloned()),
548 Some("http://from-head/".to_owned())
549 );
550 }
551
552 #[test]
553 fn read_lfsconfig_blob_missing_returns_none() {
554 let tmp = init_repo();
555 assert!(
557 read_lfsconfig_blob(tmp.path(), ":.lfsconfig")
558 .unwrap()
559 .is_none()
560 );
561 assert!(
562 read_lfsconfig_blob(tmp.path(), "HEAD:.lfsconfig")
563 .unwrap()
564 .is_none()
565 );
566 }
567
568 #[test]
569 fn filter_safe_partitions_keys() {
570 let mut entries = LfsConfigEntries::new();
571 entries.insert("lfs.url".into(), vec!["http://x".into()]);
572 entries.insert("core.askpass".into(), vec!["unsafe".into()]);
573 entries.insert("lfs.extension.e.priority".into(), vec!["1".into()]);
574 entries.insert("lfs.extension.e.clean".into(), vec!["bad".into()]);
575
576 let (safe, ignored) = filter_safe(entries);
577 assert!(safe.contains_key("lfs.url"));
578 assert!(safe.contains_key("lfs.extension.e.priority"));
579 assert!(!safe.contains_key("core.askpass"));
580 assert!(!safe.contains_key("lfs.extension.e.clean"));
581 assert_eq!(ignored, vec!["core.askpass", "lfs.extension.e.clean"]);
583 }
584}