diff --git a/src/git_paoding/gitio/__init__.py b/src/git_paoding/gitio/__init__.py
index 697e5e0..6d02458 100644
--- a/src/git_paoding/gitio/__init__.py
+++ b/src/git_paoding/gitio/__init__.py
@@ -1 +1,47 @@
"""Git plumbing package."""
+
+from git_paoding.gitio.diffparse import RawDiffHunk, diff_trees, parse_diff
+from git_paoding.gitio.plumbing import (
+ GitIdentity,
+ RemoteRef,
+ TreeEntry,
+ cat_file,
+ commit_tree,
+ hash_object,
+ ls_remote,
+ ls_tree,
+ mktree,
+ rev_parse,
+ update_ref,
+)
+from git_paoding.gitio.runner import (
+ GitCommandError,
+ GitError,
+ GitFailureKind,
+ GitResult,
+ GitUnavailableError,
+ run_git,
+)
+
+__all__ = [
+ "GitCommandError",
+ "GitError",
+ "GitFailureKind",
+ "GitIdentity",
+ "GitResult",
+ "GitUnavailableError",
+ "RawDiffHunk",
+ "RemoteRef",
+ "TreeEntry",
+ "cat_file",
+ "commit_tree",
+ "diff_trees",
+ "hash_object",
+ "ls_remote",
+ "ls_tree",
+ "mktree",
+ "parse_diff",
+ "rev_parse",
+ "run_git",
+ "update_ref",
+]
diff --git a/src/git_paoding/gitio/diffparse.py b/src/git_paoding/gitio/diffparse.py
index 6ee2172..b1a5910 100644
--- a/src/git_paoding/gitio/diffparse.py
+++ b/src/git_paoding/gitio/diffparse.py
@@ -1 +1,194 @@
-"""Git diff parser."""
+"""Parse zero-context Git diffs into raw, Base-anchored hunk records."""
+
+from __future__ import annotations
+
+import ast
+import re
+from dataclasses import dataclass, field
+from pathlib import Path
+
+from git_paoding.gitio.runner import run_git
+
+_DIFF_HEADER = re.compile(r'^diff --git (?P"(?:\\.|[^"])*"|\S+) (?P.+)$')
+_HUNK_HEADER = re.compile(
+ r"^@@ -(?P\d+)(?:,(?P\d+))? "
+ r"\+(?P\d+)(?:,(?P\d+))? @@"
+)
+
+
+@dataclass(frozen=True, slots=True)
+class RawDiffHunk:
+ """One parsed hunk, or one sentinel record for a non-text file change."""
+
+ path: str
+ base_start: int
+ base_len: int
+ final_start: int
+ final_len: int
+ removed_lines: tuple[str, ...]
+ added_lines: tuple[str, ...]
+ is_add_file: bool = False
+ is_delete_file: bool = False
+ is_binary: bool = False
+ is_mode_change: bool = False
+ is_symlink: bool = False
+ no_newline_at_eof: bool = False
+
+
+@dataclass(slots=True)
+class _MutableHunk:
+ base_start: int
+ base_len: int
+ final_start: int
+ final_len: int
+ removed_lines: list[str] = field(default_factory=list)
+ added_lines: list[str] = field(default_factory=list)
+ no_newline_at_eof: bool = False
+ last_line_kind: str | None = None
+
+
+@dataclass(slots=True)
+class _FileDiff:
+ path: str
+ hunks: list[_MutableHunk] = field(default_factory=list)
+ is_add_file: bool = False
+ is_delete_file: bool = False
+ is_binary: bool = False
+ is_mode_change: bool = False
+ is_symlink: bool = False
+
+
+def _decode_header_path(value: str) -> str:
+ if value.startswith('"'):
+ decoded = ast.literal_eval(value)
+ if not isinstance(decoded, str):
+ raise ValueError(f"Invalid quoted Git path: {value}")
+ return decoded
+ return value
+
+
+def _strip_prefix(path: str) -> str:
+ if path.startswith(("a/", "b/")):
+ return path[2:]
+ return path
+
+
+def _finalize_file(file_diff: _FileDiff | None, records: list[RawDiffHunk]) -> None:
+ if file_diff is None:
+ return
+ hunks = file_diff.hunks or [_MutableHunk(0, 0, 0, 0)]
+ for hunk in hunks:
+ records.append(
+ RawDiffHunk(
+ path=file_diff.path,
+ base_start=hunk.base_start,
+ base_len=hunk.base_len,
+ final_start=hunk.final_start,
+ final_len=hunk.final_len,
+ removed_lines=tuple(hunk.removed_lines),
+ added_lines=tuple(hunk.added_lines),
+ is_add_file=file_diff.is_add_file,
+ is_delete_file=file_diff.is_delete_file,
+ is_binary=file_diff.is_binary,
+ is_mode_change=file_diff.is_mode_change,
+ is_symlink=file_diff.is_symlink,
+ no_newline_at_eof=hunk.no_newline_at_eof,
+ )
+ )
+
+
+def parse_diff(diff: bytes | str) -> tuple[RawDiffHunk, ...]:
+ """Parse output from ``git diff -U0 --no-renames``."""
+
+ text = diff.decode("utf-8", errors="surrogateescape") if isinstance(diff, bytes) else diff
+ records: list[RawDiffHunk] = []
+ current_file: _FileDiff | None = None
+ current_hunk: _MutableHunk | None = None
+
+ for line_with_end in text.splitlines(keepends=True):
+ line = line_with_end.removesuffix("\n")
+ header_match = _DIFF_HEADER.match(line)
+ if header_match is not None:
+ _finalize_file(current_file, records)
+ base_path = _decode_header_path(header_match.group("base"))
+ final_path = _decode_header_path(header_match.group("final"))
+ path = _strip_prefix(final_path if final_path != "/dev/null" else base_path)
+ current_file = _FileDiff(path=path)
+ current_hunk = None
+ continue
+ if current_file is None:
+ continue
+
+ if line.startswith("new file mode "):
+ current_file.is_add_file = True
+ current_file.is_symlink = line.endswith(" 120000")
+ continue
+ if line.startswith("deleted file mode "):
+ current_file.is_delete_file = True
+ current_file.is_symlink = line.endswith(" 120000")
+ continue
+ if line.startswith("old mode ") or line.startswith("new mode "):
+ current_file.is_mode_change = True
+ if line.endswith(" 120000"):
+ current_file.is_symlink = True
+ continue
+ if line.startswith("index ") and line.endswith(" 120000"):
+ current_file.is_symlink = True
+ continue
+ if line.startswith("Binary files ") or line == "GIT binary patch":
+ current_file.is_binary = True
+ continue
+
+ hunk_match = _HUNK_HEADER.match(line)
+ if hunk_match is not None:
+ current_hunk = _MutableHunk(
+ base_start=int(hunk_match.group("base_start")),
+ base_len=int(hunk_match.group("base_len") or "1"),
+ final_start=int(hunk_match.group("final_start")),
+ final_len=int(hunk_match.group("final_len") or "1"),
+ )
+ current_file.hunks.append(current_hunk)
+ continue
+ if current_hunk is None:
+ continue
+
+ if line.startswith("-"):
+ current_hunk.removed_lines.append(line_with_end[1:])
+ current_hunk.last_line_kind = "removed"
+ elif line.startswith("+"):
+ current_hunk.added_lines.append(line_with_end[1:])
+ current_hunk.last_line_kind = "added"
+ elif line == r"\ No newline at end of file":
+ current_hunk.no_newline_at_eof = True
+ target = (
+ current_hunk.removed_lines
+ if current_hunk.last_line_kind == "removed"
+ else current_hunk.added_lines
+ )
+ if target and target[-1].endswith("\n"):
+ target[-1] = target[-1][:-1]
+
+ _finalize_file(current_file, records)
+ return tuple(records)
+
+
+def diff_trees(repo: Path, base: str, final: str) -> tuple[RawDiffHunk, ...]:
+ """Read and parse a deterministic, zero-context tree diff."""
+
+ output = run_git(
+ (
+ "-c",
+ "core.quotePath=false",
+ "diff",
+ "--no-color",
+ "--no-ext-diff",
+ "--no-textconv",
+ "--unified=0",
+ "--no-renames",
+ base,
+ final,
+ "--",
+ ),
+ cwd=repo,
+ ).stdout
+ return parse_diff(output)
diff --git a/src/git_paoding/gitio/plumbing.py b/src/git_paoding/gitio/plumbing.py
index 5fa5588..367910e 100644
--- a/src/git_paoding/gitio/plumbing.py
+++ b/src/git_paoding/gitio/plumbing.py
@@ -1 +1,160 @@
-"""Git object plumbing helpers."""
+"""Typed helpers for Git object and reference plumbing."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Literal, Sequence, cast
+
+from git_paoding.gitio.runner import run_git
+
+ObjectType = Literal["blob", "tree", "commit", "tag"]
+TreeObjectType = Literal["blob", "tree", "commit"]
+
+
+@dataclass(frozen=True, slots=True)
+class TreeEntry:
+ """One direct child returned by ``git ls-tree``."""
+
+ mode: str
+ object_type: TreeObjectType
+ oid: str
+ path: str
+
+
+@dataclass(frozen=True, slots=True)
+class GitIdentity:
+ """Identity and optional timestamp used by ``git commit-tree``."""
+
+ name: str
+ email: str
+ date: str | None = None
+
+
+@dataclass(frozen=True, slots=True)
+class RemoteRef:
+ """One ref advertised by ``git ls-remote``."""
+
+ oid: str
+ ref: str
+
+
+def rev_parse(repo: Path, revision: str) -> str:
+ """Resolve and verify a revision or object expression."""
+
+ result = run_git(("rev-parse", "--verify", "--end-of-options", revision), cwd=repo)
+ return result.stdout_text().strip()
+
+
+def cat_file(repo: Path, oid: str, *, object_type: ObjectType = "blob") -> bytes:
+ """Read an object while requiring its expected Git type."""
+
+ return run_git(("cat-file", object_type, oid), cwd=repo).stdout
+
+
+def hash_object(repo: Path, data: bytes, *, object_type: ObjectType = "blob") -> str:
+ """Write an object to the repository object database and return its OID."""
+
+ result = run_git(
+ ("hash-object", "-w", "--stdin", "-t", object_type),
+ cwd=repo,
+ input_data=data,
+ )
+ return result.stdout_text().strip()
+
+
+def ls_tree(repo: Path, treeish: str) -> tuple[TreeEntry, ...]:
+ """List the direct entries of a tree without consulting the index."""
+
+ output = run_git(("ls-tree", "-z", treeish), cwd=repo).stdout
+ entries: list[TreeEntry] = []
+ for raw_entry in output.split(b"\0"):
+ if not raw_entry:
+ continue
+ metadata, raw_path = raw_entry.split(b"\t", maxsplit=1)
+ raw_mode, raw_type, raw_oid = metadata.split(b" ", maxsplit=2)
+ object_type = raw_type.decode("ascii")
+ if object_type not in {"blob", "tree", "commit"}:
+ raise ValueError(f"Unexpected ls-tree object type: {object_type}")
+ entries.append(
+ TreeEntry(
+ mode=raw_mode.decode("ascii"),
+ object_type=cast(TreeObjectType, object_type),
+ oid=raw_oid.decode("ascii"),
+ path=raw_path.decode("utf-8", errors="surrogateescape"),
+ )
+ )
+ return tuple(entries)
+
+
+def mktree(repo: Path, entries: Sequence[TreeEntry]) -> str:
+ """Write a tree from direct entries and return its OID."""
+
+ records: list[bytes] = []
+ for entry in entries:
+ path = entry.path.encode("utf-8", errors="surrogateescape")
+ metadata = f"{entry.mode} {entry.object_type} {entry.oid}\t".encode()
+ records.append(metadata + path + b"\0")
+ result = run_git(("mktree", "-z"), cwd=repo, input_data=b"".join(records))
+ return result.stdout_text().strip()
+
+
+def commit_tree(
+ repo: Path,
+ tree_oid: str,
+ message: str,
+ *,
+ parents: Sequence[str] = (),
+ author: GitIdentity | None = None,
+ committer: GitIdentity | None = None,
+) -> str:
+ """Create a commit object without changing HEAD, a worktree, or the index."""
+
+ args = ["commit-tree", tree_oid]
+ for parent in parents:
+ args.extend(("-p", parent))
+
+ command_env: dict[str, str] = {}
+ if author is not None:
+ command_env["GIT_AUTHOR_NAME"] = author.name
+ command_env["GIT_AUTHOR_EMAIL"] = author.email
+ if author.date is not None:
+ command_env["GIT_AUTHOR_DATE"] = author.date
+ if committer is not None:
+ command_env["GIT_COMMITTER_NAME"] = committer.name
+ command_env["GIT_COMMITTER_EMAIL"] = committer.email
+ if committer.date is not None:
+ command_env["GIT_COMMITTER_DATE"] = committer.date
+
+ result = run_git(
+ args,
+ cwd=repo,
+ input_data=message.encode("utf-8", errors="surrogateescape"),
+ env=command_env,
+ )
+ return result.stdout_text().strip()
+
+
+def update_ref(repo: Path, ref: str, new_oid: str | None, *, old_oid: str | None = None) -> None:
+ """Create, compare-and-swap, or delete a ref."""
+
+ if new_oid is None:
+ args = ["update-ref", "-d", ref]
+ if old_oid is not None:
+ args.append(old_oid)
+ else:
+ args = ["update-ref", ref, new_oid]
+ if old_oid is not None:
+ args.append(old_oid)
+ run_git(args, cwd=repo)
+
+
+def ls_remote(repo: Path, remote: str, *patterns: str) -> tuple[RemoteRef, ...]:
+ """Read refs advertised by a remote without fetching or updating local refs."""
+
+ output = run_git(("ls-remote", remote, *patterns), cwd=repo).stdout
+ refs: list[RemoteRef] = []
+ for line in output.splitlines():
+ raw_oid, raw_ref = line.split(b"\t", maxsplit=1)
+ refs.append(RemoteRef(oid=raw_oid.decode("ascii"), ref=raw_ref.decode("utf-8")))
+ return tuple(refs)
diff --git a/src/git_paoding/gitio/runner.py b/src/git_paoding/gitio/runner.py
index b97075b..7966155 100644
--- a/src/git_paoding/gitio/runner.py
+++ b/src/git_paoding/gitio/runner.py
@@ -1 +1,133 @@
-"""Git subprocess runner."""
+"""The single process boundary for invoking Git."""
+
+from __future__ import annotations
+
+import os
+import subprocess
+from dataclasses import dataclass
+from enum import Enum
+from pathlib import Path
+from typing import Mapping, Sequence
+
+
+class GitFailureKind(str, Enum):
+ """Stable categories for failures reported by Git."""
+
+ NOT_REPOSITORY = "not-repository"
+ UNKNOWN_REVISION = "unknown-revision"
+ MISSING_OBJECT = "missing-object"
+ INVALID_INPUT = "invalid-input"
+ REMOTE = "remote"
+ OTHER = "other"
+
+
+@dataclass(frozen=True, slots=True)
+class GitResult:
+ """Successful Git command output."""
+
+ stdout: bytes
+ stderr: str
+
+ def stdout_text(self) -> str:
+ """Decode standard output without losing unusual path bytes."""
+
+ return self.stdout.decode("utf-8", errors="surrogateescape")
+
+
+class GitError(RuntimeError):
+ """Base class for failures at the Git process boundary."""
+
+
+class GitUnavailableError(GitError):
+ """Raised when the Git executable cannot be found."""
+
+
+class GitCommandError(GitError):
+ """A non-zero Git command result with a mapped failure category."""
+
+ def __init__(
+ self,
+ *,
+ args: tuple[str, ...],
+ cwd: Path,
+ returncode: int,
+ stderr: str,
+ kind: GitFailureKind,
+ ) -> None:
+ self.args_list = args
+ self.cwd = cwd
+ self.returncode = returncode
+ self.stderr = stderr
+ self.kind = kind
+ detail = stderr.strip() or "Git exited without an error message"
+ super().__init__(f"git {' '.join(args)} failed in {cwd}: {detail}")
+
+
+def _classify_failure(stderr: str) -> GitFailureKind:
+ normalized = stderr.casefold()
+ if "not a git repository" in normalized:
+ return GitFailureKind.NOT_REPOSITORY
+ if any(
+ marker in normalized
+ for marker in (
+ "unknown revision",
+ "ambiguous argument",
+ "needed a single revision",
+ "not a valid object name",
+ )
+ ):
+ return GitFailureKind.UNKNOWN_REVISION
+ if any(marker in normalized for marker in ("missing blob", "missing tree", "bad object")):
+ return GitFailureKind.MISSING_OBJECT
+ if any(marker in normalized for marker in ("malformed", "invalid path", "invalid object")):
+ return GitFailureKind.INVALID_INPUT
+ if any(
+ marker in normalized
+ for marker in (
+ "could not read from remote",
+ "could not resolve host",
+ "authentication failed",
+ )
+ ):
+ return GitFailureKind.REMOTE
+ return GitFailureKind.OTHER
+
+
+def run_git(
+ args: Sequence[str],
+ *,
+ cwd: Path,
+ input_data: bytes | None = None,
+ env: Mapping[str, str] | None = None,
+) -> GitResult:
+ """Run Git in an explicit repository directory and return byte-preserving output."""
+
+ command_args = tuple(args)
+ process_env = os.environ.copy()
+ process_env["LC_ALL"] = "C"
+ if env is not None:
+ process_env.update(env)
+
+ try:
+ completed = subprocess.run(
+ ("git", *command_args),
+ cwd=cwd,
+ env=process_env,
+ input=input_data,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ check=False,
+ )
+ except FileNotFoundError as error:
+ raise GitUnavailableError("Git executable was not found on PATH") from error
+
+ stderr = completed.stderr.decode("utf-8", errors="surrogateescape")
+ if completed.returncode != 0:
+ raise GitCommandError(
+ args=command_args,
+ cwd=cwd,
+ returncode=completed.returncode,
+ stderr=stderr,
+ kind=_classify_failure(stderr),
+ )
+ return GitResult(stdout=completed.stdout, stderr=stderr)
diff --git a/tests/conftest.py b/tests/conftest.py
index 7437d02..4c92034 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1 +1,179 @@
-"""Shared pytest fixtures."""
+"""Shared pytest fixtures backed by real, temporary Git repositories."""
+
+from __future__ import annotations
+
+import shutil
+from collections.abc import Iterator, Mapping
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Protocol, TypeAlias
+
+import pytest
+
+from git_paoding.gitio.plumbing import GitIdentity, commit_tree, update_ref
+from git_paoding.gitio.runner import run_git
+
+
+@dataclass(frozen=True, slots=True)
+class RepoFile:
+ """A file state for the scratch repository builder."""
+
+ content: str | bytes
+ executable: bool = False
+ symlink: bool = False
+
+
+FileValue: TypeAlias = str | bytes | RepoFile
+RepoState: TypeAlias = Mapping[str, FileValue]
+
+
+@dataclass(frozen=True, slots=True)
+class ScratchRepository:
+ """A two-commit scratch repository and its important object IDs."""
+
+ path: Path
+ base_oid: str
+ final_oid: str
+ base_tree_oid: str
+ final_tree_oid: str
+
+
+class ScratchRepoFactory(Protocol):
+ """Create a scratch repository from Base and Final file maps."""
+
+ def __call__(self, base: RepoState, final: RepoState) -> ScratchRepository: ...
+
+
+@dataclass(frozen=True, slots=True)
+class _IsolationSnapshot:
+ head_oid: bytes
+ index_entries: bytes
+ status: bytes
+
+
+@dataclass(frozen=True, slots=True)
+class _RegisteredRepository:
+ repo: ScratchRepository
+ snapshot: _IsolationSnapshot
+
+
+def _snapshot(repo: Path) -> _IsolationSnapshot:
+ return _IsolationSnapshot(
+ head_oid=run_git(("rev-parse", "HEAD"), cwd=repo).stdout,
+ index_entries=run_git(("ls-files", "--stage", "-z"), cwd=repo).stdout,
+ status=run_git(("status", "--porcelain=v1", "-z"), cwd=repo).stdout,
+ )
+
+
+def _clear_worktree(repo: Path) -> None:
+ for child in repo.iterdir():
+ if child.name == ".git":
+ continue
+ if child.is_dir() and not child.is_symlink():
+ shutil.rmtree(child)
+ else:
+ child.unlink()
+
+
+def _materialize_state(repo: Path, state: RepoState) -> None:
+ _clear_worktree(repo)
+ for relative_path, value in state.items():
+ path = Path(relative_path)
+ if path.is_absolute() or ".." in path.parts:
+ raise ValueError(f"Scratch repository path must be relative and contained: {path}")
+ spec = value if isinstance(value, RepoFile) else RepoFile(content=value)
+ destination = repo / path
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ if spec.symlink:
+ if not isinstance(spec.content, str):
+ raise TypeError("A symlink target must be text")
+ destination.symlink_to(spec.content)
+ continue
+ data = spec.content.encode() if isinstance(spec.content, str) else spec.content
+ destination.write_bytes(data)
+ if spec.executable:
+ destination.chmod(destination.stat().st_mode | 0o111)
+
+
+@pytest.fixture
+def _repo_registry() -> list[_RegisteredRepository]:
+ return []
+
+
+@pytest.fixture
+def scratch_repo_factory(
+ tmp_path: Path, _repo_registry: list[_RegisteredRepository]
+) -> ScratchRepoFactory:
+ """Build clean Base/Final commits and register them for isolation checks."""
+
+ counter = 0
+
+ def factory(base: RepoState, final: RepoState) -> ScratchRepository:
+ nonlocal counter
+ counter += 1
+ repo = tmp_path / f"repo-{counter}"
+ repo.mkdir()
+ run_git(("init", "--quiet", "--initial-branch=main"), cwd=repo)
+
+ identity = GitIdentity(
+ name="git-paoding tests",
+ email="git-paoding@localhost",
+ date="2000-01-01T00:00:00+00:00",
+ )
+ _materialize_state(repo, base)
+ run_git(("add", "--all"), cwd=repo)
+ base_tree_oid = run_git(("write-tree",), cwd=repo).stdout_text().strip()
+ base_oid = commit_tree(
+ repo,
+ base_tree_oid,
+ "Base\n",
+ author=identity,
+ committer=identity,
+ )
+ update_ref(repo, "refs/heads/main", base_oid)
+
+ _materialize_state(repo, final)
+ run_git(("add", "--all"), cwd=repo)
+ final_tree_oid = run_git(("write-tree",), cwd=repo).stdout_text().strip()
+ final_identity = GitIdentity(
+ name=identity.name,
+ email=identity.email,
+ date="2000-01-01T00:00:01+00:00",
+ )
+ final_oid = commit_tree(
+ repo,
+ final_tree_oid,
+ "Final\n",
+ parents=(base_oid,),
+ author=final_identity,
+ committer=final_identity,
+ )
+ update_ref(repo, "refs/heads/main", final_oid, old_oid=base_oid)
+
+ scratch_repo = ScratchRepository(
+ path=repo,
+ base_oid=base_oid,
+ final_oid=final_oid,
+ base_tree_oid=base_tree_oid,
+ final_tree_oid=final_tree_oid,
+ )
+ _repo_registry.append(
+ _RegisteredRepository(repo=scratch_repo, snapshot=_snapshot(scratch_repo.path))
+ )
+ return scratch_repo
+
+ return factory
+
+
+@pytest.fixture(autouse=True)
+def _assert_integration_repository_isolation(
+ request: pytest.FixtureRequest,
+ _repo_registry: list[_RegisteredRepository],
+) -> Iterator[None]:
+ """Assert integration tests leave HEAD, index, and worktree status unchanged."""
+
+ yield
+ if request.node.get_closest_marker("integration") is None:
+ return
+ for registered in _repo_registry:
+ assert _snapshot(registered.repo.path) == registered.snapshot
diff --git a/tests/golden/diffs/add.diff b/tests/golden/diffs/add.diff
new file mode 100644
index 0000000..860a762
--- /dev/null
+++ b/tests/golden/diffs/add.diff
@@ -0,0 +1,8 @@
+diff --git a/added.txt b/added.txt
+new file mode 100644
+index 0000000..4cb29ea
+--- /dev/null
++++ b/added.txt
+@@ -0,0 +1,2 @@
++first
++second
diff --git a/tests/golden/diffs/add.json b/tests/golden/diffs/add.json
new file mode 100644
index 0000000..bd498ab
--- /dev/null
+++ b/tests/golden/diffs/add.json
@@ -0,0 +1,12 @@
+[
+ {
+ "path": "added.txt",
+ "base_start": 0,
+ "base_len": 0,
+ "final_start": 1,
+ "final_len": 2,
+ "removed_lines": [],
+ "added_lines": ["first\n", "second\n"],
+ "is_add_file": true
+ }
+]
diff --git a/tests/golden/diffs/binary.diff b/tests/golden/diffs/binary.diff
new file mode 100644
index 0000000..fd42ffc
--- /dev/null
+++ b/tests/golden/diffs/binary.diff
@@ -0,0 +1,3 @@
+diff --git a/image.bin b/image.bin
+index f76dd23..a52bc11 100644
+Binary files a/image.bin and b/image.bin differ
diff --git a/tests/golden/diffs/binary.json b/tests/golden/diffs/binary.json
new file mode 100644
index 0000000..fe8bac8
--- /dev/null
+++ b/tests/golden/diffs/binary.json
@@ -0,0 +1,12 @@
+[
+ {
+ "path": "image.bin",
+ "base_start": 0,
+ "base_len": 0,
+ "final_start": 0,
+ "final_len": 0,
+ "removed_lines": [],
+ "added_lines": [],
+ "is_binary": true
+ }
+]
diff --git a/tests/golden/diffs/delete.diff b/tests/golden/diffs/delete.diff
new file mode 100644
index 0000000..186e204
--- /dev/null
+++ b/tests/golden/diffs/delete.diff
@@ -0,0 +1,8 @@
+diff --git a/deleted.txt b/deleted.txt
+deleted file mode 100644
+index 4cb29ea..0000000
+--- a/deleted.txt
++++ /dev/null
+@@ -1,2 +0,0 @@
+-first
+-second
diff --git a/tests/golden/diffs/delete.json b/tests/golden/diffs/delete.json
new file mode 100644
index 0000000..bd4b90e
--- /dev/null
+++ b/tests/golden/diffs/delete.json
@@ -0,0 +1,12 @@
+[
+ {
+ "path": "deleted.txt",
+ "base_start": 1,
+ "base_len": 2,
+ "final_start": 0,
+ "final_len": 0,
+ "removed_lines": ["first\n", "second\n"],
+ "added_lines": [],
+ "is_delete_file": true
+ }
+]
diff --git a/tests/golden/diffs/mode.diff b/tests/golden/diffs/mode.diff
new file mode 100644
index 0000000..addf924
--- /dev/null
+++ b/tests/golden/diffs/mode.diff
@@ -0,0 +1,3 @@
+diff --git a/script.sh b/script.sh
+old mode 100644
+new mode 100755
diff --git a/tests/golden/diffs/mode.json b/tests/golden/diffs/mode.json
new file mode 100644
index 0000000..adcc7a0
--- /dev/null
+++ b/tests/golden/diffs/mode.json
@@ -0,0 +1,12 @@
+[
+ {
+ "path": "script.sh",
+ "base_start": 0,
+ "base_len": 0,
+ "final_start": 0,
+ "final_len": 0,
+ "removed_lines": [],
+ "added_lines": [],
+ "is_mode_change": true
+ }
+]
diff --git a/tests/golden/diffs/modify.diff b/tests/golden/diffs/modify.diff
new file mode 100644
index 0000000..d4086f8
--- /dev/null
+++ b/tests/golden/diffs/modify.diff
@@ -0,0 +1,7 @@
+diff --git a/example.txt b/example.txt
+index 422c2b7..ce95eac 100644
+--- a/example.txt
++++ b/example.txt
+@@ -2 +2 @@ one
+-old line
++new line
diff --git a/tests/golden/diffs/modify.json b/tests/golden/diffs/modify.json
new file mode 100644
index 0000000..c8261ff
--- /dev/null
+++ b/tests/golden/diffs/modify.json
@@ -0,0 +1,11 @@
+[
+ {
+ "path": "example.txt",
+ "base_start": 2,
+ "base_len": 1,
+ "final_start": 2,
+ "final_len": 1,
+ "removed_lines": ["old line\n"],
+ "added_lines": ["new line\n"]
+ }
+]
diff --git a/tests/golden/diffs/no-newline.diff b/tests/golden/diffs/no-newline.diff
new file mode 100644
index 0000000..dd2c5cd
--- /dev/null
+++ b/tests/golden/diffs/no-newline.diff
@@ -0,0 +1,9 @@
+diff --git a/value.txt b/value.txt
+index 3367afd..3e75765 100644
+--- a/value.txt
++++ b/value.txt
+@@ -1 +1 @@
+-before
+\ No newline at end of file
++after
+\ No newline at end of file
diff --git a/tests/golden/diffs/no-newline.json b/tests/golden/diffs/no-newline.json
new file mode 100644
index 0000000..d9246ea
--- /dev/null
+++ b/tests/golden/diffs/no-newline.json
@@ -0,0 +1,12 @@
+[
+ {
+ "path": "value.txt",
+ "base_start": 1,
+ "base_len": 1,
+ "final_start": 1,
+ "final_len": 1,
+ "removed_lines": ["before"],
+ "added_lines": ["after"],
+ "no_newline_at_eof": true
+ }
+]
diff --git a/tests/golden/diffs/symlink.diff b/tests/golden/diffs/symlink.diff
new file mode 100644
index 0000000..d315011
--- /dev/null
+++ b/tests/golden/diffs/symlink.diff
@@ -0,0 +1,8 @@
+diff --git a/current b/current
+new file mode 120000
+index 0000000..1de5659
+--- /dev/null
++++ b/current
+@@ -0,0 +1 @@
++releases/latest
+\ No newline at end of file
diff --git a/tests/golden/diffs/symlink.json b/tests/golden/diffs/symlink.json
new file mode 100644
index 0000000..76376ce
--- /dev/null
+++ b/tests/golden/diffs/symlink.json
@@ -0,0 +1,14 @@
+[
+ {
+ "path": "current",
+ "base_start": 0,
+ "base_len": 0,
+ "final_start": 1,
+ "final_len": 1,
+ "removed_lines": [],
+ "added_lines": ["releases/latest"],
+ "is_add_file": true,
+ "is_symlink": true,
+ "no_newline_at_eof": true
+ }
+]
diff --git a/tests/integration/test_diffparse.py b/tests/integration/test_diffparse.py
new file mode 100644
index 0000000..45f450a
--- /dev/null
+++ b/tests/integration/test_diffparse.py
@@ -0,0 +1,48 @@
+"""Real-Git integration tests for diff extraction and canonical isolation."""
+
+from __future__ import annotations
+
+import pytest
+
+from conftest import RepoFile, ScratchRepoFactory
+from git_paoding.gitio.diffparse import diff_trees
+
+
+@pytest.mark.integration
+def test_diff_trees_covers_text_and_whole_file_changes(
+ scratch_repo_factory: ScratchRepoFactory,
+) -> None:
+ repo = scratch_repo_factory(
+ {
+ "modified.txt": "before\n",
+ "deleted.txt": "deleted\n",
+ "binary.bin": b"\x00before",
+ "script.sh": "echo hi\n",
+ "link": RepoFile("old-target", symlink=True),
+ "unterminated.txt": "before",
+ "old-name.txt": "moved\n",
+ },
+ {
+ "modified.txt": "after\n",
+ "added.txt": "added\n",
+ "binary.bin": b"\x00after",
+ "script.sh": RepoFile("echo hi\n", executable=True),
+ "link": RepoFile("new-target", symlink=True),
+ "unterminated.txt": "after",
+ "new-name.txt": "moved\n",
+ },
+ )
+
+ hunks = diff_trees(repo.path, repo.base_oid, repo.final_oid)
+ by_path = {hunk.path: hunk for hunk in hunks}
+
+ assert by_path["modified.txt"].removed_lines == ("before\n",)
+ assert by_path["modified.txt"].added_lines == ("after\n",)
+ assert by_path["added.txt"].is_add_file
+ assert by_path["deleted.txt"].is_delete_file
+ assert by_path["binary.bin"].is_binary
+ assert by_path["script.sh"].is_mode_change
+ assert by_path["link"].is_symlink
+ assert by_path["unterminated.txt"].no_newline_at_eof
+ assert by_path["old-name.txt"].is_delete_file
+ assert by_path["new-name.txt"].is_add_file
diff --git a/tests/integration/test_plumbing.py b/tests/integration/test_plumbing.py
new file mode 100644
index 0000000..28af412
--- /dev/null
+++ b/tests/integration/test_plumbing.py
@@ -0,0 +1,84 @@
+"""Integration tests for object-database-only Git helpers."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+
+from conftest import ScratchRepoFactory
+from git_paoding.gitio.plumbing import (
+ GitIdentity,
+ cat_file,
+ commit_tree,
+ hash_object,
+ ls_remote,
+ ls_tree,
+ mktree,
+ rev_parse,
+ update_ref,
+)
+from git_paoding.gitio.runner import run_git
+
+
+@pytest.mark.integration
+def test_hash_and_cat_file_round_trip(scratch_repo_factory: ScratchRepoFactory) -> None:
+ repo = scratch_repo_factory({"tracked.txt": "base\n"}, {"tracked.txt": "final\n"})
+ content = b"object database only\n"
+
+ oid = hash_object(repo.path, content)
+
+ assert cat_file(repo.path, oid) == content
+
+
+@pytest.mark.integration
+def test_mktree_from_ls_tree_reproduces_tree_oid(
+ scratch_repo_factory: ScratchRepoFactory,
+) -> None:
+ repo = scratch_repo_factory(
+ {"root.txt": "base\n"},
+ {"root.txt": "final\n", "nested/child.txt": "child\n"},
+ )
+
+ entries = ls_tree(repo.path, repo.final_tree_oid)
+
+ assert mktree(repo.path, entries) == repo.final_tree_oid
+
+
+@pytest.mark.integration
+def test_commit_tree_and_update_non_head_ref_do_not_move_head(
+ scratch_repo_factory: ScratchRepoFactory,
+) -> None:
+ repo = scratch_repo_factory({"a.txt": "a\n"}, {"a.txt": "b\n"})
+ identity = GitIdentity(
+ name="git-paoding tests",
+ email="git-paoding@localhost",
+ date="2000-01-02T00:00:00+00:00",
+ )
+
+ commit_oid = commit_tree(
+ repo.path,
+ repo.final_tree_oid,
+ "Generated\n",
+ parents=(repo.final_oid,),
+ author=identity,
+ committer=identity,
+ )
+ update_ref(repo.path, "refs/heads/generated", commit_oid)
+
+ assert rev_parse(repo.path, "refs/heads/generated") == commit_oid
+ assert rev_parse(repo.path, "HEAD") == repo.final_oid
+
+
+@pytest.mark.integration
+def test_ls_remote_reads_local_remote_without_fetching(
+ tmp_path: Path, scratch_repo_factory: ScratchRepoFactory
+) -> None:
+ repo = scratch_repo_factory({"a.txt": "a\n"}, {"a.txt": "b\n"})
+ remote = tmp_path / "remote.git"
+ run_git(("clone", "--quiet", "--bare", str(repo.path), str(remote)), cwd=tmp_path)
+
+ refs = ls_remote(repo.path, str(remote), "refs/heads/main")
+
+ assert refs[0].oid == repo.final_oid
+ assert refs[0].ref == "refs/heads/main"
diff --git a/tests/integration/test_runner.py b/tests/integration/test_runner.py
new file mode 100644
index 0000000..5ca4967
--- /dev/null
+++ b/tests/integration/test_runner.py
@@ -0,0 +1,36 @@
+"""Integration tests for the typed Git runner boundary."""
+
+from __future__ import annotations
+
+import pytest
+
+from conftest import ScratchRepoFactory
+from git_paoding.gitio.runner import GitCommandError, GitFailureKind, run_git
+
+
+@pytest.mark.integration
+def test_runner_applies_identity_environment(scratch_repo_factory: ScratchRepoFactory) -> None:
+ repo = scratch_repo_factory({}, {})
+
+ result = run_git(
+ ("var", "GIT_AUTHOR_IDENT"),
+ cwd=repo.path,
+ env={
+ "GIT_AUTHOR_NAME": "Test Author",
+ "GIT_AUTHOR_EMAIL": "author@example.test",
+ "GIT_AUTHOR_DATE": "2001-02-03T04:05:06+00:00",
+ },
+ )
+
+ assert result.stdout_text().startswith("Test Author ")
+
+
+@pytest.mark.integration
+def test_runner_maps_unknown_revision(scratch_repo_factory: ScratchRepoFactory) -> None:
+ repo = scratch_repo_factory({}, {})
+
+ with pytest.raises(GitCommandError) as raised:
+ run_git(("rev-parse", "--verify", "missing-revision"), cwd=repo.path)
+
+ assert raised.value.kind is GitFailureKind.UNKNOWN_REVISION
+ assert raised.value.stderr
diff --git a/tests/unit/test_diffparse_golden.py b/tests/unit/test_diffparse_golden.py
new file mode 100644
index 0000000..c339b3c
--- /dev/null
+++ b/tests/unit/test_diffparse_golden.py
@@ -0,0 +1,35 @@
+"""Golden-file tests for zero-context diff parsing."""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from typing import Any, cast
+
+import pytest
+
+from git_paoding.gitio.diffparse import RawDiffHunk, parse_diff
+
+GOLDEN_DIR = Path(__file__).parents[1] / "golden" / "diffs"
+
+
+def _load_expected(path: Path) -> tuple[RawDiffHunk, ...]:
+ payload = cast(list[dict[str, Any]], json.loads(path.read_text()))
+ records: list[RawDiffHunk] = []
+ for item in payload:
+ item["removed_lines"] = tuple(item["removed_lines"])
+ item["added_lines"] = tuple(item["added_lines"])
+ records.append(RawDiffHunk(**item))
+ return tuple(records)
+
+
+@pytest.mark.unit
+@pytest.mark.parametrize(
+ "case",
+ ["modify", "add", "delete", "binary", "mode", "symlink", "no-newline"],
+)
+def test_parse_diff_golden(case: str) -> None:
+ diff = (GOLDEN_DIR / f"{case}.diff").read_bytes()
+ expected = _load_expected(GOLDEN_DIR / f"{case}.json")
+
+ assert parse_diff(diff) == expected