Skip to content

Commit 2a99e5f

Browse files
mprpicclaude
andcommitted
Add configurable issue ID validation in title
Read a regex pattern from `git config commit-editor.issue-pattern` and validate that the commit title starts with a matching issue ID followed by a colon. A new validation bar displays errors and clears automatically when the title is corrected. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Martin Prpič <martin.prpic@gmail.com>
1 parent 3fd8b49 commit 2a99e5f

File tree

7 files changed

+241
-7
lines changed

7 files changed

+241
-7
lines changed

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ This document describes how to work with the commit-editor project.
66

77
## Project Structure
88

9-
```
9+
```text
1010
src/commit_editor/
1111
├── __init__.py # Package init with version
1212
├── cli.py # CLI entry point (argparse)

README.md

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ with [Textual](https://textual.textualize.io/).
1414
- **White space**: Trailing white space is automatically stripped when a file is saved; an empty newline is inserted
1515
at the end of the file if not present.
1616
- **Signed-off-by toggle**: Quickly add or remove a `Signed-off-by` trailer with a keyboard shortcut
17+
- **Issue ID validation**: Optionally enforce that commit titles start with an issue ID (e.g. `PROJ-123:`)
1718
- **Status bar**: Shows current cursor position (line/column) and title length with warnings
1819

1920
## Installation
@@ -58,6 +59,26 @@ commit-editor path/to/file.txt
5859
Additional key bindings are noted in the Textual
5960
[`TextArea` documentation](https://textual.textualize.io/widgets/text_area/#bindings).
6061

62+
## Configuration
63+
64+
### Issue ID Validation
65+
66+
You can require commit titles to start with an issue ID by setting a regex pattern:
67+
68+
```bash
69+
# Per-repository
70+
git config commit-editor.issue-pattern 'PROJ-\d+'
71+
72+
# Global
73+
git config --global commit-editor.issue-pattern 'PROJ-\d+'
74+
```
75+
76+
The pattern is matched against the **start of the title** and must be followed by a colon (`:`). For example, with
77+
the pattern `PROJ-\d+`:
78+
79+
- valid: `PROJ-123: fix login bug`
80+
- invalid: `fix login bug`
81+
6182
## Commit Message Format
6283

6384
This editor enforces the widely-accepted git commit message conventions:
@@ -68,10 +89,6 @@ This editor enforces the widely-accepted git commit message conventions:
6889

6990
## Future Improvements
7091

71-
- Support adding a "Co-authored-by" trailer for AI attribution
72-
- Word-level spellchecking
73-
- Config file support (`.commit.toml` project or global level or `pyproject.toml`); support tweaking line length limits
74-
- Jira (or other issue tracker) ID checking (e.g. title starts with `ABC-123: `)
7592
- Color theme support
7693

7794
## License

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,7 @@ select = ["F", "E", "W", "I", "N"]
5151
[tool.pytest.ini_options]
5252
asyncio_mode = "auto"
5353
asyncio_default_fixture_loop_scope = "function"
54+
55+
[tool.mdlint.rules.MD013]
56+
line_length = 120
57+
code_blocks = false

src/commit_editor/app.py

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import re
12
from collections.abc import Callable
23
from pathlib import Path
34

@@ -13,12 +14,13 @@
1314
from textual.widgets import Input, OptionList, Static, TextArea
1415
from textual.widgets._option_list import Option
1516

16-
from commit_editor.git import get_signed_off_by
17+
from commit_editor.git import get_issue_pattern, get_signed_off_by
1718
from commit_editor.spelling import WORD_PATTERN, SpellCheckCache
1819

1920
TITLE_MAX_LENGTH = 50
2021
BODY_MAX_LENGTH = 72
2122
_SUGGESTION_PREFIX = "Suggestions for"
23+
_ISSUE_ID_ERROR = "Missing/Invalid issue ID in title!"
2224

2325
AI_COAUTHOR_MODELS = [
2426
("Claude Opus 4.6", "noreply@anthropic.com"),
@@ -325,6 +327,51 @@ def get_cursor_position(self) -> tuple[int, int]:
325327
return row + 1, col + 1
326328

327329

330+
class ValidationBar(Static):
331+
"""Bar for showing validation error messages. Hidden when empty."""
332+
333+
DEFAULT_CSS = """
334+
ValidationBar {
335+
height: auto;
336+
background: $surface;
337+
color: $error;
338+
padding: 0 1;
339+
display: none;
340+
}
341+
ValidationBar.has-errors {
342+
display: block;
343+
}
344+
"""
345+
346+
def __init__(self, *args, **kwargs):
347+
super().__init__(*args, **kwargs)
348+
self._errors: list[tuple[str, str]] = []
349+
350+
def set_error(self, key: str, message: str) -> None:
351+
"""Add or update a validation error by key."""
352+
for i, (k, _) in enumerate(self._errors):
353+
if k == key:
354+
self._errors[i] = (key, message)
355+
self._refresh_display()
356+
return
357+
self._errors.append((key, message))
358+
self._refresh_display()
359+
360+
def clear_error(self, key: str) -> None:
361+
"""Remove a validation error by key."""
362+
self._errors = [(k, m) for k, m in self._errors if k != key]
363+
self._refresh_display()
364+
365+
def _refresh_display(self) -> None:
366+
"""Update the display based on current errors."""
367+
if self._errors:
368+
self.update("\n".join(msg for _, msg in self._errors))
369+
self.add_class("has-errors")
370+
else:
371+
self.update("")
372+
self.remove_class("has-errors")
373+
374+
328375
class StatusBar(Static):
329376
"""Status bar showing cursor position and title length."""
330377

@@ -433,9 +480,17 @@ def __init__(self, filename: Path):
433480
self._original_content = ""
434481
self._prompt_mode: str | None = None # Track active prompt type
435482
self._spell_timer = None
483+
self._issue_pattern: re.Pattern[str] | None = None
484+
pattern = get_issue_pattern()
485+
if pattern:
486+
try:
487+
self._issue_pattern = re.compile(pattern + r":")
488+
except re.error:
489+
pass
436490

437491
def compose(self) -> ComposeResult:
438492
yield CommitTextArea(id="editor", show_line_numbers=True, highlight_cursor_line=True)
493+
yield ValidationBar(id="validation")
439494
yield StatusBar(id="status")
440495
yield MessageBar(id="message")
441496

@@ -450,6 +505,7 @@ def on_mount(self) -> None:
450505
editor.focus()
451506

452507
self._update_status_bar()
508+
self._validate_issue_id()
453509

454510
def check_action(self, action: str, parameters: tuple) -> bool | None:
455511
"""Disable editor actions when in prompt mode."""
@@ -514,6 +570,7 @@ def on_editor_changed(self, event: CommitTextArea.Changed) -> None:
514570
self.query_one("#message", MessageBar).clear()
515571

516572
self._update_status_bar()
573+
self._validate_issue_id()
517574

518575
@on(CommitTextArea.SelectionChanged)
519576
def on_selection_changed(self, event: CommitTextArea.SelectionChanged) -> None:
@@ -531,6 +588,18 @@ def _update_status_bar(self) -> None:
531588

532589
status.update_status(line, col, title_length, self.dirty)
533590

591+
def _validate_issue_id(self) -> None:
592+
"""Validate issue ID in the title and update the validation bar."""
593+
if self._issue_pattern is None:
594+
return
595+
editor = self.query_one("#editor", CommitTextArea)
596+
title = editor.text.split("\n")[0] if editor.text else ""
597+
validation_bar = self.query_one("#validation", ValidationBar)
598+
if self._issue_pattern.match(title):
599+
validation_bar.clear_error("issue_id")
600+
else:
601+
validation_bar.set_error("issue_id", _ISSUE_ID_ERROR)
602+
534603
def _show_message(self, message: str, error: bool = False) -> None:
535604
"""Show a message in the message bar."""
536605
message_bar = self.query_one("#message", MessageBar)

src/commit_editor/git.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,21 @@ def get_user_email() -> str | None:
3131
return None
3232

3333

34+
def get_issue_pattern() -> str | None:
35+
try:
36+
result = subprocess.run(
37+
["git", "config", "commit-editor.issue-pattern"],
38+
capture_output=True,
39+
text=True,
40+
check=True,
41+
)
42+
return result.stdout.strip() or None
43+
except subprocess.CalledProcessError:
44+
return None
45+
except FileNotFoundError:
46+
return None
47+
48+
3449
def get_signed_off_by() -> str | None:
3550
name = get_user_name()
3651
email = get_user_email()

tests/test_app.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
CommitEditorApp,
99
CommitTextArea,
1010
MessageBar,
11+
ValidationBar,
1112
_format_coauthor,
1213
)
1314

@@ -612,3 +613,84 @@ async def test_signoff_after_coauthor_no_blank_line(self, temp_file):
612613
)
613614
# Trailers should be on adjacent lines
614615
assert signoff_idx == coauthor_idx + 1
616+
617+
618+
class TestIssueIdValidation:
619+
"""Tests for issue ID validation in commit title."""
620+
621+
async def test_no_pattern_configured(self, temp_file):
622+
"""When no pattern is configured, validation bar should be hidden."""
623+
temp_file.write_text("Any title without issue ID")
624+
with patch("commit_editor.app.get_issue_pattern", return_value=None):
625+
app = CommitEditorApp(temp_file)
626+
async with app.run_test():
627+
validation_bar = app.query_one("#validation", ValidationBar)
628+
assert "has-errors" not in validation_bar.classes
629+
630+
async def test_invalid_title_shows_error(self, temp_file):
631+
"""Invalid title should show error in validation bar."""
632+
temp_file.write_text("Fix something")
633+
with patch("commit_editor.app.get_issue_pattern", return_value=r"AIPCC-\d+"):
634+
app = CommitEditorApp(temp_file)
635+
async with app.run_test():
636+
validation_bar = app.query_one("#validation", ValidationBar)
637+
assert "has-errors" in validation_bar.classes
638+
639+
async def test_valid_title_no_error(self, temp_file):
640+
"""Valid title should not show error in validation bar."""
641+
temp_file.write_text("AIPCC-123: Fix something")
642+
with patch("commit_editor.app.get_issue_pattern", return_value=r"AIPCC-\d+"):
643+
app = CommitEditorApp(temp_file)
644+
async with app.run_test():
645+
validation_bar = app.query_one("#validation", ValidationBar)
646+
assert "has-errors" not in validation_bar.classes
647+
648+
async def test_title_becomes_valid_clears_error(self, temp_file):
649+
"""Error should disappear when title becomes valid."""
650+
temp_file.write_text("Fix something")
651+
with patch("commit_editor.app.get_issue_pattern", return_value=r"AIPCC-\d+"):
652+
app = CommitEditorApp(temp_file)
653+
async with app.run_test() as pilot:
654+
validation_bar = app.query_one("#validation", ValidationBar)
655+
assert "has-errors" in validation_bar.classes
656+
657+
# Edit title to be valid
658+
editor = app.query_one("#editor", CommitTextArea)
659+
editor.load_text("AIPCC-123: Fix something")
660+
await pilot.pause()
661+
662+
assert "has-errors" not in validation_bar.classes
663+
664+
async def test_error_shown_on_mount(self, temp_file):
665+
"""Error should be shown immediately on mount with invalid title."""
666+
temp_file.write_text("Bad title")
667+
with patch("commit_editor.app.get_issue_pattern", return_value=r"AIPCC-\d+"):
668+
app = CommitEditorApp(temp_file)
669+
async with app.run_test():
670+
validation_bar = app.query_one("#validation", ValidationBar)
671+
assert "has-errors" in validation_bar.classes
672+
673+
async def test_error_persists_while_typing(self, temp_file):
674+
"""Error should persist while title remains invalid during typing."""
675+
temp_file.write_text("Fix something")
676+
with patch("commit_editor.app.get_issue_pattern", return_value=r"AIPCC-\d+"):
677+
app = CommitEditorApp(temp_file)
678+
async with app.run_test() as pilot:
679+
validation_bar = app.query_one("#validation", ValidationBar)
680+
assert "has-errors" in validation_bar.classes
681+
682+
# Type more text (title still invalid)
683+
editor = app.query_one("#editor", CommitTextArea)
684+
editor.load_text("Fix something else")
685+
await pilot.pause()
686+
687+
assert "has-errors" in validation_bar.classes
688+
689+
async def test_invalid_regex_treated_as_unconfigured(self, temp_file):
690+
"""Invalid regex pattern should be treated as unconfigured (no error, no crash)."""
691+
temp_file.write_text("Any title")
692+
with patch("commit_editor.app.get_issue_pattern", return_value="[invalid"):
693+
app = CommitEditorApp(temp_file)
694+
async with app.run_test():
695+
validation_bar = app.query_one("#validation", ValidationBar)
696+
assert "has-errors" not in validation_bar.classes

tests/test_git.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from unittest.mock import patch
22

3-
from commit_editor.git import get_signed_off_by, get_user_email, get_user_name
3+
from commit_editor.git import get_issue_pattern, get_signed_off_by, get_user_email, get_user_name
44

55

66
class TestGetUserName:
@@ -111,3 +111,50 @@ def test_returns_none_when_email_missing(self):
111111

112112
result = get_signed_off_by()
113113
assert result is None
114+
115+
116+
class TestGetIssuePattern:
117+
"""Tests for get_issue_pattern function."""
118+
119+
def test_successful_config_read(self):
120+
"""Should return the pattern string when git config succeeds."""
121+
with patch("commit_editor.git.subprocess.run") as mock_run:
122+
mock_run.return_value.stdout = "AIPCC-\\d+\n"
123+
mock_run.return_value.returncode = 0
124+
125+
result = get_issue_pattern()
126+
assert result == "AIPCC-\\d+"
127+
128+
mock_run.assert_called_once_with(
129+
["git", "config", "commit-editor.issue-pattern"],
130+
capture_output=True,
131+
text=True,
132+
check=True,
133+
)
134+
135+
def test_missing_config(self):
136+
"""Should return None when git config is not set."""
137+
with patch("commit_editor.git.subprocess.run") as mock_run:
138+
from subprocess import CalledProcessError
139+
140+
mock_run.side_effect = CalledProcessError(1, "git config")
141+
142+
result = get_issue_pattern()
143+
assert result is None
144+
145+
def test_git_not_found(self):
146+
"""Should return None when git is not installed."""
147+
with patch("commit_editor.git.subprocess.run") as mock_run:
148+
mock_run.side_effect = FileNotFoundError()
149+
150+
result = get_issue_pattern()
151+
assert result is None
152+
153+
def test_empty_config(self):
154+
"""Should return None when git config returns empty string."""
155+
with patch("commit_editor.git.subprocess.run") as mock_run:
156+
mock_run.return_value.stdout = "\n"
157+
mock_run.return_value.returncode = 0
158+
159+
result = get_issue_pattern()
160+
assert result is None

0 commit comments

Comments
 (0)