Skip to main content

grit_lib/
submodule_gitdir.rs

1//! Submodule gitdir paths when `extensions.submodulePathConfig` is enabled.
2//!
3//! Mirrors Git's `create_default_gitdir_config` / `validate_submodule_git_dir` logic
4//! enough for upstream tests (encoded paths, nesting checks, conflict resolution).
5
6use std::collections::HashSet;
7use std::fs;
8use std::path::{Component, Path, PathBuf};
9
10use sha1::{Digest, Sha1};
11
12use crate::config::{ConfigFile, ConfigScope};
13use crate::error::{Error, Result};
14use crate::index::Index;
15use crate::objects::{ObjectId, ObjectKind};
16use crate::odb::Odb;
17
18/// Filesystem path to the separate git directory for a submodule at `submodule_worktree_rel`
19/// (path relative to the superproject work tree), under `super_git_dir`.
20///
21/// Git nests additional `modules/` segments for each path component (e.g. path `a/b` →
22/// `<super>/modules/a/modules/b`), not a single `modules/a/b` directory.
23#[must_use]
24pub fn submodule_modules_git_dir(super_git_dir: &Path, submodule_worktree_rel: &str) -> PathBuf {
25    let mut out = super_git_dir.to_path_buf();
26    for seg in submodule_worktree_rel.split('/').filter(|s| !s.is_empty()) {
27        out.push("modules");
28        out.push(seg);
29    }
30    out
31}
32
33/// Returns whether `extensions.submodulePathConfig` is enabled in `git_dir/config`.
34pub fn submodule_path_config_enabled(git_dir: &Path) -> bool {
35    let config_path = git_dir.join("config");
36    let Ok(content) = fs::read_to_string(&config_path) else {
37        return false;
38    };
39    let mut in_extensions = false;
40    for line in content.lines() {
41        let trimmed = line.trim();
42        if trimmed.starts_with('[') {
43            in_extensions = trimmed.eq_ignore_ascii_case("[extensions]");
44            continue;
45        }
46        if in_extensions {
47            if let Some((k, v)) = trimmed.split_once('=') {
48                if k.trim().eq_ignore_ascii_case("submodulepathconfig") {
49                    return parse_bool(v.trim());
50                }
51            }
52        }
53    }
54    false
55}
56
57fn parse_bool(s: &str) -> bool {
58    matches!(s.to_ascii_lowercase().as_str(), "true" | "yes" | "on" | "1")
59}
60
61fn is_rfc3986_unreserved(b: u8) -> bool {
62    b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~')
63}
64
65fn is_casefolding_rfc3986_unreserved(b: u8) -> bool {
66    matches!(b, b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~')
67}
68
69fn percent_encode(name: &str, pred: fn(u8) -> bool) -> String {
70    let mut out = String::new();
71    for &b in name.as_bytes() {
72        if pred(b) {
73            out.push(b as char);
74        } else {
75            out.push_str(&format!("%{:02x}", b));
76        }
77    }
78    out
79}
80
81/// Returns true if `path` looks like a git directory (`HEAD` and `objects/` exist).
82pub fn is_git_directory(path: &Path) -> bool {
83    path.join("HEAD").is_file() && path.join("objects").is_dir()
84}
85
86fn last_modules_segment(git_dir_abs: &Path) -> Option<String> {
87    let s = git_dir_abs.to_string_lossy();
88    let marker = "/modules/";
89    let mut p = 0usize;
90    let mut last_start = None;
91    while let Some(idx) = s[p..].find(marker) {
92        let start = p + idx + marker.len();
93        last_start = Some(start);
94        p = start + 1;
95    }
96    last_start.map(|start| s[start..].to_string())
97}
98
99fn path_inside_other_gitdir(git_dir: &Path, submodule_name: &str) -> bool {
100    let suffix = submodule_name.as_bytes();
101    let gd = git_dir.to_string_lossy();
102    let gd_bytes = gd.as_bytes();
103    if gd_bytes.len() <= suffix.len() {
104        return false;
105    }
106    let cut = gd_bytes.len() - suffix.len();
107    if gd_bytes[cut - 1] != b'/' {
108        return false;
109    }
110    if &gd_bytes[cut..] != suffix {
111        return false;
112    }
113    for i in cut..gd_bytes.len() {
114        if gd_bytes[i] == b'/' {
115            let prefix = Path::new(std::str::from_utf8(&gd_bytes[..i]).unwrap_or(""));
116            if is_git_directory(prefix) {
117                return true;
118            }
119        }
120    }
121    false
122}
123
124fn resolve_gitdir_value(work_tree: &Path, gitdir_cfg: &str) -> PathBuf {
125    let p = Path::new(gitdir_cfg.trim());
126    if p.is_absolute() {
127        p.to_path_buf()
128    } else {
129        work_tree.join(p)
130    }
131}
132
133fn canonical_abs(path: &Path) -> PathBuf {
134    fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
135}
136
137fn existing_gitdir_abs_paths(
138    work_tree: &Path,
139    cfg: &ConfigFile,
140    except_name: &str,
141) -> Result<HashSet<PathBuf>> {
142    let mut set = HashSet::new();
143    let suffix = ".gitdir";
144    for e in &cfg.entries {
145        if !e.key.starts_with("submodule.") || !e.key.ends_with(suffix) {
146            continue;
147        }
148        let inner = &e.key["submodule.".len()..e.key.len() - suffix.len()];
149        if inner == except_name {
150            continue;
151        }
152        if let Some(v) = e.value.as_deref() {
153            let abs = canonical_abs(&resolve_gitdir_value(work_tree, v));
154            set.insert(abs);
155        }
156    }
157    Ok(set)
158}
159
160fn gitdir_conflicts_with_existing(
161    work_tree: &Path,
162    cfg: &ConfigFile,
163    abs_gitdir: &Path,
164    submodule_name: &str,
165) -> Result<bool> {
166    let canon = canonical_abs(abs_gitdir);
167    let existing = existing_gitdir_abs_paths(work_tree, cfg, submodule_name)?;
168    Ok(existing.contains(&canon))
169}
170
171fn ignore_case_from_config(git_dir: &Path) -> bool {
172    let config_path = git_dir.join("config");
173    let Ok(content) = fs::read_to_string(&config_path) else {
174        return false;
175    };
176    let mut in_core = false;
177    for line in content.lines() {
178        let trimmed = line.trim();
179        if trimmed.starts_with('[') {
180            in_core = trimmed.eq_ignore_ascii_case("[core]");
181            continue;
182        }
183        if in_core {
184            if let Some((k, v)) = trimmed.split_once('=') {
185                if k.trim().eq_ignore_ascii_case("ignorecase") {
186                    return parse_bool(v.trim());
187                }
188            }
189        }
190    }
191    false
192}
193
194fn fold_case_git_path(s: &str) -> String {
195    s.to_ascii_lowercase()
196}
197
198fn check_casefolding_conflict(
199    proposed_abs: &Path,
200    submodule_name: &str,
201    suffixes_match: bool,
202    taken_folded: &HashSet<String>,
203) -> bool {
204    let last = last_modules_segment(proposed_abs).unwrap_or_default();
205    let folded_last = fold_case_git_path(&last);
206    let folded_name = fold_case_git_path(submodule_name);
207    if suffixes_match {
208        taken_folded.contains(&folded_last)
209    } else {
210        taken_folded.contains(&folded_name) || taken_folded.contains(&folded_last)
211    }
212}
213
214/// Validates a legacy submodule gitdir path (extension disabled): name suffix and no nesting clash.
215pub fn validate_legacy_submodule_git_dir(git_dir: &Path, submodule_name: &str) -> Result<()> {
216    let gd = git_dir.to_string_lossy();
217    let suffix = submodule_name;
218    if gd.len() <= suffix.len() {
219        return Err(Error::ConfigError(
220            "submodule name not a suffix of git dir".into(),
221        ));
222    }
223    let cut = gd.len() - suffix.len();
224    if gd
225        .as_bytes()
226        .get(cut.wrapping_sub(1))
227        .is_none_or(|&b| b != b'/')
228    {
229        return Err(Error::ConfigError(
230            "submodule name not a suffix of git dir".into(),
231        ));
232    }
233    if &gd[cut..] != suffix {
234        return Err(Error::ConfigError(
235            "submodule name not a suffix of git dir".into(),
236        ));
237    }
238    if path_inside_other_gitdir(git_dir, submodule_name) {
239        return Err(Error::ConfigError(
240            "submodule git dir inside another submodule git dir".into(),
241        ));
242    }
243    Ok(())
244}
245
246/// Validates an encoded submodule gitdir path when `submodulePathConfig` is enabled.
247pub fn validate_encoded_submodule_git_dir(
248    work_tree: &Path,
249    cfg: &ConfigFile,
250    git_dir: &Path,
251    submodule_name: &str,
252    super_git_dir: &Path,
253) -> Result<()> {
254    let last = last_modules_segment(git_dir)
255        .ok_or_else(|| Error::ConfigError("submodule gitdir missing /modules/ segment".into()))?;
256    if last.contains('/') {
257        return Err(Error::ConfigError(
258            "encoded submodule gitdir must not contain '/' in module segment".into(),
259        ));
260    }
261    if is_git_directory(git_dir)
262        && gitdir_conflicts_with_existing(work_tree, cfg, git_dir, submodule_name)?
263    {
264        return Err(Error::ConfigError(
265            "submodule gitdir conflicts with existing".into(),
266        ));
267    }
268    if cfg!(unix) && ignore_case_from_config(super_git_dir) {
269        let mut taken: HashSet<String> = HashSet::new();
270        let suffix = ".gitdir";
271        for e in &cfg.entries {
272            if !e.key.starts_with("submodule.") || !e.key.ends_with(suffix) {
273                continue;
274            }
275            let inner = &e.key["submodule.".len()..e.key.len() - suffix.len()];
276            if inner == submodule_name {
277                continue;
278            }
279            if let Some(v) = e.value.as_deref() {
280                let abs = canonical_abs(&resolve_gitdir_value(work_tree, v));
281                if let Some(seg) = last_modules_segment(&abs) {
282                    taken.insert(fold_case_git_path(&seg));
283                }
284            }
285        }
286        let suffixes_match = last == submodule_name;
287        if check_casefolding_conflict(git_dir, submodule_name, suffixes_match, &taken) {
288            return Err(Error::ConfigError(
289                "case-folding conflict for submodule gitdir".into(),
290            ));
291        }
292    }
293    Ok(())
294}
295
296fn repo_git_path_append(git_dir: &Path, tail: &str) -> PathBuf {
297    let mut buf = git_dir.to_path_buf();
298    if !tail.is_empty() {
299        buf.push(tail);
300    }
301    buf
302}
303
304/// Returns the 40-character hex SHA-1 of a blob object for `data` (same as `git hash-object`).
305pub fn hash_blob_sha1_hex(data: &[u8]) -> String {
306    let header = format!("blob {}\0", data.len());
307    let mut hasher = Sha1::new();
308    hasher.update(header.as_bytes());
309    hasher.update(data);
310    hex::encode(hasher.finalize())
311}
312
313/// Computes `submodule.<name>.gitdir` as a path relative to the work tree when not already set.
314pub fn compute_default_submodule_gitdir(
315    work_tree: &Path,
316    git_dir: &Path,
317    cfg: &ConfigFile,
318    submodule_name: &str,
319) -> Result<String> {
320    let key = format!("submodule.{submodule_name}.gitdir");
321    for e in &cfg.entries {
322        if e.key == key {
323            if let Some(v) = e.value.as_deref() {
324                return Ok(v.to_string());
325            }
326        }
327    }
328
329    let try_set = |rel_under_git: &str| -> Option<String> {
330        let abs = repo_git_path_append(git_dir, rel_under_git);
331        if validate_encoded_submodule_git_dir(work_tree, cfg, &abs, submodule_name, git_dir)
332            .is_err()
333        {
334            return None;
335        }
336        Some(format!(".git/{}", rel_under_git.replace('\\', "/")))
337    };
338
339    // Plain `modules/<name>` only when `name` has no directory separators: `PathBuf::push`
340    // splits on `/`, so e.g. `nested/sub` would become `modules/nested/sub` and the encoded
341    // validation rejects a multi-level tail under `modules/`.
342    let rel_plain = format!("modules/{}", submodule_name.replace('\\', "/"));
343    if !submodule_name.contains('/') && !submodule_name.contains('\\') {
344        if let Some(v) = try_set(&rel_plain) {
345            return Ok(v);
346        }
347    }
348
349    let enc = percent_encode(submodule_name, is_rfc3986_unreserved);
350    let rel_enc = format!("modules/{enc}");
351    if let Some(v) = try_set(&rel_enc) {
352        return Ok(v);
353    }
354
355    let enc_cf = percent_encode(submodule_name, is_casefolding_rfc3986_unreserved);
356    let rel_cf = format!("modules/{enc_cf}");
357    if let Some(v) = try_set(&rel_cf) {
358        return Ok(v);
359    }
360
361    for c in b'0'..=b'9' {
362        let rel = format!("modules/{}{}", enc, c as char);
363        if let Some(v) = try_set(&rel) {
364            return Ok(v);
365        }
366        let rel2 = format!("modules/{}{}", enc_cf, c as char);
367        if let Some(v) = try_set(&rel2) {
368            return Ok(v);
369        }
370    }
371
372    let hex = hash_blob_sha1_hex(submodule_name.as_bytes());
373    let rel_h = format!("modules/{hex}");
374    if let Some(v) = try_set(&rel_h) {
375        return Ok(v);
376    }
377
378    Err(Error::ConfigError(
379        "failed to allocate submodule gitdir path".into(),
380    ))
381}
382
383/// Ensures `submodule.<name>.gitdir` exists, writing it via [`compute_default_submodule_gitdir`] if needed.
384pub fn ensure_submodule_gitdir_config(
385    work_tree: &Path,
386    git_dir: &Path,
387    cfg: &mut ConfigFile,
388    submodule_name: &str,
389) -> Result<String> {
390    let key = format!("submodule.{submodule_name}.gitdir");
391    if let Some(existing) = cfg.entries.iter().find(|e| e.key == key) {
392        if let Some(v) = existing.value.as_deref() {
393            return Ok(v.to_string());
394        }
395    }
396    let value = compute_default_submodule_gitdir(work_tree, git_dir, cfg, submodule_name)?;
397    cfg.set(&key, &value)?;
398    cfg.write()?;
399    Ok(value)
400}
401
402/// Resolves the absolute filesystem path of a submodule's git directory.
403pub fn submodule_gitdir_filesystem_path(
404    work_tree: &Path,
405    git_dir: &Path,
406    cfg: &ConfigFile,
407    submodule_name: &str,
408) -> Result<PathBuf> {
409    if submodule_path_config_enabled(git_dir) {
410        let key = format!("submodule.{submodule_name}.gitdir");
411        let value = cfg
412            .entries
413            .iter()
414            .find(|e| e.key == key)
415            .and_then(|e| e.value.clone())
416            .ok_or_else(|| {
417                Error::ConfigError(format!(
418                    "submodule.{submodule_name}.gitdir is not set (submodulePathConfig enabled)"
419                ))
420            })?;
421        Ok(resolve_gitdir_value(work_tree, &value))
422    } else {
423        Ok(git_dir.join("modules").join(submodule_name))
424    }
425}
426
427/// Migrates legacy submodule dirs under `.git/modules/`: sets `submodule.*.gitdir` and enables the extension.
428pub fn migrate_gitdir_configs(work_tree: &Path, git_dir: &Path) -> Result<()> {
429    let modules_root = git_dir.join("modules");
430    if !modules_root.is_dir() {
431        return Ok(());
432    }
433
434    let config_path = git_dir.join("config");
435    let content = fs::read_to_string(&config_path).map_err(Error::Io)?;
436    let mut cfg = ConfigFile::parse(&config_path, &content, ConfigScope::Local)?;
437
438    for entry in fs::read_dir(&modules_root).map_err(Error::Io)? {
439        let entry = entry.map_err(Error::Io)?;
440        let name = entry.file_name();
441        let name_str = name.to_string_lossy();
442        if name_str == "." || name_str == ".." {
443            continue;
444        }
445        let gd_path = modules_root.join(&name);
446        if !is_git_directory(&gd_path) {
447            continue;
448        }
449        let key = format!("submodule.{name_str}.gitdir");
450        if cfg.entries.iter().any(|e| e.key == key) {
451            continue;
452        }
453        let _ = ensure_submodule_gitdir_config(work_tree, git_dir, &mut cfg, &name_str)?;
454    }
455
456    let mut repo_version = 0u32;
457    if let Some(v) = cfg
458        .entries
459        .iter()
460        .find(|e| e.key == "core.repositoryformatversion")
461    {
462        if let Some(s) = v.value.as_deref() {
463            repo_version = s.parse().unwrap_or(0);
464        }
465    }
466    if repo_version == 0 {
467        cfg.set("core.repositoryformatversion", "1")?;
468    }
469    cfg.set("extensions.submodulePathConfig", "true")?;
470    cfg.write()?;
471    Ok(())
472}
473
474/// Returns true if `new_path` is strictly inside a gitlink path recorded in `index` (stage 0).
475pub fn path_inside_indexed_submodule(index: &Index, new_path: &str) -> bool {
476    let new_norm = new_path.replace('\\', "/");
477    for e in &index.entries {
478        if e.mode != 0o160000 || e.stage() != 0 {
479            continue;
480        }
481        let ce = String::from_utf8_lossy(&e.path).replace('\\', "/");
482        let ce_len = ce.len();
483        if new_norm.len() <= ce_len {
484            continue;
485        }
486        if new_norm.as_bytes().get(ce_len) != Some(&b'/') {
487            continue;
488        }
489        if !new_norm.starts_with(&ce) {
490            continue;
491        }
492        if new_norm.len() == ce_len + 1 {
493            continue;
494        }
495        return true;
496    }
497    false
498}
499
500/// Returns true if `new_path` is under a submodule path declared in `.gitmodules`.
501pub fn path_inside_registered_submodule(work_tree: &Path, new_path: &str) -> bool {
502    let gitmodules = work_tree.join(".gitmodules");
503    let Ok(content) = fs::read_to_string(&gitmodules) else {
504        return false;
505    };
506    let Ok(mf) = ConfigFile::parse(&gitmodules, &content, ConfigScope::Local) else {
507        return false;
508    };
509    let mut paths: Vec<String> = Vec::new();
510    for e in &mf.entries {
511        if e.key.starts_with("submodule.") && e.key.ends_with(".path") {
512            if let Some(p) = e.value.as_deref() {
513                paths.push(p.replace('\\', "/"));
514            }
515        }
516    }
517    let new_norm = new_path.replace('\\', "/");
518    for p in paths {
519        if new_norm == p || new_norm.starts_with(&format!("{p}/")) {
520            return true;
521        }
522    }
523    false
524}
525
526/// Fails when `submodulePathConfig` is off and `new_path` would nest inside an existing submodule.
527///
528/// `index` is optional; when set, checked gitlinks match Git's `die_path_inside_submodule`.
529pub fn die_path_inside_submodule_when_disabled(
530    git_dir: &Path,
531    work_tree: &Path,
532    new_path: &str,
533    index: Option<&Index>,
534) -> Result<()> {
535    if submodule_path_config_enabled(git_dir) {
536        return Ok(());
537    }
538    if path_inside_registered_submodule(work_tree, new_path) {
539        return Err(Error::ConfigError(
540            "cannot add submodule: path inside existing submodule".into(),
541        ));
542    }
543    if let Some(ix) = index {
544        if path_inside_indexed_submodule(ix, new_path) {
545            return Err(Error::ConfigError(
546                "cannot add submodule: path inside existing submodule".into(),
547            ));
548        }
549    }
550    Ok(())
551}
552
553/// Sets `core.worktree` in the submodule repo at `modules_dir` via `grit --git-dir`.
554pub fn set_submodule_repo_worktree(grit_bin: &Path, modules_dir: &Path, sub_worktree: &Path) {
555    let _ = std::process::Command::new(grit_bin)
556        .arg("--git-dir")
557        .arg(modules_dir)
558        .arg("config")
559        .arg("core.worktree")
560        .arg(sub_worktree)
561        .status();
562}
563
564/// Writes `sub_worktree/.git` as a gitfile pointing at `modules_dir` (relative when possible).
565pub fn write_submodule_gitfile(sub_worktree: &Path, modules_dir: &Path) -> Result<()> {
566    let rel = pathdiff_relative(sub_worktree, modules_dir);
567    let line = format!("gitdir: {rel}\n");
568    fs::write(sub_worktree.join(".git"), line).map_err(Error::Io)?;
569    Ok(())
570}
571
572fn pathdiff_relative(from: &Path, to: &Path) -> String {
573    let from_c = fs::canonicalize(from).unwrap_or_else(|_| from.to_path_buf());
574    let to_c = fs::canonicalize(to).unwrap_or_else(|_| to.to_path_buf());
575    let from_comp: Vec<Component<'_>> = from_c.components().collect();
576    let to_comp: Vec<Component<'_>> = to_c.components().collect();
577    let mut i = 0usize;
578    while i < from_comp.len() && i < to_comp.len() && from_comp[i] == to_comp[i] {
579        i += 1;
580    }
581    let mut out = PathBuf::new();
582    for _ in i..from_comp.len() {
583        out.push("..");
584    }
585    for c in &to_comp[i..] {
586        out.push(c.as_os_str());
587    }
588    out.to_string_lossy().replace('\\', "/")
589}
590
591/// Writes the gitfile and `core.worktree` for a submodule using configured `submodule.<name>.gitdir`.
592pub fn connect_submodule_work_tree_and_git_dir(
593    grit_bin: &Path,
594    work_tree: &Path,
595    super_git_dir: &Path,
596    cfg: &ConfigFile,
597    submodule_name: &str,
598    sub_worktree: &Path,
599) -> Result<()> {
600    let modules_dir =
601        submodule_gitdir_filesystem_path(work_tree, super_git_dir, cfg, submodule_name)?;
602    write_submodule_gitfile(sub_worktree, &modules_dir)?;
603    set_submodule_repo_worktree(grit_bin, &modules_dir, sub_worktree);
604    Ok(())
605}
606
607/// If `modules_dir/HEAD` is missing, sets it to `oid_hex` when that commit exists in `objects/`.
608pub fn init_submodule_head_from_gitlink(modules_dir: &Path, oid_hex: &str) -> Result<()> {
609    let head = modules_dir.join("HEAD");
610    if head.exists() {
611        return Ok(());
612    }
613    let obj_dir = modules_dir.join("objects");
614    if !obj_dir.is_dir() {
615        return Ok(());
616    }
617    let odb = Odb::new(&obj_dir);
618    let oid = ObjectId::from_hex(oid_hex)?;
619    let obj = odb.read(&oid)?;
620    if obj.kind != ObjectKind::Commit {
621        return Ok(());
622    }
623    fs::write(&head, format!("{oid_hex}\n")).map_err(Error::Io)?;
624    Ok(())
625}
626
627#[cfg(test)]
628mod submodule_modules_git_dir_tests {
629    use super::submodule_modules_git_dir;
630    use std::path::Path;
631
632    #[test]
633    fn nested_path_inserts_modules_per_segment() {
634        let super_git = Path::new("/repo/.git");
635        assert_eq!(
636            submodule_modules_git_dir(super_git, "sub1/sub2"),
637            Path::new("/repo/.git/modules/sub1/modules/sub2")
638        );
639    }
640
641    #[test]
642    fn single_segment_one_modules_join() {
643        let super_git = Path::new("/repo/.git");
644        assert_eq!(
645            submodule_modules_git_dir(super_git, "sub1"),
646            Path::new("/repo/.git/modules/sub1")
647        );
648    }
649}