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 crate::transfer::apply_fetch_ref_updates(
494 local,
495 store_batch,
496 &mut updates,
497 crate::transfer::FetchRefRejectHint {
498 prune: opts.prune,
499 positive: &positive,
500 remote_refs,
501 remote_name: opts.remote_name.as_deref(),
502 },
503 opts.initial_remote_fetch,
504 )?;
505 }
506
507 crate::fetch::finish_initial_remote_fetch_layout(local, opts, default_branch.as_deref())?;
508 crate::transfer::finish_anonymous_url_fetch(
509 local_git_dir,
510 opts,
511 explicit_refspec_fetch,
512 default_branch.as_deref(),
513 remote_refs,
514 &matched,
515 )?;
516 let new_shallow = if opts.initial_remote_fetch {
517 remote_shallow.iter().copied().collect()
518 } else {
519 Vec::new()
520 };
521 Ok(FetchOutcome {
522 updates,
523 default_branch,
524 new_shallow,
525 new_unshallow: Vec::new(),
526 transfer,
527 filter_sent: false,
528 promisor: None,
529 })
530}
531
532fn map_bundle_err(e: BundleError) -> Error {
533 match e {
534 BundleError::Cancelled => Error::Cancelled,
535 BundleError::MissingPrerequisite { .. } | BundleError::PrerequisitesNotConnected => {
536 Error::BundleMissingPrerequisites
537 }
538 other => Error::Message(other.to_string()),
539 }
540}
541
542#[cfg(test)]
543mod tests {
544 use super::*;
545 use crate::environment::RepositoryOptions;
546
547 #[test]
548 fn default_branch_follows_bundle_head_not_first_branch_line() {
549 let main = ObjectId::from_hex("f04545002f192e3ffe2e2f6341abb0665215b0c8").unwrap();
550 let feature = ObjectId::from_hex("c48fa981db2b56e99fb87fc3c2827ee4b5186d8f").unwrap();
551 let refs = vec![
552 ("refs/heads/feature".into(), feature),
553 ("refs/heads/main".into(), main),
554 ("HEAD".into(), main),
555 ];
556 assert_eq!(
557 default_branch_from_bundle_head(&refs, None).as_deref(),
558 Some("main")
559 );
560 }
561
562 #[test]
563 fn split_revision_specs_handles_double_dot_range() {
564 let tokens = vec!["HEAD~1..HEAD".to_owned()];
565 let (pos, neg) = split_revision_specs_for_bundle(&tokens);
566 assert_eq!(pos, vec!["HEAD"]);
567 assert_eq!(neg, vec!["HEAD~1"]);
568 }
569
570 #[cfg_attr(
571 not(feature = "full-tests"),
572 ignore = "starts system git or is heavy; run with --features full-tests"
573 )]
574 #[test]
575 fn write_bundle_from_rev_specs_records_tag_dwim_as_refs_tags() {
576 let tmp = tempfile::tempdir().expect("tempdir");
577 let dir = tmp.path();
578 std::process::Command::new("git")
579 .current_dir(dir)
580 .args(["init", "-q", "-b", "main", "."])
581 .env("GIT_CONFIG_GLOBAL", "/dev/null")
582 .env("GIT_CONFIG_SYSTEM", "/dev/null")
583 .status()
584 .expect("git init");
585 std::fs::write(dir.join("a.txt"), b"a\n").unwrap();
586 assert!(std::process::Command::new("git")
587 .current_dir(dir)
588 .args(["add", "a.txt"])
589 .env("GIT_CONFIG_GLOBAL", "/dev/null")
590 .env("GIT_CONFIG_SYSTEM", "/dev/null")
591 .status()
592 .unwrap()
593 .success());
594 assert!(std::process::Command::new("git")
595 .current_dir(dir)
596 .args([
597 "-c",
598 "user.name=T",
599 "-c",
600 "user.email=t@example.com",
601 "commit",
602 "-qm",
603 "one",
604 ])
605 .env("GIT_CONFIG_GLOBAL", "/dev/null")
606 .env("GIT_CONFIG_SYSTEM", "/dev/null")
607 .status()
608 .unwrap()
609 .success());
610 assert!(std::process::Command::new("git")
611 .current_dir(dir)
612 .args(["tag", "lite"])
613 .env("GIT_CONFIG_GLOBAL", "/dev/null")
614 .env("GIT_CONFIG_SYSTEM", "/dev/null")
615 .status()
616 .unwrap()
617 .success());
618
619 let repo = Repository::discover(Some(dir), &RepositoryOptions::empty()).expect("open repo");
620 let bundle_path = dir.join("t1.bundle");
621 write_bundle_from_rev_specs(&repo, &bundle_path, &["lite".to_owned(), "main".to_owned()])
622 .expect("write bundle");
623
624 let header = crate::bundle::Bundle::open(&bundle_path)
625 .expect("open bundle")
626 .header()
627 .clone();
628 let names: Vec<_> = header.refs.iter().map(|(n, _)| n.as_str()).collect();
629 assert!(
630 names.contains(&"refs/tags/lite"),
631 "expected refs/tags/lite in {names:?}"
632 );
633 assert!(
634 !names.contains(&"refs/heads/lite"),
635 "must not record tag as branch: {names:?}"
636 );
637 assert!(names.contains(&"refs/heads/main"));
638 }
639}