Skip to main content

git_lfs_git/
extension.rs

1//! Pointer extension config (`lfs.extension.<name>.{clean,smudge,priority}`).
2//!
3//! Extensions chain external programs around each LFS object's clean/smudge
4//! cycle (`docs/extensions.md`). They're declared via three keys per
5//! extension; `priority` is the only one that can come from `.lfsconfig`.
6
7use std::collections::BTreeSet;
8use std::path::Path;
9use std::process::Command;
10
11/// One configured extension. Missing knobs come back as empty/0 to mirror
12/// upstream's zero-value Extension struct — callers that *run* extensions
13/// must reject empty `clean`/`smudge` themselves.
14#[derive(Debug, Clone)]
15pub struct ExtensionConfig {
16    pub name: String,
17    pub clean: String,
18    pub smudge: String,
19    pub priority: i64,
20}
21
22/// Discover and resolve every configured extension. Sorted ascending by
23/// priority, with name as the tiebreaker so duplicate priorities (which
24/// upstream errors on, but which we currently surface) at least come out
25/// deterministically.
26pub fn list_extensions(cwd: &Path) -> Vec<ExtensionConfig> {
27    let mut extensions: Vec<ExtensionConfig> = list_extension_names(cwd)
28        .into_iter()
29        .map(|name| read_extension(cwd, &name))
30        .collect();
31    extensions.sort_by(|a, b| a.priority.cmp(&b.priority).then(a.name.cmp(&b.name)));
32    extensions
33}
34
35/// Discover extension names from any source — local/global/system git
36/// config plus `.lfsconfig`. We deliberately enumerate from raw config
37/// (rather than `get_effective`) because we want to find names declared
38/// only in `.lfsconfig` too; the per-key resolution still goes through
39/// `get_effective` so the safe-key filter is honored.
40pub fn list_extension_names(cwd: &Path) -> BTreeSet<String> {
41    let mut names = BTreeSet::new();
42
43    if let Ok(out) = Command::new("git")
44        .arg("-C")
45        .arg(cwd)
46        .args([
47            "config",
48            "--name-only",
49            "--get-regexp",
50            r"^lfs\.extension\..*\.(clean|smudge|priority)$",
51        ])
52        .output()
53        && out.status.success()
54    {
55        for line in String::from_utf8_lossy(&out.stdout).lines() {
56            if let Some(name) = extension_name_from_key(line) {
57                names.insert(name);
58            }
59        }
60    }
61
62    if let Some(root) = repo_root(cwd) {
63        let lfsconfig = root.join(".lfsconfig");
64        if lfsconfig.is_file()
65            && let Ok(out) = Command::new("git")
66                .arg("-C")
67                .arg(&root)
68                .args([
69                    "config",
70                    "--file=.lfsconfig",
71                    "--name-only",
72                    "--get-regexp",
73                    r"^lfs\.extension\..*\.(clean|smudge|priority)$",
74                ])
75                .output()
76            && out.status.success()
77        {
78            for line in String::from_utf8_lossy(&out.stdout).lines() {
79                if let Some(name) = extension_name_from_key(line) {
80                    names.insert(name);
81                }
82            }
83        }
84    }
85
86    names
87}
88
89fn repo_root(cwd: &Path) -> Option<std::path::PathBuf> {
90    let out = Command::new("git")
91        .arg("-C")
92        .arg(cwd)
93        .args(["rev-parse", "--show-toplevel"])
94        .output()
95        .ok()?;
96    if !out.status.success() {
97        return None;
98    }
99    let s = String::from_utf8_lossy(&out.stdout).trim().to_owned();
100    if s.is_empty() {
101        None
102    } else {
103        Some(std::path::PathBuf::from(s))
104    }
105}
106
107/// Pull the `<name>` out of `lfs.extension.<name>.<prop>`. The middle
108/// component can contain dots (rare for extensions, but defensible),
109/// so split on the suffix not on `.`.
110fn extension_name_from_key(key: &str) -> Option<String> {
111    let rest = key.strip_prefix("lfs.extension.")?;
112    for suffix in [".clean", ".smudge", ".priority"] {
113        if let Some(name) = rest.strip_suffix(suffix) {
114            return Some(name.to_owned());
115        }
116    }
117    None
118}
119
120fn read_extension(cwd: &Path, name: &str) -> ExtensionConfig {
121    let lookup = |suffix: &str| -> String {
122        crate::config::get_effective(cwd, &format!("lfs.extension.{name}.{suffix}"))
123            .ok()
124            .flatten()
125            .unwrap_or_default()
126    };
127    let clean = lookup("clean");
128    let smudge = lookup("smudge");
129    let priority = lookup("priority").parse::<i64>().unwrap_or(0);
130    ExtensionConfig {
131        name: name.to_owned(),
132        clean,
133        smudge,
134        priority,
135    }
136}