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