-
Notifications
You must be signed in to change notification settings - Fork 2
Hook to check stale file references #174
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
e46b63d
0d78ed1
7ede570
e6058ee
6b07251
ce177b7
87dc29f
1067af8
5c73c0b
78caaa0
5418f4d
0b29f9e
f07eec2
3c6b84b
4a98124
feac340
feba935
684564b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,89 @@ | ||
| from __future__ import annotations | ||
|
|
||
| import re | ||
| import subprocess | ||
| import sys | ||
| from dataclasses import dataclass | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class StaleReference: | ||
| """A reference in a tracked file that points to a deleted/renamed path.""" | ||
|
|
||
| from_file: str | ||
| from_line: int | ||
| to_path: str | ||
|
|
||
| def __str__(self) -> str: | ||
| """Format as file:line references deleted_path.""" | ||
| return f"{self.from_file}:{self.from_line} references {self.to_path}" | ||
|
|
||
|
|
||
| def _run_git(*args: str, check: bool = True) -> str: | ||
| return subprocess.run( | ||
| ["git", *args], # noqa: S607 | ||
| capture_output=True, | ||
| text=True, | ||
| check=check, | ||
| ).stdout | ||
|
|
||
|
|
||
| def get_deleted_paths() -> set[str]: | ||
| """Return repo-relative paths of files being deleted or renamed away in the staged commit.""" | ||
| output = _run_git("diff", "--cached", "--diff-filter=DR", "--name-status") | ||
| # Both D(eleted) and R(enamed) have the vanishing path in column 1 | ||
| return {line.split("\t")[1] for line in output.splitlines()} | ||
|
|
||
|
|
||
| def build_path_pattern(deleted_path: str) -> str: | ||
| """Build a PCRE pattern for a deleted path that matches references to it. | ||
|
|
||
| For path "a/b/c.txt", matches (with non-word/dot/dash boundaries): | ||
| - /?a/b/c.txt (full path, optional leading /) | ||
| - (../)*b/c.txt (intermediate suffix with optional ../ prefix) | ||
| - (../)*c.txt (basename with optional ../ prefix) | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This has some chance of raising false positives for common filenames such as utils.py. Similarly, there might be conflict with some language includes such as cpp's
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think we should either look at full paths or relative paths starting with
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So you're saying don't make prefixes
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Correct. Only consider absolute paths as you described them or relative paths starting with ./ or ../. This should help us to skip most C++ headers and other paths you listed as false positives. |
||
| """ | ||
| segments = deleted_path.split("/") | ||
| alternatives: list[str] = [] | ||
| for i in range(len(segments)): | ||
| suffix = "/".join(segments[i:]) | ||
| escaped_suffix = re.escape(suffix) | ||
| if i == 0: | ||
| alternatives.append(rf"/?{escaped_suffix}") | ||
| else: | ||
| alternatives.append(rf"(?:\.\./)*{escaped_suffix}") | ||
| return rf"(?<![\w.-])(?:{'|'.join(alternatives)})(?![\w.-])" | ||
|
|
||
|
|
||
| def git_grep(pattern: str) -> list[tuple[str, int]]: | ||
| """Run git grep with PCRE and return (file, line_number) tuples.""" | ||
| output = _run_git("grep", "-nP", pattern, check=False) | ||
| matches: list[tuple[str, int]] = [] | ||
| for line in output.splitlines(): | ||
| file, line_no = line.split(":", 1) | ||
| matches.append((file, int(line_no))) | ||
| return matches | ||
|
|
||
|
|
||
| def find_stale_references() -> list[StaleReference]: | ||
| """Search tracked files for references to deleted paths.""" | ||
| deleted_paths = get_deleted_paths() | ||
| return [ | ||
| StaleReference(from_file=file, from_line=line_no, to_path=deleted_path) | ||
| for deleted_path in deleted_paths | ||
| for file, line_no in git_grep(build_path_pattern(deleted_path)) | ||
| if file not in deleted_paths | ||
| ] | ||
|
|
||
|
|
||
| def main() -> int: | ||
| if stale_refs := find_stale_references(): | ||
| print("Stale references to deleted/renamed files:") | ||
| for ref in stale_refs: | ||
| print(f" {ref}") | ||
| return 1 | ||
| return 0 | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| sys.exit(main()) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,104 @@ | ||
| from __future__ import annotations | ||
|
|
||
| import re | ||
| from unittest.mock import patch | ||
|
|
||
| from dev_tools.check_stale_references import ( | ||
| StaleReference, | ||
| build_path_pattern, | ||
| find_stale_references, | ||
| ) | ||
|
|
||
|
|
||
| def test_matches_full_path() -> None: | ||
| pattern = build_path_pattern("src/lib/foo.hpp") | ||
| assert re.search(pattern, "see src/lib/foo.hpp for details") | ||
|
|
||
|
|
||
| def test_matches_full_path_with_leading_slash() -> None: | ||
| pattern = build_path_pattern("src/lib/foo.hpp") | ||
| assert re.search(pattern, "see /src/lib/foo.hpp for details") | ||
|
|
||
|
|
||
| def test_matches_partial_path() -> None: | ||
| pattern = build_path_pattern("src/lib/foo.hpp") | ||
| assert re.search(pattern, "see lib/foo.hpp here") | ||
|
|
||
|
|
||
| def test_matches_partial_path_with_dotdot() -> None: | ||
| pattern = build_path_pattern("src/lib/foo.hpp") | ||
| assert re.search(pattern, "see ../lib/foo.hpp here") | ||
|
|
||
|
|
||
| def test_matches_basename() -> None: | ||
| pattern = build_path_pattern("src/lib/foo.hpp") | ||
| assert re.search(pattern, "see foo.hpp for details") | ||
|
|
||
|
|
||
| def test_matches_basename_with_dotdot() -> None: | ||
| pattern = build_path_pattern("src/lib/foo.hpp") | ||
| assert re.search(pattern, "see ../../foo.hpp for details") | ||
|
|
||
|
|
||
| def test_no_substring_match() -> None: | ||
| pattern = build_path_pattern("src/lib/foo.hpp") | ||
| assert not re.search(pattern, "notfoo.hpp") | ||
|
|
||
|
|
||
| def test_no_match_with_dot_suffix() -> None: | ||
| pattern = build_path_pattern("src/lib/foo.hpp") | ||
| assert not re.search(pattern, "foo.hpp.bak") | ||
|
|
||
|
|
||
| def test_no_match_with_dash_suffix() -> None: | ||
| pattern = build_path_pattern("src/lib/foo.hpp") | ||
| assert not re.search(pattern, "foo.hpp-old") | ||
|
|
||
|
|
||
| def test_dot_in_extension_is_literal() -> None: | ||
| pattern = build_path_pattern("src/lib/foo.hpp") | ||
| assert not re.search(pattern, "fooXhpp") | ||
|
|
||
|
|
||
| @patch("dev_tools.check_stale_references.git_grep") | ||
| @patch("dev_tools.check_stale_references.get_deleted_paths") | ||
| def test_finds_reference_to_deleted_file(mock_deleted, mock_grep) -> None: | ||
| mock_deleted.return_value = {"src/utils/helper.hpp"} | ||
| mock_grep.return_value = [("src/main.cpp", 1)] | ||
| result = find_stale_references() | ||
| assert result == [StaleReference("src/main.cpp", 1, "src/utils/helper.hpp")] | ||
|
|
||
|
|
||
| @patch("dev_tools.check_stale_references.git_grep") | ||
| @patch("dev_tools.check_stale_references.get_deleted_paths") | ||
| def test_no_reference_no_finding(mock_deleted, mock_grep) -> None: | ||
| mock_deleted.return_value = {"src/utils/helper.hpp"} | ||
| mock_grep.return_value = [] | ||
| result = find_stale_references() | ||
| assert result == [] | ||
|
|
||
|
|
||
| @patch("dev_tools.check_stale_references.git_grep") | ||
| @patch("dev_tools.check_stale_references.get_deleted_paths") | ||
| def test_skips_deleted_file_itself(mock_deleted, mock_grep) -> None: | ||
| mock_deleted.return_value = {"src/utils/helper.hpp"} | ||
| mock_grep.return_value = [("src/utils/helper.hpp", 1)] | ||
| result = find_stale_references() | ||
| assert result == [] | ||
|
|
||
|
|
||
| @patch("dev_tools.check_stale_references.get_deleted_paths") | ||
| def test_empty_deleted_paths_returns_empty(mock_deleted) -> None: | ||
| mock_deleted.return_value = set() | ||
| assert find_stale_references() == [] | ||
|
|
||
|
|
||
| @patch("dev_tools.check_stale_references.git_grep") | ||
| @patch("dev_tools.check_stale_references.get_deleted_paths") | ||
| def test_multiple_files_with_references(mock_deleted, mock_grep) -> None: | ||
| mock_deleted.return_value = {"src/helper.hpp"} | ||
| mock_grep.return_value = [ | ||
| ("a.cpp", 1), | ||
| ("c.md", 3), | ||
| ] | ||
| assert {reference.from_file for reference in find_stale_references()} == {"a.cpp", "c.md"} |
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
PCRE support in git is optionally compiled in, but it seems all widely used distros ship with a binary that includes this. Even the tiny alpine image has it:
If we don't want to rely on the presence of PRCE in git, we'd have to revert this commit, i.e., do the boundary check from line 59 ourselves in python because only PCRE supports that in git grep.