Skip to main content

grit_lib/
bundle_remote.rs

1//! Fetch and remote helpers for on-disk bundle files (clone / `grit fetch` from a `.bundle`).
2
3use std::collections::HashSet;
4use std::fs::File;
5use std::io::{BufWriter, Write};
6use std::path::Path;
7
8use crate::bundle::{plan_bundle_create, Bundle, BundleCreatePlan, BundleError, BundleSpec};
9use crate::error::{Error, Result};
10use crate::fetch::{NoProgress, Progress};
11use crate::objects::ObjectId;
12use crate::repo::Repository;
13use crate::transfer::{
14    apply_tag_mode, classify_update, prune_tracking_refs, FetchOptions, FetchOutcome, RefUpdate,
15    TagMode, TransferStats, UpdateMode,
16};
17
18const V2_SIGNATURE: &[u8] = b"# v2 git bundle\n";
19const V3_SIGNATURE: &[u8] = b"# v3 git bundle\n";
20
21/// Whether `path` begins with a v2 or v3 git bundle signature.
22#[must_use]
23pub fn is_bundle_path(path: &Path) -> bool {
24    let Ok(bytes) = std::fs::read(path) else {
25        return false;
26    };
27    bytes.starts_with(V2_SIGNATURE) || bytes.starts_with(V3_SIGNATURE)
28}
29
30/// Ref tips and revision tokens for [`write_bundle_with_options`].
31#[derive(Debug, Clone, Default, PartialEq, Eq)]
32pub struct BundleCreateOptions {
33    /// When true, list `HEAD` and every ref under `refs/` in the bundle header (like `git bundle create --all`).
34    pub all_refs: bool,
35    /// Revision arguments (tips, ranges, exclusions). Ignored when `all_refs` is true.
36    pub rev_tokens: Vec<String>,
37}
38
39impl BundleCreateOptions {
40    /// Build options from positional revision tokens only.
41    #[must_use]
42    pub fn from_rev_tokens(rev_tokens: Vec<String>) -> Self {
43        Self {
44            all_refs: false,
45            rev_tokens,
46        }
47    }
48}
49
50/// Write a bundle file from revision arguments (supports `A..B` ranges like `git bundle create`).
51///
52/// # Errors
53///
54/// Propagates rev-list, ref resolution, and bundle write failures.
55pub fn write_bundle_from_rev_specs(
56    repo: &Repository,
57    path: &Path,
58    rev_tokens: &[String],
59) -> Result<usize> {
60    write_bundle_with_options(
61        repo,
62        path,
63        &BundleCreateOptions::from_rev_tokens(rev_tokens.to_vec()),
64    )
65}
66
67/// Resolve [`BundleSpec`] from create options without writing a file.
68///
69/// When [`BundleCreateOptions::all_refs`] is true (or `--all` appears among `rev_tokens`), the bundle
70/// header lists `HEAD` and every ref under `refs/`, matching `git bundle create --all`.
71/// Only commits are walked here; objects are enumerated once, by the pack writer.
72///
73/// # Errors
74///
75/// Propagates rev-list, ref resolution, and empty-ref failures.
76pub fn resolve_bundle_spec(repo: &Repository, options: &BundleCreateOptions) -> Result<BundleSpec> {
77    let (rev_tokens, all_from_token) = strip_all_rev_flag(&options.rev_tokens);
78    let all_refs = options.all_refs || all_from_token;
79
80    let (positive_specs, negative_specs) = split_revision_specs_for_bundle(&rev_tokens);
81
82    if positive_specs.is_empty() && !all_refs {
83        return Err(Error::Message("at least one revision is required".into()));
84    }
85
86    let rev_opts = crate::rev_list::RevListOptions {
87        boundary: true,
88        all_refs,
89        ..Default::default()
90    };
91    let revs = crate::rev_list::rev_list(repo, &positive_specs, &negative_specs, &rev_opts)?;
92
93    let include = if all_refs {
94        refs_for_bundle_header(repo)?
95    } else {
96        include_refs_from_rev_specs(repo, &positive_specs, &negative_specs, &revs)?
97    };
98
99    if include.is_empty() {
100        return Err(map_bundle_err(BundleError::EmptyRefs));
101    }
102
103    Ok(BundleSpec {
104        include,
105        exclude: revs.boundary_commits.clone(),
106        filter: None,
107    })
108}
109
110/// Plan bundle creation from revision options without writing a file.
111///
112/// # Errors
113///
114/// Same as [`resolve_bundle_spec`] and [`plan_bundle_create`].
115pub fn plan_create_with_options(
116    repo: &Repository,
117    options: &BundleCreateOptions,
118) -> Result<BundleCreatePlan> {
119    let spec = resolve_bundle_spec(repo, options)?;
120    plan_bundle_create(repo, &spec).map_err(map_bundle_err)
121}
122
123/// Write a bundle file using ref-set flags and revision tokens.
124///
125/// # Errors
126///
127/// Propagates rev-list, ref resolution, and bundle write failures.
128pub fn write_bundle_with_options(
129    repo: &Repository,
130    path: &Path,
131    options: &BundleCreateOptions,
132) -> Result<usize> {
133    let spec = resolve_bundle_spec(repo, options)?;
134    let file = File::create(path).map_err(Error::Io)?;
135    let mut out = BufWriter::new(file);
136    crate::bundle::write_bundle(repo, &spec, &mut out).map_err(map_bundle_err)?;
137    out.flush().map_err(Error::Io)?;
138    Ok(spec.include.len())
139}
140
141fn strip_all_rev_flag(tokens: &[String]) -> (Vec<String>, bool) {
142    let mut rev_tokens = Vec::new();
143    let mut all_refs = false;
144    for token in tokens {
145        if token == "--all" {
146            all_refs = true;
147        } else {
148            rev_tokens.push(token.clone());
149        }
150    }
151    (rev_tokens, all_refs)
152}
153
154fn refs_for_bundle_header(repo: &Repository) -> Result<Vec<(ObjectId, String)>> {
155    let mut include = Vec::new();
156    if let Ok(head) = crate::refs::resolve_ref(repo.git_dir(), "HEAD") {
157        include.push((head, "HEAD".to_owned()));
158    }
159    for (name, oid) in crate::refs::list_refs(repo.git_dir(), "refs/")? {
160        include.push((oid, name));
161    }
162    Ok(include)
163}
164
165fn include_refs_from_rev_specs(
166    repo: &Repository,
167    positive_specs: &[String],
168    negative_specs: &[String],
169    revs: &crate::rev_list::RevListResult,
170) -> Result<Vec<(ObjectId, String)>> {
171    let commits: HashSet<ObjectId> = revs.commits.iter().copied().collect();
172    let excluded: HashSet<ObjectId> = negative_specs
173        .iter()
174        .filter_map(|spec| crate::rev_parse::resolve_revision(repo, spec).ok())
175        .collect();
176    // A commit tip is bundled when that commit is sent. An annotated tag (or a tree or
177    // blob) is bundled unless it is excluded itself, even when its commit is a
178    // prerequisite: the recipient still needs the tag object and ref.
179    let tip_is_sent = |oid: ObjectId| match repo.odb().peel_tags(oid) {
180        Ok((commit, crate::objects::ObjectKind::Commit)) if commit == oid => {
181            commits.contains(&commit)
182        }
183        Ok(_) => !excluded.contains(&oid),
184        Err(_) => false,
185    };
186    let mut include: Vec<(ObjectId, String)> = Vec::new();
187    for spec in positive_specs {
188        if let Ok(oid) = crate::rev_parse::resolve_revision(repo, spec) {
189            if tip_is_sent(oid) {
190                let name = display_ref_for_spec(repo, spec, oid)?;
191                if !include.iter().any(|(_, n)| n == &name) {
192                    include.push((oid, name));
193                }
194            }
195        }
196    }
197    if include.is_empty() {
198        for oid in &revs.commits {
199            include.push((*oid, format!("refs/heads/bundle-tip/{}", oid.to_hex())));
200        }
201    }
202    Ok(include)
203}
204
205fn display_ref_for_spec(repo: &Repository, spec: &str, _oid: ObjectId) -> Result<String> {
206    if spec.starts_with("refs/") {
207        return Ok(spec.to_owned());
208    }
209    let exists = |name: &str| crate::refs::resolve_ref(repo.git_dir(), name).is_ok();
210    if let Some(name) = crate::worktree_ref::sole_dwim_ref_name(exists, spec) {
211        return Ok(name);
212    }
213    Ok(format!("refs/heads/{}", spec.trim_start_matches("heads/")))
214}
215
216/// Split CLI revision tokens into positive and negative specs for bundle creation.
217#[must_use]
218pub fn split_revision_specs_for_bundle(tokens: &[String]) -> (Vec<String>, Vec<String>) {
219    let mut positive = Vec::new();
220    let mut negative = Vec::new();
221    for token in tokens {
222        let (mut pos, mut neg) = crate::rev_list::split_revision_token(token);
223        positive.append(&mut pos);
224        negative.append(&mut neg);
225    }
226    (positive, negative)
227}
228
229/// Fetch from a bundle file into an existing repository (like `git fetch` from a bundle).
230///
231/// # Errors
232///
233/// Propagates bundle verification, pack ingest, refspec, and ref update failures.
234pub fn fetch_from_bundle(
235    local_git_dir: &Path,
236    bundle_path: &Path,
237    opts: &FetchOptions,
238) -> Result<FetchOutcome> {
239    fetch_from_bundle_with_progress(
240        local_git_dir,
241        bundle_path,
242        opts,
243        &mut NoProgress,
244        &crate::progress::NeverCancel,
245    )
246}
247
248/// Like [`fetch_from_bundle`], reporting bundle receive and index-pack progress.
249///
250/// The local repository is opened with [`FetchOptions::environment`], so objects land in its
251/// `GIT_OBJECT_DIRECTORY` when that is set.
252pub fn fetch_from_bundle_with_progress(
253    local_git_dir: &Path,
254    bundle_path: &Path,
255    opts: &FetchOptions,
256    progress: &mut dyn Progress,
257    cancel: &dyn crate::progress::Cancel,
258) -> Result<FetchOutcome> {
259    let repo = crate::fetch::open_fetch_target(local_git_dir, opts)?;
260    fetch_from_bundle_into(&repo, bundle_path, opts, progress, cancel)
261}
262
263/// Like [`fetch_from_bundle_with_progress`], unbundling into `repo`'s object store.
264pub(crate) fn fetch_from_bundle_into(
265    repo: &Repository,
266    bundle_path: &Path,
267    opts: &FetchOptions,
268    progress: &mut dyn Progress,
269    cancel: &dyn crate::progress::Cancel,
270) -> Result<FetchOutcome> {
271    crate::progress::poll_cancel(progress, cancel)?;
272    let bundle = Bundle::open(bundle_path).map_err(map_bundle_err)?;
273    bundle
274        .verify(repo)
275        .map_err(map_bundle_err)?
276        .into_result()
277        .map_err(map_bundle_err)?;
278
279    let (transfer, pack_oids) = if opts.dry_run {
280        (TransferStats::default(), HashSet::new())
281    } else {
282        crate::progress::poll_cancel(progress, cancel)?;
283        let unbundled = bundle
284            .unbundle_with_progress(repo, progress, cancel)
285            .map_err(map_bundle_err)?;
286        crate::progress::poll_cancel(progress, cancel)?;
287        (unbundled.transfer, unbundled.pack_object_ids)
288    };
289
290    let header = bundle.header();
291    let remote_refs = header.refs.clone();
292    let init_default_branch = repo.config().ok().and_then(|cfg| {
293        cfg.get_last_entry("init.defaultBranch")
294            .and_then(|e| e.value.clone())
295            .filter(|s| !s.is_empty())
296    });
297    let default_branch =
298        default_branch_from_bundle_head(&header.refs, init_default_branch.as_deref());
299
300    let mut effective = opts.clone();
301    if effective.remote_name.is_none() && effective.fetch_source.is_none() {
302        effective.fetch_source = Some(bundle_path.to_string_lossy().into_owned());
303    }
304
305    crate::progress::poll_cancel(progress, cancel)?;
306    complete_bundle_fetch(
307        repo,
308        &remote_refs,
309        default_branch,
310        &effective,
311        &HashSet::new(),
312        pack_oids,
313        transfer,
314    )
315}
316
317/// Branch short name to check out after cloning from a bundle, from the bundle `HEAD` OID.
318///
319/// Matches Git's `guess_remote_head` when `HEAD` is not a symref.
320fn default_branch_from_bundle_head(
321    refs: &[(String, ObjectId)],
322    init_default_branch: Option<&str>,
323) -> Option<String> {
324    let head_oid = refs
325        .iter()
326        .find(|(name, _)| name == "HEAD")
327        .map(|(_, oid)| *oid)?;
328
329    if let Some(name) = init_default_branch {
330        if bundle_branch_tip_matches(refs, name, head_oid) {
331            return Some(name.to_owned());
332        }
333    }
334
335    if init_default_branch != Some("master") && bundle_branch_tip_matches(refs, "master", head_oid)
336    {
337        return Some("master".to_owned());
338    }
339
340    for (name, oid) in refs.iter().rev() {
341        if name == "HEAD" {
342            continue;
343        }
344        if let Some(short) = name.strip_prefix("refs/heads/") {
345            if *oid == head_oid {
346                return Some(short.to_owned());
347            }
348        }
349    }
350    None
351}
352
353fn bundle_branch_tip_matches(
354    refs: &[(String, ObjectId)],
355    branch: &str,
356    head_oid: ObjectId,
357) -> bool {
358    let full = format!("refs/heads/{branch}");
359    refs.iter()
360        .any(|(name, oid)| name == &full && *oid == head_oid)
361}
362
363#[allow(clippy::too_many_arguments)]
364fn complete_bundle_fetch(
365    local: &Repository,
366    remote_refs: &[(String, ObjectId)],
367    default_branch: Option<String>,
368    opts: &FetchOptions,
369    remote_shallow: &HashSet<ObjectId>,
370    pack_oids: HashSet<ObjectId>,
371    transfer: TransferStats,
372) -> Result<FetchOutcome> {
373    let local_git_dir = local.git_dir();
374    let local_odb = local.odb();
375    let remote_head_oid = crate::transfer::remote_head_oid_from_advertised(remote_refs);
376    let filtered_remote_refs: Vec<(String, ObjectId)> = remote_refs
377        .iter()
378        .filter(|(name, _)| {
379            name != "HEAD"
380                && !name.ends_with("^{}")
381                && crate::refs::is_valid_fetch_advertised_ref(name)
382        })
383        .cloned()
384        .collect();
385
386    let crate::transfer::FetchMatchSet {
387        mut matched,
388        positive,
389        negatives,
390        explicit_refspec_fetch,
391    } = crate::transfer::build_fetch_matched_set(opts, &filtered_remote_refs, remote_head_oid)?;
392    let mut matched_oids: HashSet<ObjectId> = matched.iter().map(|m| m.oid).collect();
393    let mut seen_remote_ref: HashSet<String> =
394        matched.iter().map(|m| m.remote_ref.clone()).collect();
395
396    let local_shallow = crate::shallow::load_shallow_boundaries(local_git_dir);
397    let mut tag_shallow = local_shallow.clone();
398    tag_shallow.extend(remote_shallow.iter().copied());
399
400    let tags_present = if opts.tags == TagMode::Following {
401        crate::fetch::tags_already_local(local.refs(), remote_refs)
402    } else {
403        HashSet::new()
404    };
405    let _following_only = apply_tag_mode(
406        opts.tags,
407        remote_refs,
408        local_odb,
409        &tag_shallow,
410        &negatives,
411        &tags_present,
412        opts.force,
413        &mut matched,
414        &mut matched_oids,
415        &mut seen_remote_ref,
416    )?;
417
418    if opts.tags == TagMode::Following {
419        crate::fetch::retain_following_tags(local_odb, &mut matched, &pack_oids, &local_shallow)?;
420    }
421
422    let local_repo = (!opts.dry_run).then_some(local);
423
424    let mut updates: Vec<RefUpdate> = Vec::new();
425    if opts.prune {
426        prune_tracking_refs(local, &positive, remote_refs, opts.dry_run, &mut updates)?;
427    }
428
429    let mut store_batch: Vec<(String, crate::refs::store::RawRef)> =
430        Vec::with_capacity(matched.len());
431    for m in &matched {
432        let Some(local_ref) = &m.local_ref else {
433            updates.push(RefUpdate {
434                remote_ref: m.remote_ref.clone(),
435                local_ref: None,
436                old_oid: None,
437                new_oid: Some(m.oid),
438                mode: UpdateMode::NoChangeNeeded,
439                note: Some("not stored (empty destination)".to_owned()),
440            });
441            continue;
442        };
443
444        let old = local.resolve_ref_oid(local_ref).ok();
445        let mode = classify_update(
446            old.as_ref(),
447            &m.oid,
448            m.force,
449            m.dest_is_tag,
450            local_repo,
451            None,
452        );
453
454        let write = matches!(
455            mode,
456            UpdateMode::New | UpdateMode::FastForward | UpdateMode::Forced
457        );
458        crate::transfer::enforce_fetch_ref_update(
459            local_ref,
460            old,
461            m.oid,
462            mode,
463            opts,
464            explicit_refspec_fetch,
465            local_repo,
466        )?;
467        if write && !opts.dry_run {
468            if !crate::refs::is_valid_storable_ref_name(local_ref) {
469                updates.push(RefUpdate {
470                    remote_ref: m.remote_ref.clone(),
471                    local_ref: Some(local_ref.clone()),
472                    old_oid: old,
473                    new_oid: Some(m.oid),
474                    mode,
475                    note: Some("skipped (invalid ref name)".to_owned()),
476                });
477                continue;
478            }
479            store_batch.push((local_ref.clone(), crate::refs::store::RawRef::Direct(m.oid)));
480        }
481
482        updates.push(RefUpdate {
483            remote_ref: m.remote_ref.clone(),
484            local_ref: Some(local_ref.clone()),
485            old_oid: old,
486            new_oid: Some(m.oid),
487            mode,
488            note: None,
489        });
490    }
491
492    if !opts.dry_run && !store_batch.is_empty() {
493        let fetch_reflog = crate::fetch::prepare_fetch_apply_reflog(local, opts)?;
494        let fetch_reflog_ctx = fetch_reflog
495            .as_ref()
496            .map(crate::fetch::PreparedFetchReflog::context);
497        crate::transfer::apply_fetch_ref_updates(
498            local,
499            store_batch,
500            &mut updates,
501            crate::transfer::FetchRefRejectHint {
502                prune: opts.prune,
503                positive: &positive,
504                remote_refs,
505                remote_name: opts.remote_name.as_deref(),
506            },
507            opts.initial_remote_fetch,
508            fetch_reflog_ctx.as_ref(),
509        )?;
510    }
511
512    crate::fetch::finish_initial_remote_fetch_layout(local, opts, default_branch.as_deref())?;
513    crate::transfer::finish_anonymous_url_fetch(
514        local_git_dir,
515        opts,
516        explicit_refspec_fetch,
517        default_branch.as_deref(),
518        remote_refs,
519        &matched,
520    )?;
521    let new_shallow = if opts.initial_remote_fetch {
522        remote_shallow.iter().copied().collect()
523    } else {
524        Vec::new()
525    };
526    Ok(FetchOutcome {
527        updates,
528        default_branch,
529        new_shallow,
530        new_unshallow: Vec::new(),
531        transfer,
532        filter_sent: false,
533        promisor: None,
534    })
535}
536
537fn map_bundle_err(e: BundleError) -> Error {
538    match e {
539        BundleError::Cancelled => Error::Cancelled,
540        BundleError::MissingPrerequisite { .. } | BundleError::PrerequisitesNotConnected => {
541            Error::BundleMissingPrerequisites
542        }
543        other => Error::Message(other.to_string()),
544    }
545}
546
547#[cfg(test)]
548mod tests {
549    use super::*;
550    use crate::environment::RepositoryOptions;
551
552    #[test]
553    fn default_branch_follows_bundle_head_not_first_branch_line() {
554        let main = ObjectId::from_hex("f04545002f192e3ffe2e2f6341abb0665215b0c8").unwrap();
555        let feature = ObjectId::from_hex("c48fa981db2b56e99fb87fc3c2827ee4b5186d8f").unwrap();
556        let refs = vec![
557            ("refs/heads/feature".into(), feature),
558            ("refs/heads/main".into(), main),
559            ("HEAD".into(), main),
560        ];
561        assert_eq!(
562            default_branch_from_bundle_head(&refs, None).as_deref(),
563            Some("main")
564        );
565    }
566
567    #[test]
568    fn split_revision_specs_handles_double_dot_range() {
569        let tokens = vec!["HEAD~1..HEAD".to_owned()];
570        let (pos, neg) = split_revision_specs_for_bundle(&tokens);
571        assert_eq!(pos, vec!["HEAD"]);
572        assert_eq!(neg, vec!["HEAD~1"]);
573    }
574
575    #[cfg_attr(
576        not(feature = "full-tests"),
577        ignore = "starts system git or is heavy; run with --features full-tests"
578    )]
579    #[test]
580    fn write_bundle_from_rev_specs_records_tag_dwim_as_refs_tags() {
581        let tmp = tempfile::tempdir().expect("tempdir");
582        let dir = tmp.path();
583        std::process::Command::new("git")
584            .current_dir(dir)
585            .args(["init", "-q", "-b", "main", "."])
586            .env("GIT_CONFIG_GLOBAL", "/dev/null")
587            .env("GIT_CONFIG_SYSTEM", "/dev/null")
588            .status()
589            .expect("git init");
590        std::fs::write(dir.join("a.txt"), b"a\n").unwrap();
591        assert!(std::process::Command::new("git")
592            .current_dir(dir)
593            .args(["add", "a.txt"])
594            .env("GIT_CONFIG_GLOBAL", "/dev/null")
595            .env("GIT_CONFIG_SYSTEM", "/dev/null")
596            .status()
597            .unwrap()
598            .success());
599        assert!(std::process::Command::new("git")
600            .current_dir(dir)
601            .args([
602                "-c",
603                "user.name=T",
604                "-c",
605                "user.email=t@example.com",
606                "commit",
607                "-qm",
608                "one",
609            ])
610            .env("GIT_CONFIG_GLOBAL", "/dev/null")
611            .env("GIT_CONFIG_SYSTEM", "/dev/null")
612            .status()
613            .unwrap()
614            .success());
615        assert!(std::process::Command::new("git")
616            .current_dir(dir)
617            .args(["tag", "lite"])
618            .env("GIT_CONFIG_GLOBAL", "/dev/null")
619            .env("GIT_CONFIG_SYSTEM", "/dev/null")
620            .status()
621            .unwrap()
622            .success());
623
624        let repo = Repository::discover(Some(dir), &RepositoryOptions::empty()).expect("open repo");
625        let bundle_path = dir.join("t1.bundle");
626        write_bundle_from_rev_specs(&repo, &bundle_path, &["lite".to_owned(), "main".to_owned()])
627            .expect("write bundle");
628
629        let header = crate::bundle::Bundle::open(&bundle_path)
630            .expect("open bundle")
631            .header()
632            .clone();
633        let names: Vec<_> = header.refs.iter().map(|(n, _)| n.as_str()).collect();
634        assert!(
635            names.contains(&"refs/tags/lite"),
636            "expected refs/tags/lite in {names:?}"
637        );
638        assert!(
639            !names.contains(&"refs/heads/lite"),
640            "must not record tag as branch: {names:?}"
641        );
642        assert!(names.contains(&"refs/heads/main"));
643    }
644}