Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 11 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,16 +146,18 @@ provided.

### Checking a range of commits

Use `--range` to check all commits in a revision range. All commits are
checked and a single non-zero exit code is returned if any fail:

```bash
# all non-merge commits between tags
git rev-list --no-merges v1.0..v2.0 | while read -r rev; do
commit-guard "$rev" || git log -1 --oneline "$rev"
done

# only subject checks on a PR range
git rev-list --no-merges origin/main..HEAD | while read -r rev; do
commit-guard "$rev" --enable subject,imperative
done
# check all commits in a PR
commit-guard --range origin/main..HEAD

# check between two tags
commit-guard --range v1.0..v2.0

# only subject checks on a range
commit-guard --range origin/main..HEAD --enable subject,imperative
```

### pre-commit
Expand Down
69 changes: 56 additions & 13 deletions src/git_commit_guard/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,19 @@ def _get_message(rev):
sys.exit(f"git error: {stderr}")


def _get_range_revs(rev_range):
try:
output = subprocess.check_output( # noqa: S603
["git", "log", "--format=%H", rev_range], # noqa: S607
text=True,
stderr=subprocess.PIPE,
timeout=GIT_TIMEOUT,
).strip()
except subprocess.CalledProcessError as e:
sys.exit(f"git error: {e.stderr.strip()}")
return output.split("\n") if output else []


@dataclass
class Args:
rev: str | None
Expand All @@ -233,6 +246,7 @@ class Args:
require_scope: bool
allowed_types: frozenset
max_subject_length: int
rev_range: str | None


def _resolve_enabled(args, config, parser):
Expand Down Expand Up @@ -330,14 +344,25 @@ def _parse_args():
metavar="N",
help=f"maximum subject line length (default: {MAX_SUBJECT_LEN})",
)
parser.add_argument(
"--range",
dest="rev_range",
metavar="REF..REF",
help="check all commits in the given revision range",
)
args = parser.parse_args()
config = _load_config()
enabled = _resolve_enabled(args, config, parser)
allowed_scopes, require_scope = _resolve_scopes(args, config)
allowed_types = _resolve_types(args, config)
max_subject_length = _resolve_max_subject_length(args, config)

if args.message_file:
if args.rev_range:
if args.rev is not None or args.message_file:
parser.error("--range cannot be combined with rev or --message-file")
rev = None
message = ""
elif args.message_file:
rev = None
message = _strip_comments(args.message_file.read_text().strip())
elif args.rev:
Expand All @@ -358,6 +383,7 @@ def _parse_args():
require_scope=require_scope,
allowed_types=allowed_types,
max_subject_length=max_subject_length,
rev_range=args.rev_range,
)


Expand All @@ -371,15 +397,8 @@ def _report(result):
return 0 if result.ok else 1


def main():
args = _parse_args()
lines = args.message.split("\n")

if Check.IMPERATIVE in args.enabled:
_ensure_nltk_data()

result = Result()

def _run_checks(args, rev, message, result):
lines = message.split("\n")
desc = None
if Check.SUBJECT in args.enabled:
desc = check_subject(
Expand All @@ -399,8 +418,32 @@ def main():
if Check.BODY in args.enabled:
check_body(lines, result)
if Check.SIGNED_OFF in args.enabled:
check_signed_off(args.message, result)
if Check.SIGNATURE in args.enabled and args.rev:
check_signature(args.rev, result)
check_signed_off(message, result)
if Check.SIGNATURE in args.enabled and rev:
check_signature(rev, result)


def main():
args = _parse_args()

if Check.IMPERATIVE in args.enabled:
_ensure_nltk_data()

if args.rev_range:
revs = _get_range_revs(args.rev_range)
if not revs:
sys.stderr.write("no commits in range\n")
return 0
failed = False
for rev in revs:
message = _strip_comments(_get_message(rev))
sys.stderr.write(f"{rev[:7]} {message.split('\n')[0]}\n")
result = Result()
_run_checks(args, rev, message, result)
if _report(result) != 0:
failed = True
return 1 if failed else 0

result = Result()
_run_checks(args, args.rev, args.message, result)
return _report(result)
111 changes: 111 additions & 0 deletions tests/test_git_commit_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
_download_if_missing,
_ensure_nltk_data,
_get_message,
_get_range_revs,
_load_config,
_parse_checks,
_parse_config_checks,
Expand Down Expand Up @@ -831,3 +832,113 @@ def test_types_cli_overrides_config(self, tmp_path):
),
):
assert main() == 0

def test_range_all_pass(self):
with (
patch(
"sys.argv",
["cg", "--range", "origin/main..HEAD", "--disable", "signature"],
),
patch(
"git_commit_guard._get_range_revs",
return_value=["abc1234", "def5678"],
),
patch("git_commit_guard._get_message", return_value=_VALID_MSG),
):
assert main() == 0

def test_range_one_fails(self):
messages = {"abc1234": _VALID_MSG, "def5678": "not a valid commit message"}
with (
patch(
"sys.argv",
[
"cg",
"--range",
"origin/main..HEAD",
"--disable",
"signature,body,signed-off,imperative",
],
),
patch(
"git_commit_guard._get_range_revs",
return_value=["abc1234", "def5678"],
),
patch(
"git_commit_guard._get_message",
side_effect=lambda rev: messages[rev],
),
):
assert main() == 1

def test_range_all_fail_returns_one(self):
with (
patch(
"sys.argv",
[
"cg",
"--range",
"origin/main..HEAD",
"--disable",
"signature,body,signed-off,imperative",
],
),
patch(
"git_commit_guard._get_range_revs",
return_value=["abc1234"],
),
patch(
"git_commit_guard._get_message",
return_value="not a valid commit message",
),
):
assert main() == 1

def test_range_empty_returns_zero(self, capsys):
with (
patch("sys.argv", ["cg", "--range", "origin/main..HEAD"]),
patch("git_commit_guard._get_range_revs", return_value=[]),
):
assert main() == 0
assert "no commits in range" in capsys.readouterr().err

def test_range_conflicts_with_rev(self):
with (
patch("sys.argv", ["cg", "abc123", "--range", "origin/main..HEAD"]),
pytest.raises(SystemExit),
):
main()

def test_range_conflicts_with_message_file(self, tmp_path):
f = tmp_path / "msg"
f.write_text(_VALID_MSG)
with (
patch(
"sys.argv",
["cg", "--message-file", str(f), "--range", "origin/main..HEAD"],
),
pytest.raises(SystemExit),
):
main()


class TestGetRangeRevs:
def test_returns_shas(self):
with patch(
"git_commit_guard.subprocess.check_output",
return_value="abc1234\ndef5678",
):
assert _get_range_revs("origin/main..HEAD") == ["abc1234", "def5678"]

def test_empty_range_returns_empty_list(self):
with patch("git_commit_guard.subprocess.check_output", return_value=""):
assert _get_range_revs("origin/main..HEAD") == []

def test_invalid_range_exits(self):
err = subprocess.CalledProcessError(128, "git")
err.stderr = "fatal: bad revision 'bogus'"
with (
patch("git_commit_guard.subprocess.check_output", side_effect=err),
pytest.raises(SystemExit, match="git error"),
):
_get_range_revs("bogus")
Loading