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