1use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8
9use crate::combined_tree_diff::{combined_diff_paths_filtered, CombinedTreeDiffOptions};
10use crate::diff::{diff_trees, DiffStatus};
11use crate::error::Result;
12use crate::index::MODE_GITLINK;
13use crate::objects::{parse_commit, ObjectId, ObjectKind};
14use crate::refs;
15use crate::repo::Repository;
16
17fn resolve_remote_url_to_local_git_dir(url: &str, base_for_relative: &Path) -> Option<PathBuf> {
18 let url = url.trim();
19 if url.starts_with("git://")
20 || url.starts_with("http://")
21 || url.starts_with("https://")
22 || is_ssh_transport_url(url)
23 {
24 return None;
25 }
26 let path_str = url.strip_prefix("file://").unwrap_or(url);
27 let mut p = PathBuf::from(path_str);
28 if p.is_relative() {
29 p = base_for_relative.join(p);
30 }
31 let p = if p.ends_with(".git") || p.join("HEAD").exists() {
32 p
33 } else {
34 p.join(".git")
35 };
36 if p.join("HEAD").exists() {
37 Some(p)
38 } else {
39 None
40 }
41}
42
43fn is_ssh_transport_url(url: &str) -> bool {
44 if url.starts_with("ssh://") || url.starts_with("git+ssh://") {
45 return true;
46 }
47 if url.contains("://") {
48 return false;
49 }
50 let colon = url.find(':');
51 let slash = url.find('/');
52 colon.is_some_and(|ci| slash.is_none_or(|si| ci < si))
53}
54
55fn oids_not_on_remote_repo(
57 submodule_repo: &Repository,
58 oids: &[ObjectId],
59 remote_git_dir: &Path,
60) -> Result<bool> {
61 if oids.is_empty() {
62 return Ok(false);
63 }
64 let remote_heads = refs::list_refs(remote_git_dir, "refs/heads/")?;
65 let negative: Vec<String> = remote_heads.iter().map(|(_, o)| o.to_hex()).collect();
66 let positive: Vec<String> = oids.iter().map(|o| o.to_hex()).collect();
67 let options = RevListOptions::default();
68 let r = rev_list(submodule_repo, &positive, &negative, &options)?;
69 Ok(!r.commits.is_empty())
70}
71use crate::rev_list::{rev_list, RevListOptions};
72use crate::state::{resolve_head, HeadState};
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub enum PushRecurseSubmodules {
77 Off,
79 Check,
81 OnDemand,
83 Only,
85}
86
87pub fn parse_push_recurse_submodules_arg(
91 opt: &str,
92 arg: &str,
93) -> std::result::Result<PushRecurseSubmodules, String> {
94 let arg = arg.trim();
95 if arg.is_empty() {
96 return Err(format!("option `{opt}` requires a value"));
97 }
98
99 if arg == "only-is-on-demand" {
101 return Ok(PushRecurseSubmodules::OnDemand);
102 }
103
104 match crate::config::parse_bool(arg) {
105 Ok(true) => Err(format!("bad {opt} argument: {arg}")),
106 Ok(false) => Ok(PushRecurseSubmodules::Off),
107 Err(_) => {
108 if arg.eq_ignore_ascii_case("on-demand") {
109 Ok(PushRecurseSubmodules::OnDemand)
110 } else if arg.eq_ignore_ascii_case("check") {
111 Ok(PushRecurseSubmodules::Check)
112 } else if arg.eq_ignore_ascii_case("only") {
113 Ok(PushRecurseSubmodules::Only)
114 } else if arg.eq_ignore_ascii_case("no") || arg.eq_ignore_ascii_case("false") {
115 Ok(PushRecurseSubmodules::Off)
116 } else {
117 Err(format!("bad {opt} argument: {arg}"))
118 }
119 }
120 }
121}
122
123fn mode_from_octal(mode_str: &str) -> Option<u32> {
124 u32::from_str_radix(mode_str, 8).ok()
125}
126
127fn is_gitlink_mode(mode_str: &str) -> bool {
128 mode_from_octal(mode_str) == Some(MODE_GITLINK)
129}
130
131pub fn collect_changed_gitlinks_for_push(
139 repo: &Repository,
140 commit_tips: &[ObjectId],
141 exclude_remote_name: &str,
142 fallback_remote_git_dir: Option<&Path>,
143) -> Result<HashMap<String, Vec<ObjectId>>> {
144 if commit_tips.is_empty() {
145 return Ok(HashMap::new());
146 }
147
148 let prefix = format!("refs/remotes/{exclude_remote_name}/");
149 let remote_refs = refs::list_refs(&repo.git_dir, &prefix)?;
150 let mut negative_hex: Vec<String> = remote_refs.iter().map(|(_, oid)| oid.to_hex()).collect();
151
152 if negative_hex.is_empty() {
153 if let Some(rgd) = fallback_remote_git_dir {
154 let heads = refs::list_refs(rgd, "refs/heads/")?;
155 negative_hex = heads.iter().map(|(_, oid)| oid.to_hex()).collect();
156 }
157 }
158
159 let positive_hex: Vec<String> = commit_tips.iter().map(|o| o.to_hex()).collect();
160 let options = RevListOptions::default();
161 let walked = rev_list(repo, &positive_hex, &negative_hex, &options)?;
162
163 let odb = &repo.odb;
164 let walk_opts = CombinedTreeDiffOptions {
165 recursive: true,
166 tree_in_recursive: false,
167 };
168
169 let mut by_path: HashMap<String, Vec<ObjectId>> = HashMap::new();
170
171 for commit_oid in walked.commits {
172 let obj = odb.read(&commit_oid)?;
173 if obj.kind != ObjectKind::Commit {
174 continue;
175 }
176 let commit = parse_commit(&obj.data)?;
177 let parents = commit.parents;
178
179 if parents.is_empty() {
180 let entries = diff_trees(odb, None, Some(&commit.tree), "")?;
181 for e in entries {
182 if !is_gitlink_mode(&e.new_mode) {
183 continue;
184 }
185 let path = e.path().to_string();
186 by_path.entry(path).or_default().push(e.new_oid);
187 }
188 } else if parents.len() == 1 {
189 let pobj = odb.read(&parents[0])?;
190 if pobj.kind != ObjectKind::Commit {
191 continue;
192 }
193 let parent = parse_commit(&pobj.data)?;
194 let entries = diff_trees(odb, Some(&parent.tree), Some(&commit.tree), "")?;
195 for e in entries {
196 if !matches!(
197 e.status,
198 DiffStatus::Added
199 | DiffStatus::Modified
200 | DiffStatus::TypeChanged
201 | DiffStatus::Renamed
202 ) {
203 continue;
204 }
205 let (mode, oid) = match e.status {
206 DiffStatus::Deleted => continue,
207 _ => (&e.new_mode, e.new_oid),
208 };
209 if !is_gitlink_mode(mode) {
210 continue;
211 }
212 let path = e
213 .new_path
214 .as_deref()
215 .or(e.old_path.as_deref())
216 .unwrap_or("");
217 if path.is_empty() {
218 continue;
219 }
220 by_path.entry(path.to_string()).or_default().push(oid);
221 }
222 } else {
223 let paths =
224 combined_diff_paths_filtered(odb, &commit.tree, &parents, &walk_opts, None)?;
225 for p in paths {
226 if (p.merge_mode & 0o170000) != MODE_GITLINK {
227 continue;
228 }
229 if p.merge_oid.is_zero() {
230 continue;
231 }
232 by_path.entry(p.path).or_default().push(p.merge_oid);
233 }
234 }
235 }
236
237 for v in by_path.values_mut() {
238 v.sort();
239 v.dedup();
240 }
241
242 Ok(by_path)
243}
244
245pub fn submodule_gitlinks_touched_in_range(
251 repo: &Repository,
252 excl: Option<ObjectId>,
253 incl: ObjectId,
254) -> Result<bool> {
255 let positive = vec![incl.to_hex()];
256 let negative = excl.map(|e| vec![e.to_hex()]).unwrap_or_default();
257 let options = RevListOptions::default();
258 let walked = rev_list(repo, &positive, &negative, &options)?;
259 let odb = &repo.odb;
260 let walk_opts = CombinedTreeDiffOptions {
261 recursive: true,
262 tree_in_recursive: false,
263 };
264
265 for commit_oid in walked.commits {
266 let obj = odb.read(&commit_oid)?;
267 if obj.kind != ObjectKind::Commit {
268 continue;
269 }
270 let commit = parse_commit(&obj.data)?;
271 let parents = commit.parents;
272
273 if parents.is_empty() {
274 continue;
278 } else if parents.len() == 1 {
279 let pobj = odb.read(&parents[0])?;
280 if pobj.kind != ObjectKind::Commit {
281 continue;
282 }
283 let parent = parse_commit(&pobj.data)?;
284 let entries = diff_trees(odb, Some(&parent.tree), Some(&commit.tree), "")?;
285 for e in entries {
286 if !matches!(
287 e.status,
288 DiffStatus::Added
289 | DiffStatus::Modified
290 | DiffStatus::TypeChanged
291 | DiffStatus::Renamed
292 ) {
293 continue;
294 }
295 let mode = match e.status {
296 DiffStatus::Deleted => continue,
297 _ => &e.new_mode,
298 };
299 if is_gitlink_mode(mode) {
300 return Ok(true);
301 }
302 }
303 } else {
304 let paths =
305 combined_diff_paths_filtered(odb, &commit.tree, &parents, &walk_opts, None)?;
306 for p in paths {
307 if (p.merge_mode & 0o170000) == MODE_GITLINK && !p.merge_oid.is_zero() {
308 return Ok(true);
309 }
310 }
311 }
312 }
313
314 Ok(false)
315}
316
317pub fn submodule_worktree_path(super_repo: &Repository, rel_path: &str) -> PathBuf {
319 super_repo
320 .work_tree
321 .as_ref()
322 .map(|wt| wt.join(rel_path))
323 .unwrap_or_else(|| super_repo.git_dir.join(rel_path))
324}
325
326fn submodule_populated_at(super_repo: &Repository, rel_path: &str) -> bool {
328 let wd = submodule_worktree_path(super_repo, rel_path);
329 wd.join(".git").exists()
330}
331
332pub fn submodule_commits_fully_pushed(
334 super_repo: &Repository,
335 rel_path: &str,
336 oids: &[ObjectId],
337) -> Result<bool> {
338 if oids.is_empty() {
339 return Ok(true);
340 }
341
342 let wd = submodule_worktree_path(super_repo, rel_path);
343 if !wd.join(".git").exists() {
344 return Ok(true);
346 }
347
348 let sub = Repository::discover(Some(&wd))?;
349 let odb = &sub.odb;
350
351 for oid in oids {
352 let obj = match odb.read(oid) {
353 Ok(o) => o,
354 Err(_) => return Ok(false),
355 };
356 match obj.kind {
357 ObjectKind::Commit => {}
358 ObjectKind::Tag => {
359 return Err(crate::error::Error::Message(format!(
360 "submodule entry '{rel_path}' ({}) is a tag, not a commit",
361 oid.to_hex()
362 )));
363 }
364 other => {
365 return Err(crate::error::Error::Message(format!(
366 "submodule entry '{rel_path}' ({}) is a {other:?}, not a commit",
367 oid.to_hex()
368 )));
369 }
370 }
371 }
372
373 let all_refs = refs::list_refs(&sub.git_dir, "refs/")?;
374 let negative: Vec<String> = all_refs.iter().map(|(_, o)| o.to_hex()).collect();
375 let positive: Vec<String> = oids.iter().map(|o| o.to_hex()).collect();
376 let options = RevListOptions::default();
377 let r = rev_list(&sub, &positive, &negative, &options)?;
378 Ok(r.commits.is_empty())
379}
380
381pub fn submodule_needs_push_to_remote(
383 super_repo: &Repository,
384 rel_path: &str,
385 _remote_name: &str,
386 oids: &[ObjectId],
387) -> Result<bool> {
388 if oids.is_empty() {
389 return Ok(false);
390 }
391
392 if !submodule_populated_at(super_repo, rel_path) {
393 return Ok(false);
394 }
395
396 let wd = submodule_worktree_path(super_repo, rel_path);
397 let sub = Repository::discover(Some(&wd))?;
398
399 for oid in oids {
400 let obj = match sub.odb.read(oid) {
401 Ok(o) => o,
402 Err(_) => return Ok(false),
403 };
404 if obj.kind != ObjectKind::Commit {
405 return Ok(false);
406 }
407 }
408
409 let all_remote_tracking = refs::list_refs(&sub.git_dir, "refs/remotes/")?;
412 if !all_remote_tracking.is_empty() {
413 let negative: Vec<String> = all_remote_tracking
414 .iter()
415 .map(|(_, o)| o.to_hex())
416 .collect();
417 let positive: Vec<String> = oids.iter().map(|o| o.to_hex()).collect();
418 let options = RevListOptions::default();
419 let r = rev_list(&sub, &positive, &negative, &options)?;
420 return Ok(!r.commits.is_empty());
421 }
422
423 let cfg = crate::config::ConfigSet::load(Some(&sub.git_dir), true).unwrap_or_default();
426 let mut saw_url = false;
427 for entry in cfg.entries() {
428 let Some(rest) = entry.key.strip_prefix("remote.") else {
429 continue;
430 };
431 let Some((_remote, key)) = rest.split_once('.') else {
432 continue;
433 };
434 if key != "url" {
435 continue;
436 }
437 saw_url = true;
438 let Some(val) = entry.value.as_deref() else {
439 continue;
440 };
441 let Some(remote_git_dir) = resolve_remote_url_to_local_git_dir(val, &wd) else {
442 continue;
443 };
444 if oids_not_on_remote_repo(&sub, oids, &remote_git_dir)? {
445 return Ok(true);
446 }
447 }
448 if !saw_url {
449 return Ok(false);
450 }
451 Ok(false)
452}
453
454pub fn verify_push_gitlinks_are_commits(
459 repo: &Repository,
460 changed: &HashMap<String, Vec<ObjectId>>,
461) -> Result<()> {
462 for (path, oids) in changed {
463 let sub_odb = if submodule_populated_at(repo, path) {
464 let wd = submodule_worktree_path(repo, path);
465 Repository::discover(Some(&wd)).ok().map(|s| s.odb)
466 } else {
467 None
468 };
469
470 for oid in oids {
471 let obj = match repo.odb.read(oid) {
472 Ok(o) => o,
473 Err(crate::error::Error::ObjectNotFound(_)) => {
474 let Some(ref sodb) = sub_odb else {
475 return Err(crate::error::Error::ObjectNotFound(oid.to_hex()));
476 };
477 sodb.read(oid)?
478 }
479 Err(e) => return Err(e),
480 };
481 match obj.kind {
482 ObjectKind::Commit => {}
483 ObjectKind::Tag => {
484 return Err(crate::error::Error::Message(format!(
485 "submodule entry '{path}' ({}) is a tag, not a commit",
486 oid.to_hex()
487 )));
488 }
489 other => {
490 return Err(crate::error::Error::Message(format!(
491 "submodule entry '{path}' ({}) is a {other:?}, not a commit",
492 oid.to_hex()
493 )));
494 }
495 }
496 }
497 }
498 Ok(())
499}
500
501pub fn find_unpushed_submodule_paths(
503 super_repo: &Repository,
504 pushed_commit_tips: &[ObjectId],
505 remote_name: &str,
506 fallback_remote_git_dir: Option<&Path>,
507) -> Result<Vec<String>> {
508 let changed = collect_changed_gitlinks_for_push(
509 super_repo,
510 pushed_commit_tips,
511 remote_name,
512 fallback_remote_git_dir,
513 )?;
514 let mut needs: Vec<String> = Vec::new();
515 for (path, oids) in changed {
516 if submodule_needs_push_to_remote(super_repo, &path, remote_name, &oids)? {
517 needs.push(path);
518 }
519 }
520 needs.sort();
521 needs.dedup();
522 Ok(needs)
523}
524
525pub fn format_unpushed_submodules_error(paths: &[String]) -> String {
527 let mut msg = String::from(
528 "The following submodule paths contain changes that can\n\
529not be found on any remote:\n",
530 );
531 for p in paths {
532 msg.push_str(&format!(" {p}\n"));
533 }
534 msg.push_str(
535 "\nPlease try\n\n\
536\tgit push --recurse-submodules=on-demand\n\n\
537or cd to the path and use\n\n\
538\tgit push\n\n\
539to push them to a remote.\n\n\
540Aborting.",
541 );
542 msg
543}
544
545pub fn head_ref_short_name(git_dir: &Path) -> Result<String> {
547 let head = resolve_head(git_dir)?;
548 Ok(match head {
549 HeadState::Branch { refname, .. } => refname
550 .strip_prefix("refs/heads/")
551 .unwrap_or(&refname)
552 .to_string(),
553 HeadState::Detached { .. } | HeadState::Invalid => "HEAD".to_string(),
554 })
555}
556
557fn refspec_is_pushable_for_validation(spec: &str) -> bool {
558 if spec.starts_with('+') {
559 return refspec_is_pushable_for_validation(&spec[1..]);
560 }
561 if spec == ":" || spec == "+:" {
562 return false;
563 }
564 if spec.contains('*') {
565 return false;
566 }
567 let (src, _) = if let Some(i) = spec.find(':') {
568 (&spec[..i], &spec[i + 1..])
569 } else {
570 (spec, spec)
571 };
572 !src.is_empty()
573}
574
575pub fn validate_submodule_push_refspecs(
577 submodule_git_dir: &Path,
578 superproject_head_branch: &str,
579 refspecs: &[String],
580) -> Result<()> {
581 for spec in refspecs {
582 if !refspec_is_pushable_for_validation(spec) {
583 continue;
584 }
585 let (force, rest) = spec
586 .strip_prefix('+')
587 .map(|s| (true, s))
588 .unwrap_or((false, spec.as_str()));
589 let (src, _) = if let Some(i) = rest.find(':') {
590 (&rest[..i], &rest[i + 1..])
591 } else {
592 (rest, rest)
593 };
594 if src.is_empty() {
595 continue;
596 }
597
598 let sub_head = resolve_head(submodule_git_dir)?;
599 let (detached, head_branch) = match &sub_head {
600 HeadState::Branch { refname, .. } => (
601 false,
602 refname
603 .strip_prefix("refs/heads/")
604 .unwrap_or(refname)
605 .to_string(),
606 ),
607 _ => (true, String::new()),
608 };
609
610 let matches = count_src_refspec_matches(submodule_git_dir, src)?;
611 match matches {
612 1 => {}
613 _ => {
614 if src == "HEAD" && (detached || head_branch == superproject_head_branch) {
615 continue;
619 }
620 return Err(crate::error::Error::Message(format!(
621 "src refspec '{src}' must name a ref"
622 )));
623 }
624 }
625 let _ = force;
626 }
627 Ok(())
628}
629
630fn count_src_refspec_matches(git_dir: &Path, src: &str) -> Result<usize> {
631 if src.starts_with("refs/") {
632 return Ok(usize::from(refs::resolve_ref(git_dir, src).is_ok()));
633 }
634 if src.len() == 40 && src.parse::<ObjectId>().is_ok() {
635 return Ok(1);
636 }
637 let mut n = 0usize;
638 for prefix in ["refs/heads/", "refs/tags/", "refs/remotes/"] {
639 let full = format!("{prefix}{src}");
640 if refs::resolve_ref(git_dir, &full).is_ok() {
641 n += 1;
642 }
643 }
644 Ok(n)
645}