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
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,16 @@ checks pass. Pipe to `jq` for filtering:
commit-guard --range origin/main..HEAD --output jsonl | jq 'select(.ok == false)'
```

Use `--output-file FILE` to write JSONL to a file while keeping human-readable
text on stdout:

```bash
commit-guard --range origin/main..HEAD --output-file results.jsonl
```

`--output-file` is independent of `--output`: combining both writes JSONL to
both stdout and the file.

### GitHub Actions

```yaml
Expand Down Expand Up @@ -299,6 +309,18 @@ jobs:
require-trailer: 'Closes,Reviewed-by'
max-subject-length: '100'
min-description-length: '10'
output-file: results.jsonl
```

When `output-file` is set the action exposes the path as an output:

```yaml
- uses: benner/commit-guard@v0.14.1
id: cg
with:
range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
output-file: results.jsonl
- run: jq 'select(.ok == false)' "${{ steps.cg.outputs.output-file }}"
```

### pre-commit
Expand Down
11 changes: 11 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@ inputs:
require-trailer:
description: Comma-separated list of required trailers (e.g. Closes,Reviewed-by)
required: false
output-file:
description: Write JSONL results to this file path (text still goes to stdout)
required: false
outputs:
output-file:
description: Path to the JSONL output file (set only when output-file input is provided)
value: ${{ steps.run.outputs.output-file }}
runs:
using: composite
steps:
Expand All @@ -57,6 +64,7 @@ runs:
run: uv tool install git-commit-guard
shell: bash
- name: Run commit-guard
id: run
env:
CG_REV: ${{ inputs.rev }}
CG_RANGE: ${{ inputs.range }}
Expand All @@ -70,6 +78,7 @@ runs:
CG_ALLOW_EMPTY: ${{ inputs.allow-empty }}
CG_INCLUDE_MERGES: ${{ inputs.include-merges }}
CG_REQUIRE_TRAILER: ${{ inputs.require-trailer }}
CG_OUTPUT_FILE: ${{ inputs.output-file }}
run: |
ARGS=()
[[ -n "$CG_REV" ]] && ARGS+=("$CG_REV")
Expand All @@ -86,5 +95,7 @@ runs:
[[ "$CG_ALLOW_EMPTY" == "true" ]] && ARGS+=(--allow-empty)
[[ "$CG_INCLUDE_MERGES" == "true" ]] && ARGS+=(--include-merges)
[[ -n "$CG_REQUIRE_TRAILER" ]] && ARGS+=(--require-trailer "$CG_REQUIRE_TRAILER")
[[ -n "$CG_OUTPUT_FILE" ]] && ARGS+=(--output-file "$CG_OUTPUT_FILE")
commit-guard "${ARGS[@]}"
[[ -n "$CG_OUTPUT_FILE" ]] && echo "output-file=$CG_OUTPUT_FILE" >> "$GITHUB_OUTPUT"
shell: bash
83 changes: 54 additions & 29 deletions src/git_commit_guard/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import contextlib
import json
import os
import re
Expand Down Expand Up @@ -305,6 +306,7 @@ class Args:
include_merges: bool
required_trailers: list
output: OutputFormat
output_file: Path | None


def _resolve_enabled(args, config, parser):
Expand Down Expand Up @@ -454,6 +456,12 @@ def _parse_args():
default=OutputFormat.TEXT,
help="output format: text (default) or jsonl",
)
parser.add_argument(
"--output-file",
type=Path,
metavar="FILE",
help="write JSONL results to FILE (text still goes to stdout)",
)
args = parser.parse_args()
config = _load_config()
enabled = _resolve_enabled(args, config, parser)
Expand Down Expand Up @@ -500,11 +508,12 @@ def _parse_args():
include_merges=args.include_merges,
required_trailers=required_trailers,
output=OutputFormat(args.output),
output_file=args.output_file,
)


def _report_jsonl(result, sha, subject):
record = {
def _jsonl_record(result, sha, subject):
return {
"sha": sha,
"subject": subject,
"ok": result.ok,
Expand All @@ -513,10 +522,17 @@ def _report_jsonl(result, sha, subject):
for check, level, msg in result.errors
],
}
print(json.dumps(record))


def _report_jsonl(result, sha, subject):
print(json.dumps(_jsonl_record(result, sha, subject)))
return 0 if result.ok else 1


def _write_jsonl_record(result, sha, subject, file):
file.write(json.dumps(_jsonl_record(result, sha, subject)) + "\n")


def _report_text(result):
color = sys.stdout.isatty()
for check, level, msg in result.errors:
Expand Down Expand Up @@ -561,29 +577,38 @@ def _run_checks(args, rev, message, result):
def main():
args = _parse_args()

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

subject = args.message.split("\n")[0]
result = Result()
_run_checks(args, args.rev, args.message, result)
if args.output == OutputFormat.JSONL:
return _report_jsonl(result, args.rev, subject)
return _report_text(result)
with (
args.output_file.open("w")
if args.output_file
else contextlib.nullcontext() as out_file
):
if args.rev_range:
revs = _get_range_revs(args.rev_range, include_merges=args.include_merges)
if not revs:
sys.stderr.write("no commits in range\n")
return 0 if args.allow_empty else 1
failed = False
for rev in revs:
message = _strip_comments(_get_message(rev))
subject = message.split("\n")[0]
result = Result()
_run_checks(args, rev, message, result)
if args.output == OutputFormat.JSONL:
if _report_jsonl(result, rev, subject) != 0:
failed = True
else:
print(f"{rev[:7]} {subject}")
if _report_text(result) != 0:
failed = True
if out_file:
_write_jsonl_record(result, rev, subject, out_file)
return 1 if failed else 0

subject = args.message.split("\n")[0]
result = Result()
_run_checks(args, args.rev, args.message, result)
if out_file:
_write_jsonl_record(result, args.rev, subject, out_file)
if args.output == OutputFormat.JSONL:
return _report_jsonl(result, args.rev, subject)
return _report_text(result)
95 changes: 95 additions & 0 deletions tests/test_git_commit_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -1421,3 +1421,98 @@ def test_range_emits_one_line_per_commit(self, capsys):
data = json.loads(line)
assert data["sha"] == rev
assert data["ok"] is True


class TestOutputFile:
def test_single_commit_writes_jsonl_to_file(self, tmp_path, capsys):
msg_file = tmp_path / "msg"
msg_file.write_text(_VALID_MSG)
out_file = tmp_path / "results.jsonl"
with patch(
"sys.argv",
[
"cg",
"--message-file",
str(msg_file),
"--disable",
"signature,imperative",
"--output-file",
str(out_file),
],
):
assert main() == 0
assert "all checks passed" in capsys.readouterr().out
data = json.loads(out_file.read_text())
assert data["ok"] is True
assert data["subject"] == "fix: add thing"

def test_output_jsonl_and_output_file_both_written(self, tmp_path, capsys):
msg_file = tmp_path / "msg"
msg_file.write_text(_VALID_MSG)
out_file = tmp_path / "results.jsonl"
with patch(
"sys.argv",
[
"cg",
"--message-file",
str(msg_file),
"--disable",
"signature,imperative",
"--output",
"jsonl",
"--output-file",
str(out_file),
],
):
assert main() == 0
stdout_data = json.loads(capsys.readouterr().out)
file_data = json.loads(out_file.read_text())
assert stdout_data["ok"] is True
assert file_data["ok"] is True
assert stdout_data["subject"] == file_data["subject"]

def test_range_writes_one_line_per_commit(self, tmp_path):
revs = ["aaa", "bbb"]
messages = [_VALID_MSG] * len(revs)
out_file = tmp_path / "results.jsonl"
with (
patch(
"sys.argv",
[
"cg",
"--range",
"HEAD~2..HEAD",
"--disable",
"signature,imperative",
"--output-file",
str(out_file),
],
),
patch("git_commit_guard._get_range_revs", return_value=revs),
patch("git_commit_guard._get_message", side_effect=messages),
):
assert main() == 0
lines = out_file.read_text().strip().splitlines()
assert len(lines) == len(revs)
for line, rev in zip(lines, revs, strict=True):
assert json.loads(line)["sha"] == rev

def test_failed_commit_written_to_file(self, tmp_path):
msg_file = tmp_path / "msg"
msg_file.write_text("fix: add thing")
out_file = tmp_path / "results.jsonl"
with patch(
"sys.argv",
[
"cg",
"--message-file",
str(msg_file),
"--disable",
"signature,imperative",
"--output-file",
str(out_file),
],
):
assert main() == 1
data = json.loads(out_file.read_text())
assert data["ok"] is False
Loading