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.
@@ -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
+ [![PyPI](https://img.shields.io/pypi/v/commit-editor.svg)](https://pypi.python.org/pypi/commit-editor)
30
+ [![Python](https://img.shields.io/pypi/pyversions/commit-editor.svg)](https://pypi.python.org/pypi/commit-editor)
31
+ [![License](https://img.shields.io/pypi/l/commit-editor.svg)](https://github.com/mprpic/commit-editor/blob/main/LICENSE)
32
+ [![CI](https://github.com/mprpic/commit-editor/workflows/CI/badge.svg)](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
+ [![PyPI](https://img.shields.io/pypi/v/commit-editor.svg)](https://pypi.python.org/pypi/commit-editor)
4
+ [![Python](https://img.shields.io/pypi/pyversions/commit-editor.svg)](https://pypi.python.org/pypi/commit-editor)
5
+ [![License](https://img.shields.io/pypi/l/commit-editor.svg)](https://github.com/mprpic/commit-editor/blob/main/LICENSE)
6
+ [![CI](https://github.com/mprpic/commit-editor/workflows/CI/badge.svg)](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,3 @@
1
+ """A terminal-based git commit message editor with opinionated formatting."""
2
+
3
+ __version__ = "0.1.0"
@@ -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