/
miker
/
git-ai-py
Обзор
Документация
Войти
/
miker
/
git-ai-py
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
tests/unit/test_diffparse.py
413 строк
14 KB
Mike S
Initial commit
08 авг 2026, 16:43
08 авг 2026, 16:43
491f864
Код
Авторство
О чём код?
"""Тесты разбора унифицированного диффа. Половина тестов — на именах файлов, и это не паранойя: все ловушки ниже воспроизведены на живом git, а не выдуманы. Ошибка в разборе имени не падает, а тихо приписывает строки не тому файлу. """ from __future__ import annotations from pathlib import Path import pytest from git_ai_metrics.adapters.git.diffparse import AddedLine, parse_diff, unquote_path from git_ai_metrics.provenance.linemap import split_lines from tests.conftest import git, init_repo def texts(added: tuple[AddedLine, ...], /) -> list[str]: return [line.text for line in added] def numbers(added: tuple[AddedLine, ...], /) -> list[int]: return [line.lineno for line in added] class TestBasics: def test_empty_input(self) -> None: assert parse_diff("") == [] def test_text_before_first_header_ignored(self) -> None: # `git log -p` печатает сообщение коммита перед диффом. diffs = parse_diff("commit abc\nAuthor: X\n\n message\n") assert diffs == [] def test_added_lines_are_numbered_from_hunk_header(self) -> None: (diff,) = parse_diff( "diff --git a/f.txt b/f.txt\n" "--- a/f.txt\n" "+++ b/f.txt\n" "@@ -0,0 +7,2 @@\n" "+one\n" "+two\n" ) assert numbers(diff.added) == [7, 8] assert texts(diff.added) == ["one", "two"] def test_context_lines_advance_numbering(self) -> None: (diff,) = parse_diff( "diff --git a/f.txt b/f.txt\n" "--- a/f.txt\n" "+++ b/f.txt\n" "@@ -1,3 +1,4 @@\n" " a\n" " b\n" "+new\n" " c\n" ) assert numbers(diff.added) == [3] def test_deleted_lines_counted(self) -> None: (diff,) = parse_diff( "diff --git a/f.txt b/f.txt\n" "--- a/f.txt\n" "+++ b/f.txt\n" "@@ -1,3 +1,1 @@\n" "-a\n" "-b\n" "+c\n" ) assert diff.deleted == 2 assert diff.added[0].lineno == 1 def test_several_hunks(self) -> None: (diff,) = parse_diff( "diff --git a/f.txt b/f.txt\n" "--- a/f.txt\n" "+++ b/f.txt\n" "@@ -1,0 +2 @@\n" "+x\n" "@@ -9,0 +11 @@\n" "+y\n" ) assert numbers(diff.added) == [2, 11] def test_hunk_with_function_context(self) -> None: # `@@ -3,0 +4 @@ c` — хвост это контекстная функция, а не часть заголовка. (diff,) = parse_diff( "diff --git a/f.py b/f.py\n--- a/f.py\n+++ b/f.py\n@@ -3,0 +4 @@ def thing():\n+ x\n" ) assert numbers(diff.added) == [4] def test_empty_added_line(self) -> None: (diff,) = parse_diff( "diff --git a/f.txt b/f.txt\n--- a/f.txt\n+++ b/f.txt\n@@ -0,0 +1,2 @@\n+\n+x\n" ) assert texts(diff.added) == ["", "x"] def test_no_newline_marker_ignored(self) -> None: (diff,) = parse_diff( "diff --git a/f.txt b/f.txt\n" "--- a/f.txt\n" "+++ b/f.txt\n" "@@ -1 +1 @@\n" "-a\n" "\\ No newline at end of file\n" "+b\n" "\\ No newline at end of file\n" ) assert texts(diff.added) == ["b"] assert diff.deleted == 1 def test_added_line_looking_like_a_header(self) -> None: # Строка кода «diff --git ...» в коммите не должна открыть новый файл: # в дифф она приходит с ведущим плюсом. (diff,) = parse_diff( "diff --git a/doc.md b/doc.md\n" "--- a/doc.md\n" "+++ b/doc.md\n" "@@ -0,0 +1,2 @@\n" "+diff --git a/fake b/fake\n" "+@@ -1 +1 @@\n" ) assert texts(diff.added) == ["diff --git a/fake b/fake", "@@ -1 +1 @@"] def test_crlf_stripped(self) -> None: # Файл с окончаниями CRLF: дифф режется по \n, и \r остаётся в тексте. (diff,) = parse_diff( "diff --git a/f.txt b/f.txt\n--- a/f.txt\n+++ b/f.txt\n@@ -0,0 +1 @@\n+x\r\n" ) assert texts(diff.added) == ["x"] def test_unknown_header_lines_survive(self) -> None: (diff,) = parse_diff( "diff --git a/f.txt b/f.txt\n" "неизвестная строка заголовка\n" "index 1111111..2222222 100644\n" "--- a/f.txt\n" "+++ b/f.txt\n" "@@ -0,0 +1 @@\n" "+x\n" ) assert diff.new_path == "f.txt" assert texts(diff.added) == ["x"] class TestPaths: def test_new_file(self) -> None: (diff,) = parse_diff( "diff --git a/n.txt b/n.txt\n" "new file mode 100644\n" "--- /dev/null\n" "+++ b/n.txt\n" "@@ -0,0 +1 @@\n" "+x\n" ) assert (diff.old_path, diff.new_path, diff.path) == (None, "n.txt", "n.txt") def test_deleted_file(self) -> None: (diff,) = parse_diff( "diff --git a/d.txt b/d.txt\n" "deleted file mode 100644\n" "--- a/d.txt\n" "+++ /dev/null\n" "@@ -1 +0,0 @@\n" "-x\n" ) assert (diff.old_path, diff.new_path, diff.path) == ("d.txt", None, "d.txt") def test_rename_without_content_change(self) -> None: # Ни ---, ни +++, ни хунков: имена есть только в rename from/to. (diff,) = parse_diff( "diff --git a/old.txt b/new.txt\n" "similarity index 100%\n" "rename from old.txt\n" "rename to new.txt\n" ) assert (diff.old_path, diff.new_path) == ("old.txt", "new.txt") assert diff.is_rename is True assert diff.added == () def test_rename_with_space_in_name(self) -> None: (diff,) = parse_diff( "diff --git a/plain.txt b/renamed plain.txt\n" "similarity index 100%\n" "rename from plain.txt\n" "rename to renamed plain.txt\n" ) assert diff.new_path == "renamed plain.txt" def test_trailing_tab_after_name_with_space(self) -> None: # git дописывает табуляцию, чтобы имя с пробелом отделялось от хвоста. (diff,) = parse_diff( "diff --git a/with space.txt b/with space.txt\n" "--- a/with space.txt\t\n" "+++ b/with space.txt\t\n" "@@ -0,0 +1 @@\n" "+x\n" ) assert diff.new_path == "with space.txt" def test_quoted_name(self) -> None: (diff,) = parse_diff( 'diff --git "a/we\\"ird.txt" "b/we\\"ird.txt"\n' "--- /dev/null\n" '+++ "b/we\\"ird.txt"\n' "@@ -0,0 +1 @@\n" "+q\n" ) assert diff.new_path == 'we"ird.txt' def test_quoted_non_ascii_name(self) -> None: (diff,) = parse_diff( 'diff --git "a/\\320\\272.txt" "b/\\320\\272.txt"\n' '--- "a/\\320\\272.txt"\n' '+++ "b/\\320\\272.txt"\n' "@@ -0,0 +1 @@\n" "+x\n" ) assert diff.new_path == "к.txt" def test_mode_change_only_uses_header(self) -> None: (diff,) = parse_diff( "diff --git a/with space.txt b/with space.txt\nold mode 100644\nnew mode 100755\n" ) assert (diff.old_path, diff.new_path) == ("with space.txt", "with space.txt") assert diff.is_rename is False def test_mode_change_only_quoted_header(self) -> None: (diff,) = parse_diff( 'diff --git "a/\\320\\272.txt" "b/\\320\\272.txt"\nold mode 100644\nnew mode 100755\n' ) assert diff.new_path == "к.txt" def test_ambiguous_quoted_header_left_unnamed(self) -> None: (diff,) = parse_diff('diff --git "a/x" "b/y" "c/z"\nold mode 100644\n') assert diff.path is None def test_mismatched_quoted_header_left_unnamed(self) -> None: (diff,) = parse_diff('diff --git "a/x" "b/y"\nold mode 100644\n') assert diff.path is None def test_ambiguous_header_left_unnamed(self) -> None: # Настоящее переименование в заголовке неразрешимо, и угадывать нельзя: # приписать строки не тому файлу хуже, чем не назвать файл вовсе. (diff,) = parse_diff("diff --git a/one two b/three four\nold mode 100644\n") assert diff.path is None def test_several_files(self) -> None: diffs = parse_diff( "diff --git a/a.txt b/a.txt\n" "--- a/a.txt\n" "+++ b/a.txt\n" "@@ -0,0 +1 @@\n" "+x\n" "diff --git a/b.txt b/b.txt\n" "--- a/b.txt\n" "+++ b/b.txt\n" "@@ -0,0 +1 @@\n" "+y\n" ) assert [d.path for d in diffs] == ["a.txt", "b.txt"] assert texts(diffs[1].added) == ["y"] class TestBinary: def test_binary_file_marked(self) -> None: (diff,) = parse_diff( "diff --git a/b.bin b/b.bin\n" "new file mode 100644\n" "index 0000000..20f982d\n" "Binary files /dev/null and b/b.bin differ\n" ) assert diff.binary is True assert diff.added == () def test_binary_patch_marked(self) -> None: (diff,) = parse_diff( "diff --git a/b.bin b/b.bin\nGIT binary patch\nliteral 8\nzcmZQ\n" ) assert diff.binary is True class TestUnquotePath: @pytest.mark.parametrize( ("quoted", "expected"), [ ('"plain"', "plain"), ('"we\\"ird"', 'we"ird'), ('"back\\\\slash"', "back\\slash"), ('"tab\\there"', "tab\there"), ('"nl\\nhere"', "nl\nhere"), ('"\\320\\272\\320\\270\\321\\200"', "кир"), ('"a\\rb"', "a\rb"), ], ) def test_escapes(self, quoted: str, expected: str) -> None: assert unquote_path(quoted) == expected def test_unterminated_quote(self) -> None: assert unquote_path('"abc') == "abc" def test_trailing_backslash(self) -> None: assert unquote_path('"abc\\') == "abc" def test_unknown_escape_kept(self) -> None: assert unquote_path('"a\\qb"') == "aqb" def test_invalid_utf8_does_not_raise(self) -> None: # Одиночный байт 0xFF валидной UTF-8 последовательностью не является. assert isinstance(unquote_path('"\\377"'), str) class TestAgainstRealGit: """Разбор вывода настоящего git, а не сочинённого текста. Формат диффа меняется между версиями git; сочинённые строки этого не заметят, а эти тесты заметят. """ def test_full_shape(self, tmp_path: Path) -> None: repo = init_repo(tmp_path / "r") (repo / "keep.txt").write_text("a\nb\nc\n", encoding="utf-8") (repo / "gone.txt").write_text("x\n", encoding="utf-8") (repo / "with space.txt").write_text("s\n", encoding="utf-8") (repo / "кир.txt").write_text("п\n", encoding="utf-8") git(repo, "add", "-A") git(repo, "commit", "--quiet", "-m", "base") (repo / "keep.txt").write_text("a\nB\nc\nd\n", encoding="utf-8") (repo / "gone.txt").unlink() (repo / "new.txt").write_text("n\n", encoding="utf-8") git(repo, "mv", "with space.txt", "moved space.txt") git(repo, "add", "-A") git(repo, "commit", "--quiet", "-m", "change") raw = git( repo, "-c", "core.quotePath=false", "diff-tree", "--no-commit-id", "-r", "-p", "--find-renames", "--unified=0", "HEAD~1", "HEAD", ) by_path = {d.path: d for d in parse_diff(raw)} assert by_path["keep.txt"].deleted == 1 assert texts(by_path["keep.txt"].added) == ["B", "d"] assert numbers(by_path["keep.txt"].added) == [2, 4] assert by_path["gone.txt"].new_path is None assert by_path["new.txt"].old_path is None assert by_path["moved space.txt"].old_path == "with space.txt" def test_crlf_file_matches_working_copy(self, tmp_path: Path) -> None: # Тот же текст, прочитанный из рабочей копии и из диффа, обязан # совпасть — иначе в CRLF-файле не сойдётся ни один дайджест. repo = init_repo(tmp_path / "crlf") (repo / "f.txt").write_bytes(b"a\r\nb\r\n") git(repo, "add", "-A") git(repo, "commit", "--quiet", "-m", "crlf") raw = git(repo, "diff-tree", "--no-commit-id", "-r", "-p", "--root", "HEAD") (diff,) = [d for d in parse_diff(raw) if d.path == "f.txt"] # Читать содержимое обязательно байтами: `read_text` переводит # окончания строк, и \r исчез бы ещё до сравнения — тест прошёл бы, # а демон, читающий файл иначе, всё равно разъехался бы с диффом. content = (repo / "f.txt").read_bytes().decode("utf-8") assert texts(diff.added) == split_lines(content) def test_quoted_name_from_real_git(self, tmp_path: Path) -> None: repo = init_repo(tmp_path / "q") (repo / 'we"ird.txt').write_text("q\n", encoding="utf-8") git(repo, "add", "-A") git(repo, "commit", "--quiet", "-m", "quoted") raw = git( repo, "-c", "core.quotePath=false", "diff-tree", "--no-commit-id", "-r", "-p", "HEAD" ) assert 'we"ird.txt' in {d.path for d in parse_diff(raw)}