diff --git a/Cargo.lock b/Cargo.lock
index 0ecdf91f609bd8bf8ffb54c1710be98dcecdac38..37a8f7dfbc6fd72cd265dfa80d274e61412ccd0e 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -521,6 +521,20 @@
 ]
 
 [[package]]
+name = "dashmap"
+version = "6.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c"
+dependencies = [
+ "cfg-if",
+ "crossbeam-utils",
+ "hashbrown 0.14.5",
+ "lock_api",
+ "once_cell",
+ "parking_lot_core",
+]
+
+[[package]]
 name = "data-encoding"
 version = "2.11.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -772,10 +786,26 @@
 checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24"
 
 [[package]]
+name = "filetime"
+version = "0.2.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
+dependencies = [
+ "cfg-if",
+ "libc",
+]
+
+[[package]]
 name = "find-msvc-tools"
 version = "0.1.9"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+
+[[package]]
+name = "fnv"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
 
 [[package]]
 name = "foldhash"
@@ -942,6 +972,9 @@
 checksum = "ae54ae0ebd1a5a3c3f8d95dd3b5ca6e63f4fed9bfd585e13801a97d7bde8f9ce"
 dependencies = [
  "gix-actor",
+ "gix-attributes",
+ "gix-blame",
+ "gix-command",
  "gix-commitgraph",
  "gix-config",
  "gix-date",
@@ -949,15 +982,19 @@
  "gix-discover",
  "gix-error",
  "gix-features",
+ "gix-filter",
  "gix-fs",
  "gix-glob",
  "gix-hash",
  "gix-hashtable",
+ "gix-ignore",
+ "gix-index",
  "gix-lock",
  "gix-object",
  "gix-odb",
  "gix-pack",
  "gix-path",
+ "gix-pathspec",
  "gix-protocol",
  "gix-ref",
  "gix-refspec",
@@ -965,12 +1002,14 @@
  "gix-revwalk",
  "gix-sec",
  "gix-shallow",
+ "gix-submodule",
  "gix-tempfile",
  "gix-trace",
  "gix-traverse",
  "gix-url",
  "gix-utils",
  "gix-validate",
+ "gix-worktree",
  "gix-worktree-stream",
  "nonempty",
  "smallvec",
@@ -1003,6 +1042,35 @@
  "smallvec",
  "thiserror",
  "unicode-bom",
+]
+
+[[package]]
+name = "gix-bitmap"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52ebef0c26ad305747649e727bbcd56a7b7910754eb7cea88f6dff6f93c51283"
+dependencies = [
+ "gix-error",
+]
+
+[[package]]
+name = "gix-blame"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4d39a0c14af94c2edaa5eefe06d5ef2cdea55316ae9a9321314288e3f55fa4c0"
+dependencies = [
+ "gix-commitgraph",
+ "gix-date",
+ "gix-diff",
+ "gix-error",
+ "gix-hash",
+ "gix-object",
+ "gix-revwalk",
+ "gix-trace",
+ "gix-traverse",
+ "gix-worktree",
+ "smallvec",
+ "thiserror",
 ]
 
 [[package]]
@@ -1091,8 +1159,17 @@
 checksum = "3b6d9528f32d94cef2edf39a1ac01fe5a0fc44ddbb18d9e44099936047c3302b"
 dependencies = [
  "bstr",
+ "gix-command",
+ "gix-filter",
+ "gix-fs",
  "gix-hash",
+ "gix-imara-diff",
  "gix-object",
+ "gix-path",
+ "gix-tempfile",
+ "gix-trace",
+ "gix-traverse",
+ "gix-worktree",
  "thiserror",
 ]
 
@@ -1213,6 +1290,57 @@
 ]
 
 [[package]]
+name = "gix-ignore"
+version = "0.21.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d491bab9bf2c9f341dc754f425c31d5d3f63aca615312167b82e1deeaca97d8d"
+dependencies = [
+ "bstr",
+ "gix-glob",
+ "gix-path",
+ "gix-trace",
+ "unicode-bom",
+]
+
+[[package]]
+name = "gix-imara-diff"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19753d40da53d0ec41604750eeb969097a90fb2d7f7992730d904541c04e2c19"
+dependencies = [
+ "bstr",
+ "hashbrown 0.16.1",
+]
+
+[[package]]
+name = "gix-index"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e6b28cc592dc753adb58302bb14a64e412ee591a3bec77aa4df87bff74fa80d"
+dependencies = [
+ "bitflags 2.13.1",
+ "bstr",
+ "filetime",
+ "fnv",
+ "gix-bitmap",
+ "gix-features",
+ "gix-fs",
+ "gix-hash",
+ "gix-lock",
+ "gix-object",
+ "gix-traverse",
+ "gix-utils",
+ "gix-validate",
+ "hashbrown 0.17.1",
+ "itoa",
+ "libc",
+ "memmap2",
+ "rustix",
+ "smallvec",
+ "thiserror",
+]
+
+[[package]]
 name = "gix-lock"
 version = "23.0.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1307,6 +1435,21 @@
  "bstr",
  "gix-trace",
  "gix-validate",
+ "thiserror",
+]
+
+[[package]]
+name = "gix-pathspec"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3050783b41ee11511e1e8fb35623df81806194f4030395f14f48ea37c2798c9f"
+dependencies = [
+ "bitflags 2.13.1",
+ "bstr",
+ "gix-attributes",
+ "gix-config-value",
+ "gix-glob",
+ "gix-path",
  "thiserror",
 ]
 
@@ -1434,11 +1577,27 @@
 ]
 
 [[package]]
+name = "gix-submodule"
+version = "0.31.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3059890ef054066c22a94bfc6a3eaba0d806aedcd630a0bc9e5783fd88884781"
+dependencies = [
+ "bstr",
+ "gix-config",
+ "gix-path",
+ "gix-pathspec",
+ "gix-refspec",
+ "gix-url",
+ "thiserror",
+]
+
+[[package]]
 name = "gix-tempfile"
 version = "23.0.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "6ef60812443484e67bf84e444cc71b4c78ae62deb822221774a4fa0c57fdb17f"
 dependencies = [
+ "dashmap",
  "gix-fs",
  "libc",
  "parking_lot",
@@ -1517,6 +1676,24 @@
 ]
 
 [[package]]
+name = "gix-worktree"
+version = "0.53.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cef414ed275e8407cd5d53d301e83be19700b0dd3f859d2434417b58f454a2d1"
+dependencies = [
+ "bstr",
+ "gix-attributes",
+ "gix-fs",
+ "gix-glob",
+ "gix-hash",
+ "gix-ignore",
+ "gix-index",
+ "gix-object",
+ "gix-path",
+ "gix-validate",
+]
+
+[[package]]
 name = "gix-worktree-stream"
 version = "0.33.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1556,6 +1733,12 @@
 
 [[package]]
 name = "hashbrown"
+version = "0.14.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
+
+[[package]]
+name = "hashbrown"
 version = "0.16.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
@@ -1570,6 +1753,11 @@
 version = "0.17.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+dependencies = [
+ "allocator-api2",
+ "equivalent",
+ "foldhash",
+]
 
 [[package]]
 name = "heapless"
diff --git a/Cargo.toml b/Cargo.toml
index 12af51e0a56e0f37d01b6a6d2406c42c3a46dfef..fa8df52d4574b23ca67d44cd5107d908d82c546a 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -13,7 +13,7 @@
 [dependencies]
 axum = { version = "0.8", default-features = false, features = ["http1", "query", "tokio"] }
 clap = { version = "4.6", default-features = false, features = ["derive", "error-context", "help", "std", "usage"] }
