Skip to main content

grit_lib/
push_submodules.rs

1//! Submodule recursion for `git push` (`--recurse-submodules`).
2//!
3//! Mirrors the subset of Git's `submodule.c` / `transport.c` logic needed for
4//! `check`, `on-demand`, and `only` modes over local (file) transport.
5
6use 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
55/// True when `rev-list <oids> --not <remote_tip_oids>` is non-empty: some gitlink commit is not on the remote.
56fn 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/// How `git push` should recurse into submodules.
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub enum PushRecurseSubmodules {
77    /// No submodule handling (default when unset).
78    Off,
79    /// Verify gitlink targets exist on a submodule remote (`check`).
80    Check,
81    /// Push submodule repos as needed (`on-demand`).
82    OnDemand,
83    /// Push only submodules, not the superproject (`only`).
84    Only,
85}
86
87/// Parse `--recurse-submodules=<value>` or `push.recurseSubmodules` / `submodule.recurse`.
88///
89/// Returns an error for invalid values (`yes`, unknown strings, etc.).
90pub 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    // Internal sentinel used when Git recurses from `only` into a child push.
100    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
131/// Collect submodule paths and the gitlink OIDs introduced along the walk
132/// `git log <tips> --not --remotes=<remote>`, using merge-aware diffs like Git's
133/// `collect_changed_submodules`.
134///
135/// The negative side is the superproject's `refs/remotes/<remote>/*` tracking refs, exactly like
136/// Git's `find_unpushed_submodules` (`--not --remotes=<name>`). When pushing by URL (or before any
137/// fetch) there are no such tracking refs, so the walk covers the full reachable history and the
138/// per-submodule check ([`submodule_needs_push_to_remote`]) is responsible for excluding gitlink
139/// commits that already exist on the submodule's own remote. We deliberately do **not** prune the
140/// superproject walk by the destination repository's tips: doing so would skip submodule pushes
141/// when the superproject ref is already up to date on the remote but the submodule commit is not
142/// (e.g. `git push --recurse-submodules=on-demand` after a prior `--no-recurse-submodules` push).
143pub 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
243/// True when walking superproject commits in `(excl..incl]` introduces or changes a submodule
244/// gitlink (matches Git's `submodule_touches_in_range` in `submodule.c`).
245///
246/// Used by `git pull --rebase --recurse-submodules` to reject rebases when local commits already
247/// recorded submodule pointer changes.
248pub 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            // Root commits: Git's `submodule_touches_in_range` / combined-diff skips these (no
273            // parents → combined diff returns early). Do not treat "added submodule at init" as a
274            // local submodule modification for `pull --rebase` (t5572).
275            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
315/// Work tree path for a submodule at `rel_path` in the superproject.
316pub 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
324/// True if `path` under the superproject looks like a checked-out nested repo (has `.git`).
325fn 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
330/// Whether the gitlink OIDs are commits present in the submodule repo and reachable from some ref.
331pub 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        // Without a checkout Git skips the strict check (expert path).
343        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
379/// True if `oids` contains commits not reachable from `refs/remotes/<remote_name>/` in the submodule.
380pub 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    // Match Git's `submodule_needs_pushing`: `rev-list <oids> --not --remotes` uses **all**
408    // submodule remote-tracking refs, not the superproject's remote name (which may be a URL).
409    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    // No remote-tracking refs (e.g. `grit fetch` did not update `refs/remotes/*`): probe each
422    // configured `remote.*.url` that resolves to a local repo, like a one-sided `--remotes`.
423    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
452/// Ensure every gitlink OID in `changed` names a commit object (not a tag/tree/blob).
453///
454/// Tries the superproject ODB first, then the checked-out submodule's ODB (embedded repos keep
455/// submodule objects outside the superproject object store).
456pub 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
499/// Submodule paths that still need to be pushed to `remote_name` (non-empty rev-list against remote-tracking).
500pub 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
523/// Print Git's standard "unpushed submodule" error and return a formatted anyhow-friendly message.
524pub 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
543/// Resolve `HEAD` in `git_dir` to a short branch name when symbolic; `"HEAD"` when detached.
544pub 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
573/// Validate refspecs for nested submodule push (`submodule--helper push-check` subset).
574pub 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                    // Allowed:
614                    // - detached HEAD in the submodule (`HEAD:<dst>` push of current commit)
615                    // - symbolic HEAD on the same branch as the superproject.
616                    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}