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 io::{
9 BufRead,
10 BufReader,
11 Read,
12 },
13 path::Path,
14 sync::atomic::AtomicBool,
15};
16
17use gix::{
18 objs::bstr::BString,
19 prelude::ObjectIdExt,
20 progress::Discard,
21 refs::{
22 Target,
23 transaction::{
24 Change,
25 LogChange,
26 PreviousValue,
27 RefEdit,
28 RefLog,
29 },
30 },
31};
32
33use crate::{
34 error::{
35 Error,
36 Result,
37 },
38 pktline,
39};
40
41const ZERO_ID: &str = "0000000000000000000000000000000000000000";
42const CAPABILITIES: &str = concat!(
43 "report-status report-status-v2 delete-refs side-band-64k quiet ofs-delta object-format=sha1 agent=gitserver/",
44 env!("CARGO_PKG_VERSION")
45);
46
47pub fn advertise_receive_refs(repo_path: &Path) -> Result<Vec<u8>> {
48 let repo = gix::open(repo_path)?;
49 let mut out = Vec::new();
50 let head_name = repo.head_name().ok().flatten();
51 // Advertise each ref's *direct* target — for an annotated tag that is the
52 // tag object, not its peeled commit. Clients diff these ids against their
53 // own ref targets, and the update transaction later compares against the
54 // real target too; advertising peeled ids made identical tags look
55 // changed (and their no-op re-push then failed validation).
56 let mut refs: Vec<(String, gix::ObjectId)> = repo
57 .references()
58 .map_err(|e| Error::Protocol(format!("failed to open refs: {e}")))?
59 .all()
60 .map_err(|e| Error::Protocol(format!("failed to iterate refs: {e}")))?
61 .flatten()
62 .filter_map(|reference| {
63 reference
64 .try_id()
65 .map(|id| (reference.name().as_bstr().to_string(), id.detach()))
66 })
67 .collect();
68 refs.sort_by(|a, b| a.0.cmp(&b.0));
69
70 if refs.is_empty() {
71 out.extend_from_slice(&pktline::encode(
72 format!("{ZERO_ID} capabilities^{{}}\0{CAPABILITIES}\n").as_bytes(),
73 ));
74 } else {
75 let (first_name, first_id) = &refs[0];
76 let mut first = format!("{} {}\0{CAPABILITIES}", first_id, first_name);
77 if head_name
78 .as_ref()
79 .is_some_and(|head| head.as_bstr() == first_name.as_str())
80 {
81 first.push_str(&format!(" symref=HEAD:{first_name}"));
82 }
83 first.push('\n');
84 out.extend_from_slice(&pktline::encode(first.as_bytes()));
85
86 for (name, id) in refs.into_iter().skip(1) {
87 out.extend_from_slice(&pktline::encode(format!("{id} {name}\n").as_bytes()));
88 }
89 }
90
91 out.extend_from_slice(pktline::flush());
92 Ok(out)
93}
94
95pub fn receive_pack<R: Read>(repo_path: &Path, request: R) -> Result<Vec<u8>> {
96 let interrupt = AtomicBool::new(false);
97 receive_pack_with_interrupt(repo_path, request, &interrupt)
98}
99
100pub fn receive_pack_with_interrupt<R: Read>(
101 repo_path: &Path,
102 request: R,
103 interrupt: &AtomicBool,
104) -> Result<Vec<u8>> {
105 let repo = gix::open(repo_path)?;
106 let mut parsed = parse_request(request, interrupt)?;
107 let status = apply_commands(&repo, repo_path, &mut parsed, interrupt)?;
108 Ok(encode_report_status(&parsed.capabilities, &status))
109}
110
111#[derive(Default)]
112struct ReceivePackCapabilities {
113 report_status: bool,
114 report_status_v2: bool,
115}
116
117struct ReceivePackRequest<R> {
118 commands: Vec<UpdateCommand>,
119 pack: R,
120 capabilities: ReceivePackCapabilities,
121}
122
123struct UpdateCommand {
124 old_id: String,
125 new_id: String,
126 refname: String,
127}
128
129enum CommandStatus {
130 Ok(String),
131 Ng(String, String),
132}
133
134fn parse_request<R: Read>(
135 request: R,
136 interrupt: &AtomicBool,
137) -> Result<ReceivePackRequest<BufReader<R>>> {
138 let mut request = BufReader::new(request);
139 let mut commands = Vec::new();
140 let mut capabilities = ReceivePackCapabilities::default();
141
142 loop {
143 check_interrupt(interrupt)?;
144 let mut prefix = [0u8; 4];
145 match request.read_exact(&mut prefix) {
146 Ok(()) => {}
147 Err(err) if err.kind() == std::io::ErrorKind::UnexpectedEof => break,
148 Err(err) => return Err(Error::Io(err)),
149 }
150
151 let len_str = std::str::from_utf8(&prefix)
152 .map_err(|_| Error::Protocol("invalid pkt-line length prefix".into()))?;
153
154 if len_str == "0000" {
155 break;
156 }
157
158 let len = usize::from_str_radix(len_str, 16)
159 .map_err(|_| Error::Protocol("invalid pkt-line length".into()))?;
160 if len < 4 {
161 return Err(Error::Protocol("invalid pkt-line frame length".into()));
162 }
163
164 check_interrupt(interrupt)?;
165 let mut payload = vec![0u8; len - 4];
166 request.read_exact(&mut payload)?;
167
168 let (command_bytes, capability_bytes) =
169 if let Some(nul) = payload.iter().position(|b| *b == 0) {
170 (&payload[..nul], Some(&payload[nul + 1..]))
171 } else {
172 (&payload[..], None)
173 };
174
175 if let Some(capability_bytes) = capability_bytes {
176 let capabilities_line = std::str::from_utf8(capability_bytes)
177 .map_err(|_| Error::Protocol("invalid UTF-8 in receive-pack capabilities".into()))?
178 .trim_end_matches('\n');
179 for capability in capabilities_line.split_ascii_whitespace() {
180 match capability {
181 "report-status" => capabilities.report_status = true,
182 "report-status-v2" => {
183 capabilities.report_status = true;
184 capabilities.report_status_v2 = true;
185 }
186 _ => {}
187 }
188 }
189 }
190
191 let line = std::str::from_utf8(command_bytes)
192 .map_err(|_| Error::Protocol("invalid UTF-8 in update command".into()))?
193 .trim_end_matches('\n');
194 let mut parts = line.split_ascii_whitespace();
195 let Some(old_id) = parts.next() else { continue };
196 let Some(new_id) = parts.next() else { continue };
197 let Some(refname) = parts.next() else {
198 continue;
199 };
200
201 commands.push(UpdateCommand {
202 old_id: old_id.to_owned(),
203 new_id: new_id.to_owned(),
204 refname: refname.to_owned(),
205 });
206 }
207
208 Ok(ReceivePackRequest {
209 commands,
210 pack: request,
211 capabilities,
212 })
213}
214
215fn apply_commands<R: BufRead>(
216 repo: &gix::Repository,
217 repo_path: &Path,
218 request: &mut ReceivePackRequest<R>,
219 interrupt: &AtomicBool,
220) -> Result<Vec<CommandStatus>> {
221 check_interrupt(interrupt)?;
222 if request.pack.fill_buf().map(|buf: &[u8]| !buf.is_empty())? {
223 write_pack(repo, repo_path, &mut request.pack, interrupt)?;
224 }
225
226 let mut edits = Vec::with_capacity(request.commands.len());
227 for (index, command) in request.commands.iter().enumerate() {
228 check_interrupt(interrupt)?;
229 match validate_ref_update(repo, command, interrupt) {
230 Ok(edit) => edits.push((command.refname.clone(), edit)),
231 Err(err) => {
232 return Ok(request
233 .commands
234 .iter()
235 .enumerate()
236 .map(|(cmd_index, cmd)| {
237 if cmd_index == index {
238 CommandStatus::Ng(cmd.refname.clone(), err.to_string())
239 } else {
240 CommandStatus::Ng(
241 cmd.refname.clone(),
242 "transaction aborted due to another command failing validation"
243 .into(),
244 )
245 }
246 })
247 .collect());
248 }
249 }
250 }
251
252 check_interrupt(interrupt)?;
253 match repo.edit_references(edits.into_iter().map(|(_, edit)| edit)) {
254 Ok(_) => Ok(request
255 .commands
256 .iter()
257 .map(|cmd| CommandStatus::Ok(cmd.refname.clone()))
258 .collect()),
259 Err(err) => Ok(request
260 .commands
261 .iter()
262 .map(|cmd| CommandStatus::Ng(cmd.refname.clone(), format!("transaction failed: {err}")))
263 .collect()),
264 }
265}
266
267fn write_pack<R: BufRead>(
268 repo: &gix::Repository,
269 repo_path: &Path,
270 pack: &mut R,
271 interrupt: &AtomicBool,
272) -> Result<()> {
273 let mut progress = Discard;
274 // Pushes after the first send a thin pack whose delta bases (ref-deltas) live
275 // only in the existing object database; the lookup lets gix resolve them.
276 let outcome = gix_pack::Bundle::write_to_directory(
277 pack,
278 Some(repo_path.join("objects/pack").as_path()),
279 &mut progress,
280 interrupt,
281 Some(repo),
282 Default::default(),
283 );
284 if interrupt.load(std::sync::atomic::Ordering::Relaxed) {
285 return Err(Error::Io(std::io::Error::new(
286 std::io::ErrorKind::TimedOut,
287 "receive-pack timed out",
288 )));
289 }
290 let outcome =
291 outcome.map_err(|e| Error::Protocol(format!("failed to write incoming pack: {e}")))?;
292
293 if let Some(keep) = outcome.keep_path {
294 let _ = std::fs::remove_file(keep);
295 }
296 Ok(())
297}
298
299fn check_interrupt(interrupt: &AtomicBool) -> Result<()> {
300 if interrupt.load(std::sync::atomic::Ordering::Relaxed) {
301 Err(Error::Io(std::io::Error::new(
302 std::io::ErrorKind::TimedOut,
303 "receive-pack timed out",
304 )))
305 } else {
306 Ok(())
307 }
308}
309
310fn validate_ref_update(
311 repo: &gix::Repository,
312 command: &UpdateCommand,
313 interrupt: &AtomicBool,
314) -> Result<RefEdit> {
315 if command.new_id == ZERO_ID {
316 return validate_ref_delete(repo, command);
317 }
318
319 let is_branch = command.refname.starts_with("refs/heads/");
320 let is_tag = command.refname.starts_with("refs/tags/");
321 let new_id = gix::ObjectId::from_hex(command.new_id.as_bytes())
322 .map_err(|_| Error::Protocol(format!("invalid new object id: {}", command.new_id)))?;
323 let new_header = repo
324 .find_header(new_id)
325 .map_err(|e| Error::Protocol(format!("missing new object {}: {e}", command.new_id)))?;
326 if is_branch && new_header.kind() != gix::objs::Kind::Commit {
327 return Err(Error::Protocol(format!(
328 "updates to {} must point to a commit",
329 command.refname
330 )));
331 }
332
333 let name: gix::refs::FullName = command
334 .refname
335 .as_str()
336 .try_into()
337 .map_err(|e| Error::Protocol(format!("invalid ref name {}: {e}", command.refname)))?;
338
339 let (expected, log_message) = if command.old_id == ZERO_ID {
340 (PreviousValue::MustNotExist, BString::from("push create"))
341 } else {
342 if is_tag {
343 return Err(Error::Protocol(format!(
344 "updating existing tag {} is not allowed",
345 command.refname
346 )));
347 }
348
349 let old_id = gix::ObjectId::from_hex(command.old_id.as_bytes())
350 .map_err(|_| Error::Protocol(format!("invalid old object id: {}", command.old_id)))?;
351 if is_branch {
352 ensure_fast_forward(repo, old_id, new_id, &command.refname, interrupt)?;
353 }
354 (
355 PreviousValue::MustExistAndMatch(Target::Object(old_id)),
356 BString::from("push"),
357 )
358 };
359
360 Ok(RefEdit {
361 change: Change::Update {
362 log: LogChange {
363 mode: RefLog::AndReference,
364 force_create_reflog: false,
365 message: log_message,
366 },
367 expected,
368 new: Target::Object(new_id),
369 },
370 name,
371 deref: false,
372 })
373}
374
375/// Validate a ref deletion (`new == zero`), advertised via `delete-refs`.
376/// The branch `HEAD` points at is protected — like a forge's default branch,
377/// deleting it would leave the repository unborn.
378fn validate_ref_delete(repo: &gix::Repository, command: &UpdateCommand) -> Result<RefEdit> {
379 if command.old_id == ZERO_ID {
380 return Err(Error::Protocol(format!(
381 "invalid delete of {} (old and new are both zero)",
382 command.refname
383 )));
384 }
385 if repo
386 .head_name()
387 .ok()
388 .flatten()
389 .is_some_and(|head| head.as_bstr() == command.refname.as_str())
390 {
391 return Err(Error::Protocol(format!(
392 "deletion of the default branch {} is not allowed",
393 command.refname
394 )));
395 }
396 let old_id = gix::ObjectId::from_hex(command.old_id.as_bytes())
397 .map_err(|_| Error::Protocol(format!("invalid old object id: {}", command.old_id)))?;
398 let name: gix::refs::FullName = command
399 .refname
400 .as_str()
401 .try_into()
402 .map_err(|e| Error::Protocol(format!("invalid ref name {}: {e}", command.refname)))?;
403 Ok(RefEdit {
404 change: Change::Delete {
405 expected: PreviousValue::MustExistAndMatch(Target::Object(old_id)),
406 log: RefLog::AndReference,
407 },
408 name,
409 deref: false,
410 })
411}
412
413fn ensure_fast_forward(
414 repo: &gix::Repository,
415 old_id: gix::ObjectId,
416 new_id: gix::ObjectId,
417 refname: &str,
418 interrupt: &AtomicBool,
419) -> Result<()> {
420 check_interrupt(interrupt)?;
421 if old_id == new_id {
422 return Ok(());
423 }
424
425 let old_commit_time = repo
426 .find_object(old_id)
427 .map_err(|e| Error::Protocol(format!("failed to inspect current tip for {refname}: {e}")))?
428 .try_into_commit()
429 .map_err(|_| Error::Protocol(format!("current tip of {refname} is not a commit")))?
430 .committer()
431 .map_err(|e| Error::Protocol(format!("failed to read commit metadata for {refname}: {e}")))?
432 .seconds();
433
434 let ancestors = new_id
435 .attach(repo)
436 .ancestors()
437 .sorting(gix::revision::walk::Sorting::ByCommitTimeCutoff {
438 order: Default::default(),
439 seconds: old_commit_time,
440 })
441 .all()
442 .map_err(|e| Error::Protocol(format!("failed to walk commits for {refname}: {e}")))?;
443
444 for id in ancestors {
445 check_interrupt(interrupt)?;
446 if id.is_ok_and(|commit| commit.id == old_id) {
447 return Ok(());
448 }
449 }
450
451 Err(Error::Protocol(format!(
452 "non-fast-forward update to {refname} is not allowed"
453 )))
454}
455
456fn encode_report_status(
457 capabilities: &ReceivePackCapabilities,
458 statuses: &[CommandStatus],
459) -> Vec<u8> {
460 if !capabilities.report_status {
461 return pktline::flush().to_vec();
462 }
463
464 let mut status_lines = Vec::new();
465 status_lines.extend_from_slice(&pktline::encode(b"unpack ok\n"));
466
467 for status in statuses {
468 match status {
469 CommandStatus::Ok(refname) => {
470 status_lines
471 .extend_from_slice(&pktline::encode(format!("ok {refname}\n").as_bytes()));
472 }
473 CommandStatus::Ng(refname, message) => {
474 status_lines.extend_from_slice(&pktline::encode(
475 format!("ng {refname} {message}\n").as_bytes(),
476 ));
477 }
478 }
479 }
480 status_lines.extend_from_slice(pktline::flush());
481
482 if capabilities.report_status_v2 {
483 let mut sideband = Vec::new();
484 const MAX_BAND_PAYLOAD: usize = 65519;
485 for chunk in status_lines.chunks(MAX_BAND_PAYLOAD) {
486 let len = 4 + 1 + chunk.len();
487 sideband.extend_from_slice(format!("{len:04x}").as_bytes());
488 sideband.push(0x01);
489 sideband.extend_from_slice(chunk);
490 }
491 sideband.extend_from_slice(pktline::flush());
492 sideband
493 } else {
494 status_lines
495 }
496}
497
498#[cfg(test)]
499mod tests {
500 use std::process::Command;
501
502 use tempfile::TempDir;
503
504 use super::*;
505
506 fn create_repo_with_commit(root: &std::path::Path) -> std::path::PathBuf {
507 let repo_path = root.join("test.git");
508 let work_dir = root.join("work");
509 std::fs::create_dir(&work_dir).unwrap();
510 Command::new("git")
511 .args(["init", "--bare", repo_path.to_str().unwrap()])
512 .output()
513 .unwrap();
514 Command::new("git")
515 .args(["symbolic-ref", "HEAD", "refs/heads/main"])
516 .current_dir(&repo_path)
517 .output()
518 .unwrap();
519 Command::new("git")
520 .args([
521 "clone",
522 repo_path.to_str().unwrap(),
523 work_dir.to_str().unwrap(),
524 ])
525 .output()
526 .unwrap();
527 Command::new("git")
528 .current_dir(&work_dir)
529 .args(["commit", "--allow-empty", "-m", "init"])
530 .env("GIT_AUTHOR_NAME", "Test")
531 .env("GIT_AUTHOR_EMAIL", "t@t.com")
532 .env("GIT_COMMITTER_NAME", "Test")
533 .env("GIT_COMMITTER_EMAIL", "t@t.com")
534 .output()
535 .unwrap();
536 Command::new("git")
537 .current_dir(&work_dir)
538 .args(["push", "origin", "main"])
539 .output()
540 .unwrap();
541 repo_path
542 }
543
544 #[test]
545 fn advertise_receive_pack_refs() {
546 let root = TempDir::new().unwrap();
547 let repo_path = create_repo_with_commit(root.path());
548 let output = advertise_receive_refs(&repo_path).unwrap();
549 let output_str = String::from_utf8_lossy(&output);
550 assert!(output_str.contains("refs/heads/main"));
551 assert!(output_str.contains("report-status"));
552 }
553
554 #[test]
555 fn parse_receive_pack_request_with_capabilities() {
556 let payload = b"0000000000000000000000000000000000000000 1111111111111111111111111111111111111111 refs/heads/main\0 report-status-v2 side-band-64k\n";
557 let mut body = format!("{:04x}", payload.len() + 4).into_bytes();
558 body.extend_from_slice(payload);
559 body.extend_from_slice(b"0000PACK");
560
561 let interrupt = AtomicBool::new(false);
562 let parsed = parse_request(std::io::Cursor::new(&body), &interrupt).unwrap();
563 assert_eq!(parsed.commands.len(), 1);
564 assert!(parsed.capabilities.report_status);
565 assert!(parsed.capabilities.report_status_v2);
566 let mut pack = String::new();
567 let mut reader = parsed.pack;
568 reader.read_to_string(&mut pack).unwrap();
569 assert_eq!(pack.as_bytes(), b"PACK");
570 }
571
572 #[test]
573 fn branch_updates_require_commit_target() {
574 let root = TempDir::new().unwrap();
575 let repo_path = create_repo_with_commit(root.path());
576 let repo = gix::open(repo_path).unwrap();
577 let tree_id = Command::new("git")
578 .args(["rev-parse", "HEAD^{tree}"])
579 .current_dir(root.path().join("work"))
580 .output()
581 .unwrap();
582 let tree_id = String::from_utf8(tree_id.stdout)
583 .unwrap()
584 .trim()
585 .to_string();
586
587 let err = validate_ref_update(
588 &repo,
589 &UpdateCommand {
590 old_id: ZERO_ID.into(),
591 new_id: tree_id,
592 refname: "refs/heads/feature".into(),
593 },
594 &AtomicBool::new(false),
595 )
596 .unwrap_err();
597
598 assert!(err.to_string().contains("must point to a commit"));
599 }
600
601 #[test]
602 fn receive_thin_pack_with_ref_deltas() {
603 let root = TempDir::new().unwrap();
604 let repo_path = root.path().join("test.git");
605 let work_dir = root.path().join("work");
606 std::fs::create_dir(&work_dir).unwrap();
607 Command::new("git")
608 .args(["init", "--bare", repo_path.to_str().unwrap()])
609 .output()
610 .unwrap();
611 Command::new("git")
612 .args(["symbolic-ref", "HEAD", "refs/heads/main"])
613 .current_dir(&repo_path)
614 .output()
615 .unwrap();
616 Command::new("git")
617 .args([
618 "clone",
619 repo_path.to_str().unwrap(),
620 work_dir.to_str().unwrap(),
621 ])
622 .output()
623 .unwrap();
624
625 let git = |args: &[&str]| {
626 let output = Command::new("git")
627 .current_dir(&work_dir)
628 .args(args)
629 .env("GIT_AUTHOR_NAME", "Test")
630 .env("GIT_AUTHOR_EMAIL", "t@t.com")
631 .env("GIT_COMMITTER_NAME", "Test")
632 .env("GIT_COMMITTER_EMAIL", "t@t.com")
633 .output()
634 .unwrap();
635 assert!(output.status.success(), "git {args:?}: {output:?}");
636 String::from_utf8(output.stdout).unwrap().trim().to_string()
637 };
638
639 // A large repetitive blob so the follow-up commit deltas against it.
640 let base_content = "this line repeats to make the blob delta-friendly\n".repeat(200);
641 std::fs::write(work_dir.join("data.txt"), &base_content).unwrap();
642 git(&["add", "data.txt"]);
643 git(&["commit", "-m", "base"]);
644 git(&["push", "origin", "main"]);
645 let old_id = git(&["rev-parse", "HEAD"]);
646
647 std::fs::write(
648 work_dir.join("data.txt"),
649 format!("{base_content}one more line\n"),
650 )
651 .unwrap();
652 git(&["add", "data.txt"]);
653 git(&["commit", "-m", "append"]);
654 let new_id = git(&["rev-parse", "HEAD"]);
655
656 // Build a thin pack exactly like a push would: bases from old_id stay out.
657 let mut pack_objects = Command::new("git")
658 .current_dir(&work_dir)
659 .args(["pack-objects", "--thin", "--stdout", "--revs", "-q"])
660 .stdin(std::process::Stdio::piped())
661 .stdout(std::process::Stdio::piped())
662 .spawn()
663 .unwrap();
664 use std::io::Write as _;
665 pack_objects
666 .stdin
667 .take()
668 .unwrap()
669 .write_all(format!("{new_id}\n^{old_id}\n").as_bytes())
670 .unwrap();
671 let pack = pack_objects.wait_with_output().unwrap();
672 assert!(pack.status.success());
673 let pack = pack.stdout;
674
675 // The fix only matters if the pack really contains ref deltas.
676 let entries = gix_pack::data::input::BytesToEntriesIter::new_from_header(
677 std::io::Cursor::new(&pack),
678 gix_pack::data::input::Mode::Verify,
679 gix_pack::data::input::EntryDataMode::Ignore,
680 gix::hash::Kind::Sha1,
681 )
682 .unwrap();
683 let has_ref_delta = entries
684 .map(|e| e.unwrap())
685 .any(|entry| matches!(entry.header, gix_pack::data::entry::Header::RefDelta { .. }));
686 assert!(has_ref_delta, "test pack should contain ref delta objects");
687
688 let mut body = Vec::new();
689 body.extend_from_slice(&pktline::encode(
690 format!("{old_id} {new_id} refs/heads/main\0 report-status\n").as_bytes(),
691 ));
692 body.extend_from_slice(pktline::flush());
693 body.extend_from_slice(&pack);
694
695 let response = receive_pack(&repo_path, std::io::Cursor::new(body)).unwrap();
696 let response = String::from_utf8_lossy(&response);
697 assert!(response.contains("unpack ok"), "response: {response}");
698 assert!(
699 response.contains("ok refs/heads/main"),
700 "response: {response}"
701 );
702
703 let repo = gix::open(&repo_path).unwrap();
704 assert_eq!(repo.head_id().unwrap().detach().to_string(), new_id);
705 }
706
707 #[test]
708 fn ensure_fast_forward_respects_interrupt() {
709 let root = TempDir::new().unwrap();
710 let repo_path = create_repo_with_commit(root.path());
711 let repo = gix::open(repo_path).unwrap();
712 let head = repo.head_id().unwrap().detach();
713 let interrupt = AtomicBool::new(true);
714
715 let err =
716 ensure_fast_forward(&repo, head, head, "refs/heads/main", &interrupt).unwrap_err();
717 match err {
718 Error::Io(inner) => assert_eq!(inner.kind(), std::io::ErrorKind::TimedOut),
719 other => panic!("expected timeout io error, got {other}"),
720 }
721 }
722}