Compare commits

...

10 commits

Author SHA1 Message Date
5a05a067ac
Document the need to use recountdiff 2026-03-06 17:31:27 +01:00
05fe8f56df
Fix order of arguments to assert_equal 2026-03-06 17:30:58 +01:00
057686a6b9
Fix changes sometimes disappearing entirely
```git-check-assertions
run test/mechanicaldiff.bats
assert_success

git checkout HEAD~ bin
run test/mechanicaldiff.bats
assert_failure
assert_output --partial "not ok 9 keeps matching change with an extra removed line"
assert_output --partial "not ok 10 keeps matching change with an extra added line"
```
2026-03-06 17:30:41 +01:00
c55e0276a6
mechanicaldiff.py -> mechanicaldiff 2026-03-06 05:28:19 +01:00
3dd0d626c6
Add README 2026-03-06 04:29:23 +01:00
2aebae7aff
Add test where replacement removes entire line
```git-check-assertions
run test/mechanicaldiff.bats
assert_success

git checkout HEAD~2 bin
run test/mechanicaldiff.bats
assert_failure
assert_output --partial "-- command failed --"
```
2026-03-06 04:26:24 +01:00
0b3d0d8f46
Ignore leading and trailing blank lines for comparison
```git-check-assertions
run test/mechanicaldiff.bats
assert_success

git checkout HEAD~ bin
run test/mechanicaldiff.bats
assert_failure
assert_output --partial "-- command failed --"
```
2026-03-06 04:25:28 +01:00
3d814b5b15
Get rid of the left_out variable 2026-03-05 21:21:35 +01:00
c103d70078
Add LICENSE 2026-03-05 21:11:29 +01:00
d712a7eea2
Just return empty lists instead of None 2026-03-05 21:09:06 +01:00
6 changed files with 176 additions and 34 deletions

21
LICENSE Normal file
View file

@ -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.

9
README.md Normal file
View file

@ -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 <pattern> <replacement> | recountdiff`
It differs from [grepdiff](https://linux.die.net/man/1/grepdiff) in that it doesnt 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 doesnt make the mechanical change everywhere, but only in the lines that your original diff also made it.

View file

@ -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)

View file

@ -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
'';
}

View file

@ -26,6 +26,7 @@
pkgs.python3Packages.flake8
pkgs.shellcheck
pkgs.shfmt
pkgs.patchutils_0_4_2
];
};
}

View file

@ -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 ""
}