| ⋯ 424 unchanged lines |
| 425 | 425 | | Ok(()) |
| 426 | 426 | | } |
| 427 | 427 | | |
| 428 | + | /// Peel `wants` for a pack walk: tag wants contribute the tag object itself |
| 429 | + | /// (plus the chain down to a non-tag), and the peeled target joins the walk — |
| 430 | + | /// commits as walk tips, trees/blobs (rare tag targets) collected directly. |
| 431 | + | /// `git fetch`/`push` send tag *object* ids whenever annotated tags are |
| 432 | + | /// involved, so every pack path needs this. |
| 433 | + | fn peel_wants( |
| 434 | + | repo: &gix::Repository, |
| 435 | + | wants: &[gix::ObjectId], |
| 436 | + | ) -> std::result::Result< |
| 437 | + | (Vec<gix::ObjectId>, Vec<gix::ObjectId>), |
| 438 | + | Box<dyn std::error::Error + Send + Sync>, |
| 439 | + | > { |
| 440 | + | let mut commit_wants = Vec::new(); |
| 441 | + | let mut extras = Vec::new(); |
| 442 | + | let mut seen = HashSet::new(); |
| 443 | + | for want in wants { |
| 444 | + | let mut oid = *want; |
| 445 | + | loop { |
| 446 | + | let obj = repo.find_object(oid)?; |
| 447 | + | match obj.kind { |
| 448 | + | gix::object::Kind::Tag => { |
| 449 | + | if seen.insert(oid) { |
| 450 | + | extras.push(oid); |
| 451 | + | } |
| 452 | + | oid = gix::objs::TagRefIter::from_bytes(&obj.data, gix::hash::Kind::Sha1) |
| 453 | + | .target_id()?; |
| 454 | + | } |
| 455 | + | gix::object::Kind::Commit => { |
| 456 | + | commit_wants.push(oid); |
| 457 | + | break; |
| 458 | + | } |
| 459 | + | gix::object::Kind::Tree => { |
| 460 | + | collect_tree_oids(repo, oid, &mut seen, &mut extras)?; |
| 461 | + | break; |
| 462 | + | } |
| 463 | + | gix::object::Kind::Blob => { |
| 464 | + | if seen.insert(oid) { |
| 465 | + | extras.push(oid); |
| 466 | + | } |
| 467 | + | break; |
| 468 | + | } |
| 469 | + | } |
| 470 | + | } |
| 471 | + | } |
| 472 | + | Ok((commit_wants, extras)) |
| 473 | + | } |
| 474 | + | |
| 428 | 475 | | /// Walk commits from `wants` (excluding `haves`) and collect all |
| 429 | | - | /// reachable ObjectIds (commits, trees, blobs). |
| 476 | + | /// reachable ObjectIds (commits, trees, blobs). Tag wants are peeled, with |
| 477 | + | /// the tag objects themselves included in the result. |
| 430 | 478 | | /// |
| 431 | 479 | | /// Pass 1 of the two-pass streaming approach: only OIDs are stored, |
| 432 | 480 | | /// not object data. |
| ⋯ 2 unchanged lines |
| 435 | 483 | | wants: &[gix::ObjectId], |
| 436 | 484 | | haves: &[gix::ObjectId], |
| 437 | 485 | | ) -> std::result::Result<Vec<gix::ObjectId>, Box<dyn std::error::Error + Send + Sync>> { |
| 486 | + | let (commit_wants, extras) = peel_wants(repo, wants)?; |
| 438 | 487 | | let have_set: HashSet<gix::ObjectId> = haves.iter().copied().collect(); |
| 439 | 488 | | let mut seen = HashSet::new(); |
| 440 | 489 | | let mut oids = Vec::new(); |
| ⋯ 4 unchanged lines |
| 445 | 494 | | } |
| 446 | 495 | | |
| 447 | 496 | | let walk = repo |
| 448 | | - | .rev_walk(wants.iter().copied()) |
| 497 | + | .rev_walk(commit_wants.iter().copied()) |
| 449 | 498 | | .with_hidden(haves.iter().copied()) |
| 450 | 499 | | .all()?; |
| 451 | 500 | | |
| ⋯ 16 unchanged lines |
| 468 | 517 | | collect_tree_oids(repo, tree_oid, &mut seen, &mut oids)?; |
| 469 | 518 | | } |
| 470 | 519 | | |
| 520 | + | // Tag objects (and direct tree/blob tag targets) ride along after the |
| 521 | + | // walk; anything already collected or known to the remote is skipped. |
| 522 | + | oids.extend( |
| 523 | + | extras |
| 524 | + | .into_iter() |
| 525 | + | .filter(|oid| !seen.contains(oid) && !have_set.contains(oid)), |
| 526 | + | ); |
| 527 | + | |
| 471 | 528 | | Ok(oids) |
| 472 | 529 | | } |
| 473 | 530 | | |
| ⋯ 12 unchanged lines |
| 486 | 543 | | .collect()) |
| 487 | 544 | | } |
| 488 | 545 | | |
| 546 | + | /// Build a complete, self-contained pack (header + entries + SHA-1 trailer) |
| 547 | + | /// of everything reachable from `wants` but not from `haves`, returned as one |
| 548 | + | /// buffer. Entries are undeltified base objects — used by the *push* |
| 549 | + | /// (send-pack) client, where mirror pushes are infrequent enough that pack |
| 550 | + | /// size doesn't justify delta search. |
| 551 | + | /// |
| 552 | + | /// `haves` must exist in the local object database (callers filter the remote |
| 553 | + | /// advertisement accordingly). |
| 554 | + | pub fn build_raw_pack( |
| 555 | + | repo_path: &Path, |
| 556 | + | wants: &[gix::ObjectId], |
| 557 | + | haves: &[gix::ObjectId], |
| 558 | + | ) -> Result<Vec<u8>> { |
| 559 | + | let repo = gix::open(repo_path)?; |
| 560 | + | // Tag wants (annotated tags) are peeled inside `collect_all_oids`, with |
| 561 | + | // the tag objects themselves included in the result. |
| 562 | + | let oids = collect_all_oids(&repo, wants, haves) |
| 563 | + | .map_err(|e| Error::Protocol(format!("collecting pack objects: {e}")))?; |
| 564 | + | |
| 565 | + | let mut out = Vec::new(); |
| 566 | + | out.extend_from_slice(b"PACK"); |
| 567 | + | out.extend_from_slice(&2u32.to_be_bytes()); |
| 568 | + | out.extend_from_slice(&(oids.len() as u32).to_be_bytes()); |
| 569 | + | for oid in &oids { |
| 570 | + | let obj = repo |
| 571 | + | .find_object(*oid) |
| 572 | + | .map_err(|e| Error::Protocol(format!("reading {oid}: {e}")))?; |
| 573 | + | out.extend_from_slice(&build_base_entry(obj.kind, &obj.data)); |
| 574 | + | } |
| 575 | + | let checksum = Sha1::digest(&out); |
| 576 | + | out.extend_from_slice(&checksum); |
| 577 | + | Ok(out) |
| 578 | + | } |
| 579 | + | |
| 489 | 580 | | /// Generate the complete pack response for a Git upload-pack request. |
| 490 | 581 | | /// |
| 491 | 582 | | /// Returns an `AsyncRead` producing the side-band-64k framed response that |
| ⋯ 359 unchanged lines |
| 851 | 942 | | let pack_found = buf.windows(4).any(|window| window == b"PACK"); |
| 852 | 943 | | assert!(pack_found, "response should contain PACK signature"); |
| 853 | 944 | | } |
| 945 | + | |
| 946 | + | /// Regression: a want may be an annotated tag *object* (protocol-v2 |
| 947 | + | /// `ls-refs` advertises unpeeled ids). The walk used to choke on it |
| 948 | + | /// mid-stream, killing `git clone` of any repo with an annotated tag; |
| 949 | + | /// now the tag is peeled and the tag object itself joins the pack. |
| 950 | + | #[tokio::test] |
| 951 | + | async fn tag_object_wants_are_peeled_and_packed() { |
| 952 | + | let dir = TempDir::new().unwrap(); |
| 953 | + | let repo_path = create_repo_with_commit(dir.path()); |
| 954 | + | |
| 955 | + | let clone_path = dir.path().join("workdir"); |
| 956 | + | let out = Command::new("git") |
| 957 | + | .args(["tag", "-a", "-m", "annotated", "v1"]) |
| 958 | + | .current_dir(&clone_path) |
| 959 | + | .env("GIT_AUTHOR_NAME", "t") |
| 960 | + | .env("GIT_AUTHOR_EMAIL", "t@t") |
| 961 | + | .env("GIT_COMMITTER_NAME", "t") |
| 962 | + | .env("GIT_COMMITTER_EMAIL", "t@t") |
| 963 | + | .output() |
| 964 | + | .unwrap(); |
| 965 | + | assert!(out.status.success()); |
| 966 | + | let out = Command::new("git") |
| 967 | + | .args(["push", "-q", "origin", "v1"]) |
| 968 | + | .current_dir(&clone_path) |
| 969 | + | .output() |
| 970 | + | .unwrap(); |
| 971 | + | assert!(out.status.success()); |
| 972 | + | |
| 973 | + | let repo = gix::open(&repo_path).unwrap(); |
| 974 | + | let tag_oid = repo |
| 975 | + | .find_reference("refs/tags/v1") |
| 976 | + | .unwrap() |
| 977 | + | .try_id() |
| 978 | + | .unwrap() |
| 979 | + | .detach(); |
| 980 | + | assert_eq!( |
| 981 | + | repo.find_object(tag_oid).unwrap().kind, |
| 982 | + | gix::object::Kind::Tag, |
| 983 | + | "fixture must produce a real tag object" |
| 984 | + | ); |
| 985 | + | drop(repo); |
| 986 | + | |
| 987 | + | let pack = build_raw_pack(&repo_path, &[tag_oid], &[]).unwrap(); |
| 988 | + | assert!(pack.starts_with(b"PACK")); |
| 989 | + | let count = u32::from_be_bytes(pack[8..12].try_into().unwrap()); |
| 990 | + | // tag + commit + tree + blob |
| 991 | + | assert_eq!(count, 4, "tag object and full closure packed"); |
| 992 | + | |
| 993 | + | // The streaming (fetch-serving) path accepts the same want. |
| 994 | + | let request = UploadPackRequest { |
| 995 | + | wants: vec![tag_oid], |
| 996 | + | haves: vec![], |
| 997 | + | done: true, |
| 998 | + | capabilities: UploadPackCapabilities::default(), |
| 999 | + | shallow: ShallowRequest::default(), |
| 1000 | + | object_ids: None, |
| 1001 | + | }; |
| 1002 | + | let mut reader = generate_pack(&repo_path, &request).unwrap(); |
| 1003 | + | let mut buf = Vec::new(); |
| 1004 | + | reader.read_to_end(&mut buf).await.unwrap(); |
| 1005 | + | assert!( |
| 1006 | + | buf.windows(4).any(|w| w == b"PACK"), |
| 1007 | + | "fetch response should contain a pack, got: {:?}", |
| 1008 | + | String::from_utf8_lossy(&buf[..buf.len().min(200)]) |
| 1009 | + | ); |
| 1010 | + | } |
| 854 | 1011 | | } |