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 crate::git_path::CanonicalizeExt;
7use std::collections::HashSet;
8use std::fs;
9use std::path::{Component, Path, PathBuf};
10
11use crate::config::{ConfigFile, ConfigScope};
12use crate::error::{Error, Result};
13use crate::hash;
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 under `super_git_dir`.
19///
20/// Matches Git `submodule_name_to_gitdir` with `extensions.submodulePathConfig` off: append
21/// `modules/<name>` by splitting only on `/` and `\\` (components like `..` are kept; no
22/// normalization).
23#[must_use]
24pub fn submodule_modules_git_dir(super_git_dir: &Path, submodule_relpath: &str) -> PathBuf {
25    let mut out = super_git_dir.to_path_buf();
26    out.push("modules");
27    for seg in submodule_relpath.split(['/', '\\']) {
28        if seg.is_empty() || seg == "." {
29            continue;
30        }
31        out.push(seg);
32    }
33    out
34}
35
36/// Returns whether `extensions.submodulePathConfig` is enabled in `git_dir/config`.
37///
38/// `git_dir` should be the repository directory that holds `config` (the common git directory when
39/// using linked worktrees).
40pub fn submodule_path_config_enabled(git_dir: &Path) -> bool {
41    let config_path = git_dir.join("config");
42    let Ok(content) = fs::read_to_string(&config_path) else {
43        return false;
44    };
45    let mut in_extensions = false;
46    for line in content.lines() {
47        let trimmed = line.trim();
48        if trimmed.starts_with('[') {
49            in_extensions = trimmed.eq_ignore_ascii_case("[extensions]");
50            continue;
51        }
52        if in_extensions {
53            if let Some((k, v)) = trimmed.split_once('=') {
54                if k.trim().eq_ignore_ascii_case("submodulepathconfig") {
55                    return parse_bool(v.trim());
56                }
57            }
58        }
59    }
60    false
61}
62
63fn parse_bool(s: &str) -> bool {
64    matches!(s.to_ascii_lowercase().as_str(), "true" | "yes" | "on" | "1")
65}
66
67fn is_rfc3986_unreserved(b: u8) -> bool {
68    b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~')
69}
70
71fn is_casefolding_rfc3986_unreserved(b: u8) -> bool {
72    matches!(b, b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~')
73}
74
75fn percent_encode(name: &str, pred: fn(u8) -> bool) -> String {
76    let mut out = String::new();
77    for &b in name.as_bytes() {
78        if pred(b) {
79            out.push(b as char);
80        } else {
81            out.push_str(&format!("%{:02x}", b));
82        }
83    }
84    out
85}
86
87/// Returns true if `path` looks like a git directory (`HEAD` and `objects/` exist).
88pub fn is_git_directory(path: &Path) -> bool {
89    path.join("HEAD").is_file() && path.join("objects").is_dir()
90}
91
92/// Whether `git_dir` is a usable Git directory, with the same rules as Git's repository
93/// discovery: a valid `HEAD` in `git_dir` (including linked-worktree admin dirs) and
94/// `objects/` + `refs/` in the common git directory (`commondir` when present).
95///
96/// Used for embedded-repository detection and to validate local remotes.
97#[must_use]
98pub(crate) fn is_valid_git_dir(git_dir: &Path) -> bool {
99    if !git_head_ref_valid(&git_dir.join("HEAD")) {
100        return false;
101    }
102    let object_store = crate::repo::common_git_dir_for_config(git_dir);
103    object_store.join("objects").is_dir() && object_store.join("refs").is_dir()
104}
105
106#[cfg(test)]
107mod embedded_git_dir_tests {
108    use super::*;
109    use std::fs;
110
111    #[test]
112    fn placeholder_dot_git_dir_is_not_a_repository() {
113        let tmp = tempfile::tempdir().unwrap();
114        let git = tmp.path().join(".git");
115        fs::create_dir_all(&git).unwrap();
116        assert!(!is_valid_git_dir(&git));
117    }
118
119    #[test]
120    fn minimal_init_layout_is_a_repository() {
121        let tmp = tempfile::tempdir().unwrap();
122        let git = tmp.path().join(".git");
123        fs::create_dir_all(git.join("objects")).unwrap();
124        fs::create_dir_all(git.join("refs/heads")).unwrap();
125        fs::write(git.join("HEAD"), "ref: refs/heads/main\n").unwrap();
126        assert!(is_valid_git_dir(&git));
127    }
128
129    #[test]
130    fn linked_worktree_admin_dir_uses_commondir_for_stores() {
131        let tmp = tempfile::tempdir().unwrap();
132        let common = tmp.path().join("repo.git");
133        fs::create_dir_all(common.join("objects")).unwrap();
134        fs::create_dir_all(common.join("refs/heads")).unwrap();
135        let admin = common.join("worktrees/linked");
136        fs::create_dir_all(&admin).unwrap();
137        fs::write(admin.join("HEAD"), "ref: refs/heads/main\n").unwrap();
138        fs::write(admin.join("commondir"), "../..\n").unwrap();
139        assert!(is_valid_git_dir(&admin));
140        assert!(
141            !admin.join("objects").is_dir(),
142            "admin dir must not need local objects/"
143        );
144    }
145}
146
147fn git_head_ref_valid(head_path: &Path) -> bool {
148    let Ok(meta) = fs::symlink_metadata(head_path) else {
149        return false;
150    };
151    if meta.file_type().is_symlink() {
152        if let Ok(target) = fs::read_link(head_path) {
153            return target.to_string_lossy().starts_with("refs/");
154        }
155        return false;
156    }
157    let Ok(content) = fs::read_to_string(head_path) else {
158        return false;
159    };
160    let trimmed = content.trim();
161    if let Some(rest) = trimmed.strip_prefix("ref:") {
162        return rest.trim().starts_with("refs/");
163    }
164    ObjectId::from_hex(trimmed).is_ok()
165}
166
167/// Rejects submodule paths that traverse symlinks (Git `validate_submodule_path`).
168///
169/// Path components follow Git `is_dir_sep`: on Unix only `/` separates; on Windows both `/`
170/// and `\` do. A carriage return in a single segment (e.g. `sub\r`) must not be split.
171pub fn validate_submodule_path(work_tree: &Path, rel: &str) -> Result<()> {
172    if rel.is_empty() {
173        return Err(Error::Config("empty submodule path".into()));
174    }
175    let mut cur = work_tree.to_path_buf();
176    #[cfg(windows)]
177    let parts = rel.split(|c| c == '/' || c == '\\');
178    #[cfg(not(windows))]
179    let parts = rel.split('/');
180    for comp in parts.filter(|s| !s.is_empty()) {
181        cur.push(comp);
182        let meta = match fs::symlink_metadata(&cur) {
183            Ok(m) => m,
184            Err(_) => continue,
185        };
186        if meta.file_type().is_symlink() {
187            return Err(Error::Config(
188                format!("expected '{comp}' in submodule path '{rel}' not to be a symbolic link")
189                    .into(),
190            ));
191        }
192    }
193    Ok(())
194}
195
196fn last_modules_segment(git_dir_abs: &Path) -> Option<String> {
197    let s = git_dir_abs.to_string_lossy();
198    let marker = "/modules/";
199    let mut p = 0usize;
200    let mut last_start = None;
201    while let Some(idx) = s[p..].find(marker) {
202        let start = p + idx + marker.len();
203        last_start = Some(start);
204        p = start + 1;
205    }
206    last_start.map(|start| s[start..].to_string())
207}
208
209fn path_inside_other_gitdir(git_dir: &Path, submodule_name: &str) -> bool {
210    submodule_gitdir_outer_conflict(git_dir, submodule_name).is_some()
211}
212
213/// When a proposed `.git/modules/<name>` path would sit inside another submodule's git dir
214/// (e.g. names `hippo` and `hippo/hooks`), returns the outer git directory path (Git
215/// `validate_submodule_legacy_git_dir`).
216#[must_use]
217pub fn submodule_gitdir_outer_conflict(git_dir: &Path, submodule_name: &str) -> Option<PathBuf> {
218    let suffix = submodule_name.as_bytes();
219    let gd = git_dir.to_string_lossy();
220    let gd_bytes = gd.as_bytes();
221    if gd_bytes.len() <= suffix.len() {
222        return None;
223    }
224    let cut = gd_bytes.len() - suffix.len();
225    if gd_bytes[cut - 1] != b'/' {
226        return None;
227    }
228    if &gd_bytes[cut..] != suffix {
229        return None;
230    }
231    for i in cut..gd_bytes.len() {
232        if gd_bytes[i] == b'/' {
233            let prefix = Path::new(std::str::from_utf8(&gd_bytes[..i]).unwrap_or(""));
234            if is_git_directory(prefix) {
235                return Some(prefix.to_path_buf());
236            }
237        }
238    }
239    None
240}
241
242fn resolve_gitdir_value(work_tree: &Path, gitdir_cfg: &str) -> PathBuf {
243    let p = Path::new(gitdir_cfg.trim());
244    if p.is_absolute() {
245        p.to_path_buf()
246    } else {
247        work_tree.join(p)
248    }
249}
250
251fn canonical_abs(path: &Path) -> PathBuf {
252    path.canonicalize_simplified()
253        .unwrap_or_else(|_| path.to_path_buf())
254}
255
256fn existing_gitdir_abs_paths(
257    work_tree: &Path,
258    cfg: &ConfigFile,
259    except_name: &str,
260) -> Result<HashSet<PathBuf>> {
261    let mut set = HashSet::new();
262    let suffix = ".gitdir";
263    for e in &cfg.entries {
264        if !e.key.starts_with("submodule.") || !e.key.ends_with(suffix) {
265            continue;
266        }
267        let inner = &e.key["submodule.".len()..e.key.len() - suffix.len()];
268        if inner == except_name {
269            continue;
270        }
271        if let Some(v) = e.value.as_deref() {
272            let abs = canonical_abs(&resolve_gitdir_value(work_tree, v));
273            set.insert(abs);
274        }
275    }
276    Ok(set)
277}
278
279fn gitdir_conflicts_with_existing(
280    work_tree: &Path,
281    cfg: &ConfigFile,
282    abs_gitdir: &Path,
283    submodule_name: &str,
284) -> Result<bool> {
285    let canon = canonical_abs(abs_gitdir);
286    let existing = existing_gitdir_abs_paths(work_tree, cfg, submodule_name)?;
287    Ok(existing.contains(&canon))
288}
289
290fn ignore_case_from_config(git_dir: &Path) -> bool {
291    let config_path = git_dir.join("config");
292    let Ok(content) = fs::read_to_string(&config_path) else {
293        return false;
294    };
295    let mut in_core = false;
296    for line in content.lines() {
297        let trimmed = line.trim();
298        if trimmed.starts_with('[') {
299            in_core = trimmed.eq_ignore_ascii_case("[core]");
300            continue;
301        }
302        if in_core {
303            if let Some((k, v)) = trimmed.split_once('=') {
304                if k.trim().eq_ignore_ascii_case("ignorecase") {
305                    return parse_bool(v.trim());
306                }
307            }
308        }
309    }
310    false
311}
312
313fn fold_case_git_path(s: &str) -> String {
314    s.to_ascii_lowercase()
315}
316
317fn check_casefolding_conflict(
318    proposed_abs: &Path,
319    submodule_name: &str,
320    suffixes_match: bool,
321    taken_folded: &HashSet<String>,
322) -> bool {
323    let last = last_modules_segment(proposed_abs).unwrap_or_default();
324    let folded_last = fold_case_git_path(&last);
325    let folded_name = fold_case_git_path(submodule_name);
326    if suffixes_match {
327        taken_folded.contains(&folded_last)
328    } else {
329        taken_folded.contains(&folded_name) || taken_folded.contains(&folded_last)
330    }
331}
332
333/// Validates a legacy submodule gitdir path (extension disabled): name suffix and no nesting clash.
334pub fn validate_legacy_submodule_git_dir(git_dir: &Path, submodule_name: &str) -> Result<()> {
335    let gd = git_dir.to_string_lossy();
336    let suffix = submodule_name;
337    if gd.len() <= suffix.len() {
338        return Err(Error::Config(
339            "submodule name not a suffix of git dir".into(),
340        ));
341    }
342    let cut = gd.len() - suffix.len();
343    if gd
344        .as_bytes()
345        .get(cut.wrapping_sub(1))
346        .is_none_or(|&b| b != b'/')
347    {
348        return Err(Error::Config(
349            "submodule name not a suffix of git dir".into(),
350        ));
351    }
352    if &gd[cut..] != suffix {
353        return Err(Error::Config(
354            "submodule name not a suffix of git dir".into(),
355        ));
356    }
357    if path_inside_other_gitdir(git_dir, submodule_name) {
358        return Err(Error::Config(
359            "submodule git dir inside another submodule git dir".into(),
360        ));
361    }
362    Ok(())
363}
364
365/// Validates an encoded submodule gitdir path when `submodulePathConfig` is enabled.
366pub fn validate_encoded_submodule_git_dir(
367    work_tree: &Path,
368    cfg: &ConfigFile,
369    git_dir: &Path,
370    submodule_name: &str,
371    super_git_dir: &Path,
372) -> Result<()> {
373    let last = last_modules_segment(git_dir)
374        .ok_or_else(|| Error::Config("submodule gitdir missing /modules/ segment".into()))?;
375    if last.contains('/') {
376        return Err(Error::Config(
377            "encoded submodule gitdir must not contain '/' in module segment".into(),
378        ));
379    }
380    if is_git_directory(git_dir)
381        && gitdir_conflicts_with_existing(work_tree, cfg, git_dir, submodule_name)?
382    {
383        return Err(Error::Config(
384            "submodule gitdir conflicts with existing".into(),
385        ));
386    }
387    if cfg!(unix) && ignore_case_from_config(super_git_dir) {
388        let mut taken: HashSet<String> = HashSet::new();
389        let suffix = ".gitdir";
390        for e in &cfg.entries {
391            if !e.key.starts_with("submodule.") || !e.key.ends_with(suffix) {
392                continue;
393            }
394            let inner = &e.key["submodule.".len()..e.key.len() - suffix.len()];
395            if inner == submodule_name {
396                continue;
397            }
398            if let Some(v) = e.value.as_deref() {
399                let abs = canonical_abs(&resolve_gitdir_value(work_tree, v));
400                if let Some(seg) = last_modules_segment(&abs) {
401                    taken.insert(fold_case_git_path(&seg));
402                }
403            }
404        }
405        let suffixes_match = last == submodule_name;
406        if check_casefolding_conflict(git_dir, submodule_name, suffixes_match, &taken) {
407            return Err(Error::Config(
408                "case-folding conflict for submodule gitdir".into(),
409            ));
410        }
411    }
412    Ok(())
413}
414
415fn repo_git_path_append(git_dir: &Path, tail: &str) -> PathBuf {
416    let mut buf = git_dir.to_path_buf();
417    if !tail.is_empty() {
418        buf.push(tail);
419    }
420    buf
421}
422
423/// Returns the 40-character hex SHA-1 of a blob object for `data` (same as `git hash-object`).
424pub fn hash_blob_sha1_hex(data: &[u8]) -> Result<String> {
425    Ok(hash::hash_object(crate::objects::HashAlgo::Sha1, ObjectKind::Blob, data)?.to_hex())
426}
427
428/// Computes `submodule.<name>.gitdir` as a path relative to the work tree when not already set.
429pub fn compute_default_submodule_gitdir(
430    work_tree: &Path,
431    git_dir: &Path,
432    cfg: &ConfigFile,
433    submodule_name: &str,
434) -> Result<String> {
435    let key = format!("submodule.{submodule_name}.gitdir");
436    for e in &cfg.entries {
437        if e.key == key {
438            if let Some(v) = e.value.as_deref() {
439                return Ok(v.to_string());
440            }
441        }
442    }
443
444    let try_set = |rel_under_git: &str| -> Option<String> {
445        let abs = repo_git_path_append(git_dir, rel_under_git);
446        if validate_encoded_submodule_git_dir(work_tree, cfg, &abs, submodule_name, git_dir)
447            .is_err()
448        {
449            return None;
450        }
451        Some(format!(".git/{}", rel_under_git.replace('\\', "/")))
452    };
453
454    // Plain `modules/<name>` only when `name` has no directory separators: `PathBuf::push`
455    // splits on `/`, so e.g. `nested/sub` would become `modules/nested/sub` and the encoded
456    // validation rejects a multi-level tail under `modules/`.
457    let rel_plain = format!("modules/{}", submodule_name.replace('\\', "/"));
458    if !submodule_name.contains('/') && !submodule_name.contains('\\') {
459        if let Some(v) = try_set(&rel_plain) {
460            return Ok(v);
461        }
462    }
463
464    let enc = percent_encode(submodule_name, is_rfc3986_unreserved);
465    let rel_enc = format!("modules/{enc}");
466    if let Some(v) = try_set(&rel_enc) {
467        return Ok(v);
468    }
469
470    let enc_cf = percent_encode(submodule_name, is_casefolding_rfc3986_unreserved);
471    let rel_cf = format!("modules/{enc_cf}");
472    if let Some(v) = try_set(&rel_cf) {
473        return Ok(v);
474    }
475
476    for c in b'0'..=b'9' {
477        let rel = format!("modules/{}{}", enc, c as char);
478        if let Some(v) = try_set(&rel) {
479            return Ok(v);
480        }
481        let rel2 = format!("modules/{}{}", enc_cf, c as char);
482        if let Some(v) = try_set(&rel2) {
483            return Ok(v);
484        }
485    }
486
487    let hex = hash_blob_sha1_hex(submodule_name.as_bytes())?;
488    let rel_h = format!("modules/{hex}");
489    if let Some(v) = try_set(&rel_h) {
490        return Ok(v);
491    }
492
493    Err(Error::Config(
494        "failed to allocate submodule gitdir path".into(),
495    ))
496}
497
498/// Ensures `submodule.<name>.gitdir` exists, writing it via [`compute_default_submodule_gitdir`] if needed.
499pub fn ensure_submodule_gitdir_config(
500    work_tree: &Path,
501    git_dir: &Path,
502    cfg: &mut ConfigFile,
503    submodule_name: &str,
504) -> Result<String> {
505    let key = format!("submodule.{submodule_name}.gitdir");
506    if let Some(existing) = cfg.entries.iter().find(|e| e.key == key) {
507        if let Some(v) = existing.value.as_deref() {
508            return Ok(v.to_string());
509        }
510    }
511    let value = compute_default_submodule_gitdir(work_tree, git_dir, cfg, submodule_name)?;
512    cfg.set(&key, &value)?;
513    cfg.write()?;
514    Ok(value)
515}
516
517/// Resolves the absolute filesystem path of a submodule's git directory.
518pub fn submodule_gitdir_filesystem_path(
519    work_tree: &Path,
520    git_dir: &Path,
521    cfg: &ConfigFile,
522    submodule_name: &str,
523) -> Result<PathBuf> {
524    if submodule_path_config_enabled(git_dir) {
525        let key = format!("submodule.{submodule_name}.gitdir");
526        let value = cfg
527            .entries
528            .iter()
529            .find(|e| e.key == key)
530            .and_then(|e| e.value.clone())
531            .ok_or_else(|| {
532                Error::Config(
533                    format!(
534                    "submodule.{submodule_name}.gitdir is not set (submodulePathConfig enabled)"
535                )
536                    .into(),
537                )
538            })?;
539        Ok(resolve_gitdir_value(work_tree, &value))
540    } else {
541        Ok(git_dir.join("modules").join(submodule_name))
542    }
543}
544
545/// Migrates legacy submodule dirs under `.git/modules/`: sets `submodule.*.gitdir` and enables the extension.
546pub fn migrate_gitdir_configs(work_tree: &Path, git_dir: &Path) -> Result<()> {
547    let modules_root = git_dir.join("modules");
548    if !modules_root.is_dir() {
549        return Ok(());
550    }
551
552    let config_path = git_dir.join("config");
553    let content = fs::read_to_string(&config_path).map_err(Error::Io)?;
554    let mut cfg = ConfigFile::parse(&config_path, &content, ConfigScope::Local)?;
555
556    for entry in fs::read_dir(&modules_root).map_err(Error::Io)? {
557        let entry = entry.map_err(Error::Io)?;
558        let name = entry.file_name();
559        let name_str = name.to_string_lossy();
560        if name_str == "." || name_str == ".." {
561            continue;
562        }
563        let gd_path = modules_root.join(&name);
564        if !is_git_directory(&gd_path) {
565            continue;
566        }
567        let key = format!("submodule.{name_str}.gitdir");
568        if cfg.entries.iter().any(|e| e.key == key) {
569            continue;
570        }
571        let _ = ensure_submodule_gitdir_config(work_tree, git_dir, &mut cfg, &name_str)?;
572    }
573
574    let mut repo_version = 0u32;
575    if let Some(v) = cfg
576        .entries
577        .iter()
578        .find(|e| e.key == "core.repositoryformatversion")
579    {
580        if let Some(s) = v.value.as_deref() {
581            repo_version = s.parse().unwrap_or(0);
582        }
583    }
584    if repo_version == 0 {
585        cfg.set("core.repositoryformatversion", "1")?;
586    }
587    cfg.set("extensions.submodulePathConfig", "true")?;
588    cfg.write()?;
589    Ok(())
590}
591
592/// Returns true if `new_path` is strictly inside a gitlink path recorded in `index` (stage 0).
593pub fn path_inside_indexed_submodule(index: &Index, new_path: &str) -> bool {
594    let new_norm = new_path.replace('\\', "/");
595    for e in &index.entries {
596        if e.mode != 0o160000 || e.stage() != 0 {
597            continue;
598        }
599        let ce = String::from_utf8_lossy(&e.path).replace('\\', "/");
600        let ce_len = ce.len();
601        if new_norm.len() <= ce_len {
602            continue;
603        }
604        if new_norm.as_bytes().get(ce_len) != Some(&b'/') {
605            continue;
606        }
607        if !new_norm.starts_with(&ce) {
608            continue;
609        }
610        if new_norm.len() == ce_len + 1 {
611            continue;
612        }
613        return true;
614    }
615    false
616}
617
618/// Returns true if `new_path` is under a submodule path declared in `.gitmodules`.
619pub fn path_inside_registered_submodule(work_tree: &Path, new_path: &str) -> bool {
620    let gitmodules = work_tree.join(".gitmodules");
621    let Ok(content) = fs::read_to_string(&gitmodules) else {
622        return false;
623    };
624    let Ok(mf) = ConfigFile::parse(&gitmodules, &content, ConfigScope::Local) else {
625        return false;
626    };
627    let mut paths: Vec<String> = Vec::new();
628    for e in &mf.entries {
629        if e.key.starts_with("submodule.") && e.key.ends_with(".path") {
630            if let Some(p) = e.value.as_deref() {
631                paths.push(p.replace('\\', "/"));
632            }
633        }
634    }
635    let new_norm = new_path.replace('\\', "/");
636    for p in paths {
637        if new_norm == p || new_norm.starts_with(&format!("{p}/")) {
638            return true;
639        }
640    }
641    false
642}
643
644/// True when `new_path` is the same as or nested under a `.gitmodules` submodule **name** (the
645/// `submodule.<name>.*` section name), which may contain `/`.
646///
647/// Git rejects such paths when `submodulePathConfig` is off (`die_path_inside_submodule` on
648/// logical names). When the extension is enabled, encoded gitdirs lift this restriction.
649pub fn path_inside_registered_submodule_name(work_tree: &Path, new_path: &str) -> bool {
650    let gitmodules = work_tree.join(".gitmodules");
651    let Ok(content) = fs::read_to_string(&gitmodules) else {
652        return false;
653    };
654    let Ok(mf) = ConfigFile::parse(&gitmodules, &content, ConfigScope::Local) else {
655        return false;
656    };
657    let mut names: Vec<String> = Vec::new();
658    for e in &mf.entries {
659        if !e.key.starts_with("submodule.") {
660            continue;
661        }
662        let rest = &e.key["submodule.".len()..];
663        if let Some(last_dot) = rest.rfind('.') {
664            let name = rest[..last_dot].replace('\\', "/");
665            if !name.is_empty() {
666                names.push(name);
667            }
668        }
669    }
670    names.sort();
671    names.dedup();
672    let new_norm = new_path.replace('\\', "/");
673    for n in names {
674        if new_norm == n || new_norm.starts_with(&format!("{n}/")) {
675            return true;
676        }
677    }
678    false
679}
680
681/// Fails when `submodulePathConfig` is off and `new_path` would nest inside an existing submodule.
682///
683/// `index` is optional; when set, checked gitlinks match Git's `die_path_inside_submodule`.
684pub fn die_path_inside_submodule_when_disabled(
685    git_dir: &Path,
686    work_tree: &Path,
687    new_path: &str,
688    index: Option<&Index>,
689) -> Result<()> {
690    if submodule_path_config_enabled(git_dir) {
691        return Ok(());
692    }
693    if path_inside_registered_submodule(work_tree, new_path) {
694        return Err(Error::Config(
695            "cannot add submodule: path inside existing submodule".into(),
696        ));
697    }
698    if let Some(ix) = index {
699        if path_inside_indexed_submodule(ix, new_path) {
700            return Err(Error::Config(
701                "cannot add submodule: path inside existing submodule".into(),
702            ));
703        }
704    }
705    Ok(())
706}
707
708/// Sets `core.worktree` in the submodule repo at `modules_dir`.
709///
710/// Stores a path relative to `modules_dir` (e.g. `../../../sub1`), matching C Git and
711/// `test_git_directory_exists` in the ported submodule tests.
712pub fn set_submodule_repo_worktree(modules_dir: &Path, sub_worktree: &Path) -> Result<()> {
713    let wt_rel = pathdiff_relative(modules_dir, sub_worktree);
714    let config_path = modules_dir.join("config");
715    let mut file = match ConfigFile::from_path(&config_path, ConfigScope::Local)? {
716        Some(f) => f,
717        None => ConfigFile::parse(&config_path, "", ConfigScope::Local)?,
718    };
719    file.set("core.worktree", &wt_rel)?;
720    file.write()?;
721    Ok(())
722}
723
724/// Writes `sub_worktree/.git` as a gitfile pointing at `modules_dir` (relative when possible).
725pub fn write_submodule_gitfile(sub_worktree: &Path, modules_dir: &Path) -> Result<()> {
726    let rel = pathdiff_relative(sub_worktree, modules_dir);
727    let line = format!("gitdir: {rel}\n");
728    fs::write(sub_worktree.join(".git"), line).map_err(Error::Io)?;
729    Ok(())
730}
731
732fn pathdiff_relative(from: &Path, to: &Path) -> String {
733    let from_c = from
734        .canonicalize_simplified()
735        .unwrap_or_else(|_| from.to_path_buf());
736    let to_c = to
737        .canonicalize_simplified()
738        .unwrap_or_else(|_| to.to_path_buf());
739    let from_comp: Vec<Component<'_>> = from_c.components().collect();
740    let to_comp: Vec<Component<'_>> = to_c.components().collect();
741    let mut i = 0usize;
742    while i < from_comp.len() && i < to_comp.len() && from_comp[i] == to_comp[i] {
743        i += 1;
744    }
745    let mut out = PathBuf::new();
746    for _ in i..from_comp.len() {
747        out.push("..");
748    }
749    for c in &to_comp[i..] {
750        out.push(c.as_os_str());
751    }
752    out.to_string_lossy().replace('\\', "/")
753}
754
755/// Writes the gitfile and `core.worktree` for a submodule using configured `submodule.<name>.gitdir`.
756pub fn connect_submodule_work_tree_and_git_dir(
757    work_tree: &Path,
758    super_git_dir: &Path,
759    cfg: &ConfigFile,
760    submodule_name: &str,
761    sub_worktree: &Path,
762) -> Result<()> {
763    let modules_dir =
764        submodule_gitdir_filesystem_path(work_tree, super_git_dir, cfg, submodule_name)?;
765    write_submodule_gitfile(sub_worktree, &modules_dir)?;
766    set_submodule_repo_worktree(&modules_dir, sub_worktree)?;
767    Ok(())
768}
769
770/// If `modules_dir/HEAD` is missing, sets it to `oid_hex` when that commit exists in `objects/`.
771pub fn init_submodule_head_from_gitlink(modules_dir: &Path, oid_hex: &str) -> Result<()> {
772    let head = modules_dir.join("HEAD");
773    if head.exists() {
774        return Ok(());
775    }
776    let obj_dir = modules_dir.join("objects");
777    if !obj_dir.is_dir() {
778        return Ok(());
779    }
780    let odb = Odb::new(&obj_dir);
781    let oid = ObjectId::from_hex(oid_hex)?;
782    let obj = odb.read(&oid)?;
783    if obj.kind != ObjectKind::Commit {
784        return Ok(());
785    }
786    fs::write(&head, format!("{oid_hex}\n")).map_err(Error::Io)?;
787    Ok(())
788}
789
790#[cfg(test)]
791mod submodule_modules_git_dir_tests {
792    use super::submodule_modules_git_dir;
793    use std::path::Path;
794
795    #[test]
796    fn nested_path_under_single_modules_prefix() {
797        let super_git = Path::new("/repo/.git");
798        assert_eq!(
799            submodule_modules_git_dir(super_git, "sub1/sub2"),
800            Path::new("/repo/.git/modules/sub1/sub2")
801        );
802        assert_eq!(
803            submodule_modules_git_dir(super_git, r"..\foo"),
804            Path::new("/repo/.git/modules/../foo")
805        );
806    }
807
808    #[test]
809    fn single_segment_one_modules_join() {
810        let super_git = Path::new("/repo/.git");
811        assert_eq!(
812            submodule_modules_git_dir(super_git, "sub1"),
813            Path::new("/repo/.git/modules/sub1")
814        );
815    }
816}