1use 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#[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
36pub 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
87pub fn is_git_directory(path: &Path) -> bool {
89 path.join("HEAD").is_file() && path.join("objects").is_dir()
90}
91
92pub 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#[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
256pub 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
288pub 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
346pub 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
355pub 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 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
425pub 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
444pub 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
469pub 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
516pub 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
542pub 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
568pub 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
605pub 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
632pub 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
647pub 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
674pub 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
690pub 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}