1use 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#[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#[derive(Debug, Clone, Default, PartialEq, Eq)]
32pub struct BundleCreateOptions {
33 pub all_refs: bool,
35 pub rev_tokens: Vec<String>,
37}
38
39impl BundleCreateOptions {
40 #[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
50pub 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
67pub 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
110pub 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
123pub 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 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#[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
229pub 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
248pub 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
263pub(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 preflight_bundle_fetch_refspecs(&bundle, opts)?;
274 bundle
275 .verify(repo)
276 .map_err(map_bundle_err)?
277 .into_result()
278 .map_err(map_bundle_err)?;
279
280 let (transfer, pack_oids) = if opts.dry_run {
281 (TransferStats::default(), HashSet::new())
282 } else {
283 crate::progress::poll_cancel(progress, cancel)?;
284 let unbundled = bundle
285 .unbundle_with_progress(repo, progress, cancel)
286 .map_err(map_bundle_err)?;
287 crate::progress::poll_cancel(progress, cancel)?;
288 (unbundled.transfer, unbundled.pack_object_ids)
289 };
290
291 let header = bundle.header();
292 let remote_refs = header.refs.clone();
293 let init_default_branch = repo.config().ok().and_then(|cfg| {
294 cfg.get_last_entry("init.defaultBranch")
295 .and_then(|e| e.value.clone())
296 .filter(|s| !s.is_empty())
297 });
298 let default_branch =
299 default_branch_from_bundle_head(&header.refs, init_default_branch.as_deref());
300
301 let mut effective = opts.clone();
302 if effective.remote_name.is_none() && effective.fetch_source.is_none() {
303 effective.fetch_source = Some(bundle_path.to_string_lossy().into_owned());
304 }
305
306 crate::progress::poll_cancel(progress, cancel)?;
307 complete_bundle_fetch(
308 repo,
309 &remote_refs,
310 default_branch,
311 &effective,
312 &HashSet::new(),
313 pack_oids,
314 transfer,
315 )
316}
317
318fn default_branch_from_bundle_head(
322 refs: &[(String, ObjectId)],
323 init_default_branch: Option<&str>,
324) -> Option<String> {
325 let head_oid = refs
326 .iter()
327 .find(|(name, _)| name == "HEAD")
328 .map(|(_, oid)| *oid)?;
329
330 if let Some(name) = init_default_branch {
331 if bundle_branch_tip_matches(refs, name, head_oid) {
332 return Some(name.to_owned());
333 }
334 }
335
336 if init_default_branch != Some("master") && bundle_branch_tip_matches(refs, "master", head_oid)
337 {
338 return Some("master".to_owned());
339 }
340
341 for (name, oid) in refs.iter().rev() {
342 if name == "HEAD" {
343 continue;
344 }
345 if let Some(short) = name.strip_prefix("refs/heads/") {
346 if *oid == head_oid {
347 return Some(short.to_owned());
348 }
349 }
350 }
351 None
352}
353
354fn bundle_branch_tip_matches(
355 refs: &[(String, ObjectId)],
356 branch: &str,
357 head_oid: ObjectId,
358) -> bool {
359 let full = format!("refs/heads/{branch}");
360 refs.iter()
361 .any(|(name, oid)| name == &full && *oid == head_oid)
362}
363
364#[allow(clippy::too_many_arguments)]
365fn complete_bundle_fetch(
366 local: &Repository,
367 remote_refs: &[(String, ObjectId)],
368 default_branch: Option<String>,
369 opts: &FetchOptions,
370 remote_shallow: &HashSet<ObjectId>,
371 pack_oids: HashSet<ObjectId>,
372 transfer: TransferStats,
373) -> Result<FetchOutcome> {
374 let local_git_dir = local.git_dir();
375 let local_odb = local.odb();
376 let remote_head_oid = crate::transfer::remote_head_oid_from_advertised(remote_refs);
377 let filtered_remote_refs: Vec<(String, ObjectId)> = remote_refs
378 .iter()
379 .filter(|(name, _)| {
380 name != "HEAD"
381 && !name.ends_with("^{}")
382 && crate::refs::is_valid_fetch_advertised_ref(name)
383 })
384 .cloned()
385 .collect();
386
387 let crate::transfer::FetchMatchSet {
388 mut matched,
389 positive,
390 negatives,
391 explicit_refspec_fetch,
392 } = crate::transfer::build_fetch_matched_set(opts, &filtered_remote_refs, remote_head_oid)?;
393 let mut matched_oids: HashSet<ObjectId> = matched.iter().map(|m| m.oid).collect();
394 let mut seen_remote_ref: HashSet<String> =
395 matched.iter().map(|m| m.remote_ref.clone()).collect();
396
397 let local_shallow = crate::shallow::load_shallow_boundaries(local_git_dir);
398 let mut tag_shallow = local_shallow.clone();
399 tag_shallow.extend(remote_shallow.iter().copied());
400
401 let tags_present = if opts.tags == TagMode::Following {
402 crate::fetch::tags_already_local(local.refs(), remote_refs)
403 } else {
404 HashSet::new()
405 };
406 let _following_only = apply_tag_mode(
407 opts.tags,
408 remote_refs,
409 local_odb,
410 &tag_shallow,
411 &negatives,
412 &tags_present,
413 opts.force,
414 &mut matched,
415 &mut matched_oids,
416 &mut seen_remote_ref,
417 )?;
418
419 if opts.tags == TagMode::Following {
420 crate::fetch::retain_following_tags(local_odb, &mut matched, &pack_oids, &local_shallow)?;
421 }
422
423 let local_repo = (!opts.dry_run).then_some(local);
424
425 let mut updates: Vec<RefUpdate> = Vec::new();
426 if opts.prune {
427 prune_tracking_refs(local, &positive, remote_refs, opts.dry_run, &mut updates)?;
428 }
429
430 let mut store_batch: Vec<(String, crate::refs::store::RawRef)> =
431 Vec::with_capacity(matched.len());
432 for m in &matched {
433 let Some(local_ref) = &m.local_ref else {
434 updates.push(RefUpdate {
435 remote_ref: m.remote_ref.clone(),
436 local_ref: None,
437 old_oid: None,
438 new_oid: Some(m.oid),
439 mode: UpdateMode::NoChangeNeeded,
440 note: Some("not stored (empty destination)".to_owned()),
441 });
442 continue;
443 };
444
445 let old = local.resolve_ref_oid(local_ref).ok();
446 let mode = classify_update(
447 old.as_ref(),
448 &m.oid,
449 m.force,
450 m.dest_is_tag,
451 local_repo,
452 None,
453 );
454
455 let write = matches!(
456 mode,
457 UpdateMode::New | UpdateMode::FastForward | UpdateMode::Forced
458 );
459 crate::transfer::enforce_fetch_ref_update(
460 local_ref,
461 old,
462 m.oid,
463 mode,
464 opts,
465 explicit_refspec_fetch,
466 local_repo,
467 )?;
468 if write && !opts.dry_run {
469 if !crate::refs::is_valid_storable_ref_name(local_ref) {
470 updates.push(RefUpdate {
471 remote_ref: m.remote_ref.clone(),
472 local_ref: Some(local_ref.clone()),
473 old_oid: old,
474 new_oid: Some(m.oid),
475 mode,
476 note: Some("skipped (invalid ref name)".to_owned()),
477 });
478 continue;
479 }
480 store_batch.push((local_ref.clone(), crate::refs::store::RawRef::Direct(m.oid)));
481 }
482
483 updates.push(RefUpdate {
484 remote_ref: m.remote_ref.clone(),
485 local_ref: Some(local_ref.clone()),
486 old_oid: old,
487 new_oid: Some(m.oid),
488 mode,
489 note: None,
490 });
491 }
492
493 if !opts.dry_run && !store_batch.is_empty() {
494 let fetch_reflog = crate::fetch::prepare_fetch_apply_reflog(local, opts)?;
495 let fetch_reflog_ctx = fetch_reflog
496 .as_ref()
497 .map(crate::fetch::PreparedFetchReflog::context);
498 crate::transfer::apply_fetch_ref_updates(
499 local,
500 store_batch,
501 &mut updates,
502 crate::transfer::FetchRefRejectHint {
503 prune: opts.prune,
504 positive: &positive,
505 remote_refs,
506 remote_name: opts.remote_name.as_deref(),
507 },
508 opts.initial_remote_fetch,
509 fetch_reflog_ctx.as_ref(),
510 )?;
511 }
512
513 crate::fetch::finish_initial_remote_fetch_layout(local, opts, default_branch.as_deref())?;
514 crate::transfer::finish_anonymous_url_fetch(
515 local_git_dir,
516 opts,
517 explicit_refspec_fetch,
518 default_branch.as_deref(),
519 remote_refs,
520 &matched,
521 )?;
522 let new_shallow = if opts.initial_remote_fetch {
523 remote_shallow.iter().copied().collect()
524 } else {
525 Vec::new()
526 };
527 Ok(FetchOutcome {
528 updates,
529 default_branch,
530 new_shallow,
531 new_unshallow: Vec::new(),
532 transfer,
533 filter_sent: false,
534 promisor: None,
535 })
536}
537
538fn preflight_bundle_fetch_refspecs(bundle: &Bundle, opts: &FetchOptions) -> Result<()> {
540 use crate::fetch_request::FetchRefspecError;
541 use crate::remote::Remote;
542
543 let header_refs = &bundle.header().refs;
544 let effective = if opts.refspecs.is_empty() {
545 &opts.configured_refspecs
546 } else {
547 &opts.refspecs
548 };
549
550 let implicit_head_fetch = effective.is_empty()
551 || (effective.len() == 1
552 && effective[0].trim_start_matches('+') == Remote::ANONYMOUS_FETCH_REFSPEC);
553
554 if implicit_head_fetch {
555 if crate::transfer::remote_head_oid_from_advertised(header_refs).is_none() {
556 return Err(Error::FetchRefspec(FetchRefspecError::SourceNotFound {
557 ref_name: "HEAD".to_owned(),
558 spec: "HEAD".to_owned(),
559 }));
560 }
561 return Ok(());
562 }
563
564 let remote_head_oid = crate::transfer::remote_head_oid_from_advertised(header_refs);
565 let filtered_remote_refs: Vec<(String, ObjectId)> = header_refs
566 .iter()
567 .filter(|(name, _)| {
568 name != "HEAD"
569 && !name.ends_with("^{}")
570 && crate::refs::is_valid_fetch_advertised_ref(name)
571 })
572 .cloned()
573 .collect();
574 crate::transfer::build_fetch_matched_set(opts, &filtered_remote_refs, remote_head_oid)?;
575 Ok(())
576}
577
578fn map_bundle_err(e: BundleError) -> Error {
579 match e {
580 BundleError::Cancelled => Error::Cancelled,
581 BundleError::MissingPrerequisite { .. } | BundleError::PrerequisitesNotConnected => {
582 Error::BundleMissingPrerequisites
583 }
584 other => Error::Message(other.to_string()),
585 }
586}
587
588#[cfg(test)]
589mod tests {
590 use super::*;
591 use crate::environment::RepositoryOptions;
592
593 #[test]
594 fn default_branch_follows_bundle_head_not_first_branch_line() {
595 let main = ObjectId::from_hex("f04545002f192e3ffe2e2f6341abb0665215b0c8").unwrap();
596 let feature = ObjectId::from_hex("c48fa981db2b56e99fb87fc3c2827ee4b5186d8f").unwrap();
597 let refs = vec![
598 ("refs/heads/feature".into(), feature),
599 ("refs/heads/main".into(), main),
600 ("HEAD".into(), main),
601 ];
602 assert_eq!(
603 default_branch_from_bundle_head(&refs, None).as_deref(),
604 Some("main")
605 );
606 }
607
608 #[test]
609 fn split_revision_specs_handles_double_dot_range() {
610 let tokens = vec!["HEAD~1..HEAD".to_owned()];
611 let (pos, neg) = split_revision_specs_for_bundle(&tokens);
612 assert_eq!(pos, vec!["HEAD"]);
613 assert_eq!(neg, vec!["HEAD~1"]);
614 }
615
616 #[cfg_attr(
617 not(feature = "full-tests"),
618 ignore = "starts system git or is heavy; run with --features full-tests"
619 )]
620 #[test]
621 fn write_bundle_from_rev_specs_records_tag_dwim_as_refs_tags() {
622 let tmp = tempfile::tempdir().expect("tempdir");
623 let dir = tmp.path();
624 std::process::Command::new("git")
625 .current_dir(dir)
626 .args(["init", "-q", "-b", "main", "."])
627 .env("GIT_CONFIG_GLOBAL", "/dev/null")
628 .env("GIT_CONFIG_SYSTEM", "/dev/null")
629 .status()
630 .expect("git init");
631 std::fs::write(dir.join("a.txt"), b"a\n").unwrap();
632 assert!(std::process::Command::new("git")
633 .current_dir(dir)
634 .args(["add", "a.txt"])
635 .env("GIT_CONFIG_GLOBAL", "/dev/null")
636 .env("GIT_CONFIG_SYSTEM", "/dev/null")
637 .status()
638 .unwrap()
639 .success());
640 assert!(std::process::Command::new("git")
641 .current_dir(dir)
642 .args([
643 "-c",
644 "user.name=T",
645 "-c",
646 "user.email=t@example.com",
647 "commit",
648 "-qm",
649 "one",
650 ])
651 .env("GIT_CONFIG_GLOBAL", "/dev/null")
652 .env("GIT_CONFIG_SYSTEM", "/dev/null")
653 .status()
654 .unwrap()
655 .success());
656 assert!(std::process::Command::new("git")
657 .current_dir(dir)
658 .args(["tag", "lite"])
659 .env("GIT_CONFIG_GLOBAL", "/dev/null")
660 .env("GIT_CONFIG_SYSTEM", "/dev/null")
661 .status()
662 .unwrap()
663 .success());
664
665 let repo = Repository::discover(Some(dir), &RepositoryOptions::empty()).expect("open repo");
666 let bundle_path = dir.join("t1.bundle");
667 write_bundle_from_rev_specs(&repo, &bundle_path, &["lite".to_owned(), "main".to_owned()])
668 .expect("write bundle");
669
670 let header = crate::bundle::Bundle::open(&bundle_path)
671 .expect("open bundle")
672 .header()
673 .clone();
674 let names: Vec<_> = header.refs.iter().map(|(n, _)| n.as_str()).collect();
675 assert!(
676 names.contains(&"refs/tags/lite"),
677 "expected refs/tags/lite in {names:?}"
678 );
679 assert!(
680 !names.contains(&"refs/heads/lite"),
681 "must not record tag as branch: {names:?}"
682 );
683 assert!(names.contains(&"refs/heads/main"));
684 }
685}