-gix = { version = "0.84", default-features = false, features = ["parallel", "sha1", "sha256"] }
+gix = { version = "0.84", default-features = false, features = ["blame", "parallel", "sha1", "sha256"] }
 gix-pack = { version = "0.71", default-features = false, features = ["generate", "sha1", "sha256", "streaming-input"] }
 rand = "0.10"
 rusqlite = { version = "0.40", default-features = false, features = ["backup", "bundled"] }
diff --git a/scripts/check-m2-3 b/scripts/check-m2-3
new file mode 100755
index 0000000000000000000000000000000000000000..a692cff9b2fbde07bca1a293ed815ebf1f27913b
--- /dev/null
+++ b/scripts/check-m2-3
@@ -1,0 +1,5 @@
+#!/bin/sh
+set -eu
+
+./scripts/check
+cargo test --locked --release --test git_reads --test git_repository
diff --git a/src/git/mod.rs b/src/git/mod.rs
index d3e4cb4664a67ac8a81cdf810e1a23f85c806f0d..db96461ed5b4ee33a02a09b1e73a57d88cca03a4 100644
--- a/src/git/mod.rs
+++ b/src/git/mod.rs
@@ -1,5 +1,10 @@
 pub(crate) mod http;
 pub(crate) mod packetline;
+#[allow(
+    dead_code,
+    reason = "M2.3 establishes repository reads before the HTTP handlers call them"
+)]
+pub(crate) mod read;
 pub(crate) mod receive_pack;
 pub(crate) mod repository;
 pub(crate) mod transport;
