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/// When `refs/remotes/<remote>/` is empty (path remotes, or before any fetch), pass
136/// `fallback_remote_git_dir` with the push destination repository so we exclude its
137/// `refs/heads/*` tips instead (matches Git's reachable set for fresh remotes).
138pub 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
245/// True when walking superproject commits in `(excl..incl]` introduces or changes a submodule
246/// gitlink (matches Git's `submodule_touches_in_range` in `submodule.c`).
247///
248/// Used by `git pull --rebase --recurse-submodules` to reject rebases when local commits already
249/// recorded submodule pointer changes.
250pub 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            // Root commits: Git's `submodule_touches_in_range` / combined-diff skips these (no
275            // parents → combined diff returns early). Do not treat "added submodule at init" as a
276            // local submodule modification for `pull --rebase` (t5572).
277            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
317/// Work tree path for a submodule at `rel_path` in the superproject.
318pub 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
326/// True if `path` under the superproject looks like a checked-out nested repo (has `.git`).
327fn 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
332/// Whether the gitlink OIDs are commits present in the submodule repo and reachable from some ref.
333pub 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        // Without a checkout Git skips the strict check (expert path).
345        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
381/// True if `oids` contains commits not reachable from `refs/remotes/<remote_name>/` in the submodule.
382pub 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    // Match Git's `submodule_needs_pushing`: `rev-list <oids> --not --remotes` uses **all**
410    // submodule remote-tracking refs, not the superproject's remote name (which may be a URL).
411    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    // No remote-tracking refs (e.g. `grit fetch` did not update `refs/remotes/*`): probe each
424    // configured `remote.*.url` that resolves to a local repo, like a one-sided `--remotes`.
425    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
454/// Ensure every gitlink OID in `changed` names a commit object (not a tag/tree/blob).
455///
456/// Tries the superproject ODB first, then the checked-out submodule's ODB (embedded repos keep
457/// submodule objects outside the superproject object store).
458pub 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
501/// Submodule paths that still need to be pushed to `remote_name` (non-empty rev-list against remote-tracking).
502pub 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
525/// Print Git's standard "unpushed submodule" error and return a formatted anyhow-friendly message.
526pub 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
545/// Resolve `HEAD` in `git_dir` to a short branch name when symbolic; `"HEAD"` when detached.
546pub 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
575/// Validate refspecs for nested submodule push (`submodule--helper push-check` subset).
576pub 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                    // Allowed:
616                    // - detached HEAD in the submodule (`HEAD:<dst>` push of current commit)
617                    // - symbolic HEAD on the same branch as the superproject.
618                    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}