Skip to content

Commit

Permalink
Merge pull request #8 from akaihola/git-diff-mode-change
Browse files Browse the repository at this point in the history
Git diff mode change fix
  • Loading branch information
akaihola authored Jun 29, 2020
2 parents cdd3369 + 71fbbad commit 6771d11
Show file tree
Hide file tree
Showing 4 changed files with 184 additions and 25 deletions.
11 changes: 8 additions & 3 deletions src/darker/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@
from darker.black_diff import diff_and_get_opcodes, opcodes_to_chunks, run_black
from darker.chooser import choose_lines
from darker.command_line import ISORT_INSTRUCTION, parse_command_line
from darker.git_diff import get_edit_linenums, git_diff, git_diff_name_only
from darker.git_diff import (
GitDiffParseError,
get_edit_linenums,
git_diff,
git_diff_name_only,
)
from darker.import_sorting import SortImports, apply_isort
from darker.utils import get_common_root, joinlines
from darker.verification import NotEquivalentError, verify_ast_unchanged
Expand Down Expand Up @@ -57,11 +62,11 @@ def format_edited_parts(
# 2. do the git diff
logger.debug("Looking at %s", ", ".join(str(s) for s in remaining_srcs))
logger.debug("Git root: %s", git_root)
git_diff_output = git_diff(remaining_srcs, git_root, context_lines)
git_diff_result = git_diff(remaining_srcs, git_root, context_lines)

# 3. extract changed line numbers for each to-file
remaining_srcs = set()
for src_relative, edited_linenums in get_edit_linenums(git_diff_output):
for src_relative, edited_linenums in get_edit_linenums(git_diff_result):
src = git_root / src_relative
if not edited_linenums:
continue
Expand Down
75 changes: 58 additions & 17 deletions src/darker/git_diff.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
to obtain a list of line numbers in the to-file (modified file)
which were changed from the from-file (file before modification)::
>>> path, linenums = next(get_edit_linenums(b'''\\
>>> diff_result = GitDiffResult(b'''\\
... diff --git mymodule.py mymodule.py
... index a57921c..a8afb81 100644
... --- mymodule.py
Expand All @@ -20,7 +20,8 @@
... @@ -10 +11 @@ # ...and +11 from this line
... -Old tenth line
... +Replacement for tenth line
... '''))
... ''', ['git', 'diff'])
>>> path, linenums = next(get_edit_linenums(diff_result))
>>> print(path)
mymodule.py
>>> linenums
Expand All @@ -30,14 +31,19 @@
import logging
from pathlib import Path
from subprocess import check_output
from typing import Generator, Iterable, List, Tuple
from typing import Generator, Iterable, List, NamedTuple, Tuple

from darker.utils import Buf

logger = logging.getLogger(__name__)


def git_diff(paths: Iterable[Path], cwd: Path, context_lines: int) -> bytes:
class GitDiffResult(NamedTuple):
output: bytes
command: List[str]


def git_diff(paths: Iterable[Path], cwd: Path, context_lines: int) -> GitDiffResult:
"""Run ``git diff -U<context_lines> <path>`` and return the output"""
relative_paths = {p.resolve().relative_to(cwd) for p in paths}
cmd = [
Expand All @@ -50,7 +56,7 @@ def git_diff(paths: Iterable[Path], cwd: Path, context_lines: int) -> bytes:
*[str(path) for path in relative_paths],
]
logger.debug("[%s]$ %s", cwd, " ".join(cmd))
return check_output(cmd, cwd=str(cwd))
return GitDiffResult(check_output(cmd, cwd=str(cwd)), cmd)


def parse_range(s: str) -> Tuple[int, int]:
Expand Down Expand Up @@ -87,8 +93,12 @@ def should_reformat_file(path: Path) -> bool:
return path.suffix == ".py"


class GitDiffParseError(Exception):
pass


def get_edit_chunks(
patch: bytes,
git_diff_result: GitDiffResult,
) -> Generator[Tuple[Path, List[Tuple[int, int]]], None, None]:
"""Yield ranges of changed line numbers in Git diff to-file
Expand All @@ -104,36 +114,67 @@ def get_edit_chunks(
E.g. ``[42, 7]`` means lines 42, 43, 44, 45, 46, 47 and 48 were changed.
"""
if not patch:
if not git_diff_result.output:
return
lines = Buf(patch)
lines = Buf(git_diff_result.output)
command = " ".join(git_diff_result.command)

def expect_line(expect_startswith: str = "", catch_stop: bool = True) -> str:
if catch_stop:
try:
line = next(lines)
except StopIteration:
raise GitDiffParseError(f"Unexpected end of output from '{command}'")
else:
line = next(lines)
if not line.startswith(expect_startswith):
raise GitDiffParseError(
f"Expected an '{expect_startswith}' line, got '{line}' from '{command}'"
)
return line

while True:
try:
if not lines.next_line_startswith("diff --git "):
return
diff_git_line = expect_line("diff --git ", catch_stop=False)
except StopIteration:
return
_, _, path_a, path_b = next(lines).split(" ")
try:
_, _, path_a, path_b = diff_git_line.split(" ")
except ValueError:
raise GitDiffParseError(f"Can't parse '{diff_git_line}'")
path = Path(path_a)

assert next(lines).startswith("index ")
path_a_line = next(lines)
assert path_a_line == f"--- {path_a}", (path_a_line, path_a)
assert next(lines) == f"+++ {path_a}"
try:
expect_line("index ")
except GitDiffParseError:
lines.seek_line(-1)
expect_line("old mode ")
expect_line("new mode ")
expect_line("index ")
expect_line(f"--- {path_a}")
expect_line(f"+++ {path_a}")
if should_reformat_file(path):
yield path, list(get_edit_chunks_for_one_file(lines))
else:
skip_file(lines, path)


def get_edit_linenums(patch: bytes,) -> Generator[Tuple[Path, List[int]], None, None]:
def get_edit_linenums(
git_diff_result: GitDiffResult,
) -> Generator[Tuple[Path, List[int]], None, None]:
"""Yield changed line numbers in Git diff to-file
The patch must be in ``git diff -U<num>`` format, and only contain differences for a
single file.
"""
paths_and_ranges = get_edit_chunks(patch)
try:
paths_and_ranges = get_edit_chunks(git_diff_result)
except GitDiffParseError:
raise RuntimeError(
"Can't get line numbers for diff output from: %s",
" ".join(git_diff_result.command),
)
for path, ranges in paths_and_ranges:
if not ranges:
logger.debug(f"Found no edited lines for %s", path)
Expand Down
116 changes: 112 additions & 4 deletions src/darker/tests/test_git_diff.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
import pytest

from darker.git_diff import (
GitDiffParseError,
GitDiffResult,
get_edit_chunks,
get_edit_chunks_for_one_file,
get_edit_linenums,
Expand All @@ -13,7 +15,8 @@


def test_get_edit_linenums():
((path, chunks),) = list(get_edit_linenums(CHANGE_SECOND_LINE.encode("ascii")))
diff_result = GitDiffResult(CHANGE_SECOND_LINE.encode("ascii"), ["git", "diff"])
((path, chunks),) = list(get_edit_linenums(diff_result))
assert path == Path("test1.py")
assert chunks == [2]

Expand All @@ -33,13 +36,15 @@ def test_get_edit_chunks_for_one_file():


def test_get_edit_chunks_one_file():
path, chunks = next(get_edit_chunks(CHANGE_SECOND_LINE.encode("ascii")))
diff_result = GitDiffResult(CHANGE_SECOND_LINE.encode("ascii"), ["git", "diff"])
path, chunks = next(get_edit_chunks(diff_result))
assert path == Path("test1.py")
assert chunks == [(2, 3)]


def test_get_edit_chunks_two_files():
paths_and_chunks = get_edit_chunks(TWO_FILES_CHANGED.encode("ascii"))
diff_result = GitDiffResult(TWO_FILES_CHANGED.encode("ascii"), ["git", "diff"])
paths_and_chunks = get_edit_chunks(diff_result)
path, chunks = next(paths_and_chunks)
assert path == Path("src/darker/git_diff.py")
assert chunks == [(104, 108)]
Expand All @@ -49,6 +54,109 @@ def test_get_edit_chunks_two_files():


def test_get_edit_chunks_empty():
gen = get_edit_chunks(b"")
gen = get_edit_chunks(GitDiffResult(b"", ["git", "diff"]))
with pytest.raises(StopIteration):
next(gen)


@pytest.mark.parametrize(
"git_diff_lines",
[[], ["diff --git path_a path_b", "index ", "--- path_a", "+++ path_a"]],
)
def test_get_edit_chunks_empty_output(git_diff_lines):
git_diff_result = GitDiffResult(
"".join(f"{line}\n" for line in git_diff_lines).encode("ascii"),
["git", "diff"],
)
result = list(get_edit_chunks(git_diff_result))
assert result == []


@pytest.mark.parametrize(
"first_line",
["diff --git ", "diff --git path_a", "diff --git path_a path_b path_c"],
)
def test_get_edit_chunks_cant_parse(first_line):
output = f"{first_line}\n"
git_diff_result = GitDiffResult(output.encode("ascii"), ["git", "diff"])
with pytest.raises(GitDiffParseError) as exc:
list(get_edit_chunks(git_diff_result))
assert str(exc.value) == f"Can't parse '{first_line}'"


@pytest.mark.parametrize(
"git_diff_lines, expect",
[
(["first line doesn't have diff --git"], "diff --git ",),
(
["diff --git path_a path_b", "second line doesn't have old mode"],
"old mode ",
),
(
[
"diff --git path_a path_b",
"old mode ",
"third line doesn't have new mode",
],
"new mode ",
),
(
[
"diff --git path_a path_b",
"old mode ",
"new mode ",
"fourth line doesn't have index",
],
"index ",
),
(
[
"diff --git path_a path_b",
"index ",
"third line doesn't have --- path_a",
],
"--- path_a",
),
(
[
"diff --git path_a path_b",
"index ",
"--- path_a",
"fourth line doesn't have +++ path_a",
],
"+++ path_a",
),
],
)
def test_get_edit_chunks_unexpected_line(git_diff_lines, expect):
git_diff_result = GitDiffResult(
"".join(f"{line}\n" for line in git_diff_lines).encode("ascii"),
["git", "diff"],
)
with pytest.raises(GitDiffParseError) as exc:
list(get_edit_chunks(git_diff_result))
expect_exception_message = (
f"Expected an '{expect}' line, got '{git_diff_lines[-1]}' from 'git diff'"
)
assert str(exc.value) == expect_exception_message


@pytest.mark.parametrize(
"git_diff_lines",
[
["diff --git path_a path_b"],
["diff --git path_a path_b", "old mode "],
["diff --git path_a path_b", "old mode ", "new mode "],
["diff --git path_a path_b", "old mode ", "new mode ", "index "],
["diff --git path_a path_b", "index "],
["diff --git path_a path_b", "index ", "--- path_a"],
],
)
def test_get_edit_chunks_unexpected_end(git_diff_lines):
git_diff_result = GitDiffResult(
"".join(f"{line}\n" for line in git_diff_lines).encode("ascii"),
["git", "diff"],
)
with pytest.raises(GitDiffParseError) as exc:
list(get_edit_chunks(git_diff_result))
assert str(exc.value) == "Unexpected end of output from 'git diff'"
7 changes: 6 additions & 1 deletion src/darker/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,15 @@ def __next__(self) -> str:
def __iter__(self) -> "Buf":
return self

def seek_line(self, lines_delta: int) -> None:
assert lines_delta <= 0
for _ in range(-lines_delta):
self._buf.seek(self._line_starts.pop())

def next_line_startswith(self, prefix: Union[str, Tuple[str, ...]]) -> bool:
try:
return next(self).startswith(prefix)
except StopIteration:
return False
finally:
self._buf.seek(self._line_starts.pop())
self.seek_line(-1)

0 comments on commit 6771d11

Please sign in to comment.