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(
144 repo: &Repository,
145 commit_tips: &[ObjectId],
146 exclude_remote_name: &str,
147 _fallback_remote_git_dir: Option<&Path>,
148) -> Result<HashMap<String, Vec<ObjectId>>> {
149 if commit_tips.is_empty() {
150 return Ok(HashMap::new());
151 }
152
153 let prefix = format!("refs/remotes/{exclude_remote_name}/");
154 let remote_refs = refs::list_refs(&repo.git_dir, &prefix)?;
155 let negative_hex: Vec<String> = remote_refs.iter().map(|(_, oid)| oid.to_hex()).collect();
156
157 let positive_hex: Vec<String> = commit_tips.iter().map(|o| o.to_hex()).collect();
158 let options = RevListOptions::default();
159 let walked = rev_list(repo, &positive_hex, &negative_hex, &options)?;
160
161 let odb = &repo.odb;
162 let walk_opts = CombinedTreeDiffOptions {
163 recursive: true,
164 tree_in_recursive: false,
165 };
166
167 let mut by_path: HashMap<String, Vec<ObjectId>> = HashMap::new();
168
169 for commit_oid in walked.commits {
170 let obj = odb.read(&commit_oid)?;
171 if obj.kind != ObjectKind::Commit {
172 continue;
173 }
174 let commit = parse_commit(&obj.data)?;
175 let parents = commit.parents;
176
177 if parents.is_empty() {
178 let entries = diff_trees(odb, None, Some(&commit.tree), "")?;
179 for e in entries {
180 if !is_gitlink_mode(&e.new_mode) {
181 continue;
182 }
183 let path = e.path().to_string();
184 by_path.entry(path).or_default().push(e.new_oid);
185 }
186 } else if parents.len() == 1 {
187 let pobj = odb.read(&parents[0])?;
188 if pobj.kind != ObjectKind::Commit {
189 continue;
190 }
191 let parent = parse_commit(&pobj.data)?;
192 let entries = diff_trees(odb, Some(&parent.tree), Some(&commit.tree), "")?;
193 for e in entries {
194 if !matches!(
195 e.status,
196 DiffStatus::Added
197 | DiffStatus::Modified
198 | DiffStatus::TypeChanged
199 | DiffStatus::Renamed
200 ) {
201 continue;
202 }
203 let (mode, oid) = match e.status {
204 DiffStatus::Deleted => continue,
205 _ => (&e.new_mode, e.new_oid),
206 };
207 if !is_gitlink_mode(mode) {
208 continue;
209 }
210 let path = e
211 .new_path
212 .as_deref()
213 .or(e.old_path.as_deref())
214 .unwrap_or("");
215 if path.is_empty() {
216 continue;
217 }
218 by_path.entry(path.to_string()).or_default().push(oid);
219 }
220 } else {
221 let paths =
222 combined_diff_paths_filtered(odb, &commit.tree, &parents, &walk_opts, None)?;
223 for p in paths {
224 if (p.merge_mode & 0o170000) != MODE_GITLINK {
225 continue;
226 }
227 if p.merge_oid.is_zero() {
228 continue;
229 }
230 by_path.entry(p.path).or_default().push(p.merge_oid);
231 }
232 }
233 }
234
235 for v in by_path.values_mut() {
236 v.sort();
237 v.dedup();
238 }
239
240 Ok(by_path)
241}
242
243pub fn submodule_gitlinks_touched_in_range(
249 repo: &Repository,
250 excl: Option<ObjectId>,
251 incl: ObjectId,
252) -> Result<bool> {
253 let positive = vec![incl.to_hex()];
254 let negative = excl.map(|e| vec![e.to_hex()]).unwrap_or_default();
255 let options = RevListOptions::default();
256 let walked = rev_list(repo, &positive, &negative, &options)?;
257 let odb = &repo.odb;
258 let walk_opts = CombinedTreeDiffOptions {
259 recursive: true,
260 tree_in_recursive: false,
261 };
262
263 for commit_oid in walked.commits {
264 let obj = odb.read(&commit_oid)?;
265 if obj.kind != ObjectKind::Commit {
266 continue;
267 }
268 let commit = parse_commit(&obj.data)?;
269 let parents = commit.parents;
270
271 if parents.is_empty() {
272 continue;
276 } else if parents.len() == 1 {
277 let pobj = odb.read(&parents[0])?;
278 if pobj.kind != ObjectKind::Commit {
279 continue;
280 }
281 let parent = parse_commit(&pobj.data)?;
282 let entries = diff_trees(odb, Some(&parent.tree), Some(&commit.tree), "")?;
283 for e in entries {
284 if !matches!(
285 e.status,
286 DiffStatus::Added
287 | DiffStatus::Modified
288 | DiffStatus::TypeChanged
289 | DiffStatus::Renamed
290 ) {
291 continue;
292 }
293 let mode = match e.status {
294 DiffStatus::Deleted => continue,
295 _ => &e.new_mode,
296 };
297 if is_gitlink_mode(mode) {
298 return Ok(true);
299 }
300 }
301 } else {
302 let paths =
303 combined_diff_paths_filtered(odb, &commit.tree, &parents, &walk_opts, None)?;
304 for p in paths {
305 if (p.merge_mode & 0o170000) == MODE_GITLINK && !p.merge_oid.is_zero() {
306 return Ok(true);
307 }
308 }
309 }
310 }
311
312 Ok(false)
313}
314
315pub fn submodule_worktree_path(super_repo: &Repository, rel_path: &str) -> PathBuf {
317 super_repo
318 .work_tree
319 .as_ref()
320 .map(|wt| wt.join(rel_path))
321 .unwrap_or_else(|| super_repo.git_dir.join(rel_path))
322}
323
324fn submodule_populated_at(super_repo: &Repository, rel_path: &str) -> bool {
326 let wd = submodule_worktree_path(super_repo, rel_path);
327 wd.join(".git").exists()
328}
329
330pub fn submodule_commits_fully_pushed(
332 super_repo: &Repository,
333 rel_path: &str,
334 oids: &[ObjectId],
335) -> Result<bool> {
336 if oids.is_empty() {
337 return Ok(true);
338 }
339
340 let wd = submodule_worktree_path(super_repo, rel_path);
341 if !wd.join(".git").exists() {
342 return Ok(true);
344 }
345
346 let sub = Repository::discover(Some(&wd))?;
347 let odb = &sub.odb;
348
349 for oid in oids {
350 let obj = match odb.read(oid) {
351 Ok(o) => o,
352 Err(_) => return Ok(false),
353 };
354 match obj.kind {
355 ObjectKind::Commit => {}
356 ObjectKind::Tag => {
357 return Err(crate::error::Error::Message(format!(
358 "submodule entry '{rel_path}' ({}) is a tag, not a commit",
359 oid.to_hex()
360 )));
361 }
362 other => {
363 return Err(crate::error::Error::Message(format!(
364 "submodule entry '{rel_path}' ({}) is a {other:?}, not a commit",
365 oid.to_hex()
366 )));
367 }
368 }
369 }
370
371 let all_refs = refs::list_refs(&sub.git_dir, "refs/")?;
372 let negative: Vec<String> = all_refs.iter().map(|(_, o)| o.to_hex()).collect();
373 let positive: Vec<String> = oids.iter().map(|o| o.to_hex()).collect();
374 let options = RevListOptions::default();
375 let r = rev_list(&sub, &positive, &negative, &options)?;
376 Ok(r.commits.is_empty())
377}
378
379pub fn submodule_needs_push_to_remote(
381 super_repo: &Repository,
382 rel_path: &str,
383 _remote_name: &str,
384 oids: &[ObjectId],
385) -> Result<bool> {
386 if oids.is_empty() {
387 return Ok(false);
388 }
389
390 if !submodule_populated_at(super_repo, rel_path) {
391 return Ok(false);
392 }
393
394 let wd = submodule_worktree_path(super_repo, rel_path);
395 let sub = Repository::discover(Some(&wd))?;
396
397 for oid in oids {
398 let obj = match sub.odb.read(oid) {
399 Ok(o) => o,
400 Err(_) => return Ok(false),
401 };
402 if obj.kind != ObjectKind::Commit {
403 return Ok(false);
404 }
405 }
406
407 let all_remote_tracking = refs::list_refs(&sub.git_dir, "refs/remotes/")?;
410 if !all_remote_tracking.is_empty() {
411 let negative: Vec<String> = all_remote_tracking
412 .iter()
413 .map(|(_, o)| o.to_hex())
414 .collect();
415 let positive: Vec<String> = oids.iter().map(|o| o.to_hex()).collect();
416 let options = RevListOptions::default();
417 let r = rev_list(&sub, &positive, &negative, &options)?;
418 return Ok(!r.commits.is_empty());
419 }
420
421 let cfg = crate::config::ConfigSet::load(Some(&sub.git_dir), true).unwrap_or_default();
424 let mut saw_url = false;
425 for entry in cfg.entries() {
426 let Some(rest) = entry.key.strip_prefix("remote.") else {
427 continue;
428 };
429 let Some((_remote, key)) = rest.split_once('.') else {
430 continue;
431 };
432 if key != "url" {
433 continue;
434 }
435 saw_url = true;
436 let Some(val) = entry.value.as_deref() else {
437 continue;
438 };
439 let Some(remote_git_dir) = resolve_remote_url_to_local_git_dir(val, &wd) else {
440 continue;
441 };
442 if oids_not_on_remote_repo(&sub, oids, &remote_git_dir)? {
443 return Ok(true);
444 }
445 }
446 if !saw_url {
447 return Ok(false);
448 }
449 Ok(false)
450}
451
452pub fn verify_push_gitlinks_are_commits(
457 repo: &Repository,
458 changed: &HashMap<String, Vec<ObjectId>>,
459) -> Result<()> {
460 for (path, oids) in changed {
461 let sub_odb = if submodule_populated_at(repo, path) {
462 let wd = submodule_worktree_path(repo, path);
463 Repository::discover(Some(&wd)).ok().map(|s| s.odb)
464 } else {
465 None
466 };
467
468 for oid in oids {
469 let obj = match repo.odb.read(oid) {
470 Ok(o) => o,
471 Err(crate::error::Error::ObjectNotFound(_)) => {
472 let Some(ref sodb) = sub_odb else {
473 return Err(crate::error::Error::ObjectNotFound(oid.to_hex()));
474 };
475 sodb.read(oid)?
476 }
477 Err(e) => return Err(e),
478 };
479 match obj.kind {
480 ObjectKind::Commit => {}
481 ObjectKind::Tag => {
482 return Err(crate::error::Error::Message(format!(
483 "submodule entry '{path}' ({}) is a tag, not a commit",
484 oid.to_hex()
485 )));
486 }
487 other => {
488 return Err(crate::error::Error::Message(format!(
489 "submodule entry '{path}' ({}) is a {other:?}, not a commit",
490 oid.to_hex()
491 )));
492 }
493 }
494 }
495 }
496 Ok(())
497}
498
499pub fn find_unpushed_submodule_paths(
501 super_repo: &Repository,
502 pushed_commit_tips: &[ObjectId],
503 remote_name: &str,
504 fallback_remote_git_dir: Option<&Path>,
505) -> Result<Vec<String>> {
506 let changed = collect_changed_gitlinks_for_push(
507 super_repo,
508 pushed_commit_tips,
509 remote_name,
510 fallback_remote_git_dir,
511 )?;
512 let mut needs: Vec<String> = Vec::new();
513 for (path, oids) in changed {
514 if submodule_needs_push_to_remote(super_repo, &path, remote_name, &oids)? {
515 needs.push(path);
516 }
517 }
518 needs.sort();
519 needs.dedup();
520 Ok(needs)
521}
522
523pub fn format_unpushed_submodules_error(paths: &[String]) -> String {
525 let mut msg = String::from(
526 "The following submodule paths contain changes that can\n\
527not be found on any remote:\n",
528 );
529 for p in paths {
530 msg.push_str(&format!(" {p}\n"));
531 }
532 msg.push_str(
533 "\nPlease try\n\n\
534\tgit push --recurse-submodules=on-demand\n\n\
535or cd to the path and use\n\n\
536\tgit push\n\n\
537to push them to a remote.\n\n\
538Aborting.",
539 );
540 msg
541}
542
543pub fn head_ref_short_name(git_dir: &Path) -> Result<String> {
545 let head = resolve_head(git_dir)?;
546 Ok(match head {
547 HeadState::Branch { refname, .. } => refname
548 .strip_prefix("refs/heads/")
549 .unwrap_or(&refname)
550 .to_string(),
551 HeadState::Detached { .. } | HeadState::Invalid => "HEAD".to_string(),
552 })
553}
554
555fn refspec_is_pushable_for_validation(spec: &str) -> bool {
556 if spec.starts_with('+') {
557 return refspec_is_pushable_for_validation(&spec[1..]);
558 }
559 if spec == ":" || spec == "+:" {
560 return false;
561 }
562 if spec.contains('*') {
563 return false;
564 }
565 let (src, _) = if let Some(i) = spec.find(':') {
566 (&spec[..i], &spec[i + 1..])
567 } else {
568 (spec, spec)
569 };
570 !src.is_empty()
571}
572
573pub fn validate_submodule_push_refspecs(
575 submodule_git_dir: &Path,
576 superproject_head_branch: &str,
577 refspecs: &[String],
578) -> Result<()> {
579 for spec in refspecs {
580 if !refspec_is_pushable_for_validation(spec) {
581 continue;
582 }
583 let (force, rest) = spec
584 .strip_prefix('+')
585 .map(|s| (true, s))
586 .unwrap_or((false, spec.as_str()));
587 let (src, _) = if let Some(i) = rest.find(':') {
588 (&rest[..i], &rest[i + 1..])
589 } else {
590 (rest, rest)
591 };
592 if src.is_empty() {
593 continue;
594 }
595
596 let sub_head = resolve_head(submodule_git_dir)?;
597 let (detached, head_branch) = match &sub_head {
598 HeadState::Branch { refname, .. } => (
599 false,
600 refname
601 .strip_prefix("refs/heads/")
602 .unwrap_or(refname)
603 .to_string(),
604 ),
605 _ => (true, String::new()),
606 };
607
608 let matches = count_src_refspec_matches(submodule_git_dir, src)?;
609 match matches {
610 1 => {}
611 _ => {
612 if src == "HEAD" && (detached || head_branch == superproject_head_branch) {
613 continue;
617 }
618 return Err(crate::error::Error::Message(format!(
619 "src refspec '{src}' must name a ref"
620 )));
621 }
622 }
623 let _ = force;
624 }
625 Ok(())
626}
627
628fn count_src_refspec_matches(git_dir: &Path, src: &str) -> Result<usize> {
629 if src.starts_with("refs/") {
630 return Ok(usize::from(refs::resolve_ref(git_dir, src).is_ok()));
631 }
632 if src.len() == 40 && src.parse::<ObjectId>().is_ok() {
633 return Ok(1);
634 }
635 let mut n = 0usize;
636 for prefix in ["refs/heads/", "refs/tags/", "refs/remotes/"] {
637 let full = format!("{prefix}{src}");
638 if refs::resolve_ref(git_dir, &full).is_ok() {
639 n += 1;
640 }
641 }
642 Ok(n)
643}