anvilsign in

collin/anvil

main / vendor / gitserver-core / src / receive_pack.rs
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 // gix-pack 0.74 moved `object_hash` out of `Options` into its own argument;
277 // it used to come from `Options::default()`, i.e. always Sha1. Taking it
278 // from the repository is what that default was standing in for.
279 let outcome = gix_pack::Bundle::write_to_directory(
280 pack,
281 Some(repo_path.join("objects/pack").as_path()),
282 &mut progress,
283 interrupt,
284 Some(repo),
285 repo.object_hash(),
286 Default::default(),
287 );
288 if interrupt.load(std::sync::atomic::Ordering::Relaxed) {
289 return Err(Error::Io(std::io::Error::new(
290 std::io::ErrorKind::TimedOut,
291 "receive-pack timed out",
292 )));
293 }
294 let outcome =
295 outcome.map_err(|e| Error::Protocol(format!("failed to write incoming pack: {e}")))?;
296
297 if let Some(keep) = outcome.keep_path {
298 let _ = std::fs::remove_file(keep);
299 }
300 Ok(())
301}
302
303fn check_interrupt(interrupt: &AtomicBool) -> Result<()> {
304 if interrupt.load(std::sync::atomic::Ordering::Relaxed) {
305 Err(Error::Io(std::io::Error::new(
306 std::io::ErrorKind::TimedOut,
307 "receive-pack timed out",
308 )))
309 } else {
310 Ok(())
311 }
312}
313
314fn validate_ref_update(
315 repo: &gix::Repository,
316 command: &UpdateCommand,
317 interrupt: &AtomicBool,
318) -> Result<RefEdit> {
319 if command.new_id == ZERO_ID {
320 return validate_ref_delete(repo, command);
321 }
322
323 let is_branch = command.refname.starts_with("refs/heads/");
324 let is_tag = command.refname.starts_with("refs/tags/");
325 let new_id = gix::ObjectId::from_hex(command.new_id.as_bytes())
326 .map_err(|_| Error::Protocol(format!("invalid new object id: {}", command.new_id)))?;
327 let new_header = repo
328 .find_header(new_id)
329 .map_err(|e| Error::Protocol(format!("missing new object {}: {e}", command.new_id)))?;
330 if is_branch && new_header.kind() != gix::objs::Kind::Commit {
331 return Err(Error::Protocol(format!(
332 "updates to {} must point to a commit",
333 command.refname
334 )));
335 }
336
337 let name: gix::refs::FullName = command
338 .refname
339 .as_str()
340 .try_into()
341 .map_err(|e| Error::Protocol(format!("invalid ref name {}: {e}", command.refname)))?;
342
343 let (expected, log_message) = if command.old_id == ZERO_ID {
344 (PreviousValue::MustNotExist, BString::from("push create"))
345 } else {
346 if is_tag {
347 return Err(Error::Protocol(format!(
348 "updating existing tag {} is not allowed",
349 command.refname
350 )));
351 }
352
353 let old_id = gix::ObjectId::from_hex(command.old_id.as_bytes())
354 .map_err(|_| Error::Protocol(format!("invalid old object id: {}", command.old_id)))?;
355 if is_branch {
356 ensure_fast_forward(repo, old_id, new_id, &command.refname, interrupt)?;
357 }
358 (
359 PreviousValue::MustExistAndMatch(Target::Object(old_id)),
360 BString::from("push"),
361 )
362 };
363
364 Ok(RefEdit {
365 change: Change::Update {
366 log: LogChange {
367 mode: RefLog::AndReference,
368 force_create_reflog: false,
369 message: log_message,
370 },
371 expected,
372 new: Target::Object(new_id),
373 },
374 name,
375 deref: false,
376 })
377}
378
379/// Validate a ref deletion (`new == zero`), advertised via `delete-refs`.
380/// The branch `HEAD` points at is protected — like a forge's default branch,
381/// deleting it would leave the repository unborn.
382fn validate_ref_delete(repo: &gix::Repository, command: &UpdateCommand) -> Result<RefEdit> {
383 if command.old_id == ZERO_ID {
384 return Err(Error::Protocol(format!(
385 "invalid delete of {} (old and new are both zero)",
386 command.refname
387 )));
388 }
389 if repo
390 .head_name()
391 .ok()
392 .flatten()
393 .is_some_and(|head| head.as_bstr() == command.refname.as_str())
394 {
395 return Err(Error::Protocol(format!(
396 "deletion of the default branch {} is not allowed",
397 command.refname
398 )));
399 }
400 let old_id = gix::ObjectId::from_hex(command.old_id.as_bytes())
401 .map_err(|_| Error::Protocol(format!("invalid old object id: {}", command.old_id)))?;
402 let name: gix::refs::FullName = command
403 .refname
404 .as_str()
405 .try_into()
406 .map_err(|e| Error::Protocol(format!("invalid ref name {}: {e}", command.refname)))?;
407 Ok(RefEdit {
408 change: Change::Delete {
409 expected: PreviousValue::MustExistAndMatch(Target::Object(old_id)),
410 log: RefLog::AndReference,
411 },
412 name,
413 deref: false,
414 })
415}
416
417fn ensure_fast_forward(
418 repo: &gix::Repository,
419 old_id: gix::ObjectId,
420 new_id: gix::ObjectId,
421 refname: &str,
422 interrupt: &AtomicBool,
423) -> Result<()> {
424 check_interrupt(interrupt)?;
425 if old_id == new_id {
426 return Ok(());
427 }
428
429 let old_commit_time = repo
430 .find_object(old_id)
431 .map_err(|e| Error::Protocol(format!("failed to inspect current tip for {refname}: {e}")))?
432 .try_into_commit()
433 .map_err(|_| Error::Protocol(format!("current tip of {refname} is not a commit")))?
434 .committer()
435 .map_err(|e| Error::Protocol(format!("failed to read commit metadata for {refname}: {e}")))?
436 .seconds();
437
438 let ancestors = new_id
439 .attach(repo)
440 .ancestors()
441 .sorting(gix::revision::walk::Sorting::ByCommitTimeCutoff {
442 order: Default::default(),
443 seconds: old_commit_time,
444 })
445 .all()
446 .map_err(|e| Error::Protocol(format!("failed to walk commits for {refname}: {e}")))?;
447
448 for id in ancestors {
449 check_interrupt(interrupt)?;
450 if id.is_ok_and(|commit| commit.id == old_id) {
451 return Ok(());
452 }
453 }
454
455 Err(Error::Protocol(format!(
456 "non-fast-forward update to {refname} is not allowed"
457 )))
458}
459
460fn encode_report_status(
461 capabilities: &ReceivePackCapabilities,
462 statuses: &[CommandStatus],
463) -> Vec<u8> {
464 if !capabilities.report_status {
465 return pktline::flush().to_vec();
466 }
467
468 let mut status_lines = Vec::new();
469 status_lines.extend_from_slice(&pktline::encode(b"unpack ok\n"));
470
471 for status in statuses {
472 match status {
473 CommandStatus::Ok(refname) => {
474 status_lines
475 .extend_from_slice(&pktline::encode(format!("ok {refname}\n").as_bytes()));
476 }
477 CommandStatus::Ng(refname, message) => {
478 status_lines.extend_from_slice(&pktline::encode(
479 format!("ng {refname} {message}\n").as_bytes(),
480 ));
481 }
482 }
483 }
484 status_lines.extend_from_slice(pktline::flush());
485
486 if capabilities.report_status_v2 {
487 let mut sideband = Vec::new();
488 const MAX_BAND_PAYLOAD: usize = 65519;
489 for chunk in status_lines.chunks(MAX_BAND_PAYLOAD) {
490 let len = 4 + 1 + chunk.len();
491 sideband.extend_from_slice(format!("{len:04x}").as_bytes());
492 sideband.push(0x01);
493 sideband.extend_from_slice(chunk);
494 }
495 sideband.extend_from_slice(pktline::flush());
496 sideband
497 } else {
498 status_lines
499 }
500}
501
502#[cfg(test)]
503mod tests {
504 use std::process::Command;
505
506 use tempfile::TempDir;
507
508 use super::*;
509
510 fn create_repo_with_commit(root: &std::path::Path) -> std::path::PathBuf {
511 let repo_path = root.join("test.git");
512 let work_dir = root.join("work");
513 std::fs::create_dir(&work_dir).unwrap();
514 Command::new("git")
515 .args(["init", "--bare", repo_path.to_str().unwrap()])
516 .output()
517 .unwrap();
518 Command::new("git")
519 .args(["symbolic-ref", "HEAD", "refs/heads/main"])
520 .current_dir(&repo_path)
521 .output()
522 .unwrap();
523 Command::new("git")
524 .args([
525 "clone",
526 repo_path.to_str().unwrap(),
527 work_dir.to_str().unwrap(),
528 ])
529 .output()
530 .unwrap();
531 Command::new("git")
532 .current_dir(&work_dir)
533 .args(["commit", "--allow-empty", "-m", "init"])
534 .env("GIT_AUTHOR_NAME", "Test")
535 .env("GIT_AUTHOR_EMAIL", "t@t.com")
536 .env("GIT_COMMITTER_NAME", "Test")
537 .env("GIT_COMMITTER_EMAIL", "t@t.com")
538 .output()
539 .unwrap();
540 Command::new("git")
541 .current_dir(&work_dir)
542 .args(["push", "origin", "main"])
543 .output()
544 .unwrap();
545 repo_path
546 }
547
548 #[test]
549 fn advertise_receive_pack_refs() {
550 let root = TempDir::new().unwrap();
551 let repo_path = create_repo_with_commit(root.path());
552 let output = advertise_receive_refs(&repo_path).unwrap();
553 let output_str = String::from_utf8_lossy(&output);
554 assert!(output_str.contains("refs/heads/main"));
555 assert!(output_str.contains("report-status"));
556 }
557
558 #[test]
559 fn parse_receive_pack_request_with_capabilities() {
560 let payload = b"0000000000000000000000000000000000000000 1111111111111111111111111111111111111111 refs/heads/main\0 report-status-v2 side-band-64k\n";
561 let mut body = format!("{:04x}", payload.len() + 4).into_bytes();
562 body.extend_from_slice(payload);
563 body.extend_from_slice(b"0000PACK");
564
565 let interrupt = AtomicBool::new(false);
566 let parsed = parse_request(std::io::Cursor::new(&body), &interrupt).unwrap();
567 assert_eq!(parsed.commands.len(), 1);
568 assert!(parsed.capabilities.report_status);
569 assert!(parsed.capabilities.report_status_v2);
570 let mut pack = String::new();
571 let mut reader = parsed.pack;
572 reader.read_to_string(&mut pack).unwrap();
573 assert_eq!(pack.as_bytes(), b"PACK");
574 }
575
576 #[test]
577 fn branch_updates_require_commit_target() {
578 let root = TempDir::new().unwrap();
579 let repo_path = create_repo_with_commit(root.path());
580 let repo = gix::open(repo_path).unwrap();
581 let tree_id = Command::new("git")
582 .args(["rev-parse", "HEAD^{tree}"])
583 .current_dir(root.path().join("work"))
584 .output()
585 .unwrap();
586 let tree_id = String::from_utf8(tree_id.stdout)
587 .unwrap()
588 .trim()
589 .to_string();
590
591 let err = validate_ref_update(
592 &repo,
593 &UpdateCommand {
594 old_id: ZERO_ID.into(),
595 new_id: tree_id,
596 refname: "refs/heads/feature".into(),
597 },
598 &AtomicBool::new(false),
599 )
600 .unwrap_err();
601
602 assert!(err.to_string().contains("must point to a commit"));
603 }
604
605 #[test]
606 fn receive_thin_pack_with_ref_deltas() {
607 let root = TempDir::new().unwrap();
608 let repo_path = root.path().join("test.git");
609 let work_dir = root.path().join("work");
610 std::fs::create_dir(&work_dir).unwrap();
611 Command::new("git")
612 .args(["init", "--bare", repo_path.to_str().unwrap()])
613 .output()
614 .unwrap();
615 Command::new("git")
616 .args(["symbolic-ref", "HEAD", "refs/heads/main"])
617 .current_dir(&repo_path)
618 .output()
619 .unwrap();
620 Command::new("git")
621 .args([
622 "clone",
623 repo_path.to_str().unwrap(),
624 work_dir.to_str().unwrap(),
625 ])
626 .output()
627 .unwrap();
628
629 let git = |args: &[&str]| {
630 let output = Command::new("git")
631 .current_dir(&work_dir)
632 .args(args)
633 .env("GIT_AUTHOR_NAME", "Test")
634 .env("GIT_AUTHOR_EMAIL", "t@t.com")
635 .env("GIT_COMMITTER_NAME", "Test")
636 .env("GIT_COMMITTER_EMAIL", "t@t.com")
637 .output()
638 .unwrap();
639 assert!(output.status.success(), "git {args:?}: {output:?}");
640 String::from_utf8(output.stdout).unwrap().trim().to_string()
641 };
642
643 // A large repetitive blob so the follow-up commit deltas against it.
644 let base_content = "this line repeats to make the blob delta-friendly\n".repeat(200);
645 std::fs::write(work_dir.join("data.txt"), &base_content).unwrap();
646 git(&["add", "data.txt"]);
647 git(&["commit", "-m", "base"]);
648 git(&["push", "origin", "main"]);
649 let old_id = git(&["rev-parse", "HEAD"]);
650
651 std::fs::write(
652 work_dir.join("data.txt"),
653 format!("{base_content}one more line\n"),
654 )
655 .unwrap();
656 git(&["add", "data.txt"]);
657 git(&["commit", "-m", "append"]);
658 let new_id = git(&["rev-parse", "HEAD"]);
659
660 // Build a thin pack exactly like a push would: bases from old_id stay out.
661 let mut pack_objects = Command::new("git")
662 .current_dir(&work_dir)
663 .args(["pack-objects", "--thin", "--stdout", "--revs", "-q"])
664 .stdin(std::process::Stdio::piped())
665 .stdout(std::process::Stdio::piped())
666 .spawn()
667 .unwrap();
668 use std::io::Write as _;
669 pack_objects
670 .stdin
671 .take()
672 .unwrap()
673 .write_all(format!("{new_id}\n^{old_id}\n").as_bytes())
674 .unwrap();
675 let pack = pack_objects.wait_with_output().unwrap();
676 assert!(pack.status.success());
677 let pack = pack.stdout;
678
679 // The fix only matters if the pack really contains ref deltas.
680 let entries = gix_pack::data::input::BytesToEntriesIter::new_from_header(
681 std::io::Cursor::new(&pack),
682 gix_pack::data::input::Mode::Verify,
683 gix_pack::data::input::EntryDataMode::Ignore,
684 gix::hash::Kind::Sha1,
685 )
686 .unwrap();
687 let has_ref_delta = entries
688 .map(|e| e.unwrap())
689 .any(|entry| matches!(entry.header, gix_pack::data::entry::Header::RefDelta { .. }));
690 assert!(has_ref_delta, "test pack should contain ref delta objects");
691
692 let mut body = Vec::new();
693 body.extend_from_slice(&pktline::encode(
694 format!("{old_id} {new_id} refs/heads/main\0 report-status\n").as_bytes(),
695 ));
696 body.extend_from_slice(pktline::flush());
697 body.extend_from_slice(&pack);
698
699 let response = receive_pack(&repo_path, std::io::Cursor::new(body)).unwrap();
700 let response = String::from_utf8_lossy(&response);
701 assert!(response.contains("unpack ok"), "response: {response}");
702 assert!(
703 response.contains("ok refs/heads/main"),
704 "response: {response}"
705 );
706
707 let repo = gix::open(&repo_path).unwrap();
708 assert_eq!(repo.head_id().unwrap().detach().to_string(), new_id);
709 }
710
711 #[test]
712 fn ensure_fast_forward_respects_interrupt() {
713 let root = TempDir::new().unwrap();
714 let repo_path = create_repo_with_commit(root.path());
715 let repo = gix::open(repo_path).unwrap();
716 let head = repo.head_id().unwrap().detach();
717 let interrupt = AtomicBool::new(true);
718
719 let err =
720 ensure_fast_forward(&repo, head, head, "refs/heads/main", &interrupt).unwrap_err();
721 match err {
722 Error::Io(inner) => assert_eq!(inner.kind(), std::io::ErrorKind::TimedOut),
723 other => panic!("expected timeout io error, got {other}"),
724 }
725 }
726}