commit-editor 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- commit_editor-0.1.0/PKG-INFO +101 -0
- commit_editor-0.1.0/README.md +75 -0
- commit_editor-0.1.0/pyproject.toml +51 -0
- commit_editor-0.1.0/src/commit_editor/__init__.py +3 -0
- commit_editor-0.1.0/src/commit_editor/app.py +475 -0
- commit_editor-0.1.0/src/commit_editor/cli.py +32 -0
- commit_editor-0.1.0/src/commit_editor/git.py +40 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: commit-editor
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A terminal-based git commit message editor with opinionated formatting
|
|
5
|
+
Author: Martin Prpič
|
|
6
|
+
Author-email: Martin Prpič <martin.prpic@gmail.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Software Development :: Version Control :: Git
|
|
18
|
+
Classifier: Topic :: Text Editors
|
|
19
|
+
Requires-Dist: textual>=0.50.0
|
|
20
|
+
Requires-Dist: pytest ; extra == 'dev'
|
|
21
|
+
Requires-Dist: pytest-asyncio ; extra == 'dev'
|
|
22
|
+
Requires-Dist: ruff ; extra == 'dev'
|
|
23
|
+
Requires-Python: >=3.11
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# Commit Editor
|
|
28
|
+
|
|
29
|
+
[](https://pypi.python.org/pypi/commit-editor)
|
|
30
|
+
[](https://pypi.python.org/pypi/commit-editor)
|
|
31
|
+
[](https://github.com/mprpic/commit-editor/blob/main/LICENSE)
|
|
32
|
+
[](https://github.com/mprpic/commit-editor/actions)
|
|
33
|
+
|
|
34
|
+
An opinionated, terminal-based text editor for git commit messages, built
|
|
35
|
+
with [Textual](https://textual.textualize.io/).
|
|
36
|
+
|
|
37
|
+
- **Title length warning**: Characters beyond position 50 on the first line are highlighted in red
|
|
38
|
+
- **Auto-wrap body text**: Lines in the commit body (line 3+) are automatically wrapped at 72 characters (except for
|
|
39
|
+
long strings that can't be wrapped, such as URLs)
|
|
40
|
+
- **White space**: Trailing white space is automatically stripped when a file is saved; an empty newline is inserted
|
|
41
|
+
at the end of the file if not present.
|
|
42
|
+
- **Signed-off-by toggle**: Quickly add or remove a `Signed-off-by` trailer with a keyboard shortcut
|
|
43
|
+
- **Status bar**: Shows current cursor position (line/column) and title length with warnings
|
|
44
|
+
|
|
45
|
+
## Installation
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
# Using uv
|
|
49
|
+
uv tool install commit-editor
|
|
50
|
+
|
|
51
|
+
# Using pip
|
|
52
|
+
pip install commit-editor
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Requirements
|
|
56
|
+
|
|
57
|
+
- Python 3.11 or later
|
|
58
|
+
- Git (for Signed-off-by functionality)
|
|
59
|
+
|
|
60
|
+
## Usage
|
|
61
|
+
|
|
62
|
+
Configure `commit-editor` as your default git commit message editor:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
git config --global core.editor commit-editor
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
When you run `git commit`, the editor will open automatically.
|
|
69
|
+
|
|
70
|
+
`commit-editor` can also be used as a standalone tool with:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
commit-editor path/to/file.txt
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Keyboard Shortcuts
|
|
77
|
+
|
|
78
|
+
| Shortcut | Action |
|
|
79
|
+
|----------|------------------------------|
|
|
80
|
+
| `Ctrl+S` | Save the file |
|
|
81
|
+
| `Ctrl+Q` | Quit |
|
|
82
|
+
| `Ctrl+O` | Toggle Signed-off-by trailer |
|
|
83
|
+
|
|
84
|
+
## Commit Message Format
|
|
85
|
+
|
|
86
|
+
This editor enforces the widely-accepted git commit message conventions:
|
|
87
|
+
|
|
88
|
+
1. **Title (line 1)**: Should be 50 characters or less; characters beyond 50 are highlighted in red as a warning.
|
|
89
|
+
2. **Blank line (line 2)**: Separates the title from the body.
|
|
90
|
+
3. **Body (line 3+)**: Should wrap at 72 characters; long lines are wrapped automatically as you type.
|
|
91
|
+
|
|
92
|
+
## Future Improvements
|
|
93
|
+
|
|
94
|
+
- Support adding a "Co-authored-by" trailer for AI attribution
|
|
95
|
+
- Word-level spellchecking
|
|
96
|
+
- Config file support (`.commit.toml` project or global level or `pyproject.toml`); support tweaking line length limits
|
|
97
|
+
- Jira (or other issue tracker) ID checking (e.g. title starts with `ABC-123: `)
|
|
98
|
+
|
|
99
|
+
## License
|
|
100
|
+
|
|
101
|
+
MIT
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# Commit Editor
|
|
2
|
+
|
|
3
|
+
[](https://pypi.python.org/pypi/commit-editor)
|
|
4
|
+
[](https://pypi.python.org/pypi/commit-editor)
|
|
5
|
+
[](https://github.com/mprpic/commit-editor/blob/main/LICENSE)
|
|
6
|
+
[](https://github.com/mprpic/commit-editor/actions)
|
|
7
|
+
|
|
8
|
+
An opinionated, terminal-based text editor for git commit messages, built
|
|
9
|
+
with [Textual](https://textual.textualize.io/).
|
|
10
|
+
|
|
11
|
+
- **Title length warning**: Characters beyond position 50 on the first line are highlighted in red
|
|
12
|
+
- **Auto-wrap body text**: Lines in the commit body (line 3+) are automatically wrapped at 72 characters (except for
|
|
13
|
+
long strings that can't be wrapped, such as URLs)
|
|
14
|
+
- **White space**: Trailing white space is automatically stripped when a file is saved; an empty newline is inserted
|
|
15
|
+
at the end of the file if not present.
|
|
16
|
+
- **Signed-off-by toggle**: Quickly add or remove a `Signed-off-by` trailer with a keyboard shortcut
|
|
17
|
+
- **Status bar**: Shows current cursor position (line/column) and title length with warnings
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
# Using uv
|
|
23
|
+
uv tool install commit-editor
|
|
24
|
+
|
|
25
|
+
# Using pip
|
|
26
|
+
pip install commit-editor
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### Requirements
|
|
30
|
+
|
|
31
|
+
- Python 3.11 or later
|
|
32
|
+
- Git (for Signed-off-by functionality)
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
Configure `commit-editor` as your default git commit message editor:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
git config --global core.editor commit-editor
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
When you run `git commit`, the editor will open automatically.
|
|
43
|
+
|
|
44
|
+
`commit-editor` can also be used as a standalone tool with:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
commit-editor path/to/file.txt
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Keyboard Shortcuts
|
|
51
|
+
|
|
52
|
+
| Shortcut | Action |
|
|
53
|
+
|----------|------------------------------|
|
|
54
|
+
| `Ctrl+S` | Save the file |
|
|
55
|
+
| `Ctrl+Q` | Quit |
|
|
56
|
+
| `Ctrl+O` | Toggle Signed-off-by trailer |
|
|
57
|
+
|
|
58
|
+
## Commit Message Format
|
|
59
|
+
|
|
60
|
+
This editor enforces the widely-accepted git commit message conventions:
|
|
61
|
+
|
|
62
|
+
1. **Title (line 1)**: Should be 50 characters or less; characters beyond 50 are highlighted in red as a warning.
|
|
63
|
+
2. **Blank line (line 2)**: Separates the title from the body.
|
|
64
|
+
3. **Body (line 3+)**: Should wrap at 72 characters; long lines are wrapped automatically as you type.
|
|
65
|
+
|
|
66
|
+
## Future Improvements
|
|
67
|
+
|
|
68
|
+
- Support adding a "Co-authored-by" trailer for AI attribution
|
|
69
|
+
- Word-level spellchecking
|
|
70
|
+
- Config file support (`.commit.toml` project or global level or `pyproject.toml`); support tweaking line length limits
|
|
71
|
+
- Jira (or other issue tracker) ID checking (e.g. title starts with `ABC-123: `)
|
|
72
|
+
|
|
73
|
+
## License
|
|
74
|
+
|
|
75
|
+
MIT
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["uv_build>=0.9.28,<0.10.0"]
|
|
3
|
+
build-backend = "uv_build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "commit-editor"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A terminal-based git commit message editor with opinionated formatting"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Martin Prpič", email = "martin.prpic@gmail.com" }
|
|
14
|
+
]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Environment :: Console",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Operating System :: OS Independent",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Topic :: Software Development :: Version Control :: Git",
|
|
26
|
+
"Topic :: Text Editors",
|
|
27
|
+
]
|
|
28
|
+
dependencies = [
|
|
29
|
+
"textual>=0.50.0",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.scripts]
|
|
33
|
+
commit-editor = "commit_editor.cli:main"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
[project.optional-dependencies]
|
|
37
|
+
dev = [
|
|
38
|
+
"pytest",
|
|
39
|
+
"pytest-asyncio",
|
|
40
|
+
"ruff",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
[tool.ruff]
|
|
44
|
+
line-length = 100
|
|
45
|
+
|
|
46
|
+
[tool.ruff.lint]
|
|
47
|
+
select = ["F", "E", "W", "I", "N"]
|
|
48
|
+
|
|
49
|
+
[tool.pytest.ini_options]
|
|
50
|
+
asyncio_mode = "auto"
|
|
51
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from rich.segment import Segment
|
|
4
|
+
from rich.style import Style
|
|
5
|
+
from rich.text import Text
|
|
6
|
+
from textual import on
|
|
7
|
+
from textual.app import App, ComposeResult
|
|
8
|
+
from textual.binding import Binding
|
|
9
|
+
from textual.strip import Strip
|
|
10
|
+
from textual.widgets import Static, TextArea
|
|
11
|
+
|
|
12
|
+
from commit_editor.git import get_signed_off_by
|
|
13
|
+
|
|
14
|
+
TITLE_MAX_LENGTH = 50
|
|
15
|
+
BODY_MAX_LENGTH = 72
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def wrap_line(line: str, width: int = 72) -> list[str]:
|
|
19
|
+
"""Wrap a single line at word boundaries to fit within width.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
line: The line to wrap.
|
|
23
|
+
width: Maximum line width (default 72).
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
List of wrapped lines.
|
|
27
|
+
"""
|
|
28
|
+
if not line:
|
|
29
|
+
return [""]
|
|
30
|
+
|
|
31
|
+
if len(line) <= width:
|
|
32
|
+
return [line]
|
|
33
|
+
|
|
34
|
+
words = line.split(" ")
|
|
35
|
+
lines: list[str] = []
|
|
36
|
+
current_line = ""
|
|
37
|
+
|
|
38
|
+
for word in words:
|
|
39
|
+
if not word:
|
|
40
|
+
# Handle multiple spaces
|
|
41
|
+
if current_line:
|
|
42
|
+
current_line += " "
|
|
43
|
+
continue
|
|
44
|
+
|
|
45
|
+
# Check if word fits on current line
|
|
46
|
+
test_line = f"{current_line} {word}".strip() if current_line else word
|
|
47
|
+
if len(test_line) <= width:
|
|
48
|
+
current_line = test_line
|
|
49
|
+
else:
|
|
50
|
+
if current_line:
|
|
51
|
+
lines.append(current_line)
|
|
52
|
+
current_line = word
|
|
53
|
+
|
|
54
|
+
if current_line:
|
|
55
|
+
lines.append(current_line)
|
|
56
|
+
|
|
57
|
+
return lines if lines else [""]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class CommitTextArea(TextArea):
|
|
61
|
+
"""TextArea with commit-message-specific highlighting and behavior."""
|
|
62
|
+
|
|
63
|
+
DEFAULT_CSS = """
|
|
64
|
+
CommitTextArea {
|
|
65
|
+
border: none;
|
|
66
|
+
padding: 0;
|
|
67
|
+
}
|
|
68
|
+
CommitTextArea:focus {
|
|
69
|
+
border: none;
|
|
70
|
+
}
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def __init__(self, *args, **kwargs):
|
|
74
|
+
super().__init__(*args, **kwargs)
|
|
75
|
+
self._last_body_text = ""
|
|
76
|
+
|
|
77
|
+
def render_line(self, y: int) -> Strip:
|
|
78
|
+
"""Render a line with custom highlighting for title overflow."""
|
|
79
|
+
strip = super().render_line(y)
|
|
80
|
+
|
|
81
|
+
# Only highlight overflow on the first line (title)
|
|
82
|
+
if y == 0:
|
|
83
|
+
lines = self.text.split("\n")
|
|
84
|
+
if lines:
|
|
85
|
+
title = lines[0]
|
|
86
|
+
if len(title) > TITLE_MAX_LENGTH:
|
|
87
|
+
strip = self._highlight_title_overflow(strip, title)
|
|
88
|
+
|
|
89
|
+
return strip
|
|
90
|
+
|
|
91
|
+
@staticmethod
|
|
92
|
+
def _highlight_title_overflow(strip: Strip, title: str) -> Strip:
|
|
93
|
+
"""Apply warning highlighting to title characters beyond 50."""
|
|
94
|
+
warning_style = Style(color="red", bold=True)
|
|
95
|
+
|
|
96
|
+
segments = list(strip)
|
|
97
|
+
new_segments = []
|
|
98
|
+
|
|
99
|
+
char_count = 0
|
|
100
|
+
title_started = False
|
|
101
|
+
|
|
102
|
+
for segment in segments:
|
|
103
|
+
text = segment.text
|
|
104
|
+
style = segment.style
|
|
105
|
+
|
|
106
|
+
if not title_started:
|
|
107
|
+
if text and title and text.strip() and title.startswith(text.strip()[:5]):
|
|
108
|
+
title_started = True
|
|
109
|
+
|
|
110
|
+
if title_started and text:
|
|
111
|
+
new_text_normal = ""
|
|
112
|
+
new_text_warning = ""
|
|
113
|
+
|
|
114
|
+
for char in text:
|
|
115
|
+
if char_count < TITLE_MAX_LENGTH:
|
|
116
|
+
new_text_normal += char
|
|
117
|
+
else:
|
|
118
|
+
new_text_warning += char
|
|
119
|
+
char_count += 1
|
|
120
|
+
|
|
121
|
+
if new_text_normal:
|
|
122
|
+
new_segments.append(Segment(new_text_normal, style))
|
|
123
|
+
if new_text_warning:
|
|
124
|
+
# Combine existing style with warning style
|
|
125
|
+
combined_style = style + warning_style if style else warning_style
|
|
126
|
+
new_segments.append(Segment(new_text_warning, combined_style))
|
|
127
|
+
else:
|
|
128
|
+
new_segments.append(segment)
|
|
129
|
+
|
|
130
|
+
return Strip(new_segments, strip.cell_length)
|
|
131
|
+
|
|
132
|
+
def wrap_current_body_line(self) -> None:
|
|
133
|
+
"""Wrap the current line if it's a body line (line 3+) and exceeds 72 chars."""
|
|
134
|
+
cursor_row, cursor_col = self.cursor_location
|
|
135
|
+
lines = self.text.split("\n")
|
|
136
|
+
|
|
137
|
+
# Only wrap body lines (index 2+, which is line 3+ in 1-indexed)
|
|
138
|
+
if cursor_row < 2 or cursor_row >= len(lines):
|
|
139
|
+
return
|
|
140
|
+
|
|
141
|
+
current_line = lines[cursor_row]
|
|
142
|
+
|
|
143
|
+
# Only wrap if line exceeds the limit
|
|
144
|
+
if len(current_line) <= BODY_MAX_LENGTH:
|
|
145
|
+
return
|
|
146
|
+
|
|
147
|
+
# Wrap the line
|
|
148
|
+
wrapped = wrap_line(current_line, BODY_MAX_LENGTH)
|
|
149
|
+
|
|
150
|
+
if len(wrapped) <= 1:
|
|
151
|
+
return
|
|
152
|
+
|
|
153
|
+
# Replace the current line with wrapped content
|
|
154
|
+
lines[cursor_row : cursor_row + 1] = wrapped
|
|
155
|
+
new_text = "\n".join(lines)
|
|
156
|
+
|
|
157
|
+
# Calculate new cursor position
|
|
158
|
+
# If cursor was beyond the wrap point, move to next line
|
|
159
|
+
if cursor_col > len(wrapped[0]):
|
|
160
|
+
new_row = cursor_row + 1
|
|
161
|
+
new_col = cursor_col - len(wrapped[0]) - 1 # -1 for the space that became newline
|
|
162
|
+
new_col = max(0, min(new_col, len(wrapped[1]) if len(wrapped) > 1 else 0))
|
|
163
|
+
else:
|
|
164
|
+
new_row = cursor_row
|
|
165
|
+
new_col = cursor_col
|
|
166
|
+
|
|
167
|
+
self.load_text(new_text)
|
|
168
|
+
self.cursor_location = (new_row, new_col)
|
|
169
|
+
|
|
170
|
+
def get_title_length(self) -> int:
|
|
171
|
+
"""Get the length of the title (first line)."""
|
|
172
|
+
lines = self.text.split("\n")
|
|
173
|
+
return len(lines[0]) if lines else 0
|
|
174
|
+
|
|
175
|
+
def get_cursor_position(self) -> tuple[int, int]:
|
|
176
|
+
"""Get the current cursor position (1-indexed line, 1-indexed column)."""
|
|
177
|
+
row, col = self.cursor_location
|
|
178
|
+
return row + 1, col + 1
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class StatusBar(Static):
|
|
182
|
+
"""Status bar showing cursor position and title length."""
|
|
183
|
+
|
|
184
|
+
DEFAULT_CSS = """
|
|
185
|
+
StatusBar {
|
|
186
|
+
height: 1;
|
|
187
|
+
background: $primary;
|
|
188
|
+
color: $text;
|
|
189
|
+
padding: 0 1;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
StatusBar .warning {
|
|
193
|
+
color: $error;
|
|
194
|
+
}
|
|
195
|
+
"""
|
|
196
|
+
|
|
197
|
+
def update_status(self, line: int, col: int, title_length: int, dirty: bool) -> None:
|
|
198
|
+
"""Update the status bar content."""
|
|
199
|
+
dirty_marker = " \\[modified]" if dirty else ""
|
|
200
|
+
|
|
201
|
+
if title_length > TITLE_MAX_LENGTH:
|
|
202
|
+
title_display = f"[bold red]Title: {title_length}[/bold red]"
|
|
203
|
+
else:
|
|
204
|
+
title_display = f"Title: {title_length}"
|
|
205
|
+
|
|
206
|
+
left = f"Ln {line}, Col {col} | {title_display}{dirty_marker}"
|
|
207
|
+
hints = "^S Save ^Q Quit ^O Sign-off"
|
|
208
|
+
left_width = len(Text.from_markup(left).plain)
|
|
209
|
+
# Account for padding on both sides
|
|
210
|
+
gap = (self.size.width - 2) - left_width - len(hints)
|
|
211
|
+
if gap >= 2:
|
|
212
|
+
self.update(f"{left}{' ' * gap}[dim]{hints}[/dim]")
|
|
213
|
+
else:
|
|
214
|
+
self.update(left)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class MessageBar(Static):
|
|
218
|
+
"""Message bar for showing status messages and prompts."""
|
|
219
|
+
|
|
220
|
+
DEFAULT_CSS = """
|
|
221
|
+
MessageBar {
|
|
222
|
+
height: 1;
|
|
223
|
+
background: $surface;
|
|
224
|
+
color: $text;
|
|
225
|
+
padding: 0 1;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
MessageBar.error {
|
|
229
|
+
color: $error;
|
|
230
|
+
}
|
|
231
|
+
"""
|
|
232
|
+
|
|
233
|
+
def __init__(self, *args, **kwargs):
|
|
234
|
+
super().__init__(*args, **kwargs)
|
|
235
|
+
self.message = ""
|
|
236
|
+
|
|
237
|
+
def show_message(self, message: str, error: bool = False) -> None:
|
|
238
|
+
"""Show a status message."""
|
|
239
|
+
self.message = message
|
|
240
|
+
if error:
|
|
241
|
+
self.add_class("error")
|
|
242
|
+
else:
|
|
243
|
+
self.remove_class("error")
|
|
244
|
+
self.update(message)
|
|
245
|
+
|
|
246
|
+
def show_prompt(self, message: str) -> None:
|
|
247
|
+
"""Show a prompt message."""
|
|
248
|
+
self.message = message
|
|
249
|
+
self.remove_class("error")
|
|
250
|
+
self.update(message)
|
|
251
|
+
|
|
252
|
+
def clear(self) -> None:
|
|
253
|
+
"""Clear the message bar content."""
|
|
254
|
+
self.message = ""
|
|
255
|
+
self.remove_class("error")
|
|
256
|
+
self.update("")
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class CommitEditorApp(App):
|
|
260
|
+
"""A terminal-based git commit message editor."""
|
|
261
|
+
|
|
262
|
+
TITLE = "commit-editor"
|
|
263
|
+
|
|
264
|
+
BINDINGS = [
|
|
265
|
+
Binding("ctrl+s", "save", "Save", show=True),
|
|
266
|
+
Binding("ctrl+q", "quit_app", "Quit", show=True),
|
|
267
|
+
Binding("ctrl+o", "toggle_signoff", "Sign-off", show=True),
|
|
268
|
+
]
|
|
269
|
+
|
|
270
|
+
DEFAULT_CSS = """
|
|
271
|
+
Screen {
|
|
272
|
+
layout: vertical;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
CommitTextArea {
|
|
276
|
+
height: 1fr;
|
|
277
|
+
}
|
|
278
|
+
"""
|
|
279
|
+
|
|
280
|
+
def __init__(self, filename: Path):
|
|
281
|
+
super().__init__()
|
|
282
|
+
self.filename = filename
|
|
283
|
+
self.dirty = False
|
|
284
|
+
self._original_content = ""
|
|
285
|
+
self._prompt_mode: str | None = None # Track active prompt type
|
|
286
|
+
|
|
287
|
+
def compose(self) -> ComposeResult:
|
|
288
|
+
yield CommitTextArea(id="editor", show_line_numbers=True, highlight_cursor_line=True)
|
|
289
|
+
yield StatusBar(id="status")
|
|
290
|
+
yield MessageBar(id="message")
|
|
291
|
+
|
|
292
|
+
def on_mount(self) -> None:
|
|
293
|
+
"""Load file content on startup."""
|
|
294
|
+
editor = self.query_one("#editor", CommitTextArea)
|
|
295
|
+
|
|
296
|
+
content = self.filename.read_text()
|
|
297
|
+
self._original_content = content
|
|
298
|
+
editor.load_text(content)
|
|
299
|
+
editor.focus()
|
|
300
|
+
|
|
301
|
+
self._update_status_bar()
|
|
302
|
+
|
|
303
|
+
def check_action(self, action: str, parameters: tuple) -> bool | None:
|
|
304
|
+
"""Disable editor actions when in prompt mode."""
|
|
305
|
+
if self._prompt_mode is not None:
|
|
306
|
+
# Allow only prompt-related actions
|
|
307
|
+
if action in ("confirm_quit", "cancel_quit"):
|
|
308
|
+
return True
|
|
309
|
+
return False
|
|
310
|
+
return True
|
|
311
|
+
|
|
312
|
+
def action_confirm_quit(self) -> None:
|
|
313
|
+
"""Confirm quit when prompted."""
|
|
314
|
+
if self._prompt_mode == "quit_confirm":
|
|
315
|
+
self._prompt_mode = None
|
|
316
|
+
self.query_one("#message", MessageBar).clear()
|
|
317
|
+
self.exit()
|
|
318
|
+
|
|
319
|
+
def action_cancel_quit(self) -> None:
|
|
320
|
+
"""Cancel quit when prompted."""
|
|
321
|
+
if self._prompt_mode == "quit_confirm":
|
|
322
|
+
self._prompt_mode = None
|
|
323
|
+
self.query_one("#message", MessageBar).clear()
|
|
324
|
+
editor = self.query_one("#editor", CommitTextArea)
|
|
325
|
+
editor.read_only = False
|
|
326
|
+
editor.focus()
|
|
327
|
+
|
|
328
|
+
def on_key(self, event) -> None:
|
|
329
|
+
"""Handle key events for prompts."""
|
|
330
|
+
if self._prompt_mode == "quit_confirm":
|
|
331
|
+
if event.key == "y":
|
|
332
|
+
event.prevent_default()
|
|
333
|
+
event.stop()
|
|
334
|
+
self.action_confirm_quit()
|
|
335
|
+
elif event.key in ("n", "escape"):
|
|
336
|
+
event.prevent_default()
|
|
337
|
+
event.stop()
|
|
338
|
+
self.action_cancel_quit()
|
|
339
|
+
|
|
340
|
+
@on(CommitTextArea.Changed)
|
|
341
|
+
def on_editor_changed(self, event: CommitTextArea.Changed) -> None:
|
|
342
|
+
"""Handle text changes - update dirty state and wrap body lines."""
|
|
343
|
+
editor = self.query_one("#editor", CommitTextArea)
|
|
344
|
+
self.dirty = editor.text != self._original_content
|
|
345
|
+
|
|
346
|
+
# Auto-wrap body lines
|
|
347
|
+
editor.wrap_current_body_line()
|
|
348
|
+
|
|
349
|
+
# Clear any message when user starts typing
|
|
350
|
+
if self._prompt_mode is None:
|
|
351
|
+
self.query_one("#message", MessageBar).clear()
|
|
352
|
+
|
|
353
|
+
self._update_status_bar()
|
|
354
|
+
|
|
355
|
+
@on(CommitTextArea.SelectionChanged)
|
|
356
|
+
def on_selection_changed(self, event: CommitTextArea.SelectionChanged) -> None:
|
|
357
|
+
"""Update status bar on cursor movement."""
|
|
358
|
+
self._update_status_bar()
|
|
359
|
+
|
|
360
|
+
def _update_status_bar(self) -> None:
|
|
361
|
+
"""Update the status bar with current state."""
|
|
362
|
+
editor = self.query_one("#editor", CommitTextArea)
|
|
363
|
+
status = self.query_one("#status", StatusBar)
|
|
364
|
+
|
|
365
|
+
line, col = editor.get_cursor_position()
|
|
366
|
+
title_length = editor.get_title_length()
|
|
367
|
+
|
|
368
|
+
status.update_status(line, col, title_length, self.dirty)
|
|
369
|
+
|
|
370
|
+
def _show_message(self, message: str, error: bool = False) -> None:
|
|
371
|
+
"""Show a message in the message bar."""
|
|
372
|
+
message_bar = self.query_one("#message", MessageBar)
|
|
373
|
+
message_bar.show_message(message, error=error)
|
|
374
|
+
|
|
375
|
+
def action_save(self) -> None:
|
|
376
|
+
"""Save the file."""
|
|
377
|
+
editor = self.query_one("#editor", CommitTextArea)
|
|
378
|
+
content = editor.text
|
|
379
|
+
|
|
380
|
+
# Strip trailing whitespace from each line and ensure file ends with a newline
|
|
381
|
+
lines = content.split("\n")
|
|
382
|
+
content = "\n".join(line.rstrip() for line in lines)
|
|
383
|
+
if not content.endswith("\n"):
|
|
384
|
+
content += "\n"
|
|
385
|
+
|
|
386
|
+
self.filename.write_text(content)
|
|
387
|
+
self._original_content = content
|
|
388
|
+
self.dirty = False
|
|
389
|
+
|
|
390
|
+
self._update_status_bar()
|
|
391
|
+
self._show_message(f"Saved {self.filename}")
|
|
392
|
+
|
|
393
|
+
def action_quit_app(self) -> None:
|
|
394
|
+
"""Quit the application, prompting if there are unsaved changes."""
|
|
395
|
+
if self.dirty:
|
|
396
|
+
self._prompt_mode = "quit_confirm"
|
|
397
|
+
# Disable editor to prevent key events from being consumed
|
|
398
|
+
editor = self.query_one("#editor", CommitTextArea)
|
|
399
|
+
editor.read_only = True
|
|
400
|
+
message_bar = self.query_one("#message", MessageBar)
|
|
401
|
+
message_bar.show_prompt("Unsaved changes. Quit anyway? (y,n,esc)")
|
|
402
|
+
else:
|
|
403
|
+
self.exit()
|
|
404
|
+
|
|
405
|
+
def action_toggle_signoff(self) -> None:
|
|
406
|
+
"""Toggle the Signed-off-by line."""
|
|
407
|
+
editor = self.query_one("#editor", CommitTextArea)
|
|
408
|
+
text = editor.text
|
|
409
|
+
lines = text.split("\n")
|
|
410
|
+
|
|
411
|
+
signoff = get_signed_off_by()
|
|
412
|
+
if not signoff:
|
|
413
|
+
self._show_message("Git user not configured", error=True)
|
|
414
|
+
return
|
|
415
|
+
|
|
416
|
+
# Find where git comments start (lines starting with #)
|
|
417
|
+
comment_start_index = len(lines)
|
|
418
|
+
for i, line in enumerate(lines):
|
|
419
|
+
if line.startswith("#"):
|
|
420
|
+
comment_start_index = i
|
|
421
|
+
break
|
|
422
|
+
|
|
423
|
+
# Split into content and comments
|
|
424
|
+
content_lines = lines[:comment_start_index]
|
|
425
|
+
comment_lines = lines[comment_start_index:]
|
|
426
|
+
|
|
427
|
+
# Remove trailing empty lines from content for clean processing
|
|
428
|
+
while content_lines and not content_lines[-1].strip():
|
|
429
|
+
content_lines.pop()
|
|
430
|
+
|
|
431
|
+
# Check if Signed-off-by already exists in content
|
|
432
|
+
has_signoff = False
|
|
433
|
+
signoff_index = -1
|
|
434
|
+
|
|
435
|
+
for i in range(len(content_lines) - 1, -1, -1):
|
|
436
|
+
line = content_lines[i]
|
|
437
|
+
if line.startswith("Signed-off-by:"):
|
|
438
|
+
has_signoff = True
|
|
439
|
+
signoff_index = i
|
|
440
|
+
break
|
|
441
|
+
elif line.strip() and not line.startswith("#"):
|
|
442
|
+
# Stop at first non-empty, non-comment, non-signoff line
|
|
443
|
+
break
|
|
444
|
+
|
|
445
|
+
if has_signoff:
|
|
446
|
+
# Remove the Signed-off-by line
|
|
447
|
+
del content_lines[signoff_index]
|
|
448
|
+
# Remove trailing blank lines from content
|
|
449
|
+
while content_lines and not content_lines[-1].strip():
|
|
450
|
+
content_lines.pop()
|
|
451
|
+
else:
|
|
452
|
+
# Add Signed-off-by with blank line if needed
|
|
453
|
+
if content_lines and content_lines[-1].strip():
|
|
454
|
+
content_lines.append("")
|
|
455
|
+
content_lines.append(signoff)
|
|
456
|
+
|
|
457
|
+
# Reassemble: content + blank line (if comments exist) + comments
|
|
458
|
+
if comment_lines:
|
|
459
|
+
# Ensure blank line between content and comments
|
|
460
|
+
new_text = "\n".join(content_lines) + "\n\n" + "\n".join(comment_lines)
|
|
461
|
+
else:
|
|
462
|
+
new_text = "\n".join(content_lines)
|
|
463
|
+
cursor_pos = editor.cursor_location
|
|
464
|
+
|
|
465
|
+
editor.load_text(new_text)
|
|
466
|
+
|
|
467
|
+
# Restore cursor position if possible
|
|
468
|
+
new_lines = new_text.split("\n")
|
|
469
|
+
max_row = len(new_lines) - 1
|
|
470
|
+
new_row = min(cursor_pos[0], max_row)
|
|
471
|
+
max_col = len(new_lines[new_row]) if new_row < len(new_lines) else 0
|
|
472
|
+
new_col = min(cursor_pos[1], max_col)
|
|
473
|
+
editor.cursor_location = (new_row, new_col)
|
|
474
|
+
|
|
475
|
+
self._update_status_bar()
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def main() -> int:
|
|
7
|
+
"""Main entry point for the commit-editor CLI."""
|
|
8
|
+
parser = argparse.ArgumentParser(
|
|
9
|
+
prog="commit-editor",
|
|
10
|
+
description="A terminal-based git commit message editor with opinionated formatting",
|
|
11
|
+
)
|
|
12
|
+
parser.add_argument(
|
|
13
|
+
"filename",
|
|
14
|
+
type=Path,
|
|
15
|
+
help="Path to the file to edit",
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
args = parser.parse_args()
|
|
19
|
+
|
|
20
|
+
if not args.filename.exists():
|
|
21
|
+
print(f"Error: File not found: {args.filename}", file=sys.stderr)
|
|
22
|
+
return 1
|
|
23
|
+
|
|
24
|
+
from commit_editor.app import CommitEditorApp
|
|
25
|
+
|
|
26
|
+
app = CommitEditorApp(args.filename)
|
|
27
|
+
app.run()
|
|
28
|
+
return 0
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
if __name__ == "__main__":
|
|
32
|
+
sys.exit(main())
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def get_user_name() -> str | None:
|
|
5
|
+
try:
|
|
6
|
+
result = subprocess.run(
|
|
7
|
+
["git", "config", "user.name"],
|
|
8
|
+
capture_output=True,
|
|
9
|
+
text=True,
|
|
10
|
+
check=True,
|
|
11
|
+
)
|
|
12
|
+
return result.stdout.strip() or None
|
|
13
|
+
except subprocess.CalledProcessError:
|
|
14
|
+
return None
|
|
15
|
+
except FileNotFoundError:
|
|
16
|
+
return None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_user_email() -> str | None:
|
|
20
|
+
try:
|
|
21
|
+
result = subprocess.run(
|
|
22
|
+
["git", "config", "user.email"],
|
|
23
|
+
capture_output=True,
|
|
24
|
+
text=True,
|
|
25
|
+
check=True,
|
|
26
|
+
)
|
|
27
|
+
return result.stdout.strip() or None
|
|
28
|
+
except subprocess.CalledProcessError:
|
|
29
|
+
return None
|
|
30
|
+
except FileNotFoundError:
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def get_signed_off_by() -> str | None:
|
|
35
|
+
name = get_user_name()
|
|
36
|
+
email = get_user_email()
|
|
37
|
+
|
|
38
|
+
if name and email:
|
|
39
|
+
return f"Signed-off-by: {name} <{email}>"
|
|
40
|
+
return None
|