diff --git a/src/git/read.rs b/src/git/read.rs
new file mode 100644
index 0000000000000000000000000000000000000000..83bb27f4e256635cc65fac6b21d751adcbd79756
--- /dev/null
+++ b/src/git/read.rs
@@ -1,0 +1,1043 @@
+use std::collections::{BTreeMap, HashSet, VecDeque};
+use std::io::Write;
+use std::path::{Path, PathBuf};
+use std::sync::Arc;
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::time::{Duration, Instant};
+
+use gix::bstr::ByteSlice;
+use gix::hash::ObjectId;
+use gix::objs::Kind as ObjectKind;
+use gix::objs::tree::{EntryKind, EntryMode};
+use gix::prelude::HeaderExt;
+use thiserror::Error;
+
+#[derive(Clone, Debug)]
+pub(crate) struct ReadLimits {
+    pub(crate) max_refs: usize,
+    pub(crate) max_history_commits: usize,
+    pub(crate) max_path_bytes: usize,
+    pub(crate) max_tree_bytes: usize,
+    pub(crate) max_tree_entries: usize,
+    pub(crate) max_commit_bytes: usize,
+    pub(crate) max_blob_bytes: usize,
+    pub(crate) max_diff_bytes: usize,
+    pub(crate) max_archive_entries: usize,
+    pub(crate) max_archive_bytes: usize,
+    pub(crate) max_duration: Duration,
+}
+
+impl Default for ReadLimits {
+    fn default() -> Self {
+        Self {
+            max_refs: 10_000,
+            max_history_commits: 10_000,
+            max_path_bytes: 4096,
+            max_tree_bytes: 16 * 1024 * 1024,
+            max_tree_entries: 100_000,
+            max_commit_bytes: 1024 * 1024,
+            max_blob_bytes: 16 * 1024 * 1024,
+            max_diff_bytes: 64 * 1024 * 1024,
+            max_archive_entries: 100_000,
+            max_archive_bytes: 256 * 1024 * 1024,
+            max_duration: Duration::from_secs(30),
+        }
+    }
+}
+
+#[derive(Clone, Debug, Default)]
+pub(crate) struct ReadCancellation {
+    cancelled: Arc<AtomicBool>,
+}
+
+impl ReadCancellation {
+    pub(crate) fn cancel(&self) {
+        self.cancelled.store(true, Ordering::Relaxed);
+    }
+}
+
+pub(crate) struct RepositoryReadService {
+    repository: gix::Repository,
+    limits: ReadLimits,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub(crate) struct RefInfo {
+    pub(crate) name: Vec<u8>,
+    pub(crate) target: ObjectId,
+    pub(crate) peeled: Option<ObjectId>,
+    pub(crate) symbolic_target: Option<Vec<u8>>,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub(crate) struct CommitInfo {
+    pub(crate) id: ObjectId,
+    pub(crate) tree: ObjectId,
+    pub(crate) parents: Vec<ObjectId>,
+    pub(crate) author_name: Vec<u8>,
+    pub(crate) author_email: Vec<u8>,
+    pub(crate) committed_at: i64,
+    pub(crate) message: Vec<u8>,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub(crate) struct TreeEntryInfo {
+    pub(crate) name: Vec<u8>,
+    pub(crate) id: ObjectId,
+    pub(crate) mode: u16,
+    pub(crate) kind: EntryKind,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub(crate) struct BlobInfo {
+    pub(crate) id: ObjectId,
+    pub(crate) mode: u16,
+    pub(crate) data: Vec<u8>,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub(crate) struct ReadmeInfo {
+    pub(crate) path: Vec<u8>,
+    pub(crate) blob: BlobInfo,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub(crate) struct DiffFile {
+    pub(crate) path: Vec<u8>,
+    pub(crate) old_id: Option<ObjectId>,
+    pub(crate) new_id: Option<ObjectId>,
+    pub(crate) old_mode: Option<u16>,
+    pub(crate) new_mode: Option<u16>,
+    pub(crate) binary: bool,
+    pub(crate) hunks: Vec<u8>,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub(crate) struct BlameHunk {
+    pub(crate) start_line: u32,
+    pub(crate) source_start_line: u32,
+    pub(crate) line_count: u32,
+    pub(crate) commit_id: ObjectId,
+    pub(crate) source_path: Option<Vec<u8>>,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub(crate) struct ArchiveStats {
+    pub(crate) entries: usize,
+    pub(crate) bytes: usize,
+}
+
+impl RepositoryReadService {
+    pub(crate) fn open(path: &Path, limits: ReadLimits) -> Result<Self, ReadError> {
+        let repository = gix::open(path).map_err(|error| ReadError::Open {
+            path: path.to_owned(),
+            reason: error.to_string(),
+        })?;
+        if !repository.is_bare() {
+            return Err(ReadError::NotBare(path.to_owned()));
+        }
+        validate_limits(&limits)?;
+        Ok(Self { repository, limits })
+    }
+
+    pub(crate) fn references(
+        &self,
+        cancellation: &ReadCancellation,
+    ) -> Result<Vec<RefInfo>, ReadError> {
+        let budget = self.budget(cancellation);
+        let platform = self
+            .repository
+            .references()
+            .map_err(|error| ReadError::Git(error.to_string()))?;
+        let iterator = platform
+            .all()
+            .map_err(|error| ReadError::Git(error.to_string()))?;
+        let mut references = Vec::new();
+        for reference in iterator {
+            budget.check()?;
+            if references.len() >= self.limits.max_refs {
+                return Err(ReadError::Limit("refs"));
+            }
+            let reference = reference.map_err(|error| ReadError::Git(error.to_string()))?;
+            if reference.name().as_bstr().len() > self.limits.max_path_bytes {
+                return Err(ReadError::Limit("ref name bytes"));
+            }
+            let Some(target) = reference.try_id().map(gix::Id::detach) else {
+                continue;
+            };
+            let name = reference.name().as_bstr().to_vec();
+            let peeled = if name.starts_with(b"refs/tags/") {
+                let mut candidate = reference.clone();
+                let candidate = candidate
+                    .peel_to_id()
+                    .map_err(|error| ReadError::Git(error.to_string()))?
+                    .detach();
+                (candidate != target).then_some(candidate)
+            } else {
+                None
+            };
+            references.push(RefInfo {
+                name,
+                target,
+                peeled,
+                symbolic_target: None,
+            });
+        }
+        references.sort_by(|left, right| left.name.cmp(&right.name));
+        if let Some(head) = self
+            .repository
+            .head_ref()
+            .map_err(|error| ReadError::Git(error.to_string()))?
+        {
+            if references.len() >= self.limits.max_refs {
+                return Err(ReadError::Limit("refs"));
+            }
+            references.insert(
+                0,
+                RefInfo {
+                    name: b"HEAD".to_vec(),
+                    target: head.id().detach(),
+                    peeled: None,
+                    symbolic_target: Some(head.name().as_bstr().to_vec()),
+                },
+            );
+        }
+        Ok(references)
+    }
+
+    pub(crate) fn commit(
+        &self,
+        id: ObjectId,
+        cancellation: &ReadCancellation,
+    ) -> Result<CommitInfo, ReadError> {
+        let budget = self.budget(cancellation);
+        self.read_commit(id, &budget)
+    }
+
+    pub(crate) fn history(
+        &self,
+        start: ObjectId,
+        cancellation: &ReadCancellation,
+    ) -> Result<Vec<CommitInfo>, ReadError> {
+        let budget = self.budget(cancellation);
+        self.history_with_budget(start, &budget)
+    }
+
+    pub(crate) fn tree(
+        &self,
+        commit: ObjectId,
+        path: &[u8],
+        cancellation: &ReadCancellation,
+    ) -> Result<Vec<TreeEntryInfo>, ReadError> {
+        validate_path(path, true, self.limits.max_path_bytes)?;
+        let budget = self.budget(cancellation);
+        let commit = self.read_commit(commit, &budget)?;
+        let tree_id = self.resolve_tree(commit.tree, path, &budget)?;
+        let tree = self.read_tree(tree_id, &budget)?;
+        if tree.entries.len() > self.limits.max_tree_entries {
+            return Err(ReadError::Limit("tree entries"));
+        }
+        tree.entries
+            .into_iter()
+            .map(|entry| {
+                budget.check()?;
+                Ok(TreeEntryInfo {
+                    name: entry.filename.to_vec(),
+                    id: entry.oid.to_owned(),
+                    mode: entry.mode.value(),
+                    kind: entry.mode.kind(),
+                })
+            })
+            .collect()
+    }
+
+    pub(crate) fn blob(
+        &self,
+        commit: ObjectId,
+        path: &[u8],
+        cancellation: &ReadCancellation,
+    ) -> Result<BlobInfo, ReadError> {
+        validate_path(path, false, self.limits.max_path_bytes)?;
+        let budget = self.budget(cancellation);
+        let commit = self.read_commit(commit, &budget)?;
+        let (id, mode) = self.resolve_blob(commit.tree, path, &budget)?;
+        self.read_blob(id, mode, &budget)
+    }
+
+    pub(crate) fn raw(
+        &self,
+        commit: ObjectId,
+        path: &[u8],
+        cancellation: &ReadCancellation,
+        output: &mut impl Write,
+    ) -> Result<usize, ReadError> {
+        validate_path(path, false, self.limits.max_path_bytes)?;
+        let budget = self.budget(cancellation);
+        let commit = self.read_commit(commit, &budget)?;
+        let (id, mode) = self.resolve_blob(commit.tree, path, &budget)?;
+        let blob = self.read_blob(id, mode, &budget)?;
+        budget.check()?;
+        output.write_all(&blob.data).map_err(ReadError::Output)?;
+        Ok(blob.data.len())
+    }
+
+    pub(crate) fn readme(
+        &self,
+        commit: ObjectId,
+        cancellation: &ReadCancellation,
+    ) -> Result<Option<ReadmeInfo>, ReadError> {
+        let budget = self.budget(cancellation);
+        let commit = self.read_commit(commit, &budget)?;
+        let tree = self.read_tree(commit.tree, &budget)?;
+        let mut candidates = Vec::new();
+        for entry in tree.entries {
+            budget.check()?;
+            if !entry.mode.is_blob() {
+                continue;
+            }
+            if let Some(priority) = readme_priority(&entry.filename) {
+                candidates.push((
+                    priority,
+                    entry.filename.to_vec(),
+                    entry.oid.to_owned(),
+                    entry.mode,
+                ));
+            }
+        }
+        candidates.sort_by(|left, right| (left.0, &left.1).cmp(&(right.0, &right.1)));
+        let Some((_, path, id, mode)) = candidates.into_iter().next() else {
+            return Ok(None);
+        };
+        Ok(Some(ReadmeInfo {
+            path,
+            blob: self.read_blob(id, mode, &budget)?,
+        }))
+    }
+
+    pub(crate) fn diff(
+        &self,
+        old_commit: ObjectId,
+        new_commit: ObjectId,
+        cancellation: &ReadCancellation,
+    ) -> Result<Vec<DiffFile>, ReadError> {
+        let budget = self.budget(cancellation);
+        let old = self.read_commit(old_commit, &budget)?;
+        let new = self.read_commit(new_commit, &budget)?;
+        let old_files = self.flatten_tree(old.tree, &budget)?;
+        let new_files = self.flatten_tree(new.tree, &budget)?;
+        let paths: BTreeMap<&[u8], ()> = old_files
+            .keys()
+            .chain(new_files.keys())
+            .map(|path| (path.as_slice(), ()))
+            .collect();
+        let mut files = Vec::new();
+        let mut bytes = 0_usize;
+        for path in paths.keys() {
+            budget.check()?;
+            let old = old_files.get(*path);
+            let new = new_files.get(*path);
+            if old == new {
+                continue;
+            }
+            let old_data = self.diff_blob(old, &budget)?;
+            let new_data = self.diff_blob(new, &budget)?;
+            bytes = checked_add(
+                bytes,
+                old_data.len(),
+                self.limits.max_diff_bytes,
+                "diff bytes",
+            )?;
+            bytes = checked_add(
+                bytes,
+                new_data.len(),
+                self.limits.max_diff_bytes,
+                "diff bytes",
+            )?;
+            let binary = old.is_some_and(|file| file.mode.is_commit())
+                || new.is_some_and(|file| file.mode.is_commit())
+                || old_data.contains(&0)
+                || new_data.contains(&0);
+            let hunks = if binary {
+                Vec::new()
+            } else {
+                unified_diff(&old_data, &new_data)?
+            };
+            bytes = checked_add(bytes, hunks.len(), self.limits.max_diff_bytes, "diff bytes")?;
+            files.push(DiffFile {
+                path: path.to_vec(),
+                old_id: old.map(|file| file.id),
+                new_id: new.map(|file| file.id),
+                old_mode: old.map(|file| file.mode.value()),
+                new_mode: new.map(|file| file.mode.value()),
+                binary,
+                hunks,
+            });
+        }
+        Ok(files)
+    }
+
+    pub(crate) fn blame(
+        &self,
+        commit: ObjectId,
+        path: &[u8],
+        cancellation: &ReadCancellation,
+    ) -> Result<Vec<BlameHunk>, ReadError> {
+        validate_path(path, false, self.limits.max_path_bytes)?;
+        let budget = self.budget(cancellation);
+        let history = self.history_with_budget(commit, &budget)?;
+        let mut candidate_bytes = 0_usize;
+        for candidate in &history {
+            budget.check()?;
+            if let Ok((id, _)) = self.resolve_blob(candidate.tree, path, &budget) {
+                let header = self
+                    .repository
+                    .objects
+                    .header(id)
+                    .map_err(|error| ReadError::Git(error.to_string()))?;
+                candidate_bytes = checked_add(
+                    candidate_bytes,
+                    usize::try_from(header.size()).map_err(|_| ReadError::Limit("blame bytes"))?,
+                    self.limits.max_diff_bytes,
+                    "blame bytes",
+                )?;
+            }
+        }
+        budget.check()?;
+        let outcome = self
+            .repository
+            .blame_file(path.as_bstr(), commit, Default::default())
+            .map_err(|error| ReadError::Git(error.to_string()))?;
+        budget.check()?;
+        if outcome.statistics.commits_traversed > self.limits.max_history_commits
+            || outcome.blob.len() > self.limits.max_blob_bytes
+        {
+            return Err(ReadError::Limit("blame"));
+        }
+        outcome
+            .entries
+            .into_iter()
+            .map(|entry| {
+                Ok(BlameHunk {
+                    start_line: entry.start_in_blamed_file + 1,
+                    source_start_line: entry.start_in_source_file + 1,
+                    line_count: entry.len.get(),
+                    commit_id: entry.commit_id,
+                    source_path: entry.source_file_name.map(|path| path.to_vec()),
+                })
+            })
+            .collect()
+    }
+
+    pub(crate) fn archive(
+        &self,
+        commit: ObjectId,
+        cancellation: &ReadCancellation,
+        output: &mut impl Write,
+    ) -> Result<ArchiveStats, ReadError> {
+        let budget = self.budget(cancellation);
+        let commit = self.read_commit(commit, &budget)?;
+        let mut writer = BoundedWriter {
+            output,
+            budget: &budget,
+            written: 0,
+            maximum: self.limits.max_archive_bytes,
+            failure: None,
+        };
+        let mut entries = 0_usize;
+        let result = self.archive_tree(
+            commit.tree,
+            &[],
+            commit.committed_at.max(0) as u64,
+            &budget,
+            &mut writer,
+            &mut entries,
+        );
+        if let Err(error) = result {
+            return Err(writer.failure.take().map_or(error, ReadFailure::into_error));
+        }
+        if let Err(error) = writer.write_all(&[0_u8; 1024]) {
+            return Err(writer
+                .failure
+                .take()
+                .map_or(ReadError::Output(error), ReadFailure::into_error));
+        }
+        Ok(ArchiveStats {
+            entries,
+            bytes: writer.written,
+        })
+    }
+
+    fn budget<'a>(&self, cancellation: &'a ReadCancellation) -> ReadBudget<'a> {
+        ReadBudget {
+            cancellation,
+            deadline: Instant::now() + self.limits.max_duration,
+        }
+    }
+
+    fn read_commit(&self, id: ObjectId, budget: &ReadBudget<'_>) -> Result<CommitInfo, ReadError> {
+        budget.check()?;
+        let object = self
+            .repository
+            .find_object(id)
+            .map_err(|error| ReadError::Git(error.to_string()))?;
+        if object.kind != ObjectKind::Commit {
+            return Err(ReadError::WrongObjectKind {
+                expected: "commit",
+                actual: object.kind,
+            });
+        }
+        if object.data.len() > self.limits.max_commit_bytes {
+            return Err(ReadError::Limit("commit bytes"));
+        }
+        let commit = object
+            .try_to_commit_ref()
+            .map_err(|error| ReadError::Git(error.to_string()))?;
+        let tree = self.parse_id(commit.tree, id)?;
+        let parents = commit
+            .parents
+            .iter()
+            .map(|parent| self.parse_id(parent, id))
+            .collect::<Result<Vec<_>, _>>()?;
+        let author = commit
+            .author()
+            .map_err(|error| ReadError::Git(error.to_string()))?;
+        let committed_at = commit
+            .time()
+            .map_err(|error| ReadError::Git(error.to_string()))?
+            .seconds;
+        Ok(CommitInfo {
+            id,
+            tree,
+            parents,
+            author_name: author.name.to_vec(),
+            author_email: author.email.to_vec(),
+            committed_at,
+            message: commit.message.to_vec(),
+        })
+    }
+
+    fn history_with_budget(
+        &self,
+        start: ObjectId,
+        budget: &ReadBudget<'_>,
+    ) -> Result<Vec<CommitInfo>, ReadError> {
+        let mut pending = VecDeque::from([start]);
+        let mut seen = HashSet::new();
+        let mut history = Vec::new();
+        while let Some(id) = pending.pop_front() {
+            budget.check()?;
+            if !seen.insert(id) {
+                continue;
+            }
+            if history.len() >= self.limits.max_history_commits {
+                return Err(ReadError::Limit("history commits"));
+            }
+            let commit = self.read_commit(id, budget)?;
+            pending.extend(commit.parents.iter().copied());
+            history.push(commit);
+        }
+        Ok(history)
+    }
+
+    fn read_tree(
+        &self,
+        id: ObjectId,
+        budget: &ReadBudget<'_>,
+    ) -> Result<gix::objs::Tree, ReadError> {
+        budget.check()?;
+        let object = self
+            .repository
+            .find_object(id)
+            .map_err(|error| ReadError::Git(error.to_string()))?;
+        if object.kind != ObjectKind::Tree {
+            return Err(ReadError::WrongObjectKind {
+                expected: "tree",
+                actual: object.kind,
+            });
+        }
+        if object.data.len() > self.limits.max_tree_bytes {
+            return Err(ReadError::Limit("tree bytes"));
+        }
+        gix::objs::Tree::try_from(object.into_tree())
+            .map_err(|error| ReadError::Git(error.to_string()))
+    }
+
+    fn resolve_tree(
+        &self,
+        root: ObjectId,
+        path: &[u8],
+        budget: &ReadBudget<'_>,
+    ) -> Result<ObjectId, ReadError> {
+        let mut tree_id = root;
+        if path.is_empty() {
+            return Ok(tree_id);
+        }
+        for component in path.split(|byte| *byte == b'/') {
+            budget.check()?;
+            let tree = self.read_tree(tree_id, budget)?;
+            let entry = tree
+                .entries
+                .iter()
+                .find(|entry| entry.filename.as_bytes() == component)
+                .ok_or_else(|| ReadError::PathNotFound(path.to_vec()))?;
+            if !entry.mode.is_tree() {
+                return Err(ReadError::NotTree(path.to_vec()));
+            }
+            tree_id = entry.oid.to_owned();
+        }
+        Ok(tree_id)
+    }
+
+    fn resolve_blob(
+        &self,
+        root: ObjectId,
+        path: &[u8],
+        budget: &ReadBudget<'_>,
+    ) -> Result<(ObjectId, EntryMode), ReadError> {
+        let mut components = path.split(|byte| *byte == b'/').peekable();
+        let mut tree_id = root;
+        while let Some(component) = components.next() {
+            budget.check()?;
+            let tree = self.read_tree(tree_id, budget)?;
+            let entry = tree
+                .entries
+                .iter()
+                .find(|entry| entry.filename.as_bytes() == component)
+                .ok_or_else(|| ReadError::PathNotFound(path.to_vec()))?;
+            if components.peek().is_some() {
+                if !entry.mode.is_tree() {
+                    return Err(ReadError::NotTree(path.to_vec()));
+                }
+                tree_id = entry.oid.to_owned();
+            } else if entry.mode.is_blob_or_symlink() {
+                return Ok((entry.oid.to_owned(), entry.mode));
+            } else {
+                return Err(ReadError::NotBlob(path.to_vec()));
+            }
+        }
+        Err(ReadError::NotBlob(path.to_vec()))
+    }
+
+    fn read_blob(
+        &self,
+        id: ObjectId,
+        mode: EntryMode,
+        budget: &ReadBudget<'_>,
+    ) -> Result<BlobInfo, ReadError> {
+        budget.check()?;
+        let object = self
+            .repository
+            .find_object(id)
+            .map_err(|error| ReadError::Git(error.to_string()))?;
+        if object.kind != ObjectKind::Blob {
+            return Err(ReadError::WrongObjectKind {
+                expected: "blob",
+                actual: object.kind,
+            });
+        }
+        if object.data.len() > self.limits.max_blob_bytes {
+            return Err(ReadError::Limit("blob bytes"));
+        }
+        Ok(BlobInfo {
+            id,
+            mode: mode.value(),
+            data: object.data.to_vec(),
+        })
+    }
+
+    fn flatten_tree(
+        &self,
+        root: ObjectId,
+        budget: &ReadBudget<'_>,
+    ) -> Result<BTreeMap<Vec<u8>, TreeFile>, ReadError> {
+        let mut files = BTreeMap::new();
+        let mut pending = vec![(Vec::new(), root)];
+        let mut entries = 0_usize;
+        while let Some((prefix, tree_id)) = pending.pop() {
+            budget.check()?;
+            let tree = self.read_tree(tree_id, budget)?;
+            for entry in tree.entries {
+                budget.check()?;
+                entries += 1;
+                if entries > self.limits.max_tree_entries {
+                    return Err(ReadError::Limit("tree entries"));
+                }
+                let path = join_git_path(&prefix, &entry.filename, self.limits.max_path_bytes)?;
+                if entry.mode.is_tree() {
+                    pending.push((path, entry.oid.to_owned()));
+                } else if entry.mode.is_blob_or_symlink() || entry.mode.is_commit() {
+                    files.insert(
+                        path,
+                        TreeFile {
+                            id: entry.oid.to_owned(),
+                            mode: entry.mode,
+                        },
+                    );
+                }
+            }
+        }
+        Ok(files)
+    }
+
+    fn diff_blob(
+        &self,
+        file: Option<&TreeFile>,
+        budget: &ReadBudget<'_>,
+    ) -> Result<Vec<u8>, ReadError> {
+        match file {
+            Some(file) if file.mode.is_commit() => Ok(Vec::new()),
+            Some(file) => Ok(self.read_blob(file.id, file.mode, budget)?.data),
+            None => Ok(Vec::new()),
+        }
+    }
+
+    fn archive_tree(
+        &self,
+        tree_id: ObjectId,
+        prefix: &[u8],
+        modified_at: u64,
+        budget: &ReadBudget<'_>,
+        writer: &mut BoundedWriter<'_, impl Write>,
+        entries: &mut usize,
+    ) -> Result<(), ReadError> {
+        let tree = self.read_tree(tree_id, budget)?;
+        for entry in tree.entries {
+            budget.check()?;
+            *entries += 1;
+            if *entries > self.limits.max_archive_entries {
+                return Err(ReadError::Limit("archive entries"));
+            }
+            let path = join_git_path(prefix, &entry.filename, self.limits.max_path_bytes)?;
+            if entry.mode.is_tree() {
+                let mut directory = path.clone();
+                directory.push(b'/');
+                write_tar_header(writer, &directory, 0o755, 0, modified_at, b'5')?;
+                self.archive_tree(
+                    entry.oid.to_owned(),
+                    &path,
+                    modified_at,
+                    budget,
+                    writer,
+                    entries,
+                )?;
+            } else if entry.mode.is_blob_or_symlink() {
+                let blob = self.read_blob(entry.oid.to_owned(), entry.mode, budget)?;
+                // Store symbolic-link content as a regular file. Extraction cannot create a link outside its destination.
+                let mode = if entry.mode.is_executable() {
+                    0o755
+                } else {
+                    0o644
+                };
+                write_tar_header(
+                    writer,
+                    &path,
+                    mode,
+                    u64::try_from(blob.data.len())
+                        .map_err(|_| ReadError::Limit("archive bytes"))?,
+                    modified_at,
+                    b'0',
+                )?;
+                writer.write_all(&blob.data).map_err(ReadError::Output)?;
+                let padding = (512 - blob.data.len() % 512) % 512;
+                if padding != 0 {
+                    writer
+                        .write_all(&[0_u8; 512][..padding])
+                        .map_err(ReadError::Output)?;
+                }
+            } else if entry.mode.is_commit() {
+                let mut directory = path;
+                directory.push(b'/');
+                write_tar_header(writer, &directory, 0o755, 0, modified_at, b'5')?;
+            }
+        }
+        Ok(())
+    }
+
+    fn parse_id(&self, input: &[u8], owner: ObjectId) -> Result<ObjectId, ReadError> {
+        let id = ObjectId::from_hex(input).map_err(|error| ReadError::DamagedObject {
+            id: owner,
+            reason: error.to_string(),
+        })?;
+        if id.kind() != self.repository.object_hash() {
+            return Err(ReadError::DamagedObject {
+                id: owner,
+                reason: "object ID uses the wrong hash format".to_owned(),
+            });
+        }
+        Ok(id)
+    }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+struct TreeFile {
+    id: ObjectId,
+    mode: EntryMode,
+}
+
+struct ReadBudget<'a> {
+    cancellation: &'a ReadCancellation,
+    deadline: Instant,
+}
+
+impl ReadBudget<'_> {
+    fn check(&self) -> Result<(), ReadError> {
+        if self.cancellation.cancelled.load(Ordering::Relaxed) {
+            return Err(ReadError::Cancelled);
+        }
+        if Instant::now() >= self.deadline {
+            return Err(ReadError::Deadline);
+        }
+        Ok(())
+    }
+}
+
+struct BoundedWriter<'a, W> {
+    output: &'a mut W,
+    budget: &'a ReadBudget<'a>,
+    written: usize,
+    maximum: usize,
+    failure: Option<ReadFailure>,
+}
+
+impl<W: Write> Write for BoundedWriter<'_, W> {
+    fn write(&mut self, buffer: &[u8]) -> std::io::Result<usize> {
+        if let Err(error) = self.budget.check() {
+            self.failure = Some(match error {
+                ReadError::Cancelled => ReadFailure::Cancelled,
+                ReadError::Deadline => ReadFailure::Deadline,
+                _ => unreachable!("a budget check has only cancellation and deadline errors"),
+            });
+            return Err(std::io::Error::other("repository read stopped"));
+        }
+        let remaining = self.maximum.saturating_sub(self.written);
+        if buffer.len() > remaining {
+            self.failure = Some(ReadFailure::Limit);
+            return Err(std::io::Error::other(
+                "repository archive reached its byte limit",
+            ));
+        }
+        let written = self.output.write(buffer)?;
+        self.written += written;
+        Ok(written)
+    }
+
+    fn flush(&mut self) -> std::io::Result<()> {
+        self.output.flush()
+    }
+}
+
+#[derive(Clone, Copy)]
+enum ReadFailure {
+    Cancelled,
+    Deadline,
+    Limit,
+}
+
+impl ReadFailure {
+    fn into_error(self) -> ReadError {
+        match self {
+            Self::Cancelled => ReadError::Cancelled,
+            Self::Deadline => ReadError::Deadline,
+            Self::Limit => ReadError::Limit("archive bytes"),
+        }
+    }
+}
+
+fn validate_limits(limits: &ReadLimits) -> Result<(), ReadError> {
+    if limits.max_refs == 0
+        || limits.max_history_commits == 0
+        || limits.max_path_bytes == 0
+        || limits.max_tree_bytes == 0
+        || limits.max_tree_entries == 0
+        || limits.max_commit_bytes == 0
+        || limits.max_blob_bytes == 0
+        || limits.max_diff_bytes == 0
+        || limits.max_archive_entries == 0
+        || limits.max_archive_bytes < 1024
+        || limits.max_duration.is_zero()
+    {
+        return Err(ReadError::InvalidLimits);
+    }
+    Ok(())
+}
+
+fn validate_path(path: &[u8], permit_empty: bool, maximum: usize) -> Result<(), ReadError> {
+    if path.is_empty() {
+        return if permit_empty {
+            Ok(())
+        } else {
+            Err(ReadError::InvalidPath)
+        };
+    }
+    if path.len() > maximum {
+        return Err(ReadError::Limit("path bytes"));
+    }
+    if path.starts_with(b"/")
+        || path.ends_with(b"/")
+        || path.contains(&0)
+        || path
+            .split(|byte| *byte == b'/')
+            .any(|component| component.is_empty() || component == b"." || component == b"..")
+    {
+        return Err(ReadError::InvalidPath);
+    }
+    Ok(())
+}
+
+fn join_git_path(prefix: &[u8], name: &[u8], maximum: usize) -> Result<Vec<u8>, ReadError> {
+    if name.is_empty() || name == b"." || name == b".." || name.contains(&b'/') || name.contains(&0)
+    {
+        return Err(ReadError::UnsafeTreeEntry(name.to_vec()));
+    }
+    let mut path = Vec::with_capacity(prefix.len() + usize::from(!prefix.is_empty()) + name.len());
+    path.extend_from_slice(prefix);
+    if !prefix.is_empty() {
+        path.push(b'/');
+    }
+    path.extend_from_slice(name);
+    if path.len() > maximum {
+        return Err(ReadError::Limit("path bytes"));
+    }
+    Ok(path)
+}
+
+fn readme_priority(name: &[u8]) -> Option<usize> {
+    const NAMES: [&[u8]; 6] = [
+        b"README",
+        b"README.md",
+        b"README.markdown",
+        b"README.txt",
+        b"README.rst",
+        b"README.adoc",
+    ];
+    NAMES
+        .iter()
+        .position(|candidate| name.eq_ignore_ascii_case(candidate))
+}
+
+fn checked_add(
+    current: usize,
+    additional: usize,
+    maximum: usize,
+    limit: &'static str,
+) -> Result<usize, ReadError> {
+    let total = current
+        .checked_add(additional)
+        .ok_or(ReadError::Limit(limit))?;
+    if total > maximum {
+        return Err(ReadError::Limit(limit));
+    }
+    Ok(total)
+}
+
+fn unified_diff(old: &[u8], new: &[u8]) -> Result<Vec<u8>, ReadError> {
+    use gix::diff::blob::unified_diff::{ConsumeBinaryHunk, ContextSize};
+    use gix::diff::blob::{Algorithm, Diff, InternedInput, UnifiedDiff};
+
+    let input = InternedInput::new(old, new);
+    let diff = Diff::compute(Algorithm::Histogram, &input);
+    UnifiedDiff::new(
+        &diff,
+        &input,
+        ConsumeBinaryHunk::new(Vec::new(), "\n"),
+        ContextSize::default(),
+    )
+    .consume()
+    .map_err(ReadError::Output)
+}
+
+fn write_tar_header(
+    writer: &mut impl Write,
+    path: &[u8],
+    mode: u64,
+    size: u64,
+    modified_at: u64,
+    kind: u8,
+) -> Result<(), ReadError> {
+    let (name, prefix) = split_tar_path(path)?;
+    let mut header = [0_u8; 512];
+    header[..name.len()].copy_from_slice(name);
+    header[345..345 + prefix.len()].copy_from_slice(prefix);
+    write_tar_octal(&mut header[100..108], mode)?;
+    write_tar_octal(&mut header[108..116], 0)?;
+    write_tar_octal(&mut header[116..124], 0)?;
+    write_tar_octal(&mut header[124..136], size)?;
+    write_tar_octal(&mut header[136..148], modified_at)?;
+    header[148..156].fill(b' ');
+    header[156] = kind;
+    header[257..263].copy_from_slice(b"ustar\0");
+    header[263..265].copy_from_slice(b"00");
+    let checksum: u64 = header.iter().map(|byte| u64::from(*byte)).sum();
+    let checksum_text = format!("{checksum:06o}\0 ");
+    header[148..156].copy_from_slice(checksum_text.as_bytes());
+    writer.write_all(&header).map_err(ReadError::Output)
+}
+
+fn split_tar_path(path: &[u8]) -> Result<(&[u8], &[u8]), ReadError> {
+    if path.len() <= 100 {
+        return Ok((path, &[]));
+    }
+    for split in (1..path.len()).rev() {
+        if path[split] == b'/'
+            && split + 1 < path.len()
+            && path.len() - split - 1 <= 100
+            && split <= 155
+        {
+            return Ok((&path[split + 1..], &path[..split]));
+        }
+    }
+    Err(ReadError::ArchivePath(path.to_vec()))
+}
+
+fn write_tar_octal(field: &mut [u8], value: u64) -> Result<(), ReadError> {
+    let text = format!("{value:o}");
+    if text.len() + 1 > field.len() {
+        return Err(ReadError::ArchiveMetadata);
+    }
+    field.fill(b'0');
+    let start = field.len() - text.len() - 1;
+    field[start..start + text.len()].copy_from_slice(text.as_bytes());
+    field[field.len() - 1] = 0;
+    Ok(())
+}
+
+#[derive(Debug, Error)]
+pub(crate) enum ReadError {
+    #[error("cannot open Git repository {path}: {reason}")]
+    Open { path: PathBuf, reason: String },
+    #[error("Git repository is not bare: {0}")]
+    NotBare(PathBuf),
+    #[error("Git read failed: {0}")]
+    Git(String),
+    #[error("expected a {expected} object, found {actual}")]
+    WrongObjectKind {
+        expected: &'static str,
+        actual: ObjectKind,
+    },
+    #[error("Git object {id} is damaged: {reason}")]
+    DamagedObject { id: ObjectId, reason: String },
+    #[error("repository read reached the {0} limit")]
+    Limit(&'static str),
+    #[error("repository read was cancelled")]
+    Cancelled,
+    #[error("repository read reached its time limit")]
+    Deadline,
+    #[error("repository read limits are not valid")]
+    InvalidLimits,
+    #[error("repository path is not valid")]
+    InvalidPath,
+    #[error("repository path does not exist: {0:?}")]
+    PathNotFound(Vec<u8>),
+    #[error("repository path is not a tree: {0:?}")]
+    NotTree(Vec<u8>),
+    #[error("repository path is not a blob: {0:?}")]
+    NotBlob(Vec<u8>),
+    #[error("Git tree entry is not safe: {0:?}")]
+    UnsafeTreeEntry(Vec<u8>),
+    #[error("repository archive path is too long: {0:?}")]
+    ArchivePath(Vec<u8>),
+    #[error("repository archive metadata is too large")]
+    ArchiveMetadata,
+    #[error("cannot write repository content: {0}")]
+    Output(std::io::Error),
+}
diff --git a/tests/git_reads.rs b/tests/git_reads.rs
new file mode 100644
index 0000000000000000000000000000000000000000..351670cf7770adbe5c019613cf75110b636efdf9
--- /dev/null
+++ b/tests/git_reads.rs
@@ -1,0 +1,428 @@
+#[allow(
+    dead_code,
+    reason = "the test uses each public read contract selectively"
+)]
+#[path = "../src/git/read.rs"]
+mod read;
+
+use std::collections::BTreeSet;
+use std::fs;
+use std::path::{Path, PathBuf};
+use std::process::Command;
+use std::time::Duration;
+
+use gix::hash::ObjectId;
+use read::{ReadCancellation, ReadError, ReadLimits, RepositoryReadService};
+use tempfile::TempDir;
+
+struct Fixture {
+    _directory: TempDir,
+    bare: PathBuf,
+    first: ObjectId,
+    second: ObjectId,
+}
+
+#[test]
+fn reads_repository_content_for_both_object_formats() {
+    for format in ["sha1", "sha256"] {
+        let fixture = fixture(format);
+        let service = RepositoryReadService::open(&fixture.bare, ReadLimits::default())
+            .expect("open the read service");
+        let cancellation = ReadCancellation::default();
+
+        let references = service.references(&cancellation).expect("read refs");
+        assert_eq!(references[0].name, b"HEAD");
+        assert_eq!(
+            references[0].symbolic_target.as_deref(),
+            Some(b"refs/heads/main".as_slice())
+        );
+        assert!(references.iter().any(|reference| {
+            reference.name == b"refs/heads/main" && reference.target == fixture.second
+        }));
+        assert!(references.iter().any(|reference| {
+            reference.name == b"refs/tags/v1" && reference.peeled == Some(fixture.first)
+        }));
+
+        let commit = service
+            .commit(fixture.second, &cancellation)
+            .expect("read a commit");
+        assert_eq!(commit.id, fixture.second);
+        assert_eq!(commit.parents, [fixture.first]);
+        assert_eq!(commit.author_name, b"Tit Test");
+        assert_eq!(commit.author_email, b"tit@example.test");
+        assert_eq!(commit.message, b"second\n");
+
+        let history = service
+            .history(fixture.second, &cancellation)
+            .expect("read history");
+        assert_eq!(
+            history.iter().map(|commit| commit.id).collect::<Vec<_>>(),
+            [fixture.second, fixture.first]
+        );
+
+        let root = service
+            .tree(fixture.second, b"", &cancellation)
+            .expect("read the root tree");
+        assert_eq!(
+            root.iter()
+                .map(|entry| entry.name.as_slice())
+                .collect::<Vec<_>>(),
+            [
+                b"README.md".as_slice(),
+                b"binary".as_slice(),
+                b"docs".as_slice(),
+                b"src".as_slice()
+            ]
+        );
+        let source = service
+            .tree(fixture.second, b"src", &cancellation)
+            .expect("read a nested tree");
+        assert_eq!(source.len(), 1);
+        assert_eq!(source[0].name, b"lib.rs");
+
+        let blob = service
+            .blob(fixture.second, b"src/lib.rs", &cancellation)
+            .expect("read a blob");
+        assert_eq!(blob.data, b"one\nchanged\nthree\n");
+        assert_eq!(blob.mode, 0o100644);
+        let mut raw = Vec::new();
+        assert_eq!(
+            service
+                .raw(fixture.second, b"src/lib.rs", &cancellation, &mut raw)
+                .expect("stream raw content"),
+            raw.len()
+        );
+        assert_eq!(raw, blob.data);
+
+        let readme = service
+            .readme(fixture.second, &cancellation)
+            .expect("select a README")
+            .expect("find a README");
+        assert_eq!(readme.path, b"README.md");
+        assert_eq!(readme.blob.data, b"Tit read service\n");
+    }
+}
+
+#[test]
+fn produces_bounded_diffs_blame_and_streaming_archives() {
+    let fixture = fixture("sha1");
+    let service = RepositoryReadService::open(&fixture.bare, ReadLimits::default())
+        .expect("open the read service");
+    let cancellation = ReadCancellation::default();
+
+    let diff = service
+        .diff(fixture.first, fixture.second, &cancellation)
+        .expect("read a diff");
+    let source = diff
+        .iter()
+        .find(|file| file.path == b"src/lib.rs")
+        .expect("find the source diff");
+    assert!(!source.binary);
+    assert!(source.hunks.windows(5).any(|window| window == b"-two\n"));
+    assert!(
+        source
+            .hunks
+            .windows(9)
+            .any(|window| window == b"+changed\n")
+    );
+    assert!(diff.iter().any(|file| file.path == b"docs/note.txt"));
+    let binary = diff
+        .iter()
+        .find(|file| file.path == b"binary")
+        .expect("find the binary diff");
+    assert!(binary.binary);
+    assert!(binary.hunks.is_empty());
+
+    let blame = service
+        .blame(fixture.second, b"src/lib.rs", &cancellation)
+        .expect("blame a file");
+    assert!(blame.iter().any(|hunk| hunk.commit_id == fixture.first));
+    assert!(blame.iter().any(|hunk| hunk.commit_id == fixture.second));
+    assert_eq!(
+        blame
+            .iter()
+            .map(|hunk| usize::try_from(hunk.line_count).expect("count blame lines"))
+            .sum::<usize>(),
+        3
+    );
+
+    let mut archive = Vec::new();
+    let stats = service
+        .archive(fixture.second, &cancellation, &mut archive)
+        .expect("stream an archive");
+    assert_eq!(stats.bytes, archive.len());
+    assert!(archive.ends_with(&[0_u8; 1024]));
+    let names = tar_names(&archive);
+    assert!(names.contains(b"README.md".as_slice()));
+    assert!(names.contains(b"src/".as_slice()));
+    assert!(names.contains(b"src/lib.rs".as_slice()));
+    assert!(names.contains(b"docs/note.txt".as_slice()));
+    let long_path = format!("docs/{}/{}.txt", "a".repeat(60), "b".repeat(60));
+    assert!(names.contains(long_path.as_bytes()));
+    let archive_path = fixture
+        .bare
+        .parent()
+        .expect("a fixture parent")
+        .join("repository.tar");
+    fs::write(&archive_path, &archive).expect("write the streamed archive");
+    let tar = Command::new("tar")
+        .args(["-tf"])
+        .arg(&archive_path)
+        .output()
+        .expect("run the system tar reader");
+    assert!(
+        tar.status.success(),
+        "tar rejected the archive: {}",
+        String::from_utf8_lossy(&tar.stderr)
+    );
+}
+
+#[test]
+fn enforces_each_read_boundary_and_rejects_unsafe_paths() {
+    let fixture = fixture("sha1");
+    let cancellation = ReadCancellation::default();
+
+    let limits = ReadLimits {
+        max_refs: 1,
+        ..ReadLimits::default()
+    };
+    let service = RepositoryReadService::open(&fixture.bare, limits).expect("open the service");
+    assert!(matches!(
+        service.references(&cancellation),
+        Err(ReadError::Limit("refs"))
+    ));
+
+    let limits = ReadLimits {
+        max_history_commits: 1,
+        ..ReadLimits::default()
+    };
+    let service = RepositoryReadService::open(&fixture.bare, limits).expect("open the service");
+    assert!(matches!(
+        service.history(fixture.second, &cancellation),
+        Err(ReadError::Limit("history commits"))
+    ));
+    assert!(matches!(
+        service.blame(fixture.second, b"src/lib.rs", &cancellation),
+        Err(ReadError::Limit("history commits"))
+    ));
+
+    let limits = ReadLimits {
+        max_blob_bytes: 4,
+        ..ReadLimits::default()
+    };
+    let service = RepositoryReadService::open(&fixture.bare, limits).expect("open the service");
+    assert!(matches!(
+        service.blob(fixture.second, b"src/lib.rs", &cancellation),
+        Err(ReadError::Limit("blob bytes"))
+    ));
+
+    let limits = ReadLimits {
+        max_commit_bytes: 1,
+        ..ReadLimits::default()
+    };
+    let service = RepositoryReadService::open(&fixture.bare, limits).expect("open the service");
+    assert!(matches!(
+        service.commit(fixture.second, &cancellation),
+        Err(ReadError::Limit("commit bytes"))
+    ));
+
+    let limits = ReadLimits {
+        max_path_bytes: 4,
+        ..ReadLimits::default()
+    };
+    let service = RepositoryReadService::open(&fixture.bare, limits).expect("open the service");
+    assert!(matches!(
+        service.blob(fixture.second, b"src/lib.rs", &cancellation),
+        Err(ReadError::Limit("path bytes"))
+    ));
+
+    let limits = ReadLimits {
+        max_tree_entries: 1,
+        ..ReadLimits::default()
+    };
+    let service = RepositoryReadService::open(&fixture.bare, limits).expect("open the service");
+    assert!(matches!(
+        service.tree(fixture.second, b"", &cancellation),
+        Err(ReadError::Limit("tree entries"))
+    ));
+
+    let limits = ReadLimits {
+        max_diff_bytes: 8,
+        ..ReadLimits::default()
+    };
+    let service = RepositoryReadService::open(&fixture.bare, limits).expect("open the service");
+    assert!(matches!(
+        service.diff(fixture.first, fixture.second, &cancellation),
+        Err(ReadError::Limit("diff bytes"))
+    ));
+
+    let limits = ReadLimits {
+        max_archive_entries: 1,
+        ..ReadLimits::default()
+    };
+    let service = RepositoryReadService::open(&fixture.bare, limits).expect("open the service");
+    assert!(matches!(
+        service.archive(fixture.second, &cancellation, &mut Vec::new()),
+        Err(ReadError::Limit("archive entries"))
+    ));
+
+    let limits = ReadLimits {
+        max_archive_bytes: 1024,
+        ..ReadLimits::default()
+    };
+    let service = RepositoryReadService::open(&fixture.bare, limits).expect("open the service");
+    assert!(matches!(
+        service.archive(fixture.second, &cancellation, &mut Vec::new()),
+        Err(ReadError::Limit("archive bytes"))
+    ));
+
+    let service = RepositoryReadService::open(&fixture.bare, ReadLimits::default())
+        .expect("open the service");
+    for path in [b"".as_slice(), b"/src", b"src/", b"src//lib.rs", b"../HEAD"] {
+        assert!(matches!(
+            service.blob(fixture.second, path, &cancellation),
+            Err(ReadError::InvalidPath)
+        ));
+    }
+    let cancelled = ReadCancellation::default();
+    cancelled.cancel();
+    assert!(matches!(
+        service.history(fixture.second, &cancelled),
+        Err(ReadError::Cancelled)
+    ));
+
+    let limits = ReadLimits {
+        max_duration: Duration::ZERO,
+        ..ReadLimits::default()
+    };
+    assert!(matches!(
+        RepositoryReadService::open(&fixture.bare, limits),
+        Err(ReadError::InvalidLimits)
+    ));
+}
+
+fn fixture(object_format: &str) -> Fixture {
+    let directory = TempDir::new().expect("create a fixture directory");
+    let worktree = directory.path().join("worktree");
+    run(Command::new("git")
+        .args(["init", "-q", "-b", "main", "--object-format", object_format])
+        .arg(&worktree));
+    run(Command::new("git")
+        .args(["config", "user.name", "Tit Test"])
+        .current_dir(&worktree));
+    run(Command::new("git")
+        .args(["config", "user.email", "tit@example.test"])
+        .current_dir(&worktree));
+    run(Command::new("git")
+        .args(["config", "commit.gpgsign", "false"])
+        .current_dir(&worktree));
+    run(Command::new("git")
+        .args(["config", "tag.gpgsign", "false"])
+        .current_dir(&worktree));
+    fs::create_dir(worktree.join("src")).expect("create a source directory");
+    fs::write(worktree.join("README.md"), b"Tit read service\n").expect("write a README");
+    fs::write(worktree.join("src/lib.rs"), b"one\ntwo\n").expect("write source");
+    fs::write(worktree.join("binary"), b"old\0binary").expect("write binary content");
+    run(Command::new("git")
+        .args(["add", "."])
+        .current_dir(&worktree));
+    run(Command::new("git")
+        .args(["commit", "-q", "-m", "first"])
+        .env("GIT_AUTHOR_DATE", "1700000000 +0000")
+        .env("GIT_COMMITTER_DATE", "1700000000 +0000")
+        .current_dir(&worktree));
+    let first = revision(&worktree, "HEAD");
+    run(Command::new("git")
+        .args(["tag", "-a", "v1", "-m", "version one"])
+        .current_dir(&worktree));
+
+    fs::create_dir(worktree.join("docs")).expect("create a documentation directory");
+    fs::write(worktree.join("src/lib.rs"), b"one\nchanged\nthree\n").expect("change source");
+    fs::write(worktree.join("binary"), b"new\0binary").expect("change binary content");
+    fs::write(worktree.join("docs/note.txt"), b"note\n").expect("write documentation");
+    let long_directory = worktree.join("docs").join("a".repeat(60));
+    fs::create_dir(&long_directory).expect("create a long archive directory");
+    fs::write(
+        long_directory.join(format!("{}.txt", "b".repeat(60))),
+        b"long archive path\n",
+    )
+    .expect("write a long archive path");
+    run(Command::new("git")
+        .args(["add", "."])
+        .current_dir(&worktree));
+    run(Command::new("git")
+        .args(["commit", "-q", "-m", "second"])
+        .env("GIT_AUTHOR_DATE", "1700000100 +0000")
+        .env("GIT_COMMITTER_DATE", "1700000100 +0000")
+        .current_dir(&worktree));
+    let second = revision(&worktree, "HEAD");
+    let bare = directory.path().join("repository.git");
+    run(Command::new("git")
+        .args(["clone", "-q", "--bare"])
+        .arg(&worktree)
+        .arg(&bare));
+    Fixture {
+        _directory: directory,
+        bare,
+        first,
+        second,
+    }
+}
+
+fn revision(repository: &Path, name: &str) -> ObjectId {
+    let output = Command::new("git")
+        .args(["rev-parse", name])
+        .current_dir(repository)
+        .output()
+        .expect("run git rev-parse");
+    assert!(output.status.success());
+    ObjectId::from_hex(output.stdout.trim_ascii()).expect("parse a fixture revision")
+}
+
+fn run(command: &mut Command) {
+    let output = command.output().expect("run the stock Git client");
+    assert!(
+        output.status.success(),
+        "Git failed: {}",
+        String::from_utf8_lossy(&output.stderr)
+    );
+}
+
+fn tar_names(archive: &[u8]) -> BTreeSet<Vec<u8>> {
+    let mut names = BTreeSet::new();
+    let mut offset = 0_usize;
+    while offset + 512 <= archive.len() {
+        let header = &archive[offset..offset + 512];
+        if header.iter().all(|byte| *byte == 0) {
+            break;
+        }
+        let name_end = header[..100]
+            .iter()
+            .position(|byte| *byte == 0)
+            .unwrap_or(100);
+        let prefix_end = header[345..500]
+            .iter()
+            .position(|byte| *byte == 0)
+            .unwrap_or(155);
+        let mut path = header[345..345 + prefix_end].to_vec();
+        if !path.is_empty() {
+            path.push(b'/');
+        }
+        path.extend_from_slice(&header[..name_end]);
+        names.insert(path);
+        let size_end = header[124..136]
+            .iter()
+            .position(|byte| *byte == 0)
+            .unwrap_or(12);
+        let size = std::str::from_utf8(&header[124..124 + size_end])
+            .expect("read a tar size")
+            .trim_start_matches('0');
+        let size = if size.is_empty() {
+            0
+        } else {
+            usize::from_str_radix(size, 8).expect("parse a tar size")
+        };
+        offset += 512 + size.div_ceil(512) * 512;
+    }
+    names
+}
