Compare commits

..

No commits in common. "5a05a067acba7d6b5696bf6e29fc26c6d7e8b833" and "72766295c03396a06befbcc5106d0cfbb4b925bc" have entirely different histories.

6 changed files with 34 additions and 176 deletions

21
LICENSE
View file

@ -1,21 +0,0 @@
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.

View file

@ -1,9 +0,0 @@
# 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,19 +4,9 @@ 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]:
) -> list[str] | None:
removed_lines = []
added_lines = []
phase = "minus"
@ -37,16 +27,14 @@ def filter_change_block(
transformed_removed = [
re.sub(search, replace, line) for line in removed_lines
]
if trim_blank_ends(transformed_removed) == trim_blank_ends(added_lines):
if transformed_removed == added_lines:
return change_lines
if len(removed_lines) != len(added_lines):
return None
kept_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
for removed, added in zip(removed_lines, added_lines):
if re.sub(search, replace, removed) == added:
kept_lines.append(f"-{removed}")
kept_lines.append(f"+{added}")
@ -54,16 +42,16 @@ def filter_change_block(
kept_lines.append(f" {removed}")
if not kept_lines:
return []
return None
return kept_lines
def filter_hunk(
hunk_lines: list[str], search: str, replace: str
) -> list[str]:
) -> list[str] | None:
if not hunk_lines:
return []
return None, False
header = hunk_lines[0]
body = hunk_lines[1:]
@ -85,7 +73,7 @@ def filter_hunk(
i += 1
if not any(line.startswith(("+", "-")) for line in output_body):
return []
return None
return [header] + output_body
@ -97,8 +85,7 @@ def main() -> None:
search = sys.argv[1]
replace = sys.argv[2]
input_data = sys.stdin.read()
lines = input_data.splitlines(keepends=True)
lines = sys.stdin.read().splitlines(keepends=True)
preamble_lines = []
sections = []
current = None
@ -127,23 +114,29 @@ 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)
output_data = "".join(output_lines)
sys.stdout.write(output_data)
if output_data != input_data:
sys.stdout.write("".join(output_lines))
if left_out:
raise SystemExit(1)

View file

@ -7,7 +7,6 @@
python3Packages,
shellcheck-minimal,
shfmt,
patchutils_0_4_2,
}:
let
@ -43,12 +42,11 @@ stdenv.mkDerivation {
python3Packages.flake8
shellcheck-minimal
shfmt
patchutils_0_4_2
];
checkPhase = ''
runHook preCheck
flake8 bin/mechanicaldiff
flake8 bin/mechanicaldiff.py
shellcheck test/mechanicaldiff.bats
shfmt -d test/mechanicaldiff.bats
bats test
@ -57,7 +55,7 @@ stdenv.mkDerivation {
installPhase = ''
mkdir -p $out/bin
cp $src/bin/mechanicaldiff $out/bin/mechanicaldiff
cp $src/bin/mechanicaldiff.py $out/bin/mechanicaldiff
chmod +x $out/bin/mechanicaldiff
'';
}

View file

@ -26,7 +26,6 @@
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 foo bar <<<"$diff_output"
run mechanicaldiff.py 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 foo bar <"$BATS_TEST_TMPDIR/diff"
run mechanicaldiff.py 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 foo bar <<<"$diff_output"
run mechanicaldiff.py 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 "beta" "beta_changed" <"$BATS_TEST_TMPDIR/diff_full"
run mechanicaldiff.py "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 foo bar <"$BATS_TEST_TMPDIR/diff_one"
run mechanicaldiff.py foo bar <"$BATS_TEST_TMPDIR/diff_one"
assert_success
output_one="$output"
run mechanicaldiff foo bar <"$BATS_TEST_TMPDIR/diff_two"
run mechanicaldiff.py foo bar <"$BATS_TEST_TMPDIR/diff_two"
assert_failure
output_two="$output"
run mechanicaldiff foo bar <"$BATS_TEST_TMPDIR/diff_all"
run mechanicaldiff.py 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 'foo\((\d+)\)' 'foo-\1' <"$BATS_TEST_TMPDIR/diff"
run mechanicaldiff.py 'foo\((\d+)\)' 'foo-\1' <"$BATS_TEST_TMPDIR/diff"
assert_success
assert_output "$(cat "$BATS_TEST_TMPDIR/diff")"
}
@ -138,109 +138,7 @@ setup() {
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")"
}
@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"
run mechanicaldiff.py foo bar <"$BATS_TEST_TMPDIR/diff"
assert_success
assert_output "$(cat "$BATS_TEST_TMPDIR/diff")"
}
@ -263,14 +161,14 @@ setup() {
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_full"
run mechanicaldiff.py 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 "$output_normalized" "$expected"
assert_equal "$expected" "$output_normalized"
}
@test "drops hunk when all pairs become context" {
@ -287,7 +185,7 @@ setup() {
git diff --no-index "$BATS_TEST_TMPDIR/old" "$BATS_TEST_TMPDIR/new" \
>"$BATS_TEST_TMPDIR/diff_full" || true
run mechanicaldiff qux quux <"$BATS_TEST_TMPDIR/diff_full"
run mechanicaldiff.py qux quux <"$BATS_TEST_TMPDIR/diff_full"
assert_failure
assert_output ""
}