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 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
317fn 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}