diff --git a/scripts/README.md b/scripts/README.md index eea8774d5..97fd769ee 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -84,7 +84,8 @@ including staged new files. Missing and untracked files are omitted. Diff selection compares a base with the working tree, including staged and unstaged changes. It selects whole current files, checks rename destinations, -and skips deleted and untracked files. +and skips deleted and untracked files. The base must resolve to one commit; +options, ranges, tree objects and unknown revisions are rejected. Explicit files may be untracked, but must exist and be within the same scope. They cannot be combined with a diff base. Both a path and its resolved symlink diff --git a/scripts/lint_scope.py b/scripts/lint_scope.py index 8b6ad9a1a..fca829d16 100644 --- a/scripts/lint_scope.py +++ b/scripts/lint_scope.py @@ -40,9 +40,17 @@ def tracked_sources(root: Path) -> list[Path]: if in_scope(root / name, root) and (root / name).is_file()}) +def resolve_base(root: Path, base: str) -> str: + """Require one commit before passing a user-supplied base to Git diff.""" + return subprocess.check_output( + ["git", "-C", str(root), "rev-parse", "--verify", "--end-of-options", + f"{base}^{{commit}}"], text=True).strip() + + def changed_sources(root: Path, base: str) -> list[Path]: + revision = resolve_base(root, base) names = git_names(root, "diff", "--no-renames", "--name-only", "-z", - "--diff-filter=d", base, "--", *git_pathspecs()) + "--diff-filter=d", revision, "--", *git_pathspecs()) return sorted({root / name for name in names if in_scope(root / name, root) and (root / name).is_file()}) diff --git a/scripts/tests/test_lint_scope.py b/scripts/tests/test_lint_scope.py index f02f1d021..46beec7d5 100644 --- a/scripts/tests/test_lint_scope.py +++ b/scripts/tests/test_lint_scope.py @@ -89,6 +89,18 @@ class ScopeTests(unittest.TestCase): with self.assertRaises(subprocess.CalledProcessError): changed_sources(self.root, "missing-reference") + def test_diff_requires_one_commit_even_when_the_worktree_is_clean(self): + source = self.source("scst/core.c") + self.git("-c", "user.name=Test", "-c", "user.email=test@example.org", + "-c", "commit.gpgsign=false", "commit", "-qm", "base") + for base in ("--quiet", "--exit-code", "--cached", "HEAD..HEAD", "HEAD^{tree}"): + with self.subTest(base=base), self.assertRaises(subprocess.CalledProcessError): + changed_sources(self.root, base) + self.git("tag", "baseline") + self.assertEqual(changed_sources(self.root, "baseline"), []) + source.write_text("int changed(void);\n") + self.assertEqual(changed_sources(self.root, "baseline"), [source]) + if __name__ == "__main__": unittest.main()