gitnc 0.1.4__tar.gz → 0.1.6__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.
- {gitnc-0.1.4 → gitnc-0.1.6}/CLAUDE.md +2 -0
- {gitnc-0.1.4 → gitnc-0.1.6}/PKG-INFO +1 -1
- {gitnc-0.1.4 → gitnc-0.1.6}/README.md +7 -0
- {gitnc-0.1.4 → gitnc-0.1.6}/gitnc/app.py +109 -19
- {gitnc-0.1.4 → gitnc-0.1.6}/pyproject.toml +1 -1
- {gitnc-0.1.4 → gitnc-0.1.6}/tests/test_app.py +197 -0
- {gitnc-0.1.4 → gitnc-0.1.6}/.devcontainer/Dockerfile +0 -0
- {gitnc-0.1.4 → gitnc-0.1.6}/.devcontainer/devcontainer.json +0 -0
- {gitnc-0.1.4 → gitnc-0.1.6}/.gitignore +0 -0
- {gitnc-0.1.4 → gitnc-0.1.6}/LICENSE +0 -0
- {gitnc-0.1.4 → gitnc-0.1.6}/gitnc/__init__.py +0 -0
- {gitnc-0.1.4 → gitnc-0.1.6}/gitnc/__main__.py +0 -0
- {gitnc-0.1.4 → gitnc-0.1.6}/gitnc/git.py +0 -0
- {gitnc-0.1.4 → gitnc-0.1.6}/gitnc/terminal.py +0 -0
- {gitnc-0.1.4 → gitnc-0.1.6}/gitnc/themes/__init__.py +0 -0
- {gitnc-0.1.4 → gitnc-0.1.6}/gitnc/themes/atom-one-dark.yaml +0 -0
- {gitnc-0.1.4 → gitnc-0.1.6}/gitnc/themes/atom-one-light.yaml +0 -0
- {gitnc-0.1.4 → gitnc-0.1.6}/gitnc/themes/catppuccin-frappe.yaml +0 -0
- {gitnc-0.1.4 → gitnc-0.1.6}/gitnc/themes/catppuccin-latte.yaml +0 -0
- {gitnc-0.1.4 → gitnc-0.1.6}/gitnc/themes/catppuccin-macchiato.yaml +0 -0
- {gitnc-0.1.4 → gitnc-0.1.6}/gitnc/themes/catppuccin-mocha.yaml +0 -0
- {gitnc-0.1.4 → gitnc-0.1.6}/gitnc/themes/dracula.yaml +0 -0
- {gitnc-0.1.4 → gitnc-0.1.6}/gitnc/themes/flexoki.yaml +0 -0
- {gitnc-0.1.4 → gitnc-0.1.6}/gitnc/themes/gruvbox.yaml +0 -0
- {gitnc-0.1.4 → gitnc-0.1.6}/gitnc/themes/midnight-commander.yaml +0 -0
- {gitnc-0.1.4 → gitnc-0.1.6}/gitnc/themes/monokai.yaml +0 -0
- {gitnc-0.1.4 → gitnc-0.1.6}/gitnc/themes/nord.yaml +0 -0
- {gitnc-0.1.4 → gitnc-0.1.6}/gitnc/themes/rose-pine-dawn.yaml +0 -0
- {gitnc-0.1.4 → gitnc-0.1.6}/gitnc/themes/rose-pine-moon.yaml +0 -0
- {gitnc-0.1.4 → gitnc-0.1.6}/gitnc/themes/rose-pine.yaml +0 -0
- {gitnc-0.1.4 → gitnc-0.1.6}/gitnc/themes/solarized-dark.yaml +0 -0
- {gitnc-0.1.4 → gitnc-0.1.6}/gitnc/themes/solarized-light.yaml +0 -0
- {gitnc-0.1.4 → gitnc-0.1.6}/gitnc/themes/textual-ansi.yaml +0 -0
- {gitnc-0.1.4 → gitnc-0.1.6}/gitnc/themes/textual-dark.yaml +0 -0
- {gitnc-0.1.4 → gitnc-0.1.6}/gitnc/themes/textual-light.yaml +0 -0
- {gitnc-0.1.4 → gitnc-0.1.6}/gitnc/themes/tokyo-night.yaml +0 -0
- {gitnc-0.1.4 → gitnc-0.1.6}/gitnc/ui_theme.py +0 -0
- {gitnc-0.1.4 → gitnc-0.1.6}/tests/__init__.py +0 -0
- {gitnc-0.1.4 → gitnc-0.1.6}/tests/gitprocess.py +0 -0
- {gitnc-0.1.4 → gitnc-0.1.6}/tests/test_git.py +0 -0
- {gitnc-0.1.4 → gitnc-0.1.6}/tests/test_terminal.py +0 -0
- {gitnc-0.1.4 → gitnc-0.1.6}/tests/test_themes.py +0 -0
|
@@ -62,6 +62,8 @@ Menu chrome is composed once and stays mounted, so `action_set_theme` / `_load_s
|
|
|
62
62
|
|
|
63
63
|
A message prefixed with a branch the settings warn against gets a question of its own first: `_commit_result()` goes through `_confirm_commit_prefix()`, which — when the message starts with `<name>:` for a name in the `commit_prefix_warn_branches` setting — asks `Warning: commit message is prefixed with branch '…'. Continue?` over the message's subject line, and only on Y moves on to `Commit N file(s)?`. N reopens the message, the same way back the commit confirmation offers. It is a separate dialog rather than a row under the commands because a row only has to be overlooked. The setting is the *Warn on prefix* text row under the branch-prefix checkbox, a space- or comma-separated list read by `_branch_names()`. The check is on the message rather than the checked-out branch, because the prefill is editable and the prefix can have been typed by hand, and it comes after the editor because that is where the message is final whichever editor wrote it. `_begin_commit_message()` raises the same text — both come from `_commit_prefix_warning()` — as a warning notification when the prefill it is about to open already carries a listed prefix, so the user hears about it before writing the message around it.
|
|
64
64
|
|
|
65
|
+
The row under it, *Warn on prefix not matching* (`commit_prefix_require_pattern`), is the opposite check through the same two places: an optional regular expression the message has to *start with*. `_starts_with_pattern()` is `re.match`, which anchors at the start and stops where the pattern does, so the setting describes the prefix alone — no `^`, no trailing `.*`. `_commit_prefix_warning()` collects both checks into one sentence (`… is prefixed with branch 'x' and does not start with a prefix matching '…'`), so a message that trips both is still asked about once. The pattern is quoted through `_escape_markup()`, because the confirm prompt and the toast both render markup and a character class would be read as a tag. A pattern that doesn't compile is reported as a warning of its own rather than skipped — a check that silently stopped checking looks exactly like a message that passed — and `SettingsDialog._prefix_pattern_hint()` says so under the row while it is being typed. It says so *beside* the help rather than in place of it: a pattern is invalid for most of the time it takes to type one (`[` already is), which is when the example is being read, and the help is two fixed lines so the dialog doesn't change height as the pattern passes in and out of compiling. Up front the pattern is only applied to a non-empty prefill: with the branch-prefix setting off nothing has been written yet, and the warning would fire on every commit.
|
|
66
|
+
|
|
65
67
|
Cancelling the message dialog rolls the index back to where the flow found it. Both halves of the mutation are undone: `_restore_externals()` re-stages the files it unstaged, and `_unstage_pending()` unstages what the stage confirmation added, from the `_pending_commit_staged` list `_begin_commit_message()` was handed. Without the second half the files stayed staged, so a second `C` on the same selection found nothing left to stage and skipped straight past `Stage N file(s) for commit?` — the question never came back. Only entries that had *nothing* staged before go on that list (`not code.has_staged_changes`): an entry that was already partly staged had its index state built by the user, and `git restore --staged` would throw that away instead of putting it back. `_commit_submit()` clears the list without running it — the files it staged are the files it just committed.
|
|
66
68
|
|
|
67
69
|
**Writing the message in an external editor:** the Options → Settings section *Commit message editor* is a three-row radio group — `SETTINGS_EDITOR_ROWS` maps each row to `COMMIT_EDITOR_BUILTIN` / `_ENVIRONMENT` / `_COMMAND`, Space picks one, and typing into the `Command:` row under it selects the command option — stored as `commit_editor` plus `commit_editor_command`. Every place the flow used to push `CommitDialog` for a message — `_begin_commit_message()` and the `on_cancel` of `_confirm_commit()` — now goes through `_edit_commit_message()`. That asks `_commit_editor_argv()` for a command line (`$EDITOR`, or the setting, split with `shlex`; `None` means the built-in dialog) and runs it with `_commit_in_external_editor()`: suspended on the previous screen like the drafting tool, with `run_commit_editor()` writing `commit_editor_template()` to a temporary `COMMIT_EDITMSG` (git's name, which is what puts editors in git-commit mode), running `<argv> <path>` and reading the file back through `strip_commit_editor_text()`. The help text and file list sit under git's scissors line (`COMMIT_EDITOR_SCISSORS`) rather than on `#` lines, so a message line starting with `#` survives. The result feeds `_commit_result()` exactly as the dialog's would: an empty message, one that is only the `branch:` prefix, or a failing exit status (`EditorResult.message is None`) cancels; anything else goes to the confirmation. Anything that stops the editor from running at all — unset `$EDITOR`, an empty or unparseable command, `EditorResult.started` false, a terminal that won't suspend — notifies and falls back to `CommitDialog`, so a bad setting never costs the user the commit. `KeyboardInterrupt` out of the editor is read as abandoning it, for the same reason as in the drafting tool. The Draft button lives in `CommitDialog`, so drafting isn't reachable with an external editor.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: gitnc
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.6
|
|
4
4
|
Summary: GitNightCommander — a TUI git log browser built with Textual
|
|
5
5
|
Project-URL: Homepage, https://github.com/thomashyldgaard/GitNightCommander
|
|
6
6
|
Project-URL: Source, https://github.com/thomashyldgaard/GitNightCommander
|
|
@@ -279,6 +279,13 @@ Options -> Settings configures the things it uses:
|
|
|
279
279
|
`Commit N file(s)?` one: `Y` continues, `N` puts you back in the message to
|
|
280
280
|
change it. When the branch prefix setting prefills such a name, the same
|
|
281
281
|
warning also pops up as the message editor opens.
|
|
282
|
+
- **Warn on prefix not matching** — optional; a regular expression the commit
|
|
283
|
+
message must start with (e.g. `[A-Z]+-\d+:` for a ticket number). It describes
|
|
284
|
+
the prefix only — it is matched at the start of the message, so no `^` and no
|
|
285
|
+
trailing `.*` are needed. A message that does not start with a match gets the
|
|
286
|
+
same confirmation and the same up-front warning as **Warn on prefix**; when
|
|
287
|
+
both settings object, the one question names both. A pattern that does not
|
|
288
|
+
compile is flagged in the settings dialog and reported in that question.
|
|
282
289
|
- **Commit message editor** — where the message is written:
|
|
283
290
|
- **Built-in editor** (the default) is the commit dialog described above.
|
|
284
291
|
- **$EDITOR** runs the editor named by the `EDITOR` environment variable;
|
|
@@ -728,14 +728,29 @@ def _strip_markup(text: str) -> str:
|
|
|
728
728
|
return re.sub(r"\[/?[^\[\]]*\]", "", text)
|
|
729
729
|
|
|
730
730
|
|
|
731
|
+
# A zero-width space: takes no cell on screen, and is enough to stop a
|
|
732
|
+
# backslash from escaping the tag that follows it. See `_escape_markup()`.
|
|
733
|
+
MARKUP_BACKSLASH_GUARD = "\u200b"
|
|
734
|
+
|
|
735
|
+
|
|
731
736
|
def _escape_markup(text: str) -> str:
|
|
732
737
|
"""`text` rendered literally, i.e. with its markup tags neutralised.
|
|
733
738
|
|
|
734
739
|
Settings values are typed by the user and drawn into a markup-rendering
|
|
735
740
|
Static, so a prompt template mentioning "[files]" would otherwise be read
|
|
736
741
|
as a style tag and swallowed.
|
|
742
|
+
|
|
743
|
+
A backslash at the end gets `MARKUP_BACKSLASH_GUARD` after it: whatever
|
|
744
|
+
the caller puts next is usually a tag — the caret's, a closing one — and
|
|
745
|
+
a "[" behind a backslash is never read as a tag, so the value's last
|
|
746
|
+
character would otherwise escape the caller's markup. Textual has no
|
|
747
|
+
escape for the backslash itself, hence a separator rather than a second
|
|
748
|
+
backslash.
|
|
737
749
|
"""
|
|
738
|
-
|
|
750
|
+
escaped = text.replace("[", "\\[")
|
|
751
|
+
if escaped.endswith("\\"):
|
|
752
|
+
escaped += MARKUP_BACKSLASH_GUARD
|
|
753
|
+
return escaped
|
|
739
754
|
|
|
740
755
|
|
|
741
756
|
# One word of a settings field plus the spaces that follow it, or a run of
|
|
@@ -831,6 +846,25 @@ def _warned_commit_prefix(message: str, branches: Iterable[str]) -> str:
|
|
|
831
846
|
return next((branch for branch in branches if message.startswith(f"{branch}:")), "")
|
|
832
847
|
|
|
833
848
|
|
|
849
|
+
def _is_valid_pattern(pattern: str) -> bool:
|
|
850
|
+
"""Whether `pattern` compiles as a regular expression."""
|
|
851
|
+
try:
|
|
852
|
+
re.compile(pattern)
|
|
853
|
+
except re.error:
|
|
854
|
+
return False
|
|
855
|
+
return True
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
def _starts_with_pattern(message: str, pattern: str) -> bool:
|
|
859
|
+
"""Whether `message` starts with something `pattern` matches.
|
|
860
|
+
|
|
861
|
+
`re.match` anchors at the start and stops where the pattern does, so the
|
|
862
|
+
setting describes the prefix alone — no `^` in front, and no `.*` behind
|
|
863
|
+
it for the rest of the message.
|
|
864
|
+
"""
|
|
865
|
+
return re.match(pattern, message) is not None
|
|
866
|
+
|
|
867
|
+
|
|
834
868
|
def _setting_str(settings: Mapping[str, object], key: str, default: str) -> str:
|
|
835
869
|
"""`settings[key]` when it holds a string, else `default`.
|
|
836
870
|
|
|
@@ -914,6 +948,7 @@ class SettingsValues(NamedTuple):
|
|
|
914
948
|
editor: str = COMMIT_EDITOR_BUILTIN
|
|
915
949
|
editor_command: str = ""
|
|
916
950
|
prefix_warn_branches: str = ""
|
|
951
|
+
prefix_require_pattern: str = ""
|
|
917
952
|
|
|
918
953
|
|
|
919
954
|
# Rows of the settings dialog, in the order they are drawn. The checkbox rows
|
|
@@ -924,14 +959,15 @@ class SettingsValues(NamedTuple):
|
|
|
924
959
|
SETTINGS_ROW_BRANCH = 0
|
|
925
960
|
SETTINGS_ROW_PREFIX = 1
|
|
926
961
|
SETTINGS_ROW_PREFIX_WARN = 2
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
962
|
+
SETTINGS_ROW_PREFIX_PATTERN = 3
|
|
963
|
+
SETTINGS_ROW_EDITOR_BUILTIN = 4
|
|
964
|
+
SETTINGS_ROW_EDITOR_ENVIRONMENT = 5
|
|
965
|
+
SETTINGS_ROW_EDITOR_COMMAND = 6
|
|
966
|
+
SETTINGS_ROW_EDITOR_COMMAND_LINE = 7
|
|
967
|
+
SETTINGS_ROW_COMMAND = 8
|
|
968
|
+
SETTINGS_ROW_PROMPT = 9
|
|
969
|
+
SETTINGS_ROW_PARSE = 10
|
|
970
|
+
SETTINGS_ROW_COUNT = 11
|
|
935
971
|
|
|
936
972
|
# Which editor each radio row selects.
|
|
937
973
|
SETTINGS_EDITOR_ROWS: dict[int, str] = {
|
|
@@ -957,6 +993,8 @@ SETTINGS_FIELD_MAX_LINES = 8
|
|
|
957
993
|
# field, which is the widest thing the dialog draws (its four-cell indent
|
|
958
994
|
# plus the frame's two borders).
|
|
959
995
|
SETTINGS_SECTION_WIDTH = 4 + SETTINGS_FIELD_WIDTH + 2
|
|
996
|
+
# Shown beside the required-prefix help while the pattern doesn't compile.
|
|
997
|
+
PREFIX_PATTERN_INVALID = "not a valid regular expression"
|
|
960
998
|
|
|
961
999
|
|
|
962
1000
|
def _dialog_theme(screen: ModalScreen) -> UITheme:
|
|
@@ -1303,6 +1341,7 @@ class SettingsDialog(ModalScreen["SettingsValues | None"]):
|
|
|
1303
1341
|
TEXT_ROWS: ClassVar[dict[int, str]] = {
|
|
1304
1342
|
SETTINGS_ROW_BRANCH: "_default_branch",
|
|
1305
1343
|
SETTINGS_ROW_PREFIX_WARN: "_prefix_warn_branches",
|
|
1344
|
+
SETTINGS_ROW_PREFIX_PATTERN: "_prefix_require_pattern",
|
|
1306
1345
|
SETTINGS_ROW_EDITOR_COMMAND_LINE: "_editor_command",
|
|
1307
1346
|
SETTINGS_ROW_COMMAND: "_draft_command",
|
|
1308
1347
|
SETTINGS_ROW_PROMPT: "_draft_prompt",
|
|
@@ -1323,12 +1362,14 @@ class SettingsDialog(ModalScreen["SettingsValues | None"]):
|
|
|
1323
1362
|
editor: str = COMMIT_EDITOR_BUILTIN,
|
|
1324
1363
|
editor_command: str = "",
|
|
1325
1364
|
prefix_warn_branches: str = "",
|
|
1365
|
+
prefix_require_pattern: str = "",
|
|
1326
1366
|
) -> None:
|
|
1327
1367
|
super().__init__()
|
|
1328
1368
|
self._cursor: int = 0
|
|
1329
1369
|
self._default_branch: str = default_branch
|
|
1330
1370
|
self._branch_prefix: bool = branch_prefix
|
|
1331
1371
|
self._prefix_warn_branches: str = prefix_warn_branches
|
|
1372
|
+
self._prefix_require_pattern: str = prefix_require_pattern
|
|
1332
1373
|
self._draft_command: str = draft_command
|
|
1333
1374
|
self._draft_prompt: str = draft_prompt
|
|
1334
1375
|
self._parse_suggestions: bool = parse_suggestions
|
|
@@ -1549,6 +1590,10 @@ class SettingsDialog(ModalScreen["SettingsValues | None"]):
|
|
|
1549
1590
|
" (branch names, separated by spaces, e.g. master main)"
|
|
1550
1591
|
f"[/{theme.dialog_hint}]"
|
|
1551
1592
|
)
|
|
1593
|
+
lines.append(
|
|
1594
|
+
self._text_row(SETTINGS_ROW_PREFIX_PATTERN, " Warn on prefix not matching: ")
|
|
1595
|
+
)
|
|
1596
|
+
lines.extend(self._prefix_pattern_hint())
|
|
1552
1597
|
lines.append("")
|
|
1553
1598
|
lines.append(self._section_header("Commit message editor"))
|
|
1554
1599
|
lines.append("")
|
|
@@ -1595,6 +1640,29 @@ class SettingsDialog(ModalScreen["SettingsValues | None"]):
|
|
|
1595
1640
|
# values are already recorded, so there is simply nothing to paint.
|
|
1596
1641
|
pass
|
|
1597
1642
|
|
|
1643
|
+
def _prefix_pattern_hint(self) -> list[str]:
|
|
1644
|
+
"""The lines under the required-prefix row: what goes in it, and —
|
|
1645
|
+
added to that rather than put in its place — that what is in it won't
|
|
1646
|
+
compile, said here, while it is being typed, rather than first at the
|
|
1647
|
+
next commit.
|
|
1648
|
+
|
|
1649
|
+
The help stays because a pattern is invalid for most of the time it
|
|
1650
|
+
takes to type one ("[" already is), which is exactly when the example
|
|
1651
|
+
is being read. It is two fixed lines for the same reason: left to
|
|
1652
|
+
wrap, the dialog grew and shrank a row as the pattern passed in and
|
|
1653
|
+
out of compiling.
|
|
1654
|
+
"""
|
|
1655
|
+
theme = _dialog_theme(self)
|
|
1656
|
+
hint = theme.dialog_hint
|
|
1657
|
+
example = _escape_markup(r"[A-Z]+-\d+:")
|
|
1658
|
+
invalid = ""
|
|
1659
|
+
if not _is_valid_pattern(self._prefix_require_pattern):
|
|
1660
|
+
invalid = f" [{theme.warning_text}]{PREFIX_PATTERN_INVALID}[/{theme.warning_text}]"
|
|
1661
|
+
return [
|
|
1662
|
+
f"[{hint}] (optional regular expression for the start of the message,[/{hint}]",
|
|
1663
|
+
f"[{hint}] e.g. {example})[/{hint}]{invalid}",
|
|
1664
|
+
]
|
|
1665
|
+
|
|
1598
1666
|
def _key_hint(self) -> str:
|
|
1599
1667
|
"""The key line under the dialog, which is not the same in a framed
|
|
1600
1668
|
field: the arrows belong to the caret there, and Tab is what leaves."""
|
|
@@ -1628,6 +1696,7 @@ class SettingsDialog(ModalScreen["SettingsValues | None"]):
|
|
|
1628
1696
|
editor=self._editor,
|
|
1629
1697
|
editor_command=self._editor_command,
|
|
1630
1698
|
prefix_warn_branches=self._prefix_warn_branches,
|
|
1699
|
+
prefix_require_pattern=self._prefix_require_pattern,
|
|
1631
1700
|
)
|
|
1632
1701
|
|
|
1633
1702
|
|
|
@@ -4051,7 +4120,9 @@ class GitNightCommanderApp(App):
|
|
|
4051
4120
|
self._pending_commit_targets = list(targets)
|
|
4052
4121
|
branch = self._commit_branch_prefix()
|
|
4053
4122
|
prefill = f"{branch}: " if branch else ""
|
|
4054
|
-
|
|
4123
|
+
# Only a prefill is checked: with nothing written yet there is no
|
|
4124
|
+
# prefix to judge, and the required pattern would fail every time.
|
|
4125
|
+
warning = self._commit_prefix_warning(prefill) if prefill else ""
|
|
4055
4126
|
if warning:
|
|
4056
4127
|
# Up front as well as in the question asked after the message:
|
|
4057
4128
|
# the prefix is already known here, and this is before the
|
|
@@ -4333,8 +4404,9 @@ class GitNightCommanderApp(App):
|
|
|
4333
4404
|
self._confirm_commit_prefix(message)
|
|
4334
4405
|
|
|
4335
4406
|
def _confirm_commit_prefix(self, message: str) -> None:
|
|
4336
|
-
"""Ask about a
|
|
4337
|
-
|
|
4407
|
+
"""Ask about a prefix the settings warn against — a listed branch,
|
|
4408
|
+
or one the required pattern doesn't match — before the commit
|
|
4409
|
+
confirmation rather than as a row inside it.
|
|
4338
4410
|
|
|
4339
4411
|
A question of its own has to be answered; a row under a list of
|
|
4340
4412
|
commands only has to be overlooked. It comes here rather than on the
|
|
@@ -4381,19 +4453,35 @@ class GitNightCommanderApp(App):
|
|
|
4381
4453
|
)
|
|
4382
4454
|
|
|
4383
4455
|
def _commit_prefix_warning(self, message: str) -> str:
|
|
4384
|
-
"""The warning for a message
|
|
4385
|
-
|
|
4456
|
+
"""The warning for a message whose prefix the settings object to, or
|
|
4457
|
+
"" when they don't: it starts with a branch named in
|
|
4458
|
+
`commit_prefix_warn_branches`, or it doesn't start with what the
|
|
4459
|
+
`commit_prefix_require_pattern` regular expression asks for. Both
|
|
4460
|
+
are reported when both apply.
|
|
4386
4461
|
|
|
4387
4462
|
One text for the notification the message editor opens with and the
|
|
4388
4463
|
question asked once the message is written, so the two can't drift.
|
|
4464
|
+
The pattern is quoted with its brackets escaped, because both of
|
|
4465
|
+
those render markup and a character class would be read as a tag.
|
|
4389
4466
|
"""
|
|
4390
|
-
|
|
4391
|
-
|
|
4392
|
-
)
|
|
4467
|
+
settings = self._load_settings()
|
|
4468
|
+
problems: list[str] = []
|
|
4469
|
+
branches = _branch_names(_setting_str(settings, "commit_prefix_warn_branches", ""))
|
|
4393
4470
|
branch = _warned_commit_prefix(message, branches)
|
|
4394
|
-
if
|
|
4471
|
+
if branch:
|
|
4472
|
+
problems.append(f"is prefixed with branch '{branch}'")
|
|
4473
|
+
pattern = _setting_str(settings, "commit_prefix_require_pattern", "")
|
|
4474
|
+
if pattern.strip():
|
|
4475
|
+
quoted = _escape_markup(pattern)
|
|
4476
|
+
if not _is_valid_pattern(pattern):
|
|
4477
|
+
# Said rather than skipped: a check that silently stopped
|
|
4478
|
+
# checking would look exactly like a message that passed.
|
|
4479
|
+
problems.append(f"can't be checked against invalid pattern '{quoted}'")
|
|
4480
|
+
elif not _starts_with_pattern(message, pattern):
|
|
4481
|
+
problems.append(f"does not start with a prefix matching '{quoted}'")
|
|
4482
|
+
if not problems:
|
|
4395
4483
|
return ""
|
|
4396
|
-
return f"Warning: commit message
|
|
4484
|
+
return f"Warning: commit message {' and '.join(problems)}"
|
|
4397
4485
|
|
|
4398
4486
|
def _commit_submit(self, message: str) -> None:
|
|
4399
4487
|
result = self.git.commit_with_selection(self._pending_commit_targets, message)
|
|
@@ -4656,6 +4744,7 @@ class GitNightCommanderApp(App):
|
|
|
4656
4744
|
editor=_setting_str(settings, "commit_editor", COMMIT_EDITOR_BUILTIN),
|
|
4657
4745
|
editor_command=_setting_str(settings, "commit_editor_command", ""),
|
|
4658
4746
|
prefix_warn_branches=_setting_str(settings, "commit_prefix_warn_branches", ""),
|
|
4747
|
+
prefix_require_pattern=_setting_str(settings, "commit_prefix_require_pattern", ""),
|
|
4659
4748
|
),
|
|
4660
4749
|
callback=self._settings_result,
|
|
4661
4750
|
)
|
|
@@ -4674,6 +4763,7 @@ class GitNightCommanderApp(App):
|
|
|
4674
4763
|
settings["commit_editor"] = values.editor
|
|
4675
4764
|
settings["commit_editor_command"] = values.editor_command
|
|
4676
4765
|
settings["commit_prefix_warn_branches"] = values.prefix_warn_branches
|
|
4766
|
+
settings["commit_prefix_require_pattern"] = values.prefix_require_pattern
|
|
4677
4767
|
if not self._save_settings(settings):
|
|
4678
4768
|
self.notify("Settings saved (could not write to disk)")
|
|
4679
4769
|
else:
|
|
@@ -346,6 +346,7 @@ try:
|
|
|
346
346
|
SETTINGS_ROW_EDITOR_COMMAND_LINE,
|
|
347
347
|
SETTINGS_ROW_EDITOR_ENVIRONMENT,
|
|
348
348
|
SETTINGS_ROW_PARSE,
|
|
349
|
+
SETTINGS_ROW_PREFIX_PATTERN,
|
|
349
350
|
SETTINGS_ROW_PREFIX_WARN,
|
|
350
351
|
SETTINGS_ROW_PROMPT,
|
|
351
352
|
SETTINGS_SECTION_WIDTH,
|
|
@@ -442,6 +443,7 @@ except ModuleNotFoundError as exc:
|
|
|
442
443
|
SETTINGS_ROW_EDITOR_COMMAND_LINE,
|
|
443
444
|
SETTINGS_ROW_EDITOR_ENVIRONMENT,
|
|
444
445
|
SETTINGS_ROW_PARSE,
|
|
446
|
+
SETTINGS_ROW_PREFIX_PATTERN,
|
|
445
447
|
SETTINGS_ROW_PREFIX_WARN,
|
|
446
448
|
SETTINGS_ROW_PROMPT,
|
|
447
449
|
SETTINGS_SECTION_WIDTH,
|
|
@@ -3426,6 +3428,64 @@ class TestSettingsDialog(unittest.TestCase):
|
|
|
3426
3428
|
|
|
3427
3429
|
self.assertEqual(saved["commit_prefix_warn_branches"], "master main")
|
|
3428
3430
|
|
|
3431
|
+
def test_the_required_prefix_pattern_is_typed_under_the_warned_prefixes(self):
|
|
3432
|
+
dialog = SettingsDialog(prefix_require_pattern="[A-Z]+")
|
|
3433
|
+
for _ in range(SETTINGS_ROW_PREFIX_PATTERN):
|
|
3434
|
+
dialog.on_key(_key_event("down"))
|
|
3435
|
+
self.assertEqual(dialog._cursor, SETTINGS_ROW_PREFIX_PATTERN)
|
|
3436
|
+
self.assertEqual(SETTINGS_ROW_PREFIX_PATTERN, SETTINGS_ROW_PREFIX_WARN + 1)
|
|
3437
|
+
|
|
3438
|
+
for key, character in (("minus", "-"), ("1", "1")):
|
|
3439
|
+
dialog.on_key(_key_event(key, character=character))
|
|
3440
|
+
|
|
3441
|
+
self.assertEqual(dialog.get_values().prefix_require_pattern, "[A-Z]+-1")
|
|
3442
|
+
self.assertEqual(dialog.get_values().prefix_warn_branches, "")
|
|
3443
|
+
|
|
3444
|
+
def test_the_required_prefix_pattern_hint_says_when_it_does_not_compile(self):
|
|
3445
|
+
invalid = _app_module.PREFIX_PATTERN_INVALID
|
|
3446
|
+
for pattern in ("", "[A-Z]+:"):
|
|
3447
|
+
hint = "\n".join(SettingsDialog(prefix_require_pattern=pattern)._prefix_pattern_hint())
|
|
3448
|
+
self.assertIn("optional", hint)
|
|
3449
|
+
self.assertNotIn(invalid, hint)
|
|
3450
|
+
self.assertIn(
|
|
3451
|
+
invalid, "\n".join(SettingsDialog(prefix_require_pattern="[A-Z")._prefix_pattern_hint())
|
|
3452
|
+
)
|
|
3453
|
+
|
|
3454
|
+
def test_the_required_prefix_pattern_help_stays_while_the_pattern_is_invalid(self):
|
|
3455
|
+
"""A pattern starting with "[" doesn't compile until its "]" is typed,
|
|
3456
|
+
and the example used to be replaced by the complaint for all of that
|
|
3457
|
+
time — the help went away just as it was being followed."""
|
|
3458
|
+
valid = SettingsDialog()._prefix_pattern_hint()
|
|
3459
|
+
invalid = SettingsDialog(prefix_require_pattern="[")._prefix_pattern_hint()
|
|
3460
|
+
|
|
3461
|
+
# Same number of rows either way, so the dialog doesn't change height.
|
|
3462
|
+
self.assertEqual(len(invalid), len(valid))
|
|
3463
|
+
self.assertEqual(invalid[:-1], valid[:-1])
|
|
3464
|
+
self.assertTrue(invalid[-1].startswith(valid[-1]))
|
|
3465
|
+
for line in invalid:
|
|
3466
|
+
self.assertLessEqual(
|
|
3467
|
+
len(_app_module._strip_markup(line.replace("\\[", ""))),
|
|
3468
|
+
_app_module.SETTINGS_SECTION_WIDTH,
|
|
3469
|
+
)
|
|
3470
|
+
|
|
3471
|
+
def test_the_required_prefix_pattern_round_trips_through_the_settings_file(self):
|
|
3472
|
+
app = _mock_app()
|
|
3473
|
+
with patch.object(
|
|
3474
|
+
app, "_load_settings", return_value={"commit_prefix_require_pattern": r"[A-Z]+-\d+:"}
|
|
3475
|
+
):
|
|
3476
|
+
app.action_settings()
|
|
3477
|
+
values = _pushed_screen(app).get_values()
|
|
3478
|
+
self.assertEqual(values.prefix_require_pattern, r"[A-Z]+-\d+:")
|
|
3479
|
+
|
|
3480
|
+
saved: dict[str, object] = {}
|
|
3481
|
+
with (
|
|
3482
|
+
patch.object(app, "_load_settings", return_value={}),
|
|
3483
|
+
patch.object(app, "_save_settings", side_effect=lambda s: saved.update(s) or True),
|
|
3484
|
+
):
|
|
3485
|
+
app._settings_result(values)
|
|
3486
|
+
|
|
3487
|
+
self.assertEqual(saved["commit_prefix_require_pattern"], r"[A-Z]+-\d+:")
|
|
3488
|
+
|
|
3429
3489
|
def test_cursor_wraps_over_every_row(self):
|
|
3430
3490
|
dialog = SettingsDialog()
|
|
3431
3491
|
|
|
@@ -3667,6 +3727,30 @@ class TestSettingsDialog(unittest.TestCase):
|
|
|
3667
3727
|
dialog.on_key(_key_event("d", character="d"))
|
|
3668
3728
|
self.assertEqual(dialog.get_values().draft_prompt, "abdc")
|
|
3669
3729
|
|
|
3730
|
+
def test_a_backslash_does_not_escape_the_tag_that_follows_it(self):
|
|
3731
|
+
"""A regex ends in a backslash halfway through typing `\\d`, and the
|
|
3732
|
+
caret's tag comes right after it: unguarded, the backslash escaped
|
|
3733
|
+
that tag and its closing half had nothing to close — a MarkupError
|
|
3734
|
+
that took the app down on the keypress."""
|
|
3735
|
+
escape = _app_module._escape_markup
|
|
3736
|
+
guard = _app_module.MARKUP_BACKSLASH_GUARD
|
|
3737
|
+
|
|
3738
|
+
self.assertEqual(escape("[A-Z]+-\\"), f"\\[A-Z]+-\\{guard}")
|
|
3739
|
+
# Only the end needs it: inside the value the next character is text.
|
|
3740
|
+
self.assertEqual(escape("\\d+:"), "\\d+:")
|
|
3741
|
+
for value, column in (("\\", 1), ("\\", 0), ("a\\b", 1), ("a\\b\\", 4)):
|
|
3742
|
+
markup = _app_module._caret_markup(value, column=column, style="reverse")
|
|
3743
|
+
self.assertNotIn("\\[", markup)
|
|
3744
|
+
try:
|
|
3745
|
+
from textual.content import Content
|
|
3746
|
+
except ImportError:
|
|
3747
|
+
continue
|
|
3748
|
+
# The real parser, when it is installed: the tags are read as
|
|
3749
|
+
# tags, and the guard costs no cell.
|
|
3750
|
+
content = Content.from_markup(markup)
|
|
3751
|
+
self.assertEqual(content.plain.replace(guard, ""), value.ljust(column + 1))
|
|
3752
|
+
self.assertEqual(content.cell_length, max(len(value), column + 1))
|
|
3753
|
+
|
|
3670
3754
|
def test_backspace_takes_the_character_in_front_of_the_caret(self):
|
|
3671
3755
|
dialog = SettingsDialog(draft_prompt="abc")
|
|
3672
3756
|
self._focus_prompt(dialog)
|
|
@@ -5324,6 +5408,119 @@ class TestCommitFlow(unittest.TestCase):
|
|
|
5324
5408
|
self.assertEqual(self._commit_screen(dialogs).prefill, "feature: ")
|
|
5325
5409
|
notify.assert_not_called()
|
|
5326
5410
|
|
|
5411
|
+
def _pattern_app(self, pattern: str, **settings: Any):
|
|
5412
|
+
entry = GitEntry(GitCode.MODIFIED_UNMODIFIED, "pkg/a.py")
|
|
5413
|
+
app, dialogs, _ = self._app([entry], staged={"pkg/a.py"})
|
|
5414
|
+
app._load_settings = MagicMock(
|
|
5415
|
+
return_value={"commit_prefix_require_pattern": pattern, **settings}
|
|
5416
|
+
)
|
|
5417
|
+
app.action_commit_files()
|
|
5418
|
+
return app, dialogs
|
|
5419
|
+
|
|
5420
|
+
def test_a_message_missing_the_required_prefix_is_asked_about(self):
|
|
5421
|
+
app, dialogs = self._pattern_app(r"[A-Z]+-\d+:")
|
|
5422
|
+
|
|
5423
|
+
dialogs._resolve("fix x")
|
|
5424
|
+
|
|
5425
|
+
self.assertEqual(
|
|
5426
|
+
dialogs.prompt(),
|
|
5427
|
+
"Warning: commit message does not start with a prefix matching"
|
|
5428
|
+
r" '\[A-Z]+-\d+:'. Continue?",
|
|
5429
|
+
)
|
|
5430
|
+
self.assertEqual(dialogs.commands(), ["fix x"])
|
|
5431
|
+
app.git.commit.assert_not_called()
|
|
5432
|
+
|
|
5433
|
+
def test_declining_the_required_prefix_warning_reopens_the_message(self):
|
|
5434
|
+
_, dialogs = self._pattern_app(r"\w+-\d+:")
|
|
5435
|
+
dialogs._resolve("fix x")
|
|
5436
|
+
|
|
5437
|
+
dialogs.cancel()
|
|
5438
|
+
|
|
5439
|
+
reopened = dialogs.pushed[-1]
|
|
5440
|
+
self.assertIsInstance(reopened, CommitDialog)
|
|
5441
|
+
self.assertEqual(reopened.prefill, "fix x")
|
|
5442
|
+
|
|
5443
|
+
def test_the_required_pattern_covers_only_the_start_of_the_message(self):
|
|
5444
|
+
"""No `.*` needed for the rest of the message, and a match further
|
|
5445
|
+
in doesn't count as a prefix."""
|
|
5446
|
+
for message, accepted in (
|
|
5447
|
+
("ABC-12: fix x\n\nbody", True),
|
|
5448
|
+
("ABC-12:", True),
|
|
5449
|
+
("fix ABC-12: x", False),
|
|
5450
|
+
("\nABC-12: x", False),
|
|
5451
|
+
):
|
|
5452
|
+
with self.subTest(message=message):
|
|
5453
|
+
_, dialogs = self._pattern_app(r"[A-Z]+-\d+:")
|
|
5454
|
+
|
|
5455
|
+
dialogs._resolve(message)
|
|
5456
|
+
|
|
5457
|
+
self.assertEqual(dialogs.prompt() == "Commit 1 file(s)?", accepted)
|
|
5458
|
+
|
|
5459
|
+
def test_a_blank_required_pattern_checks_nothing(self):
|
|
5460
|
+
_, dialogs = self._pattern_app(" ")
|
|
5461
|
+
|
|
5462
|
+
dialogs._resolve("fix x")
|
|
5463
|
+
|
|
5464
|
+
self.assertEqual(dialogs.prompt(), "Commit 1 file(s)?")
|
|
5465
|
+
|
|
5466
|
+
def test_an_invalid_required_pattern_is_reported_instead_of_skipped(self):
|
|
5467
|
+
_, dialogs = self._pattern_app("[A-Z")
|
|
5468
|
+
|
|
5469
|
+
dialogs._resolve("fix x")
|
|
5470
|
+
|
|
5471
|
+
self.assertEqual(
|
|
5472
|
+
dialogs.prompt(),
|
|
5473
|
+
r"Warning: commit message can't be checked against invalid pattern '\[A-Z'."
|
|
5474
|
+
" Continue?",
|
|
5475
|
+
)
|
|
5476
|
+
|
|
5477
|
+
def test_a_warned_branch_and_a_missing_prefix_are_reported_together(self):
|
|
5478
|
+
_, dialogs = self._pattern_app(r"\d+:", commit_prefix_warn_branches="master")
|
|
5479
|
+
|
|
5480
|
+
dialogs._resolve("master: fix x")
|
|
5481
|
+
|
|
5482
|
+
self.assertEqual(
|
|
5483
|
+
dialogs.prompt(),
|
|
5484
|
+
"Warning: commit message is prefixed with branch 'master'"
|
|
5485
|
+
r" and does not start with a prefix matching '\d+:'. Continue?",
|
|
5486
|
+
)
|
|
5487
|
+
|
|
5488
|
+
def test_a_prefill_missing_the_required_prefix_is_announced_up_front(self):
|
|
5489
|
+
entry = GitEntry(GitCode.MODIFIED_UNMODIFIED, "pkg/a.py")
|
|
5490
|
+
for branch, warned in (("feature", True), ("ABC-12", False)):
|
|
5491
|
+
with self.subTest(branch=branch):
|
|
5492
|
+
app, _, _ = self._app([entry], staged={"pkg/a.py"}, branch=branch)
|
|
5493
|
+
app._load_settings = MagicMock(
|
|
5494
|
+
return_value={
|
|
5495
|
+
"branch_prefix_in_commits": True,
|
|
5496
|
+
"commit_prefix_require_pattern": r"[A-Z]+-\d+:",
|
|
5497
|
+
}
|
|
5498
|
+
)
|
|
5499
|
+
|
|
5500
|
+
with patch.object(app, "notify") as notify:
|
|
5501
|
+
app.action_commit_files()
|
|
5502
|
+
|
|
5503
|
+
if warned:
|
|
5504
|
+
notify.assert_called_once_with(
|
|
5505
|
+
"Warning: commit message does not start with a prefix matching"
|
|
5506
|
+
r" '\[A-Z]+-\d+:'",
|
|
5507
|
+
severity="warning",
|
|
5508
|
+
)
|
|
5509
|
+
else:
|
|
5510
|
+
notify.assert_not_called()
|
|
5511
|
+
|
|
5512
|
+
def test_an_empty_prefill_is_not_checked_against_the_required_pattern(self):
|
|
5513
|
+
"""Nothing has been written yet, so there is no prefix to judge."""
|
|
5514
|
+
entry = GitEntry(GitCode.MODIFIED_UNMODIFIED, "pkg/a.py")
|
|
5515
|
+
app, dialogs, _ = self._app([entry], staged={"pkg/a.py"})
|
|
5516
|
+
app._load_settings = MagicMock(return_value={"commit_prefix_require_pattern": r"\d+:"})
|
|
5517
|
+
|
|
5518
|
+
with patch.object(app, "notify") as notify:
|
|
5519
|
+
app.action_commit_files()
|
|
5520
|
+
|
|
5521
|
+
self.assertEqual(self._commit_screen(dialogs).prefill, "")
|
|
5522
|
+
notify.assert_not_called()
|
|
5523
|
+
|
|
5327
5524
|
def test_other_prefixes_go_straight_to_the_commit_confirmation(self):
|
|
5328
5525
|
"""Only a whole listed name followed by the colon counts — not a
|
|
5329
5526
|
branch that merely starts with one, and not the name mid-message."""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|