diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..165238e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Sven van Heugten + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..012eeb9 --- /dev/null +++ b/README.md @@ -0,0 +1,9 @@ +# mechanicaldiff + +`mechanicaldiff` lets you filter down a diff to _only_ the parts that make a specific mechanical change, so you can commit those changes separately. + +Usage: `git diff | mechanicaldiff | recountdiff` + +It differs from [grepdiff](https://linux.die.net/man/1/grepdiff) in that it doesn’t just search for the lines that contain the given `pattern`, but also checks that the _only_ change in that line is that `pattern` was replaced with the given `replacement`. + +It differs from simply replacing `pattern` with `replacement` in the entire repository in that it filters down an _existing_ diff, which means that the resulting diff doesn’t make the mechanical change everywhere, but only in the lines that your original diff also made it. diff --git a/bin/mechanicaldiff.py b/bin/mechanicaldiff similarity index 78% rename from bin/mechanicaldiff.py rename to bin/mechanicaldiff index 11fc6c5..8ac824d 100755 --- a/bin/mechanicaldiff.py +++ b/bin/mechanicaldiff @@ -4,9 +4,19 @@ import re import sys +def trim_blank_ends(lines: list[str]) -> list[str]: + start = 0 + end = len(lines) + while start < end and lines[start] == "\n": + start += 1 + while end > start and lines[end - 1] == "\n": + end -= 1 + return lines[start:end] + + def filter_change_block( change_lines: list[str], search: str, replace: str -) -> list[str] | None: +) -> list[str]: removed_lines = [] added_lines = [] phase = "minus" @@ -27,14 +37,16 @@ def filter_change_block( transformed_removed = [ re.sub(search, replace, line) for line in removed_lines ] - if transformed_removed == added_lines: + if trim_blank_ends(transformed_removed) == trim_blank_ends(added_lines): return change_lines - if len(removed_lines) != len(added_lines): - return None - kept_lines = [] - for removed, added in zip(removed_lines, added_lines): + max_len = max(len(removed_lines), len(added_lines)) + for idx in range(max_len): + removed = removed_lines[idx] if idx < len(removed_lines) else None + added = added_lines[idx] if idx < len(added_lines) else None + if removed is None: + break if re.sub(search, replace, removed) == added: kept_lines.append(f"-{removed}") kept_lines.append(f"+{added}") @@ -42,16 +54,16 @@ def filter_change_block( kept_lines.append(f" {removed}") if not kept_lines: - return None + return [] return kept_lines def filter_hunk( hunk_lines: list[str], search: str, replace: str -) -> list[str] | None: +) -> list[str]: if not hunk_lines: - return None, False + return [] header = hunk_lines[0] body = hunk_lines[1:] @@ -73,7 +85,7 @@ def filter_hunk( i += 1 if not any(line.startswith(("+", "-")) for line in output_body): - return None + return [] return [header] + output_body @@ -85,7 +97,8 @@ def main() -> None: search = sys.argv[1] replace = sys.argv[2] - lines = sys.stdin.read().splitlines(keepends=True) + input_data = sys.stdin.read() + lines = input_data.splitlines(keepends=True) preamble_lines = [] sections = [] current = None @@ -114,29 +127,23 @@ def main() -> None: output_lines = [] output_lines.extend(preamble_lines) - left_out = False - for section in sections: kept_hunks = [] for hunk_lines in section["hunks"]: filtered = filter_hunk(hunk_lines, search, replace) if filtered: - if filtered != hunk_lines: - left_out = True kept_hunks.append(filtered) - elif hunk_lines: - left_out = True if not kept_hunks: - left_out = True continue output_lines.extend(section["header"]) for hunk_lines in kept_hunks: output_lines.extend(hunk_lines) - sys.stdout.write("".join(output_lines)) - if left_out: + output_data = "".join(output_lines) + sys.stdout.write(output_data) + if output_data != input_data: raise SystemExit(1) diff --git a/default.nix b/default.nix index 79ed058..54cf645 100644 --- a/default.nix +++ b/default.nix @@ -7,6 +7,7 @@ python3Packages, shellcheck-minimal, shfmt, + patchutils_0_4_2, }: let @@ -42,11 +43,12 @@ stdenv.mkDerivation { python3Packages.flake8 shellcheck-minimal shfmt + patchutils_0_4_2 ]; checkPhase = '' runHook preCheck - flake8 bin/mechanicaldiff.py + flake8 bin/mechanicaldiff shellcheck test/mechanicaldiff.bats shfmt -d test/mechanicaldiff.bats bats test @@ -55,7 +57,7 @@ stdenv.mkDerivation { installPhase = '' mkdir -p $out/bin - cp $src/bin/mechanicaldiff.py $out/bin/mechanicaldiff + cp $src/bin/mechanicaldiff $out/bin/mechanicaldiff chmod +x $out/bin/mechanicaldiff ''; } diff --git a/flake.nix b/flake.nix index 0593261..387b65c 100644 --- a/flake.nix +++ b/flake.nix @@ -26,6 +26,7 @@ pkgs.python3Packages.flake8 pkgs.shellcheck pkgs.shfmt + pkgs.patchutils_0_4_2 ]; }; } diff --git a/test/mechanicaldiff.bats b/test/mechanicaldiff.bats index beb4853..82c7ea1 100755 --- a/test/mechanicaldiff.bats +++ b/test/mechanicaldiff.bats @@ -15,7 +15,7 @@ setup() { printf "%s\n" "bar" >"$BATS_TEST_TMPDIR/new" diff_output="$(git diff --no-index "$BATS_TEST_TMPDIR/old" \ "$BATS_TEST_TMPDIR/new" || true)" - run mechanicaldiff.py foo bar <<<"$diff_output" + run mechanicaldiff foo bar <<<"$diff_output" assert_success assert_output "$diff_output" } @@ -25,7 +25,7 @@ setup() { printf "%s\n" "baz" >"$BATS_TEST_TMPDIR/new" git diff --no-index "$BATS_TEST_TMPDIR/old" "$BATS_TEST_TMPDIR/new" \ >"$BATS_TEST_TMPDIR/diff" || true - run mechanicaldiff.py foo bar <"$BATS_TEST_TMPDIR/diff" + run mechanicaldiff foo bar <"$BATS_TEST_TMPDIR/diff" assert_failure assert_output "" } @@ -39,7 +39,7 @@ setup() { diff_output="$(git diff --no-index "$BATS_TEST_TMPDIR/old" \ "$BATS_TEST_TMPDIR/new" || true)" - run mechanicaldiff.py foo bar <<<"$diff_output" + run mechanicaldiff foo bar <<<"$diff_output" assert_failure assert_output "" } @@ -75,7 +75,7 @@ setup() { "$BATS_TEST_TMPDIR/new_kept" \ >"$BATS_TEST_TMPDIR/diff_expected" || true - run mechanicaldiff.py "beta" "beta_changed" <"$BATS_TEST_TMPDIR/diff_full" + run mechanicaldiff "beta" "beta_changed" <"$BATS_TEST_TMPDIR/diff_full" assert_failure assert_output --partial "-beta" assert_output --partial "+beta_changed" @@ -96,15 +96,15 @@ setup() { cat "$BATS_TEST_TMPDIR/diff_one" "$BATS_TEST_TMPDIR/diff_two" \ >"$BATS_TEST_TMPDIR/diff_all" - run mechanicaldiff.py foo bar <"$BATS_TEST_TMPDIR/diff_one" + run mechanicaldiff foo bar <"$BATS_TEST_TMPDIR/diff_one" assert_success output_one="$output" - run mechanicaldiff.py foo bar <"$BATS_TEST_TMPDIR/diff_two" + run mechanicaldiff foo bar <"$BATS_TEST_TMPDIR/diff_two" assert_failure output_two="$output" - run mechanicaldiff.py foo bar <"$BATS_TEST_TMPDIR/diff_all" + run mechanicaldiff foo bar <"$BATS_TEST_TMPDIR/diff_all" assert_failure assert_output "${output_one}${output_two}" } @@ -122,7 +122,7 @@ setup() { git diff --no-index "$BATS_TEST_TMPDIR/old" "$BATS_TEST_TMPDIR/new" \ >"$BATS_TEST_TMPDIR/diff" || true - run mechanicaldiff.py 'foo\((\d+)\)' 'foo-\1' <"$BATS_TEST_TMPDIR/diff" + run mechanicaldiff 'foo\((\d+)\)' 'foo-\1' <"$BATS_TEST_TMPDIR/diff" assert_success assert_output "$(cat "$BATS_TEST_TMPDIR/diff")" } @@ -138,7 +138,109 @@ setup() { git diff --no-index "$BATS_TEST_TMPDIR/old" "$BATS_TEST_TMPDIR/new" \ >"$BATS_TEST_TMPDIR/diff" || true - run mechanicaldiff.py foo bar <"$BATS_TEST_TMPDIR/diff" + run mechanicaldiff foo bar <"$BATS_TEST_TMPDIR/diff" + assert_success + assert_output "$(cat "$BATS_TEST_TMPDIR/diff")" +} + +@test "matches when replacement removes entire line" { + cat >"$BATS_TEST_TMPDIR/old" <<-'EOF' + alpha + foo + omega + EOF + cat >"$BATS_TEST_TMPDIR/new" <<-'EOF' + alpha + omega + EOF + + git diff --no-index "$BATS_TEST_TMPDIR/old" "$BATS_TEST_TMPDIR/new" \ + >"$BATS_TEST_TMPDIR/diff" || true + + run mechanicaldiff foo "" <"$BATS_TEST_TMPDIR/diff" + assert_success + assert_output "$(cat "$BATS_TEST_TMPDIR/diff")" +} + +@test "keeps matching change with an extra removed line" { + cat >"$BATS_TEST_TMPDIR/old" <<-'EOF' + foo + bar + baz + EOF + cat >"$BATS_TEST_TMPDIR/new" <<-'EOF' + foo_changed + baz + EOF + + git diff --no-index "$BATS_TEST_TMPDIR/old" "$BATS_TEST_TMPDIR/new" \ + >"$BATS_TEST_TMPDIR/diff" || true + + cat >"$BATS_TEST_TMPDIR/new_kept" <<-'EOF' + foo_changed + bar + baz + EOF + git diff --no-index "$BATS_TEST_TMPDIR/old" \ + "$BATS_TEST_TMPDIR/new_kept" \ + >"$BATS_TEST_TMPDIR/diff_expected" || true + + run mechanicaldiff foo foo_changed <"$BATS_TEST_TMPDIR/diff" + assert_failure + expected="$(sed \ + -e 's/new_kept/new/g' \ + -e '/^index /d' \ + "$BATS_TEST_TMPDIR/diff_expected")" + output_normalized="$(printf '%s\n' "$output" | recountdiff | sed -e '/^index /d')" + assert_equal "$output_normalized" "$expected" +} + +@test "keeps matching change with an extra added line" { + cat >"$BATS_TEST_TMPDIR/old" <<-'EOF' + foo + baz + EOF + cat >"$BATS_TEST_TMPDIR/new" <<-'EOF' + foo_changed + bar + baz + EOF + + git diff --no-index "$BATS_TEST_TMPDIR/old" "$BATS_TEST_TMPDIR/new" \ + >"$BATS_TEST_TMPDIR/diff" || true + + cat >"$BATS_TEST_TMPDIR/new_kept" <<-'EOF' + foo_changed + baz + EOF + git diff --no-index "$BATS_TEST_TMPDIR/old" \ + "$BATS_TEST_TMPDIR/new_kept" \ + >"$BATS_TEST_TMPDIR/diff_expected" || true + + run mechanicaldiff foo foo_changed <"$BATS_TEST_TMPDIR/diff" + assert_failure + expected="$(sed \ + -e 's/new_kept/new/g' \ + -e '/^index /d' \ + "$BATS_TEST_TMPDIR/diff_expected")" + output_normalized="$(printf '%s\n' "$output" | recountdiff | sed -e '/^index /d')" + assert_equal "$output_normalized" "$expected" +} + +@test "ignores leading and trailing blank lines for comparison" { + cat >"$BATS_TEST_TMPDIR/old" <<-'EOF' + + prefix foo suffix + + EOF + cat >"$BATS_TEST_TMPDIR/new" <<-'EOF' + prefix bar suffix + EOF + + git diff --no-index "$BATS_TEST_TMPDIR/old" "$BATS_TEST_TMPDIR/new" \ + >"$BATS_TEST_TMPDIR/diff" || true + + run mechanicaldiff foo bar <"$BATS_TEST_TMPDIR/diff" assert_success assert_output "$(cat "$BATS_TEST_TMPDIR/diff")" } @@ -161,14 +263,14 @@ setup() { git diff --no-index "$BATS_TEST_TMPDIR/old" "$BATS_TEST_TMPDIR/new_kept" \ >"$BATS_TEST_TMPDIR/diff_expected" || true - run mechanicaldiff.py foo foo_changed <"$BATS_TEST_TMPDIR/diff_full" + run mechanicaldiff foo foo_changed <"$BATS_TEST_TMPDIR/diff_full" assert_failure expected="$(sed \ -e 's/new_kept/new/g' \ -e '/^index /d' \ "$BATS_TEST_TMPDIR/diff_expected")" output_normalized="$(printf '%s\n' "$output" | sed -e '/^index /d')" - assert_equal "$expected" "$output_normalized" + assert_equal "$output_normalized" "$expected" } @test "drops hunk when all pairs become context" { @@ -185,7 +287,7 @@ setup() { git diff --no-index "$BATS_TEST_TMPDIR/old" "$BATS_TEST_TMPDIR/new" \ >"$BATS_TEST_TMPDIR/diff_full" || true - run mechanicaldiff.py qux quux <"$BATS_TEST_TMPDIR/diff_full" + run mechanicaldiff qux quux <"$BATS_TEST_TMPDIR/diff_full" assert_failure assert_output "" }