anvilsign in

collin/anvil

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4//
5// Copyright (c) 2026 WJQSERVER
6
7use std::{
8 collections::{
9 BTreeSet,
10 HashSet,
11 },
12 io::Cursor,
13 path::Path,
14};
15
16use crate::{
17 error::{
18 Error,
19 Result,
20 },
21 pack::UploadPackRequest,
22 pktline,
23};
24
25const CAPABILITIES: &[&str] = &[
26 "ls-refs=unborn",
27 "fetch=shallow wait-for-done",
28 "object-format=sha1",
29];
30
31pub enum Command {
32 LsRefs(LsRefsRequest),
33 Fetch(FetchRequest),
34}
35
36#[derive(Default)]
37pub struct LsRefsRequest {
38 pub peel: bool,
39 pub symrefs: bool,
40 pub unborn: bool,
41 pub ref_prefixes: Vec<String>,
42}
43
44pub struct FetchRequest {
45 pub upload_request: UploadPackRequest,
46}
47
48pub struct ShallowUpdate {
49 pub shallow: Vec<gix::ObjectId>,
50 pub unshallow: Vec<gix::ObjectId>,
51}
52
53pub fn advertise_capabilities() -> Vec<u8> {
54 let mut out = Vec::new();
55 out.extend_from_slice(&pktline::encode_comment("service=git-upload-pack"));
56 out.extend_from_slice(pktline::flush());
57 out.extend_from_slice(&pktline::encode(b"version 2\n"));
58 for capability in CAPABILITIES {
59 out.extend_from_slice(&pktline::encode(format!("{capability}\n").as_bytes()));
60 }
61 out.extend_from_slice(pktline::flush());
62 out
63}
64
65pub fn parse_command_request(body: &[u8]) -> Result<Command> {
66 let lines = decode_pkt_lines(body)?;
67 let mut iter = lines.into_iter();
68
69 let command = iter
70 .next()
71 .ok_or_else(|| Error::Protocol("missing protocol v2 command".into()))?;
72 let command = command
73 .strip_prefix("command=")
74 .ok_or_else(|| Error::Protocol("invalid protocol v2 command line".into()))?;
75
76 let mut args = Vec::new();
77 let mut saw_delim = false;
78 for line in iter {
79 if line.is_empty() {
80 continue;
81 }
82 if line == "0001" {
83 saw_delim = true;
84 continue;
85 }
86 if saw_delim {
87 args.push(line);
88 }
89 }
90
91 match command {
92 "ls-refs" => parse_ls_refs(args),
93 "fetch" => parse_fetch(args),
94 _ => Err(Error::Protocol(format!(
95 "unsupported protocol v2 command: {command}"
96 ))),
97 }
98}
99
100pub fn ls_refs(repo_path: &Path, request: &LsRefsRequest) -> Result<Vec<u8>> {
101 let repo = gix::open(repo_path)?;
102 let mut refs = BTreeSet::new();
103
104 if let Ok(mut head) = repo.head() {
105 if let Some(id) = head
106 .try_peel_to_id()
107 .map_err(|e| Error::Protocol(e.to_string()))?
108 {
109 let mut line = format!("{} HEAD", id.detach());
110 if request.symrefs
111 && let Some(target) = head.referent_name()
112 {
113 line.push_str(&format!(" symref-target:{}", target.as_bstr()));
114 }
115 refs.insert(line);
116 } else if request.unborn
117 && let Some(target) = head.referent_name()
118 {
119 refs.insert(format!("unborn HEAD symref-target:{}", target.as_bstr()));
120 }
121 }
122
123 if let Ok(platform) = repo.references()
124 && let Ok(iter) = platform.all()
125 {
126 for mut reference in iter.flatten() {
127 let name = reference.name().as_bstr().to_string();
128 if !request.ref_prefixes.is_empty()
129 && !request
130 .ref_prefixes
131 .iter()
132 .any(|prefix| name.starts_with(prefix))
133 {
134 continue;
135 }
136
137 let mut line = match reference.try_id() {
138 Some(id) => format!("{} {name}", id.detach()),
139 None => match reference.peel_to_id() {
140 Ok(id) => format!("{} {name}", id.detach()),
141 Err(_) => continue,
142 },
143 };
144
145 if request.symrefs
146 && let Some(target) = reference.target().try_name()
147 {
148 line.push_str(&format!(" symref-target:{}", target.as_bstr()));
149 }
150
151 if request.peel
152 && let Ok(peeled) = reference.peel_to_id()
153 {
154 line.push_str(&format!(" peeled:{}", peeled.detach()));
155 }
156
157 refs.insert(line);
158 }
159 }
160
161 let mut out = Vec::new();
162 for line in refs {
163 out.extend_from_slice(&pktline::encode(format!("{line}\n").as_bytes()));
164 }
165 out.extend_from_slice(pktline::flush());
166 Ok(out)
167}
168
169pub fn encode_fetch_pack_response(pack_bytes: &[u8]) -> Vec<u8> {
170 let mut out = Vec::new();
171 out.extend_from_slice(&pktline::encode(b"packfile\n"));
172
173 let mut pos = 0;
174 while pos + 4 <= pack_bytes.len() {
175 let len_str = match std::str::from_utf8(&pack_bytes[pos..pos + 4]) {
176 Ok(v) => v,
177 Err(_) => break,
178 };
179 pos += 4;
180
181 if len_str == "0000" {
182 out.extend_from_slice(b"0000");
183 break;
184 }
185
186 let len = match usize::from_str_radix(len_str, 16) {
187 Ok(v) if v >= 4 && pos + (v - 4) <= pack_bytes.len() => v,
188 _ => break,
189 };
190
191 let frame = &pack_bytes[pos - 4..pos + (len - 4)];
192 let payload = &pack_bytes[pos..pos + (len - 4)];
193 pos += len - 4;
194
195 if payload.starts_with(&[0x01])
196 || payload.starts_with(&[0x02])
197 || payload.starts_with(&[0x03])
198 {
199 out.extend_from_slice(frame);
200 }
201 }
202
203 out
204}
205
206pub struct PrefixThenReader<R> {
207 prefix: Cursor<Vec<u8>>,
208 reader: R,
209}
210
211impl<R> PrefixThenReader<R> {
212 pub fn new(prefix: Vec<u8>, reader: R) -> Self {
213 Self {
214 prefix: Cursor::new(prefix),
215 reader,
216 }
217 }
218}
219
220pub struct PackSectionReader<R> {
221 reader: R,
222 buf: Vec<u8>,
223 out: Cursor<Vec<u8>>,
224 finished: bool,
225}
226
227impl<R> PackSectionReader<R> {
228 pub fn new(reader: R) -> Self {
229 Self {
230 reader,
231 buf: Vec::new(),
232 out: Cursor::new(Vec::new()),
233 finished: false,
234 }
235 }
236}
237
238impl<R: tokio::io::AsyncRead + Unpin> tokio::io::AsyncRead for PackSectionReader<R> {
239 fn poll_read(
240 mut self: std::pin::Pin<&mut Self>,
241 cx: &mut std::task::Context<'_>,
242 buf: &mut tokio::io::ReadBuf<'_>,
243 ) -> std::task::Poll<std::io::Result<()>> {
244 loop {
245 if (self.out.position() as usize) < self.out.get_ref().len() {
246 let remaining = &self.out.get_ref()[self.out.position() as usize..];
247 let to_copy = remaining.len().min(buf.remaining());
248 buf.put_slice(&remaining[..to_copy]);
249 let next = self.out.position() + to_copy as u64;
250 self.out.set_position(next);
251 return std::task::Poll::Ready(Ok(()));
252 }
253
254 if self.finished {
255 return std::task::Poll::Ready(Ok(()));
256 }
257
258 let mut frame_buf = [0u8; 8192];
259 let mut read_buf = tokio::io::ReadBuf::new(&mut frame_buf);
260 match std::pin::Pin::new(&mut self.reader).poll_read(cx, &mut read_buf) {
261 std::task::Poll::Pending => return std::task::Poll::Pending,
262 std::task::Poll::Ready(Err(err)) => return std::task::Poll::Ready(Err(err)),
263 std::task::Poll::Ready(Ok(())) => {
264 let filled = read_buf.filled();
265 if filled.is_empty() {
266 self.finished = true;
267 return std::task::Poll::Ready(Ok(()));
268 }
269 self.buf.extend_from_slice(filled);
270 }
271 }
272
273 let mut emitted = Vec::new();
274 loop {
275 if self.buf.len() < 4 {
276 break;
277 }
278 let len_str = match std::str::from_utf8(&self.buf[..4]) {
279 Ok(v) => v,
280 Err(_) => {
281 self.finished = true;
282 return std::task::Poll::Ready(Err(std::io::Error::other(
283 "invalid pkt-line prefix in pack response",
284 )));
285 }
286 };
287
288 if len_str == "0000" {
289 emitted.extend_from_slice(b"0000");
290 self.buf.drain(..4);
291 self.finished = true;
292 break;
293 }
294
295 let len = match usize::from_str_radix(len_str, 16) {
296 Ok(v) if v >= 4 => v,
297 _ => {
298 self.finished = true;
299 return std::task::Poll::Ready(Err(std::io::Error::other(
300 "invalid pkt-line length in pack response",
301 )));
302 }
303 };
304
305 if self.buf.len() < len {
306 break;
307 }
308
309 let frame = self.buf[..len].to_vec();
310 let payload = &frame[4..];
311 if payload.starts_with(&[0x01])
312 || payload.starts_with(&[0x02])
313 || payload.starts_with(&[0x03])
314 {
315 emitted.extend_from_slice(&frame);
316 }
317 self.buf.drain(..len);
318 }
319
320 if !emitted.is_empty() {
321 self.out = Cursor::new(emitted);
322 self.out.set_position(0);
323 }
324 }
325 }
326}
327
328impl<R: tokio::io::AsyncRead + Unpin> tokio::io::AsyncRead for PrefixThenReader<R> {
329 fn poll_read(
330 mut self: std::pin::Pin<&mut Self>,
331 cx: &mut std::task::Context<'_>,
332 buf: &mut tokio::io::ReadBuf<'_>,
333 ) -> std::task::Poll<std::io::Result<()>> {
334 if (self.prefix.position() as usize) < self.prefix.get_ref().len() {
335 let remaining = &self.prefix.get_ref()[self.prefix.position() as usize..];
336 let to_copy = remaining.len().min(buf.remaining());
337 buf.put_slice(&remaining[..to_copy]);
338 let next = self.prefix.position() + to_copy as u64;
339 self.prefix.set_position(next);
340 return std::task::Poll::Ready(Ok(()));
341 }
342
343 std::pin::Pin::new(&mut self.reader).poll_read(cx, buf)
344 }
345}
346
347pub fn encode_fetch_ready_and_acknowledgments(common: &[gix::ObjectId]) -> Vec<u8> {
348 let mut out = encode_fetch_acknowledgments(common);
349 if !common.is_empty() {
350 out.truncate(out.len() - 4);
351 out.extend_from_slice(&pktline::encode(b"ready\n"));
352 out.extend_from_slice(b"0001");
353 }
354 out
355}
356
357pub fn encode_fetch_acknowledgments(common: &[gix::ObjectId]) -> Vec<u8> {
358 let mut out = Vec::new();
359 out.extend_from_slice(&pktline::encode(b"acknowledgments\n"));
360
361 if common.is_empty() {
362 out.extend_from_slice(&pktline::encode(b"NAK\n"));
363 } else {
364 for oid in common {
365 out.extend_from_slice(&pktline::encode(format!("ACK {oid}\n").as_bytes()));
366 }
367 }
368
369 out.extend_from_slice(pktline::flush());
370 out
371}
372
373pub fn encode_shallow_info(update: &ShallowUpdate) -> Vec<u8> {
374 let mut out = Vec::new();
375 if update.shallow.is_empty() && update.unshallow.is_empty() {
376 return out;
377 }
378
379 out.extend_from_slice(&pktline::encode(b"shallow-info\n"));
380 for oid in &update.shallow {
381 out.extend_from_slice(&pktline::encode(format!("shallow {oid}\n").as_bytes()));
382 }
383 for oid in &update.unshallow {
384 out.extend_from_slice(&pktline::encode(format!("unshallow {oid}\n").as_bytes()));
385 }
386 out.extend_from_slice(b"0001");
387 out
388}
389
390pub fn common_haves(repo_path: &Path, request: &FetchRequest) -> Result<Vec<gix::ObjectId>> {
391 let repo = gix::open(repo_path)?;
392 let want_set: HashSet<gix::ObjectId> =
393 collect_want_closure(&repo, &request.upload_request.wants)?
394 .into_iter()
395 .collect();
396
397 Ok(request
398 .upload_request
399 .haves
400 .iter()
401 .copied()
402 .filter(|oid| want_set.contains(oid))
403 .collect())
404}
405
406pub fn apply_shallow_boundaries(
407 repo_path: &Path,
408 request: &mut FetchRequest,
409) -> Result<ShallowUpdate> {
410 let Some(depth) = request.upload_request.shallow.depth else {
411 return Ok(ShallowUpdate {
412 shallow: Vec::new(),
413 unshallow: Vec::new(),
414 });
415 };
416
417 let repo = gix::open(repo_path)?;
418 let previous_shallows = request.upload_request.shallow.client_shallows.clone();
419 let state = collect_depth_limited_commits(&repo, &request.upload_request, depth)?;
420
421 request.upload_request.object_ids = Some(state.included_objects.clone());
422 request
423 .upload_request
424 .haves
425 .extend(previous_shallows.iter().copied());
426
427 let next_shallows: HashSet<_> = state.shallow_boundary.iter().copied().collect();
428 let prev_shallows: HashSet<_> = previous_shallows.iter().copied().collect();
429
430 Ok(ShallowUpdate {
431 shallow: state
432 .shallow_boundary
433 .iter()
434 .copied()
435 .filter(|oid| !prev_shallows.contains(oid))
436 .collect(),
437 unshallow: previous_shallows
438 .into_iter()
439 .filter(|oid| !next_shallows.contains(oid))
440 .collect(),
441 })
442}
443
444struct DepthState {
445 included_objects: Vec<gix::ObjectId>,
446 shallow_boundary: Vec<gix::ObjectId>,
447}
448
449fn parse_ls_refs(args: Vec<String>) -> Result<Command> {
450 let mut request = LsRefsRequest::default();
451
452 for arg in args {
453 match arg.as_str() {
454 "peel" => request.peel = true,
455 "symrefs" => request.symrefs = true,
456 "unborn" => request.unborn = true,
457 _ => {
458 if let Some(prefix) = arg.strip_prefix("ref-prefix ") {
459 request.ref_prefixes.push(prefix.to_owned());
460 } else {
461 return Err(Error::Protocol(format!(
462 "unsupported ls-refs argument: {arg}"
463 )));
464 }
465 }
466 }
467 }
468
469 Ok(Command::LsRefs(request))
470}
471
472fn parse_fetch(args: Vec<String>) -> Result<Command> {
473 let mut wants = Vec::new();
474 let mut haves = Vec::new();
475 let mut done = false;
476 let mut capabilities = crate::pack::UploadPackCapabilities::default();
477 let mut shallow = crate::pack::ShallowRequest::default();
478
479 for arg in args {
480 if arg == "done" {
481 done = true;
482 } else if arg == "ofs-delta" {
483 capabilities.ofs_delta = true;
484 } else if arg == "deepen-relative" {
485 shallow.deepen_relative = true;
486 } else if arg == "thin-pack"
487 || arg == "no-progress"
488 || arg == "include-tag"
489 || arg == "wait-for-done"
490 {
491 continue;
492 } else if let Some(depth) = arg.strip_prefix("deepen ") {
493 shallow.depth = Some(
494 depth
495 .parse::<usize>()
496 .map_err(|_| Error::Protocol(format!("invalid deepen value: {depth}")))?,
497 );
498 } else if let Some(oid_hex) = arg.strip_prefix("shallow ") {
499 let oid = gix::ObjectId::from_hex(oid_hex.as_bytes())
500 .map_err(|_| Error::Protocol(format!("invalid OID in shallow: {oid_hex}")))?;
501 shallow.client_shallows.push(oid);
502 } else if let Some(oid_hex) = arg.strip_prefix("want ") {
503 let oid = gix::ObjectId::from_hex(oid_hex.as_bytes())
504 .map_err(|_| Error::Protocol(format!("invalid OID in want: {oid_hex}")))?;
505 wants.push(oid);
506 } else if let Some(oid_hex) = arg.strip_prefix("have ") {
507 let oid = gix::ObjectId::from_hex(oid_hex.as_bytes())
508 .map_err(|_| Error::Protocol(format!("invalid OID in have: {oid_hex}")))?;
509 haves.push(oid);
510 } else {
511 return Err(Error::Protocol(format!(
512 "unsupported fetch argument: {arg}"
513 )));
514 }
515 }
516
517 Ok(Command::Fetch(FetchRequest {
518 upload_request: UploadPackRequest {
519 wants,
520 haves,
521 done,
522 capabilities,
523 shallow,
524 object_ids: None,
525 },
526 }))
527}
528
529fn decode_pkt_lines(body: &[u8]) -> Result<Vec<String>> {
530 let mut pos = 0;
531 let mut out = Vec::new();
532
533 while pos < body.len() {
534 if pos + 4 > body.len() {
535 return Err(Error::Protocol("truncated pkt-line prefix".into()));
536 }
537
538 let len_str = std::str::from_utf8(&body[pos..pos + 4])
539 .map_err(|_| Error::Protocol("invalid pkt-line length prefix".into()))?;
540 pos += 4;
541
542 if len_str == "0000" {
543 break;
544 }
545 if len_str == "0001" {
546 out.push("0001".to_string());
547 continue;
548 }
549
550 let len = usize::from_str_radix(len_str, 16)
551 .map_err(|_| Error::Protocol("invalid pkt-line length".into()))?;
552 if len < 4 || pos + (len - 4) > body.len() {
553 return Err(Error::Protocol("invalid pkt-line frame length".into()));
554 }
555
556 let payload = &body[pos..pos + (len - 4)];
557 pos += len - 4;
558 let line = std::str::from_utf8(payload)
559 .map_err(|_| Error::Protocol("invalid UTF-8 in pkt-line".into()))?;
560 out.push(line.trim_end_matches('\n').to_owned());
561 }
562
563 Ok(out)
564}
565
566/// The set of objects reachable from `wants`, used to filter the client's
567/// `have` lines down to the ones we actually share.
568///
569/// Wants may be annotated tag *objects* (protocol-v2 `ls-refs` advertises
570/// unpeeled ids), so they are peeled before the commit walk — feeding a tag id
571/// to `rev_walk` fails the whole fetch with "Expected object of kind commit".
572fn collect_want_closure(
573 repo: &gix::Repository,
574 wants: &[gix::ObjectId],
575) -> Result<Vec<gix::ObjectId>> {
576 let (commit_wants, extras) =
577 crate::pack::peel_wants(repo, wants).map_err(|e| Error::Protocol(e.to_string()))?;
578 let mut seen = HashSet::new();
579 let mut out = Vec::new();
580
581 let walk = repo
582 .rev_walk(commit_wants.iter().copied())
583 .all()
584 .map_err(|e| Error::Protocol(e.to_string()))?;
585 for info_result in walk {
586 let info = info_result.map_err(|e| Error::Protocol(e.to_string()))?;
587 let commit_oid = info.id;
588 if !seen.insert(commit_oid) {
589 continue;
590 }
591 out.push(commit_oid);
592 }
593
594 // Tag objects (and direct tree/blob tag targets) are part of the closure
595 // too, so a `have` naming one counts as common.
596 out.extend(extras.into_iter().filter(|oid| !seen.contains(oid)));
597
598 Ok(out)
599}
600
601fn collect_depth_limited_commits(
602 repo: &gix::Repository,
603 request: &crate::pack::UploadPackRequest,
604 depth: usize,
605) -> Result<DepthState> {
606 use std::collections::{
607 HashSet,
608 VecDeque,
609 };
610
611 let mut queue = VecDeque::new();
612 let mut seen = HashSet::new();
613 let mut included_commits = Vec::new();
614 let mut included_objects = Vec::new();
615 let mut shallow_boundary = Vec::new();
616
617 let base_depth = if request.shallow.deepen_relative {
618 1usize
619 } else {
620 0
621 };
622 let limit = base_depth + depth;
623
624 // Wants may be annotated tag objects; walk from the peeled commit and let
625 // the tag chain itself ride along in the pack.
626 let (commit_wants, extras) = crate::pack::peel_wants(repo, &request.wants)
627 .map_err(|e| Error::Protocol(e.to_string()))?;
628 for want in &commit_wants {
629 queue.push_back((*want, 1usize));
630 }
631
632 while let Some((commit_oid, current_depth)) = queue.pop_front() {
633 if !seen.insert(commit_oid) {
634 continue;
635 }
636 included_commits.push(commit_oid);
637 included_objects.push(commit_oid);
638
639 let commit_obj = repo
640 .find_object(commit_oid)
641 .map_err(|e| Error::Protocol(e.to_string()))?;
642 let tree_oid =
643 gix::objs::CommitRefIter::from_bytes(&commit_obj.data, gix::hash::Kind::Sha1)
644 .tree_id()
645 .map_err(|e| Error::Protocol(e.to_string()))?;
646 collect_tree_oids(repo, tree_oid, &mut seen, &mut included_objects)?;
647 let parents: Vec<_> =
648 gix::objs::CommitRefIter::from_bytes(&commit_obj.data, gix::hash::Kind::Sha1)
649 .parent_ids()
650 .collect();
651
652 if current_depth >= limit || parents.is_empty() {
653 shallow_boundary.push(commit_oid);
654 continue;
655 }
656
657 for parent in parents {
658 queue.push_back((parent, current_depth + 1));
659 }
660 }
661
662 included_objects.extend(extras.into_iter().filter(|oid| !seen.contains(oid)));
663
664 Ok(DepthState {
665 included_objects,
666 shallow_boundary,
667 })
668}
669
670fn collect_tree_oids(
671 repo: &gix::Repository,
672 root_tree_oid: gix::ObjectId,
673 seen: &mut HashSet<gix::ObjectId>,
674 oids: &mut Vec<gix::ObjectId>,
675) -> Result<()> {
676 let mut stack = vec![root_tree_oid];
677
678 while let Some(tree_oid) = stack.pop() {
679 if !seen.insert(tree_oid) {
680 continue;
681 }
682
683 let tree_obj = repo
684 .find_object(tree_oid)
685 .map_err(|e| Error::Protocol(e.to_string()))?;
686 oids.push(tree_oid);
687
688 for entry_result in
689 gix::objs::TreeRefIter::from_bytes(&tree_obj.data, gix::hash::Kind::Sha1)
690 {
691 let entry = entry_result.map_err(|e| Error::Protocol(e.to_string()))?;
692 let entry_oid = entry.oid.to_owned();
693 let entry_mode = entry.mode;
694
695 if entry_mode.is_tree() {
696 stack.push(entry_oid);
697 } else if seen.insert(entry_oid) && !entry_mode.is_commit() {
698 oids.push(entry_oid);
699 }
700 }
701 }
702
703 Ok(())
704}
705
706#[cfg(test)]
707mod tests {
708 use std::path::PathBuf;
709
710 use super::*;
711 use crate::pack::ShallowRequest;
712
713 fn pkt(data: &str) -> Vec<u8> {
714 pktline::encode(data.as_bytes())
715 }
716
717 #[test]
718 fn parse_ls_refs_command() {
719 let mut body = Vec::new();
720 body.extend_from_slice(&pkt("command=ls-refs\n"));
721 body.extend_from_slice(b"0001");
722 body.extend_from_slice(&pkt("peel\n"));
723 body.extend_from_slice(&pkt("symrefs\n"));
724 body.extend_from_slice(&pkt("ref-prefix refs/heads/\n"));
725 body.extend_from_slice(b"0000");
726
727 let Command::LsRefs(req) = parse_command_request(&body).unwrap() else {
728 panic!("expected ls-refs command");
729 };
730 assert!(req.peel);
731 assert!(req.symrefs);
732 assert_eq!(req.ref_prefixes, vec!["refs/heads/"]);
733 }
734
735 #[test]
736 fn parse_fetch_command() {
737 let mut body = Vec::new();
738 body.extend_from_slice(&pkt("command=fetch\n"));
739 body.extend_from_slice(b"0001");
740 body.extend_from_slice(&pkt("ofs-delta\n"));
741 body.extend_from_slice(&pkt("want 0000000000000000000000000000000000000001\n"));
742 body.extend_from_slice(&pkt("done\n"));
743 body.extend_from_slice(b"0000");
744
745 let Command::Fetch(req) = parse_command_request(&body).unwrap() else {
746 panic!("expected fetch command");
747 };
748 assert_eq!(req.upload_request.wants.len(), 1);
749 assert!(req.upload_request.done);
750 assert!(req.upload_request.capabilities.ofs_delta);
751 }
752
753 #[test]
754 fn ls_refs_returns_unborn_head() {
755 let root = tempfile::TempDir::new().unwrap();
756 let repo_path = root.path().join("repo.git");
757 std::process::Command::new("git")
758 .args(["init", "--bare", repo_path.to_str().unwrap()])
759 .output()
760 .unwrap();
761 std::process::Command::new("git")
762 .args(["symbolic-ref", "HEAD", "refs/heads/main"])
763 .current_dir(&repo_path)
764 .output()
765 .unwrap();
766
767 let out = ls_refs(
768 &repo_path,
769 &LsRefsRequest {
770 unborn: true,
771 symrefs: true,
772 ..Default::default()
773 },
774 )
775 .unwrap();
776 let text = String::from_utf8(out).unwrap();
777 assert!(text.contains("unborn HEAD symref-target:refs/heads/main"));
778 }
779 /// Build a bare repo with two commits on `main` and an annotated tag on
780 /// the tip. Returns (bare repo path, tag object id, first commit id).
781 fn repo_with_annotated_tag(root: &Path) -> (PathBuf, gix::ObjectId, gix::ObjectId) {
782 use std::process::Command as Proc;
783
784 let bare_path = root.join("test.git");
785 let work_path = root.join("workdir");
786 let git = |args: &[&str], dir: &Path| {
787 let out = Proc::new("git")
788 .args(args)
789 .current_dir(dir)
790 .env("GIT_AUTHOR_NAME", "t")
791 .env("GIT_AUTHOR_EMAIL", "t@t")
792 .env("GIT_COMMITTER_NAME", "t")
793 .env("GIT_COMMITTER_EMAIL", "t@t")
794 .output()
795 .expect("git failed to run");
796 assert!(out.status.success(), "git {args:?} failed: {out:?}");
797 };
798
799 let out = Proc::new("git")
800 .args(["init", "--bare", "-b", "main", bare_path.to_str().unwrap()])
801 .output()
802 .unwrap();
803 assert!(out.status.success());
804 let out = Proc::new("git")
805 .args([
806 "clone",
807 bare_path.to_str().unwrap(),
808 work_path.to_str().unwrap(),
809 ])
810 .output()
811 .unwrap();
812 assert!(out.status.success());
813
814 std::fs::write(work_path.join("a.txt"), "one\n").unwrap();
815 git(&["add", "a.txt"], &work_path);
816 git(&["commit", "-m", "one"], &work_path);
817 let first = gix::open(&work_path).unwrap().head_id().unwrap().detach();
818
819 std::fs::write(work_path.join("a.txt"), "two\n").unwrap();
820 git(&["add", "a.txt"], &work_path);
821 git(&["commit", "-m", "two"], &work_path);
822 git(&["tag", "-a", "-m", "annotated", "v1"], &work_path);
823 git(&["push", "-q", "origin", "main", "v1"], &work_path);
824
825 let repo = gix::open(&bare_path).unwrap();
826 let tag_oid = repo
827 .find_reference("refs/tags/v1")
828 .unwrap()
829 .try_id()
830 .unwrap()
831 .detach();
832 assert_eq!(
833 repo.find_object(tag_oid).unwrap().kind,
834 gix::object::Kind::Tag,
835 "fixture must produce a real tag object"
836 );
837
838 (bare_path, tag_oid, first)
839 }
840
841 /// Regression: `git pull` of a repo with annotated tags sends the tag
842 /// *object* id as a want (that is what `ls-refs` advertises). Negotiation
843 /// used to feed it straight to `rev_walk`, failing every fetch with
844 /// "Expected object of kind commit but got tag".
845 #[test]
846 fn negotiation_accepts_annotated_tag_wants() {
847 let root = tempfile::TempDir::new().unwrap();
848 let (repo_path, tag_oid, first) = repo_with_annotated_tag(root.path());
849
850 let request = FetchRequest {
851 upload_request: UploadPackRequest {
852 wants: vec![tag_oid],
853 haves: vec![first],
854 done: false,
855 capabilities: Default::default(),
856 shallow: Default::default(),
857 object_ids: None,
858 },
859 };
860
861 let common = common_haves(&repo_path, &request).unwrap();
862 assert_eq!(common, vec![first], "the have is reachable from the tag");
863 }
864
865 /// The shallow (`--depth`) path peels tag wants too, and packs the tag
866 /// object itself alongside the depth-limited closure.
867 #[test]
868 fn shallow_boundaries_accept_annotated_tag_wants() {
869 let root = tempfile::TempDir::new().unwrap();
870 let (repo_path, tag_oid, first) = repo_with_annotated_tag(root.path());
871
872 let mut request = FetchRequest {
873 upload_request: UploadPackRequest {
874 wants: vec![tag_oid],
875 haves: vec![],
876 done: true,
877 capabilities: Default::default(),
878 shallow: ShallowRequest {
879 depth: Some(1),
880 ..Default::default()
881 },
882 object_ids: None,
883 },
884 };
885
886 let update = apply_shallow_boundaries(&repo_path, &mut request).unwrap();
887 assert_eq!(update.shallow.len(), 1, "depth 1 stops at the tip");
888 assert!(
889 !update.shallow.contains(&first),
890 "the parent commit is beyond the boundary"
891 );
892 let objects = request.upload_request.object_ids.unwrap();
893 assert!(objects.contains(&tag_oid), "tag object rides along");
894 }
895}