Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions src/git_paoding/gitio/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
195 changes: 194 additions & 1 deletion src/git_paoding/gitio/diffparse.py
Original file line number Diff line number Diff line change
@@ -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<base>"(?:\\.|[^"])*"|\S+) (?P<final>.+)$')
_HUNK_HEADER = re.compile(
r"^@@ -(?P<base_start>\d+)(?:,(?P<base_len>\d+))? "
r"\+(?P<final_start>\d+)(?:,(?P<final_len>\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)
Loading
Loading