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_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
33pub 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
81pub 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
214pub 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
246pub 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
304pub 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
313pub 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 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
383pub 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
402pub 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
427pub 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
474pub 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
500pub 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
526pub 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
553pub 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
564pub 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
591pub 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
607pub 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}