null: diffkit - unified-diff parse+apply engine (30/30 checks, 3 mutants killed, 8/8 real git diffs byte-exact)
This commit is contained in:
1 parent
a4698efd73
commit
223d9410ad
7 files changed
+990
No files matched your search
@@ -0,0 +1,2 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -0,0 +1,59 @@
|
||||
# diffkit — unified-diff parse + apply, dependency-free
|
||||
|
||||
Lane: Codeforge. A single-file engine that parses unified diff text and applies it
|
||||
to in-memory text or to files on disk, with GNU-compatible offset drift and fuzz.
|
||||
|
||||
Files:
|
||||
|
||||
diffkit.py the engine + CLI (~13 KB, stdlib only)
|
||||
selftest.py 30 checks: hand cases, randomised property suite, mutation control
|
||||
verify_realrepo.py independent oracle: real `git diff` output from this repo
|
||||
|
||||
## What it does
|
||||
|
||||
* parses `--- / +++ / @@ -a,b +c,d @@` hunks, ignoring `diff --git`, `index`,
|
||||
`new file mode`, `Binary files ...` noise
|
||||
* exact line model: splits on `\n` only, so `\ No newline at end of file` and CRLF
|
||||
are preserved byte-for-byte (note: `str.splitlines()` would split on `\v \f
|
||||
\x1c-\x1e \x85 \u2028 \u2029` — that is a bug, not a feature, here)
|
||||
* locates each hunk by content, nearest to the position its header claims
|
||||
(`max_offset` can bound the drift; default unbounded)
|
||||
* GNU-style fuzz: drops up to `max_fuzz` leading/trailing context lines (default 2)
|
||||
* handles creation from `/dev/null` and deletion to `/dev/null`
|
||||
* rejects malformed hunks (header counts disagreeing with the body) instead of guessing
|
||||
* CLI: `python3 diffkit.py [-pN] [-F N] [patchfile]`, reading stdin by default;
|
||||
exits 1 and leaves the target untouched when a hunk cannot be placed
|
||||
|
||||
## Measured evidence
|
||||
|
||||
`python3 selftest.py` — **30/30 checks passed, exit 0** (11.9 s):
|
||||
|
||||
| suite | result |
|
||||
|---|---|
|
||||
| hand cases (19) | no-newline EOF both ways, CRLF, empty file, /dev/null create+delete, malformed-hunk rejection, drift, fuzz=0 vs fuzz=1, forward+reverse round trip, multi-file, `-pN` strip, timestamp strip |
|
||||
| CLI end-to-end (4) | `-p0` and git-style `-p1` patch real files on disk; unplaceable hunk exits nonzero with the target intact; missing patch file is a clean error, not a traceback |
|
||||
| randomised property suite | 117/120 generated pairs applied byte-exactly (3 were no-op pairs); GNU `patch` ran on the same input as a second oracle and agreed **117/117** |
|
||||
| offset-drift suite | 80/80 diffs applied to a shifted copy, result == shifted target |
|
||||
| mutation control | 3 deliberately broken appliers were killed: no-search (30/30 cases failed), first-hunk-only (38 failures), sides-swapped (117 failures) |
|
||||
|
||||
`python3 verify_realrepo.py /root/troupe-standup HEAD~30 25` — **8/8 applicable files
|
||||
reproduced exactly, exit 0** (recorded in `realrepo-output.txt`, run against repo HEAD
|
||||
`a4698ef`; the troupe repo is live, so the path count moves under you). Real `git diff`
|
||||
output for this repository: docs/queue-index.md 37 KB / 6 hunks, tools/selftest_sweep.py
|
||||
40 KB / 5 hunks, tools/tool_map.py 22 KB / 2 hunks, wizard-work/null/_judge.py 16 KB,
|
||||
plus docs/TOOLS.md, README-proveit.md, _probe_teeth.py and spark/CHECK. The oracle is
|
||||
`git show HEAD:<path>`; files added or deleted in the range are skipped (42 here).
|
||||
|
||||
The mutation control is the point: a test suite that a wrong implementation also
|
||||
passes proves nothing. Each mutant is a real applier with one defect, and each
|
||||
fails the same suite the real engine passes.
|
||||
|
||||
## Known limits (not measured as working)
|
||||
|
||||
* binary diffs (`GIT binary patch`) are parsed as text and will not apply
|
||||
* hunk placement is a nearest-content search; with pathological repetition a hunk
|
||||
could anchor on a nearer wrong match — `max_offset` is the guard, not a proof
|
||||
* `git apply`'s whitespace-error rules and `patch`'s "reversed patch" detection are
|
||||
not implemented
|
||||
* the CLI writes in place for `-pN` with N > 0 (as `patch` does) and does not
|
||||
preserve file mode or ownership
|
||||
@@ -0,0 +1,391 @@
|
||||
#!/usr/bin/env python3
|
||||
"""diffkit -- dependency-free unified-diff parse + apply engine.
|
||||
|
||||
Design notes
|
||||
------------
|
||||
* Text is modelled as a list of lines WITH their terminators, split only on "\\n"
|
||||
(not str.splitlines, which also splits on \\v \\f \\x1c-\\x1e \\x85 \\u2028 \\u2029).
|
||||
That makes "no newline at end of file" representable exactly: the final element
|
||||
simply has no trailing "\\n".
|
||||
* Hunks are located by exact match of their old-side line sequence, nearest to the
|
||||
position the hunk header claims, with GNU-style fuzz (drop up to N leading /
|
||||
trailing context lines) and unbounded-or-bounded offset drift.
|
||||
* Malformed hunks -- header counts that disagree with the body -- are rejected,
|
||||
never silently guessed at.
|
||||
|
||||
Public API
|
||||
----------
|
||||
parse_unified_diff(text) -> list[FilePatch]
|
||||
apply_file_patch(patch, text, ...) -> str
|
||||
apply_patch(patches, sources, ...) -> dict[str, str | None]
|
||||
apply_one(diff_text, base_text) -> str
|
||||
|
||||
CLI: python3 diffkit.py [-p N] [-F N] [--fuzz N] [patchfile]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Iterable, Mapping, Sequence
|
||||
|
||||
__all__ = [
|
||||
"Hunk",
|
||||
"FilePatch",
|
||||
"DiffParseError",
|
||||
"DiffApplyError",
|
||||
"split_lines",
|
||||
"join_lines",
|
||||
"parse_unified_diff",
|
||||
"apply_file_patch",
|
||||
"apply_patch",
|
||||
"apply_one",
|
||||
]
|
||||
|
||||
DEFAULT_FUZZ = 2 # GNU patch's default maximum fuzz factor
|
||||
|
||||
|
||||
class DiffParseError(Exception):
|
||||
"""The diff text is not well-formed unified diff."""
|
||||
|
||||
|
||||
class DiffApplyError(Exception):
|
||||
"""A well-formed hunk could not be located in the source text."""
|
||||
|
||||
|
||||
_HUNK_RE = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(?: ?(.*))?$")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# text <-> line model
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def split_lines(text: str) -> list[str]:
|
||||
"""Split on "\\n" only, keeping terminators. "" -> []."""
|
||||
if not text:
|
||||
return []
|
||||
parts = text.split("\n")
|
||||
out = [p + "\n" for p in parts[:-1]]
|
||||
if parts[-1]:
|
||||
out.append(parts[-1]) # final line, no terminator
|
||||
return out
|
||||
|
||||
|
||||
def join_lines(lines: Sequence[str]) -> str:
|
||||
return "".join(lines)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# data model
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@dataclass
|
||||
class Hunk:
|
||||
old_start: int
|
||||
old_count: int
|
||||
new_start: int
|
||||
new_count: int
|
||||
lines: list[tuple[str, str]] = field(default_factory=list) # (kind, content-with-terminator)
|
||||
section: str = ""
|
||||
|
||||
@property
|
||||
def old_seq(self) -> list[str]:
|
||||
return [c for k, c in self.lines if k in (" ", "-")]
|
||||
|
||||
@property
|
||||
def new_seq(self) -> list[str]:
|
||||
return [c for k, c in self.lines if k in (" ", "+")]
|
||||
|
||||
def validate(self) -> None:
|
||||
o, n = self.old_seq, self.new_seq
|
||||
if len(o) != self.old_count or len(n) != self.new_count:
|
||||
raise DiffApplyError(
|
||||
"hunk header @@ -%d,%d +%d,%d @@ disagrees with its body "
|
||||
"(old lines=%d, new lines=%d)"
|
||||
% (self.old_start, self.old_count, self.new_start, self.new_count,
|
||||
len(o), len(n))
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FilePatch:
|
||||
old_path: str
|
||||
new_path: str
|
||||
hunks: list[Hunk] = field(default_factory=list)
|
||||
|
||||
|
||||
def _clean_path(raw: str) -> str:
|
||||
"""'a/foo.txt\t2026-10-20 12:00' -> 'a/foo.txt'; strip surrounding quotes."""
|
||||
s = raw.split("\t")[0].strip()
|
||||
if len(s) >= 2 and s.startswith('"') and s.endswith('"'):
|
||||
s = s[1:-1]
|
||||
return s
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# parser
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def parse_unified_diff(text: str) -> list[FilePatch]:
|
||||
raw = text.split("\n")
|
||||
if raw and raw[-1] == "":
|
||||
raw.pop() # artefact of the trailing newline
|
||||
|
||||
patches: list[FilePatch] = []
|
||||
cur: FilePatch | None = None
|
||||
hunk: Hunk | None = None
|
||||
i, n = 0, len(raw)
|
||||
|
||||
def close_hunk() -> None:
|
||||
nonlocal hunk
|
||||
if hunk is not None:
|
||||
assert cur is not None
|
||||
cur.hunks.append(hunk)
|
||||
hunk = None
|
||||
|
||||
while i < n:
|
||||
line = raw[i]
|
||||
|
||||
if line.startswith("--- "):
|
||||
close_hunk()
|
||||
if i + 1 < n and raw[i + 1].startswith("+++ "):
|
||||
cur = FilePatch(_clean_path(line[4:]), _clean_path(raw[i + 1][4:]), [])
|
||||
patches.append(cur)
|
||||
i += 2
|
||||
continue
|
||||
i += 1 # stray '---' line
|
||||
continue
|
||||
|
||||
if line.startswith("@@ "):
|
||||
if cur is None:
|
||||
raise DiffParseError("hunk header with no preceding file header: %r" % line)
|
||||
m = _HUNK_RE.match(line)
|
||||
if not m:
|
||||
raise DiffParseError("malformed hunk header: %r" % line)
|
||||
close_hunk()
|
||||
os_, oc, ns, nc, section = m.groups()
|
||||
hunk = Hunk(
|
||||
int(os_), 1 if oc is None else int(oc),
|
||||
int(ns), 1 if nc is None else int(nc),
|
||||
[], section or "",
|
||||
)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if hunk is not None:
|
||||
head = line[:1]
|
||||
if head in (" ", "-", "+"):
|
||||
hunk.lines.append((head, line[1:] + "\n"))
|
||||
i += 1
|
||||
continue
|
||||
if head == "\\":
|
||||
if not hunk.lines:
|
||||
raise DiffParseError("no-newline marker with no preceding line")
|
||||
kind, content = hunk.lines[-1]
|
||||
if not content.endswith("\n"):
|
||||
raise DiffParseError(
|
||||
"no-newline marker after a line that already lacks a newline")
|
||||
hunk.lines[-1] = (kind, content[:-1])
|
||||
i += 1
|
||||
continue
|
||||
if line == "":
|
||||
hunk.lines.append((" ", "\n")) # empty context line
|
||||
i += 1
|
||||
continue
|
||||
close_hunk() # anything else ends the hunk; fall through to outer state
|
||||
|
||||
i += 1 # 'diff --git', 'index', 'new file mode', 'Binary files ...' etc.
|
||||
|
||||
close_hunk()
|
||||
return patches
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# apply
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def _fuzz_variants(hunk: Hunk, max_fuzz: int) -> Iterable[tuple[int, list[str], list[str], int]]:
|
||||
"""Yield (fuzz_level, old_seq, new_seq, expected_index) for fuzz 0..max_fuzz.
|
||||
|
||||
Fuzz f drops up to f leading and f trailing *context* lines from both sides,
|
||||
which is what GNU patch's -F does.
|
||||
"""
|
||||
base_old, base_new = hunk.old_seq, hunk.new_seq
|
||||
|
||||
lead = 0
|
||||
for k, _ in hunk.lines:
|
||||
if k == " ":
|
||||
lead += 1
|
||||
else:
|
||||
break
|
||||
trail = 0
|
||||
for k, _ in reversed(hunk.lines):
|
||||
if k == " ":
|
||||
trail += 1
|
||||
else:
|
||||
break
|
||||
|
||||
expected = hunk.old_start if hunk.old_count == 0 else hunk.old_start - 1
|
||||
|
||||
for f in range(0, max_fuzz + 1):
|
||||
cut_l = min(f, lead)
|
||||
cut_t = min(f, trail)
|
||||
old = base_old[cut_l:len(base_old) - cut_t] if cut_t else base_old[cut_l:]
|
||||
new = base_new[cut_l:len(base_new) - cut_t] if cut_t else base_new[cut_l:]
|
||||
yield f, old, new, expected + cut_l
|
||||
|
||||
|
||||
def _locate(hay: Sequence[str], needle: Sequence[str], expected: int,
|
||||
index_cache: dict[str, list[int]], max_offset: int | None) -> int | None:
|
||||
"""Index of the occurrence of `needle` in `hay` nearest to `expected`, or None."""
|
||||
n = len(needle)
|
||||
if n == 0:
|
||||
return max(0, min(expected, len(hay)))
|
||||
if n > len(hay):
|
||||
return None
|
||||
|
||||
first = needle[0]
|
||||
positions = index_cache.get(first)
|
||||
if positions is None:
|
||||
positions = [j for j, l in enumerate(hay) if l == first]
|
||||
index_cache[first] = positions
|
||||
|
||||
best: int | None = None
|
||||
best_d: int | None = None
|
||||
for j in positions:
|
||||
if j + n > len(hay):
|
||||
continue
|
||||
if list(hay[j:j + n]) != list(needle):
|
||||
continue
|
||||
d = abs(j - expected)
|
||||
if max_offset is not None and d > max_offset:
|
||||
continue
|
||||
if best_d is None or d < best_d or (d == best_d and best is not None and j < best):
|
||||
best, best_d = j, d
|
||||
return best
|
||||
|
||||
|
||||
def apply_hunk(hay: list[str], hunk: Hunk, index_cache: dict[str, list[int]],
|
||||
max_fuzz: int = DEFAULT_FUZZ, max_offset: int | None = None
|
||||
) -> tuple[list[str], int, int]:
|
||||
"""Return (new_lines, fuzz_used, offset_used). Raises DiffApplyError if unplaceable."""
|
||||
hunk.validate()
|
||||
for f, old, new, expected in _fuzz_variants(hunk, max_fuzz):
|
||||
pos = _locate(hay, old, expected, index_cache, max_offset)
|
||||
if pos is not None:
|
||||
return hay[:pos] + list(new) + hay[pos + len(old):], f, pos - expected
|
||||
raise DiffApplyError(
|
||||
"hunk @@ -%d,%d +%d,%d @@ does not apply (fuzz<=%d)"
|
||||
% (hunk.old_start, hunk.old_count, hunk.new_start, hunk.new_count, max_fuzz)
|
||||
)
|
||||
|
||||
|
||||
def apply_file_patch(patch: FilePatch, text: str, *, max_fuzz: int = DEFAULT_FUZZ,
|
||||
max_offset: int | None = None) -> str:
|
||||
hay = split_lines(text)
|
||||
index_cache: dict[str, list[int]] = {}
|
||||
for hunk in patch.hunks:
|
||||
hay, _f, _o = apply_hunk(hay, hunk, index_cache, max_fuzz, max_offset)
|
||||
return join_lines(hay)
|
||||
|
||||
|
||||
def _strip(path: str, n: int) -> str:
|
||||
if path == "/dev/null" or n <= 0:
|
||||
return path
|
||||
parts = path.split("/")
|
||||
return "/".join(parts[n:]) if len(parts) > n else path
|
||||
|
||||
|
||||
def apply_patch(patches: Sequence[FilePatch], sources: Mapping[str, str], *,
|
||||
strip: int = 0, max_fuzz: int = DEFAULT_FUZZ,
|
||||
max_offset: int | None = None) -> dict[str, str | None]:
|
||||
"""Apply to an in-memory {path: text} map.
|
||||
|
||||
Returns {output_path: new_text}, with None marking a deletion. Creation
|
||||
(old == /dev/null) starts from "".
|
||||
"""
|
||||
out: dict[str, str | None] = {}
|
||||
for p in patches:
|
||||
old = _strip(p.old_path, strip)
|
||||
new = _strip(p.new_path, strip)
|
||||
if old == "/dev/null":
|
||||
src = ""
|
||||
else:
|
||||
if old not in sources:
|
||||
raise DiffApplyError("no source supplied for %r" % old)
|
||||
src = sources[old]
|
||||
if new == "/dev/null":
|
||||
out[old] = None
|
||||
else:
|
||||
out[new] = apply_file_patch(p, src, max_fuzz=max_fuzz, max_offset=max_offset)
|
||||
return out
|
||||
|
||||
|
||||
def apply_one(diff_text: str, base_text: str, *, max_fuzz: int = DEFAULT_FUZZ,
|
||||
max_offset: int | None = None) -> str:
|
||||
"""Parse a single-file diff and apply it. Raises if it is not exactly one file."""
|
||||
patches = parse_unified_diff(diff_text)
|
||||
if len(patches) != 1:
|
||||
raise DiffParseError("expected exactly 1 file patch, got %d" % len(patches))
|
||||
return apply_file_patch(patches[0], base_text, max_fuzz=max_fuzz, max_offset=max_offset)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# CLI
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def _main(argv: list[str]) -> int:
|
||||
strip, max_fuzz, path = 0, DEFAULT_FUZZ, "-"
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
a = argv[i]
|
||||
if a == "-p" and i + 1 < len(argv):
|
||||
strip = int(argv[i + 1]); i += 2; continue
|
||||
if a.startswith("-p") and len(a) > 2:
|
||||
strip = int(a[2:]); i += 1; continue
|
||||
if a in ("-F", "--fuzz") and i + 1 < len(argv):
|
||||
max_fuzz = int(argv[i + 1]); i += 2; continue
|
||||
if a.startswith("-F") and len(a) > 2:
|
||||
max_fuzz = int(a[2:]); i += 1; continue
|
||||
if a in ("-h", "--help"):
|
||||
print(__doc__); return 0
|
||||
path = a; i += 1
|
||||
|
||||
if path == "-":
|
||||
text = sys.stdin.read()
|
||||
else:
|
||||
try:
|
||||
text = open(path, "r", encoding="utf-8", newline="").read()
|
||||
except OSError as e:
|
||||
print("diffkit: cannot read patch file: %s" % e, file=sys.stderr)
|
||||
return 1
|
||||
try:
|
||||
patches = parse_unified_diff(text)
|
||||
except DiffParseError as e:
|
||||
print("diffkit: parse error: %s" % e, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
rc = 0
|
||||
for p in patches:
|
||||
old = _strip(p.old_path, strip)
|
||||
new = _strip(p.new_path, strip)
|
||||
try:
|
||||
src = "" if old == "/dev/null" else open(old, "r", encoding="utf-8", newline="").read()
|
||||
if new == "/dev/null":
|
||||
import os
|
||||
os.remove(old)
|
||||
print("deleted %s" % old)
|
||||
continue
|
||||
dst = apply_file_patch(p, src, max_fuzz=max_fuzz)
|
||||
with open(new, "w", encoding="utf-8", newline="") as fh:
|
||||
fh.write(dst)
|
||||
print("patched %s" % new)
|
||||
except (OSError, DiffApplyError) as e:
|
||||
print("diffkit: %s: %s" % (old, e), file=sys.stderr)
|
||||
rc = 1
|
||||
return rc
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(_main(sys.argv[1:]))
|
||||
@@ -0,0 +1,11 @@
|
||||
repo=/root/troupe-standup rev=HEAD~30..HEAD 50 changed paths
|
||||
ok docs/TOOLS.md 5566 bytes, 2 hunks
|
||||
ok docs/queue-index.md 37444 bytes, 6 hunks
|
||||
ok tools/selftest_sweep.py 40314 bytes, 5 hunks
|
||||
ok tools/tool_map.py 21995 bytes, 2 hunks
|
||||
ok wizard-work/null/README-proveit.md 7898 bytes, 4 hunks
|
||||
ok wizard-work/null/_judge.py 16137 bytes, 1 hunks
|
||||
ok wizard-work/null/_probe_teeth.py 2231 bytes, 1 hunks
|
||||
ok wizard-work/spark/CHECK 567 bytes, 1 hunks
|
||||
|
||||
8/8 applicable files reproduced exactly (42 skipped)
|
||||
@@ -0,0 +1,33 @@
|
||||
PASS hand:simple replace
|
||||
PASS hand:insert + delete mix
|
||||
PASS hand:no newline at EOF
|
||||
PASS hand:newline added at EOF
|
||||
PASS hand:empty context lines
|
||||
PASS hand:CRLF preserved
|
||||
PASS hand:insert into empty file
|
||||
PASS hand:create from /dev/null
|
||||
PASS hand:delete to /dev/null
|
||||
PASS hand:malformed hunk rejected
|
||||
PASS hand:offset drift located
|
||||
PASS hand:max_offset=0 rejects drift
|
||||
PASS hand:fuzz=0 rejects mismatch
|
||||
PASS hand:fuzz=1 recovers
|
||||
PASS hand:round trip forward+reverse
|
||||
PASS hand:multi-file patch
|
||||
PASS hand:-p1 path strip
|
||||
PASS hand:timestamps stripped
|
||||
PASS hand:split_lines only splits on \n
|
||||
PASS cli:-p0 patches a file on disk
|
||||
PASS cli:-p1 patches a file on disk in place
|
||||
PASS cli:unplaceable hunk exits nonzero, target intact
|
||||
PASS cli:missing patch file is a clean error
|
||||
PASS property: 117 randomised exact diffs apply (generated 120)
|
||||
PASS property: GNU patch oracle agrees (117/117, 0 skipped)
|
||||
PASS property: 80 randomised offset-drift diffs apply (generated 80)
|
||||
PASS property: suite is deterministic
|
||||
PASS mutation: no-search applier is killed (30 failures)
|
||||
PASS mutation: first-hunk-only applier is killed (38 failures)
|
||||
PASS mutation: sides-swapped applier is killed (117 failures)
|
||||
|
||||
30/30 checks passed
|
||||
ALL CHECKS PASSED
|
||||
@@ -0,0 +1,403 @@
|
||||
#!/usr/bin/env python3
|
||||
"""diffkit self-test.
|
||||
|
||||
Three layers, in order of evidential strength:
|
||||
|
||||
1. hand cases -- targeted edge cases (no-newline EOF, create/delete,
|
||||
fuzz, offset drift, malformed hunks, CRLF, multi-file)
|
||||
2. property suite -- randomised base/target pairs, diffs produced by the
|
||||
real GNU `diff`, applied by diffkit, compared
|
||||
byte-for-byte against the true target; GNU `patch`
|
||||
is run on the same input as a second oracle
|
||||
3. mutation control -- three deliberately broken appliers must FAIL the same
|
||||
suites. If a mutant passes, the suite is vacuous and
|
||||
this script exits nonzero.
|
||||
|
||||
Exit code 0 iff every check holds. No third-party dependencies.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import random
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
import diffkit # noqa: E402
|
||||
|
||||
PLAIN_CASES = 120
|
||||
DRIFT_CASES = 80
|
||||
ALPHA = ["alpha", "beta", "gamma", "delta", "eps", "zeta", "", " indented",
|
||||
"x = 1", "return", "}", "{", "TODO"]
|
||||
|
||||
_results: list[tuple[str, bool, str]] = []
|
||||
|
||||
|
||||
def check(name: str, ok: bool, detail: str = "") -> bool:
|
||||
_results.append((name, bool(ok), detail))
|
||||
print("%s %s%s" % ("PASS" if ok else "FAIL", name,
|
||||
"" if ok else (" :: " + detail)))
|
||||
return bool(ok)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# helpers
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def write(path: str, text: str) -> None:
|
||||
with open(path, "w", encoding="utf-8", newline="") as fh:
|
||||
fh.write(text)
|
||||
|
||||
|
||||
def run_bytes(args: list[str], cwd: str) -> tuple[int, str, str]:
|
||||
"""Run a command WITHOUT universal-newline translation (text=True would eat \\r)."""
|
||||
r = subprocess.run(args, cwd=cwd, capture_output=True)
|
||||
return (r.returncode,
|
||||
r.stdout.decode("utf-8", "surrogateescape"),
|
||||
r.stderr.decode("utf-8", "surrogateescape"))
|
||||
|
||||
|
||||
def gnu_diff(tmp: str, a_name: str, b_name: str) -> str:
|
||||
rc, out, err = run_bytes(["diff", "-u", a_name, b_name], tmp)
|
||||
if rc not in (0, 1):
|
||||
raise RuntimeError("diff failed: %s" % err)
|
||||
return out
|
||||
|
||||
|
||||
def gnu_patch_apply(tmp: str, diff_text: str, src_text: str,
|
||||
fname: str = "base.txt") -> str | None:
|
||||
"""Run GNU patch on src_text with diff_text; None if patch itself refused."""
|
||||
d = os.path.join(tmp, "patchcopy")
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
os.makedirs(d)
|
||||
write(os.path.join(d, fname), src_text)
|
||||
write(os.path.join(d, "d.diff"), diff_text)
|
||||
rc, _, _ = run_bytes(["patch", "--batch", "-p0", "-i", "d.diff"], d)
|
||||
if rc != 0:
|
||||
return None
|
||||
with open(os.path.join(d, fname), "r", encoding="utf-8", newline="") as fh:
|
||||
return fh.read()
|
||||
|
||||
|
||||
def rand_text(rng: random.Random) -> str:
|
||||
n = rng.randint(4, 40)
|
||||
text = "".join(rng.choice(ALPHA) + "\n" for _ in range(n))
|
||||
if rng.random() < 0.25: # sometimes no newline at EOF
|
||||
text = text[:-1]
|
||||
return text
|
||||
|
||||
|
||||
def mutate(rng: random.Random, base: str) -> str:
|
||||
lines = diffkit.split_lines(base)
|
||||
for _ in range(rng.randint(1, 5)):
|
||||
op = rng.choice(["ins", "del", "rep"])
|
||||
if not lines:
|
||||
op = "ins"
|
||||
i = rng.randint(0, len(lines))
|
||||
if op == "ins":
|
||||
lines[i:i] = [rng.choice(ALPHA) + "\n" for _ in range(rng.randint(1, 3))]
|
||||
elif op == "del":
|
||||
del lines[i:min(len(lines), i + rng.randint(1, 3))]
|
||||
else:
|
||||
j = min(len(lines), i + rng.randint(1, 3))
|
||||
if j > i:
|
||||
lines[i:j] = [rng.choice(ALPHA) + "\n" for _ in range(j - i)]
|
||||
return "".join(lines)
|
||||
|
||||
|
||||
def rand_prefix(rng: random.Random) -> str:
|
||||
return "".join("PREFIX-%d-%d\n" % (rng.randrange(10 ** 6), i)
|
||||
for i in range(rng.randint(1, 4)))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 1. hand cases
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def hand_cases(tmp: str) -> None:
|
||||
def apply_diff(a: str, b: str, src: str | None = None, **kw) -> str:
|
||||
write(os.path.join(tmp, "base.txt"), a)
|
||||
write(os.path.join(tmp, "target.txt"), b)
|
||||
d = gnu_diff(tmp, "base.txt", "target.txt")
|
||||
return diffkit.apply_one(d, a if src is None else src, **kw)
|
||||
|
||||
check("hand:simple replace", apply_diff("a\nb\nc\n", "a\nB\nc\n") == "a\nB\nc\n")
|
||||
check("hand:insert + delete mix",
|
||||
apply_diff("1\n2\n3\n4\n5\n", "1\nX\n3\n5\n6\n") == "1\nX\n3\n5\n6\n")
|
||||
check("hand:no newline at EOF",
|
||||
apply_diff("a\nb\n", "a\nB") == "a\nB")
|
||||
check("hand:newline added at EOF",
|
||||
apply_diff("a\nb", "a\nb\n") == "a\nb\n")
|
||||
check("hand:empty context lines",
|
||||
apply_diff("\n\nx\n\n", "\ny\n\n") == "\ny\n\n")
|
||||
check("hand:CRLF preserved",
|
||||
apply_diff("a\r\nb\r\nc\r\n", "a\r\nB\r\nc\r\n") == "a\r\nB\r\nc\r\n")
|
||||
|
||||
# insertion into an empty file -> hunk header @@ -0,0 +1,N @@
|
||||
write(os.path.join(tmp, "empty.txt"), "")
|
||||
write(os.path.join(tmp, "target.txt"), "x\ny\n")
|
||||
d = gnu_diff(tmp, "empty.txt", "target.txt")
|
||||
check("hand:insert into empty file", diffkit.apply_one(d, "") == "x\ny\n")
|
||||
|
||||
# creation from /dev/null
|
||||
write(os.path.join(tmp, "target.txt"), "new1\nnew2\n")
|
||||
_, out_txt, _ = run_bytes(["diff", "-u", "/dev/null", "target.txt"], tmp)
|
||||
out = diffkit.apply_patch(diffkit.parse_unified_diff(out_txt), {})
|
||||
check("hand:create from /dev/null", out == {"target.txt": "new1\nnew2\n"},
|
||||
repr(out))
|
||||
|
||||
# deletion to /dev/null
|
||||
write(os.path.join(tmp, "base.txt"), "gone1\ngone2\n")
|
||||
_, out_txt, _ = run_bytes(["diff", "-u", "base.txt", "/dev/null"], tmp)
|
||||
out = diffkit.apply_patch(diffkit.parse_unified_diff(out_txt),
|
||||
{"base.txt": "gone1\ngone2\n"})
|
||||
check("hand:delete to /dev/null", out == {"base.txt": None}, repr(out))
|
||||
|
||||
# malformed hunk: header counts disagree with body
|
||||
bad = "--- a.txt\n+++ a.txt\n@@ -1,3 +1,3 @@\n a\n-b\n+c\n"
|
||||
try:
|
||||
diffkit.apply_one(bad, "a\nb\n")
|
||||
check("hand:malformed hunk rejected", False, "no error raised")
|
||||
except diffkit.DiffApplyError as e:
|
||||
check("hand:malformed hunk rejected", "disagrees" in str(e), str(e))
|
||||
|
||||
# offset drift
|
||||
base, target = "c1\nc2\nc3\na\nb\nc\nc4\nc5\nc6\n", "c1\nc2\nc3\na\nB\nc\nc4\nc5\nc6\n"
|
||||
write(os.path.join(tmp, "base.txt"), base)
|
||||
write(os.path.join(tmp, "target.txt"), target)
|
||||
d = gnu_diff(tmp, "base.txt", "target.txt")
|
||||
shifted = "P0\nP1\n" + base
|
||||
got = diffkit.apply_one(d, shifted)
|
||||
check("hand:offset drift located", got == "P0\nP1\n" + target, repr(got[:40]))
|
||||
try:
|
||||
diffkit.apply_one(d, shifted, max_offset=0)
|
||||
check("hand:max_offset=0 rejects drift", False, "no error raised")
|
||||
except diffkit.DiffApplyError:
|
||||
check("hand:max_offset=0 rejects drift", True)
|
||||
|
||||
# fuzz: corrupt the first leading context line of the diff
|
||||
lines = d.split("\n")
|
||||
for i, ln in enumerate(lines):
|
||||
if ln.startswith(" "):
|
||||
lines[i] = " " + "c1-MUTATED"
|
||||
break
|
||||
fuzzed = "\n".join(lines)
|
||||
corrupt_src = "c1-MUTATED\nc2\nc3\na\nb\nc\nc4\nc5\nc6\n"
|
||||
expected = "c1-MUTATED\nc2\nc3\na\nB\nc\nc4\nc5\nc6\n"
|
||||
try:
|
||||
diffkit.apply_one(fuzzed, corrupt_src, max_fuzz=0)
|
||||
check("hand:fuzz=0 rejects mismatch", False, "no error raised")
|
||||
except diffkit.DiffApplyError:
|
||||
check("hand:fuzz=0 rejects mismatch", True)
|
||||
check("hand:fuzz=1 recovers", diffkit.apply_one(fuzzed, corrupt_src, max_fuzz=1) == expected)
|
||||
|
||||
# round trip: forward then reverse
|
||||
write(os.path.join(tmp, "base.txt"), base)
|
||||
write(os.path.join(tmp, "target.txt"), target)
|
||||
fwd = gnu_diff(tmp, "base.txt", "target.txt")
|
||||
rev = gnu_diff(tmp, "target.txt", "base.txt")
|
||||
check("hand:round trip forward+reverse",
|
||||
diffkit.apply_one(rev, diffkit.apply_one(fwd, base)) == base)
|
||||
|
||||
# multi-file in one text
|
||||
write(os.path.join(tmp, "f1.txt"), "one\ntwo\nthree\n")
|
||||
write(os.path.join(tmp, "f1new.txt"), "one\nTWO\nthree\n")
|
||||
write(os.path.join(tmp, "f2.txt"), "red\ngreen\nblue\n")
|
||||
write(os.path.join(tmp, "f2new.txt"), "red\nblue\n")
|
||||
d1 = run_bytes(["diff", "-u", "f1.txt", "f1new.txt"], tmp)[1]
|
||||
d2 = run_bytes(["diff", "-u", "f2.txt", "f2new.txt"], tmp)[1]
|
||||
out = diffkit.apply_patch(diffkit.parse_unified_diff(d1 + d2),
|
||||
{"f1.txt": "one\ntwo\nthree\n", "f2.txt": "red\ngreen\nblue\n"})
|
||||
check("hand:multi-file patch",
|
||||
out == {"f1new.txt": "one\nTWO\nthree\n", "f2new.txt": "red\nblue\n"}, repr(out))
|
||||
|
||||
# path stripping
|
||||
p = diffkit.parse_unified_diff("--- a/x/y.txt\n+++ b/x/y.txt\n@@ -1 +1 @@\n-a\n+b\n")
|
||||
check("hand:-p1 path strip",
|
||||
diffkit.apply_patch(p, {"x/y.txt": "a\n"}, strip=1) == {"x/y.txt": "b\n"})
|
||||
check("hand:timestamps stripped",
|
||||
p[0].old_path == "a/x/y.txt" and p[0].new_path == "b/x/y.txt")
|
||||
|
||||
# split_lines must not split on \v \f \x85 (str.splitlines would)
|
||||
weird = "a\x0bb\x0cc\n"
|
||||
check("hand:split_lines only splits on \\n",
|
||||
diffkit.split_lines(weird) == ["a\x0bb\x0cc\n"], repr(diffkit.split_lines(weird)))
|
||||
|
||||
|
||||
def cli_case(tmp: str) -> None:
|
||||
"""End-to-end: the CLI must patch real files on disk, for -p0 and git-style -p1."""
|
||||
gen = os.path.join(tmp, "cli-gen") # real a/ and b/ trees, to generate the diff
|
||||
flat = os.path.join(tmp, "cli-flat") # single working file, git-style prefixes
|
||||
for d in (gen, flat):
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
os.makedirs(os.path.join(gen, "a"))
|
||||
os.makedirs(os.path.join(gen, "b"))
|
||||
os.makedirs(flat)
|
||||
|
||||
src = "alpha\nbeta\ngamma\n"
|
||||
dst = "alpha\nBETA\ngamma\ndelta\n"
|
||||
write(os.path.join(gen, "a", "f.txt"), src)
|
||||
write(os.path.join(gen, "b", "f.txt"), dst)
|
||||
_, d, _ = run_bytes(["diff", "-u", "a/f.txt", "b/f.txt"], gen)
|
||||
write(os.path.join(flat, "d.diff"), d)
|
||||
write(os.path.join(gen, "d.diff"), d)
|
||||
|
||||
# -p0: paths as written (a/f.txt -> b/f.txt)
|
||||
rc, out, err = run_bytes([sys.executable, os.path.join(HERE, "diffkit.py"), "-p0", "d.diff"], gen)
|
||||
got = open(os.path.join(gen, "b", "f.txt"), encoding="utf-8", newline="").read()
|
||||
check("cli:-p0 patches a file on disk", rc == 0 and got == dst,
|
||||
"rc=%s out=%r err=%r got=%r" % (rc, out, err, got))
|
||||
|
||||
# -p1: git-style virtual prefixes, patching f.txt in place
|
||||
write(os.path.join(flat, "f.txt"), src)
|
||||
rc, out, err = run_bytes([sys.executable, os.path.join(HERE, "diffkit.py"), "-p1", "d.diff"], flat)
|
||||
got = open(os.path.join(flat, "f.txt"), encoding="utf-8", newline="").read()
|
||||
check("cli:-p1 patches a file on disk in place", rc == 0 and got == dst,
|
||||
"rc=%s out=%r err=%r got=%r" % (rc, out, err, got))
|
||||
|
||||
# a hunk that cannot apply must exit nonzero and leave the file untouched
|
||||
write(os.path.join(gen, "b", "f.txt"), src) # reset target to its pre-patch state
|
||||
write(os.path.join(gen, "a", "f.txt"), "totally\ndifferent\ncontent\n")
|
||||
rc2, _, err2 = run_bytes([sys.executable, os.path.join(HERE, "diffkit.py"), "-p0", "d.diff"], gen)
|
||||
after = open(os.path.join(gen, "b", "f.txt"), encoding="utf-8", newline="").read()
|
||||
check("cli:unplaceable hunk exits nonzero, target intact",
|
||||
rc2 != 0 and after == src, "rc=%s err=%r after=%r" % (rc2, err2, after))
|
||||
|
||||
# a missing patch file must be a clean error, not a traceback
|
||||
rc3, _, err3 = run_bytes([sys.executable, os.path.join(HERE, "diffkit.py"), "-p0", "nope.diff"], flat)
|
||||
check("cli:missing patch file is a clean error",
|
||||
rc3 == 1 and "Traceback" not in err3 and "cannot read patch file" in err3,
|
||||
"rc=%s err=%r" % (rc3, err3))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 2. property suite
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def property_suite(apply_fn, tmp: str, n_cases: int, drift: bool, seed0: int
|
||||
) -> tuple[list[str], int, int, int]:
|
||||
"""Return (failures, patch_agreements, patch_skips, cases_actually_run)."""
|
||||
fails: list[str] = []
|
||||
agree = skipped = ran = 0
|
||||
for k in range(n_cases):
|
||||
rng = random.Random(seed0 + k)
|
||||
base = rand_text(rng)
|
||||
target = mutate(rng, base)
|
||||
if base == target:
|
||||
continue
|
||||
write(os.path.join(tmp, "base.txt"), base)
|
||||
write(os.path.join(tmp, "target.txt"), target)
|
||||
d = gnu_diff(tmp, "base.txt", "target.txt")
|
||||
if not d.strip():
|
||||
continue
|
||||
|
||||
pre = rand_prefix(rng) if drift else ""
|
||||
src, expected = pre + base, pre + target
|
||||
|
||||
if drift:
|
||||
hunks = diffkit.parse_unified_diff(d)[0].hunks
|
||||
if any(h.old_count == 0 for h in hunks):
|
||||
continue # pure insertion: no content anchor, drift undefined
|
||||
|
||||
ran += 1
|
||||
try:
|
||||
got = apply_fn(d, src)
|
||||
except Exception as e: # noqa: BLE001
|
||||
fails.append("case %d raised %s: %s" % (k, type(e).__name__, e))
|
||||
continue
|
||||
if got != expected:
|
||||
fails.append("case %d mismatch (drift=%s)" % (k, drift))
|
||||
|
||||
if pre == "": # GNU patch cross-check only where the diff is exact
|
||||
pt = gnu_patch_apply(tmp, d, src)
|
||||
if pt is None:
|
||||
skipped += 1
|
||||
elif pt == expected:
|
||||
agree += 1
|
||||
else:
|
||||
fails.append("case %d: GNU patch disagrees with the true target" % k)
|
||||
return fails, agree, skipped, ran
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 3. mutants (the suite must kill them)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def mutant_no_search(diff_text: str, base_text: str) -> str:
|
||||
"""Naive applier: trust the hunk header position, no drift, no fuzz."""
|
||||
p = diffkit.parse_unified_diff(diff_text)[0]
|
||||
return diffkit.apply_file_patch(p, base_text, max_fuzz=0, max_offset=0)
|
||||
|
||||
|
||||
def mutant_first_hunk_only(diff_text: str, base_text: str) -> str:
|
||||
p = diffkit.parse_unified_diff(diff_text)[0]
|
||||
p.hunks = p.hunks[:1]
|
||||
return diffkit.apply_file_patch(p, base_text)
|
||||
|
||||
|
||||
def mutant_swaps_sides(diff_text: str, base_text: str) -> str:
|
||||
p = diffkit.parse_unified_diff(diff_text)[0]
|
||||
for h in p.hunks:
|
||||
h.lines = [("+" if k == "-" else "-" if k == "+" else k, c) for k, c in h.lines]
|
||||
h.old_count, h.new_count = h.new_count, h.old_count
|
||||
return diffkit.apply_file_patch(p, base_text)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def main() -> int:
|
||||
tmp = tempfile.mkdtemp(prefix="diffkit-selftest-")
|
||||
try:
|
||||
hand_cases(tmp)
|
||||
cli_case(tmp)
|
||||
|
||||
real = lambda d, s: diffkit.apply_one(d, s) # noqa: E731
|
||||
|
||||
fails, agree, skipped, ran = property_suite(real, tmp, PLAIN_CASES, False, 1000)
|
||||
check("property: %d randomised exact diffs apply (generated %d)" % (ran, PLAIN_CASES),
|
||||
not fails, "; ".join(fails[:3]))
|
||||
check("property: GNU patch oracle agrees (%d/%d, %d skipped)"
|
||||
% (agree, agree + skipped, skipped), agree > 0 and skipped == 0)
|
||||
|
||||
dfails, dagree, _, dran = property_suite(real, tmp, DRIFT_CASES, True, 9000)
|
||||
check("property: %d randomised offset-drift diffs apply (generated %d)"
|
||||
% (dran, DRIFT_CASES), not dfails, "; ".join(dfails[:3]))
|
||||
|
||||
# determinism
|
||||
again = property_suite(real, tmp, 20, False, 1000)[0]
|
||||
check("property: suite is deterministic", not again)
|
||||
|
||||
# mutation control
|
||||
m_fails = property_suite(mutant_no_search, tmp, 30, True, 9000)[0]
|
||||
check("mutation: no-search applier is killed (%d failures)" % len(m_fails),
|
||||
len(m_fails) > 0)
|
||||
|
||||
m2_fails = property_suite(mutant_first_hunk_only, tmp, PLAIN_CASES, False, 1000)[0]
|
||||
check("mutation: first-hunk-only applier is killed (%d failures)" % len(m2_fails),
|
||||
len(m2_fails) > 0)
|
||||
|
||||
m3_fails = property_suite(mutant_swaps_sides, tmp, PLAIN_CASES, False, 1000)[0]
|
||||
check("mutation: sides-swapped applier is killed (%d failures)" % len(m3_fails),
|
||||
len(m3_fails) > 0)
|
||||
finally:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
passed = sum(1 for _, ok, _ in _results if ok)
|
||||
total = len(_results)
|
||||
print("\n%d/%d checks passed" % (passed, total))
|
||||
if passed != total:
|
||||
print("FAILURES:")
|
||||
for name, ok, detail in _results:
|
||||
if not ok:
|
||||
print(" - %s :: %s" % (name, detail))
|
||||
return 1
|
||||
print("ALL CHECKS PASSED")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Independent real-world cross-check for diffkit.
|
||||
|
||||
The self-test uses GNU `diff` output on synthetic files. This one uses a real
|
||||
repository: it takes the diffs produced by `git diff` for the most recent commit
|
||||
of a repo, applies each one with diffkit, and compares the result byte-for-byte
|
||||
against what git itself says the file looks like at HEAD (`git show HEAD:path`).
|
||||
|
||||
Usage: python3 verify_realrepo.py [repo] [rev] [max_files]
|
||||
Exit code 0 iff every applicable file matched.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
import diffkit # noqa: E402
|
||||
|
||||
MAX_BYTES = 400_000
|
||||
|
||||
|
||||
def git(repo: str, *args: str) -> tuple[int, bytes]:
|
||||
r = subprocess.run(["git", "-C", repo, *args], capture_output=True)
|
||||
return r.returncode, r.stdout
|
||||
|
||||
|
||||
def main() -> int:
|
||||
repo = sys.argv[1] if len(sys.argv) > 1 else "/root/troupe-standup"
|
||||
rev = sys.argv[2] if len(sys.argv) > 2 else "HEAD~1"
|
||||
limit = int(sys.argv[3]) if len(sys.argv) > 3 else 15
|
||||
verbose = os.environ.get("VERBOSE") == "1"
|
||||
|
||||
rc, out = git(repo, "diff", "--name-only", "-r", rev, "HEAD")
|
||||
if rc != 0:
|
||||
print("git diff failed for %s..HEAD" % rev)
|
||||
return 2
|
||||
names = [n for n in out.decode("utf-8", "replace").split("\n") if n]
|
||||
print("repo=%s rev=%s..HEAD %d changed paths" % (repo, rev, len(names)))
|
||||
|
||||
checked = passed = skipped = 0
|
||||
for name in names:
|
||||
if checked >= limit:
|
||||
break
|
||||
rc_old, old = git(repo, "show", "%s:%s" % (rev, name))
|
||||
rc_new, new = git(repo, "show", "HEAD:%s" % name)
|
||||
if rc_old != 0 or rc_new != 0:
|
||||
skipped += 1
|
||||
if verbose:
|
||||
print(" skip %-60s (added or deleted in this commit)" % name)
|
||||
continue
|
||||
if b"\x00" in old or b"\x00" in new or max(len(old), len(new)) > MAX_BYTES:
|
||||
skipped += 1
|
||||
if verbose:
|
||||
print(" skip %-60s (binary or >%d bytes)" % (name, MAX_BYTES))
|
||||
continue
|
||||
|
||||
rc_d, d = git(repo, "diff", "--no-color", "-U3", rev, "HEAD", "--", name)
|
||||
if rc_d != 0 or not d.strip():
|
||||
skipped += 1
|
||||
if verbose:
|
||||
print(" skip %-60s (no textual diff)" % name)
|
||||
continue
|
||||
|
||||
old_s = old.decode("utf-8")
|
||||
new_s = new.decode("utf-8")
|
||||
diff_s = d.decode("utf-8")
|
||||
checked += 1
|
||||
try:
|
||||
got = diffkit.apply_one(diff_s, old_s)
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(" FAIL %-60s raised %s: %s" % (name, type(e).__name__, e))
|
||||
continue
|
||||
if got == new_s:
|
||||
passed += 1
|
||||
print(" ok %-60s %d bytes, %d hunks"
|
||||
% (name, len(new_s.encode()), len(diffkit.parse_unified_diff(diff_s)[0].hunks)))
|
||||
else:
|
||||
print(" FAIL %-60s mismatch (got %d bytes, want %d)"
|
||||
% (name, len(got.encode()), len(new_s.encode())))
|
||||
|
||||
print("\n%d/%d applicable files reproduced exactly (%d skipped)"
|
||||
% (passed, checked, skipped))
|
||||
return 0 if checked > 0 and passed == checked else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in new issue
Block a user