Skip to main content

grit_lib/
submodule_active.rs

1//! Submodule "active" state (`submodule.c` `is_submodule_active` parity).
2//!
3//! Used by `test-tool submodule is-active` and `git submodule add` when deciding
4//! whether to write `submodule.<name>.active`.
5
6use std::path::Path;
7
8use crate::config::{ConfigFile, ConfigScope, ConfigSet};
9use crate::index::{Index, MODE_GITLINK};
10use crate::objects::ObjectKind;
11use crate::pathspec::{matches_pathspec_with_context, PathspecMatchContext};
12use crate::repo::Repository;
13use crate::wildmatch::wildmatch;
14
15/// Returns `true` when `submodule.active` is configured (any `submodule.active` entry exists).
16fn config_has_submodule_active_key(cfg: &ConfigSet) -> bool {
17    cfg.has_key("submodule.active")
18}
19
20/// Reads `submodule.active` patterns for [`submodule_add_should_set_active`].
21///
22/// Returns `Ok(None)` when the key is absent. Returns `Err` when a value is missing (bare key),
23/// matching Git's `repo_config_get_string_multi` error path.
24fn submodule_active_pattern_values(
25    cfg: &ConfigSet,
26) -> std::result::Result<Option<Vec<String>>, String> {
27    if !config_has_submodule_active_key(cfg) {
28        return Ok(None);
29    }
30    let values = cfg.get_all("submodule.active");
31    if values.is_empty() {
32        return Ok(Some(Vec::new()));
33    }
34    for v in &values {
35        if v.is_empty() {
36            return Err("missing value for 'submodule.active'".to_string());
37        }
38    }
39    Ok(Some(values))
40}
41
42/// Whether `git submodule add` should write `submodule.<name>.active=true` to the local config.
43///
44/// Mirrors `submodule--helper.c` after registering `.gitmodules`: when `submodule.active` is
45/// absent, or multi-string read fails, always set active; otherwise set active only when no
46/// configured pattern [`wildmatch`]s the submodule path (flags `0`, matching Git).
47#[must_use]
48pub fn submodule_add_should_set_active(repo: &Repository, sm_path: &str) -> bool {
49    let Ok(cfg) = ConfigSet::load(Some(&repo.git_dir), true) else {
50        return true;
51    };
52    let path = sm_path.replace('\\', "/");
53    match submodule_active_pattern_values(&cfg) {
54        Ok(None) => true,
55        Err(_) => true,
56        Ok(Some(patterns)) => {
57            let matched = patterns
58                .iter()
59                .any(|p| wildmatch(p.trim().as_bytes(), path.as_bytes(), 0));
60            !matched
61        }
62    }
63}
64
65fn read_gitmodules_text(
66    repo: &Repository,
67    work_tree: &Path,
68) -> crate::error::Result<Option<String>> {
69    let gitmodules_path = work_tree.join(".gitmodules");
70    if gitmodules_path.exists() {
71        let content = std::fs::read_to_string(&gitmodules_path).map_err(crate::error::Error::Io)?;
72        return Ok(Some(content));
73    }
74    let index = repo.load_index()?;
75    let Some(ie) = index.get(b".gitmodules", 0) else {
76        return Ok(None);
77    };
78    let obj = repo.odb.read(&ie.oid)?;
79    if obj.kind != ObjectKind::Blob {
80        return Ok(None);
81    }
82    String::from_utf8(obj.data)
83        .map(Some)
84        .map_err(|e| crate::error::Error::ConfigError(format!("invalid .gitmodules utf-8: {e}")))
85}
86
87/// Resolve the submodule **logical name** for an index path (`.gitmodules` `submodule.<name>.path`).
88///
89/// Returns `None` when the path is not listed as a submodule in `.gitmodules` (work tree file or
90/// index blob), matching Git's `submodule_from_path` failure.
91pub fn submodule_name_for_path(
92    repo: &Repository,
93    path: &str,
94) -> crate::error::Result<Option<String>> {
95    let Some(wt) = repo.work_tree.as_ref() else {
96        return Ok(None);
97    };
98    let Some(content) = read_gitmodules_text(repo, wt)? else {
99        return Ok(None);
100    };
101    let config = ConfigFile::parse(&wt.join(".gitmodules"), &content, ConfigScope::Local)?;
102    let path_norm = path.replace('\\', "/");
103
104    #[derive(Default)]
105    struct ModuleFields {
106        path: Option<String>,
107    }
108    let mut modules: std::collections::BTreeMap<String, ModuleFields> =
109        std::collections::BTreeMap::new();
110
111    for entry in &config.entries {
112        let key = &entry.key;
113        if !key.starts_with("submodule.") {
114            continue;
115        }
116        let rest = &key["submodule.".len()..];
117        let Some(last_dot) = rest.rfind('.') else {
118            continue;
119        };
120        let name = &rest[..last_dot];
121        let var = &rest[last_dot + 1..];
122        if var == "path" {
123            modules.entry(name.to_string()).or_default().path = entry.value.clone();
124        }
125    }
126
127    for (name, f) in modules {
128        if let Some(p) = f.path {
129            let p_norm = p.replace('\\', "/");
130            if p_norm == path_norm {
131                return Ok(Some(name));
132            }
133        }
134    }
135    Ok(None)
136}
137
138fn parse_pathspec_exclude(spec: &str) -> (bool, &str) {
139    let s = spec.trim();
140    if let Some(rest) = s.strip_prefix(":!") {
141        return (true, rest);
142    }
143    if let Some(rest) = s.strip_prefix(":^") {
144        return (true, rest);
145    }
146    if let Some(inner) = s.strip_prefix(":(exclude)") {
147        return (true, inner);
148    }
149    if let Some(inner) = s.strip_prefix(":(exclude,") {
150        if let Some(close) = inner.find(')') {
151            return (true, &inner[close + 1..]);
152        }
153    }
154    (false, s)
155}
156
157fn index_gitlink_match_for_path(index: &Index, path: &str) -> Option<PathspecMatchContext> {
158    for e in &index.entries {
159        if e.stage() != 0 {
160            continue;
161        }
162        if e.mode != MODE_GITLINK {
163            continue;
164        }
165        let name = String::from_utf8_lossy(&e.path);
166        let name = name.replace('\\', "/");
167        if name == path {
168            return Some(PathspecMatchContext {
169                is_directory: false,
170                is_git_submodule: true,
171            });
172        }
173    }
174    None
175}
176
177fn spec_matches_submodule_path(
178    _index: &Index,
179    spec: &str,
180    path: &str,
181    ctx: PathspecMatchContext,
182) -> bool {
183    let (is_exclude, pattern_src) = parse_pathspec_exclude(spec);
184    let pattern = pattern_src.trim();
185    if pattern.is_empty() && !is_exclude {
186        return false;
187    }
188    matches_pathspec_with_context(pattern, path, ctx)
189}
190
191/// Whether `path` matches `submodule.active` pathspecs using the given index (Git `match_pathspec`).
192///
193/// On missing string values (bare `submodule.active`), returns an error message suitable for stderr.
194pub fn submodule_active_pathspec_match(
195    index: &Index,
196    specs: &[String],
197    path: &str,
198) -> std::result::Result<bool, String> {
199    for v in specs {
200        if v.is_empty() {
201            return Err("error: missing value for 'submodule.active'".to_string());
202        }
203    }
204    let ctx = index_gitlink_match_for_path(index, path).unwrap_or(PathspecMatchContext {
205        is_directory: false,
206        is_git_submodule: true,
207    });
208
209    let positive = specs
210        .iter()
211        .any(|s| !parse_pathspec_exclude(s).0 && spec_matches_submodule_path(index, s, path, ctx));
212    if !positive {
213        return Ok(false);
214    }
215    let excluded = specs
216        .iter()
217        .any(|s| parse_pathspec_exclude(s).0 && spec_matches_submodule_path(index, s, path, ctx));
218    Ok(!excluded)
219}
220
221/// Git `is_submodule_active` for the current repository and index (`HEAD` + `.gitmodules` mapping).
222///
223/// Returns `Ok(true)` / `Ok(false)` for normal checks, or `Err` when `submodule.active` has a bare
224/// entry (Git `config_error_nonbool`).
225pub fn is_submodule_active(repo: &Repository, path: &str) -> std::result::Result<bool, String> {
226    let path_norm = path.replace('\\', "/");
227    let Some(name) = submodule_name_for_path(repo, &path_norm).map_err(|e| e.to_string())? else {
228        return Ok(false);
229    };
230
231    let cfg = ConfigSet::load(Some(&repo.git_dir), true).map_err(|e| e.to_string())?;
232    let per_key = format!("submodule.{name}.active");
233    if let Some(res) = cfg.get_bool(&per_key) {
234        let b = res.map_err(|_| format!("invalid boolean for '{per_key}'"))?;
235        return Ok(b);
236    }
237
238    if config_has_submodule_active_key(&cfg) {
239        let values = cfg.get_all("submodule.active");
240        let index = repo.load_index().map_err(|e| e.to_string())?;
241        return submodule_active_pathspec_match(&index, &values, &path_norm);
242    }
243
244    let url_key = format!("submodule.{name}.url");
245    Ok(cfg.get(&url_key).is_some())
246}