gitnc 0.1.4__tar.gz → 0.1.5__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.
Files changed (42) hide show
  1. {gitnc-0.1.4 → gitnc-0.1.5}/CLAUDE.md +2 -0
  2. {gitnc-0.1.4 → gitnc-0.1.5}/PKG-INFO +1 -1
  3. {gitnc-0.1.4 → gitnc-0.1.5}/README.md +7 -0
  4. {gitnc-0.1.4 → gitnc-0.1.5}/gitnc/app.py +79 -18
  5. {gitnc-0.1.4 → gitnc-0.1.5}/pyproject.toml +1 -1
  6. {gitnc-0.1.4 → gitnc-0.1.5}/tests/test_app.py +156 -0
  7. {gitnc-0.1.4 → gitnc-0.1.5}/.devcontainer/Dockerfile +0 -0
  8. {gitnc-0.1.4 → gitnc-0.1.5}/.devcontainer/devcontainer.json +0 -0
  9. {gitnc-0.1.4 → gitnc-0.1.5}/.gitignore +0 -0
  10. {gitnc-0.1.4 → gitnc-0.1.5}/LICENSE +0 -0
  11. {gitnc-0.1.4 → gitnc-0.1.5}/gitnc/__init__.py +0 -0
  12. {gitnc-0.1.4 → gitnc-0.1.5}/gitnc/__main__.py +0 -0
  13. {gitnc-0.1.4 → gitnc-0.1.5}/gitnc/git.py +0 -0
  14. {gitnc-0.1.4 → gitnc-0.1.5}/gitnc/terminal.py +0 -0
  15. {gitnc-0.1.4 → gitnc-0.1.5}/gitnc/themes/__init__.py +0 -0
  16. {gitnc-0.1.4 → gitnc-0.1.5}/gitnc/themes/atom-one-dark.yaml +0 -0
  17. {gitnc-0.1.4 → gitnc-0.1.5}/gitnc/themes/atom-one-light.yaml +0 -0
  18. {gitnc-0.1.4 → gitnc-0.1.5}/gitnc/themes/catppuccin-frappe.yaml +0 -0
  19. {gitnc-0.1.4 → gitnc-0.1.5}/gitnc/themes/catppuccin-latte.yaml +0 -0
  20. {gitnc-0.1.4 → gitnc-0.1.5}/gitnc/themes/catppuccin-macchiato.yaml +0 -0
  21. {gitnc-0.1.4 → gitnc-0.1.5}/gitnc/themes/catppuccin-mocha.yaml +0 -0
  22. {gitnc-0.1.4 → gitnc-0.1.5}/gitnc/themes/dracula.yaml +0 -0
  23. {gitnc-0.1.4 → gitnc-0.1.5}/gitnc/themes/flexoki.yaml +0 -0
  24. {gitnc-0.1.4 → gitnc-0.1.5}/gitnc/themes/gruvbox.yaml +0 -0
  25. {gitnc-0.1.4 → gitnc-0.1.5}/gitnc/themes/midnight-commander.yaml +0 -0
  26. {gitnc-0.1.4 → gitnc-0.1.5}/gitnc/themes/monokai.yaml +0 -0
  27. {gitnc-0.1.4 → gitnc-0.1.5}/gitnc/themes/nord.yaml +0 -0
  28. {gitnc-0.1.4 → gitnc-0.1.5}/gitnc/themes/rose-pine-dawn.yaml +0 -0
  29. {gitnc-0.1.4 → gitnc-0.1.5}/gitnc/themes/rose-pine-moon.yaml +0 -0
  30. {gitnc-0.1.4 → gitnc-0.1.5}/gitnc/themes/rose-pine.yaml +0 -0
  31. {gitnc-0.1.4 → gitnc-0.1.5}/gitnc/themes/solarized-dark.yaml +0 -0
  32. {gitnc-0.1.4 → gitnc-0.1.5}/gitnc/themes/solarized-light.yaml +0 -0
  33. {gitnc-0.1.4 → gitnc-0.1.5}/gitnc/themes/textual-ansi.yaml +0 -0
  34. {gitnc-0.1.4 → gitnc-0.1.5}/gitnc/themes/textual-dark.yaml +0 -0
  35. {gitnc-0.1.4 → gitnc-0.1.5}/gitnc/themes/textual-light.yaml +0 -0
  36. {gitnc-0.1.4 → gitnc-0.1.5}/gitnc/themes/tokyo-night.yaml +0 -0
  37. {gitnc-0.1.4 → gitnc-0.1.5}/gitnc/ui_theme.py +0 -0
  38. {gitnc-0.1.4 → gitnc-0.1.5}/tests/__init__.py +0 -0
  39. {gitnc-0.1.4 → gitnc-0.1.5}/tests/gitprocess.py +0 -0
  40. {gitnc-0.1.4 → gitnc-0.1.5}/tests/test_git.py +0 -0
  41. {gitnc-0.1.4 → gitnc-0.1.5}/tests/test_terminal.py +0 -0
  42. {gitnc-0.1.4 → gitnc-0.1.5}/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. 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.4
3
+ Version: 0.1.5
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;
@@ -831,6 +831,25 @@ def _warned_commit_prefix(message: str, branches: Iterable[str]) -> str:
831
831
  return next((branch for branch in branches if message.startswith(f"{branch}:")), "")
832
832
 
833
833
 
834
+ def _is_valid_pattern(pattern: str) -> bool:
835
+ """Whether `pattern` compiles as a regular expression."""
836
+ try:
837
+ re.compile(pattern)
838
+ except re.error:
839
+ return False
840
+ return True
841
+
842
+
843
+ def _starts_with_pattern(message: str, pattern: str) -> bool:
844
+ """Whether `message` starts with something `pattern` matches.
845
+
846
+ `re.match` anchors at the start and stops where the pattern does, so the
847
+ setting describes the prefix alone — no `^` in front, and no `.*` behind
848
+ it for the rest of the message.
849
+ """
850
+ return re.match(pattern, message) is not None
851
+
852
+
834
853
  def _setting_str(settings: Mapping[str, object], key: str, default: str) -> str:
835
854
  """`settings[key]` when it holds a string, else `default`.
836
855
 
@@ -914,6 +933,7 @@ class SettingsValues(NamedTuple):
914
933
  editor: str = COMMIT_EDITOR_BUILTIN
915
934
  editor_command: str = ""
916
935
  prefix_warn_branches: str = ""
936
+ prefix_require_pattern: str = ""
917
937
 
918
938
 
919
939
  # Rows of the settings dialog, in the order they are drawn. The checkbox rows
@@ -924,14 +944,15 @@ class SettingsValues(NamedTuple):
924
944
  SETTINGS_ROW_BRANCH = 0
925
945
  SETTINGS_ROW_PREFIX = 1
926
946
  SETTINGS_ROW_PREFIX_WARN = 2
927
- SETTINGS_ROW_EDITOR_BUILTIN = 3
928
- SETTINGS_ROW_EDITOR_ENVIRONMENT = 4
929
- SETTINGS_ROW_EDITOR_COMMAND = 5
930
- SETTINGS_ROW_EDITOR_COMMAND_LINE = 6
931
- SETTINGS_ROW_COMMAND = 7
932
- SETTINGS_ROW_PROMPT = 8
933
- SETTINGS_ROW_PARSE = 9
934
- SETTINGS_ROW_COUNT = 10
947
+ SETTINGS_ROW_PREFIX_PATTERN = 3
948
+ SETTINGS_ROW_EDITOR_BUILTIN = 4
949
+ SETTINGS_ROW_EDITOR_ENVIRONMENT = 5
950
+ SETTINGS_ROW_EDITOR_COMMAND = 6
951
+ SETTINGS_ROW_EDITOR_COMMAND_LINE = 7
952
+ SETTINGS_ROW_COMMAND = 8
953
+ SETTINGS_ROW_PROMPT = 9
954
+ SETTINGS_ROW_PARSE = 10
955
+ SETTINGS_ROW_COUNT = 11
935
956
 
936
957
  # Which editor each radio row selects.
937
958
  SETTINGS_EDITOR_ROWS: dict[int, str] = {
@@ -1303,6 +1324,7 @@ class SettingsDialog(ModalScreen["SettingsValues | None"]):
1303
1324
  TEXT_ROWS: ClassVar[dict[int, str]] = {
1304
1325
  SETTINGS_ROW_BRANCH: "_default_branch",
1305
1326
  SETTINGS_ROW_PREFIX_WARN: "_prefix_warn_branches",
1327
+ SETTINGS_ROW_PREFIX_PATTERN: "_prefix_require_pattern",
1306
1328
  SETTINGS_ROW_EDITOR_COMMAND_LINE: "_editor_command",
1307
1329
  SETTINGS_ROW_COMMAND: "_draft_command",
1308
1330
  SETTINGS_ROW_PROMPT: "_draft_prompt",
@@ -1323,12 +1345,14 @@ class SettingsDialog(ModalScreen["SettingsValues | None"]):
1323
1345
  editor: str = COMMIT_EDITOR_BUILTIN,
1324
1346
  editor_command: str = "",
1325
1347
  prefix_warn_branches: str = "",
1348
+ prefix_require_pattern: str = "",
1326
1349
  ) -> None:
1327
1350
  super().__init__()
1328
1351
  self._cursor: int = 0
1329
1352
  self._default_branch: str = default_branch
1330
1353
  self._branch_prefix: bool = branch_prefix
1331
1354
  self._prefix_warn_branches: str = prefix_warn_branches
1355
+ self._prefix_require_pattern: str = prefix_require_pattern
1332
1356
  self._draft_command: str = draft_command
1333
1357
  self._draft_prompt: str = draft_prompt
1334
1358
  self._parse_suggestions: bool = parse_suggestions
@@ -1549,6 +1573,12 @@ class SettingsDialog(ModalScreen["SettingsValues | None"]):
1549
1573
  " (branch names, separated by spaces, e.g. master main)"
1550
1574
  f"[/{theme.dialog_hint}]"
1551
1575
  )
1576
+ lines.append(
1577
+ self._text_row(SETTINGS_ROW_PREFIX_PATTERN, " Warn on prefix not matching: ")
1578
+ )
1579
+ lines.append(
1580
+ f"[{theme.dialog_hint}] ({self._prefix_pattern_hint()})[/{theme.dialog_hint}]"
1581
+ )
1552
1582
  lines.append("")
1553
1583
  lines.append(self._section_header("Commit message editor"))
1554
1584
  lines.append("")
@@ -1595,6 +1625,15 @@ class SettingsDialog(ModalScreen["SettingsValues | None"]):
1595
1625
  # values are already recorded, so there is simply nothing to paint.
1596
1626
  pass
1597
1627
 
1628
+ def _prefix_pattern_hint(self) -> str:
1629
+ """The line under the required-prefix row: what goes in it, or that
1630
+ what is in it won't compile — said here, while it is being typed,
1631
+ rather than first at the next commit."""
1632
+ if not _is_valid_pattern(self._prefix_require_pattern):
1633
+ return "not a valid regular expression"
1634
+ example = _escape_markup(r"[A-Z]+-\d+:")
1635
+ return f"optional regular expression for the start of the message, e.g. {example}"
1636
+
1598
1637
  def _key_hint(self) -> str:
1599
1638
  """The key line under the dialog, which is not the same in a framed
1600
1639
  field: the arrows belong to the caret there, and Tab is what leaves."""
@@ -1628,6 +1667,7 @@ class SettingsDialog(ModalScreen["SettingsValues | None"]):
1628
1667
  editor=self._editor,
1629
1668
  editor_command=self._editor_command,
1630
1669
  prefix_warn_branches=self._prefix_warn_branches,
1670
+ prefix_require_pattern=self._prefix_require_pattern,
1631
1671
  )
1632
1672
 
1633
1673
 
@@ -4051,7 +4091,9 @@ class GitNightCommanderApp(App):
4051
4091
  self._pending_commit_targets = list(targets)
4052
4092
  branch = self._commit_branch_prefix()
4053
4093
  prefill = f"{branch}: " if branch else ""
4054
- warning = self._commit_prefix_warning(prefill)
4094
+ # Only a prefill is checked: with nothing written yet there is no
4095
+ # prefix to judge, and the required pattern would fail every time.
4096
+ warning = self._commit_prefix_warning(prefill) if prefill else ""
4055
4097
  if warning:
4056
4098
  # Up front as well as in the question asked after the message:
4057
4099
  # the prefix is already known here, and this is before the
@@ -4333,8 +4375,9 @@ class GitNightCommanderApp(App):
4333
4375
  self._confirm_commit_prefix(message)
4334
4376
 
4335
4377
  def _confirm_commit_prefix(self, message: str) -> None:
4336
- """Ask about a branch prefix the settings warn against, before the
4337
- commit confirmation rather than as a row inside it.
4378
+ """Ask about a prefix the settings warn against — a listed branch,
4379
+ or one the required pattern doesn't match — before the commit
4380
+ confirmation rather than as a row inside it.
4338
4381
 
4339
4382
  A question of its own has to be answered; a row under a list of
4340
4383
  commands only has to be overlooked. It comes here rather than on the
@@ -4381,19 +4424,35 @@ class GitNightCommanderApp(App):
4381
4424
  )
4382
4425
 
4383
4426
  def _commit_prefix_warning(self, message: str) -> str:
4384
- """The warning for a message prefixed with a branch named in the
4385
- `commit_prefix_warn_branches` setting, or "" when it isn't.
4427
+ """The warning for a message whose prefix the settings object to, or
4428
+ "" when they don't: it starts with a branch named in
4429
+ `commit_prefix_warn_branches`, or it doesn't start with what the
4430
+ `commit_prefix_require_pattern` regular expression asks for. Both
4431
+ are reported when both apply.
4386
4432
 
4387
4433
  One text for the notification the message editor opens with and the
4388
4434
  question asked once the message is written, so the two can't drift.
4435
+ The pattern is quoted with its brackets escaped, because both of
4436
+ those render markup and a character class would be read as a tag.
4389
4437
  """
4390
- branches = _branch_names(
4391
- _setting_str(self._load_settings(), "commit_prefix_warn_branches", "")
4392
- )
4438
+ settings = self._load_settings()
4439
+ problems: list[str] = []
4440
+ branches = _branch_names(_setting_str(settings, "commit_prefix_warn_branches", ""))
4393
4441
  branch = _warned_commit_prefix(message, branches)
4394
- if not branch:
4442
+ if branch:
4443
+ problems.append(f"is prefixed with branch '{branch}'")
4444
+ pattern = _setting_str(settings, "commit_prefix_require_pattern", "")
4445
+ if pattern.strip():
4446
+ quoted = _escape_markup(pattern)
4447
+ if not _is_valid_pattern(pattern):
4448
+ # Said rather than skipped: a check that silently stopped
4449
+ # checking would look exactly like a message that passed.
4450
+ problems.append(f"can't be checked against invalid pattern '{quoted}'")
4451
+ elif not _starts_with_pattern(message, pattern):
4452
+ problems.append(f"does not start with a prefix matching '{quoted}'")
4453
+ if not problems:
4395
4454
  return ""
4396
- return f"Warning: commit message is prefixed with branch '{branch}'"
4455
+ return f"Warning: commit message {' and '.join(problems)}"
4397
4456
 
4398
4457
  def _commit_submit(self, message: str) -> None:
4399
4458
  result = self.git.commit_with_selection(self._pending_commit_targets, message)
@@ -4656,6 +4715,7 @@ class GitNightCommanderApp(App):
4656
4715
  editor=_setting_str(settings, "commit_editor", COMMIT_EDITOR_BUILTIN),
4657
4716
  editor_command=_setting_str(settings, "commit_editor_command", ""),
4658
4717
  prefix_warn_branches=_setting_str(settings, "commit_prefix_warn_branches", ""),
4718
+ prefix_require_pattern=_setting_str(settings, "commit_prefix_require_pattern", ""),
4659
4719
  ),
4660
4720
  callback=self._settings_result,
4661
4721
  )
@@ -4674,6 +4734,7 @@ class GitNightCommanderApp(App):
4674
4734
  settings["commit_editor"] = values.editor
4675
4735
  settings["commit_editor_command"] = values.editor_command
4676
4736
  settings["commit_prefix_warn_branches"] = values.prefix_warn_branches
4737
+ settings["commit_prefix_require_pattern"] = values.prefix_require_pattern
4677
4738
  if not self._save_settings(settings):
4678
4739
  self.notify("Settings saved (could not write to disk)")
4679
4740
  else:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "gitnc"
7
- version = "0.1.4"
7
+ version = "0.1.5"
8
8
  description = "GitNightCommander — a TUI git log browser built with Textual"
9
9
  requires-python = ">=3.12"
10
10
  dependencies = [
@@ -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,47 @@ 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
+ self.assertIn("optional", SettingsDialog()._prefix_pattern_hint())
3446
+ self.assertIn(
3447
+ "optional", SettingsDialog(prefix_require_pattern="[A-Z]+:")._prefix_pattern_hint()
3448
+ )
3449
+ self.assertEqual(
3450
+ SettingsDialog(prefix_require_pattern="[A-Z")._prefix_pattern_hint(),
3451
+ "not a valid regular expression",
3452
+ )
3453
+
3454
+ def test_the_required_prefix_pattern_round_trips_through_the_settings_file(self):
3455
+ app = _mock_app()
3456
+ with patch.object(
3457
+ app, "_load_settings", return_value={"commit_prefix_require_pattern": r"[A-Z]+-\d+:"}
3458
+ ):
3459
+ app.action_settings()
3460
+ values = _pushed_screen(app).get_values()
3461
+ self.assertEqual(values.prefix_require_pattern, r"[A-Z]+-\d+:")
3462
+
3463
+ saved: dict[str, object] = {}
3464
+ with (
3465
+ patch.object(app, "_load_settings", return_value={}),
3466
+ patch.object(app, "_save_settings", side_effect=lambda s: saved.update(s) or True),
3467
+ ):
3468
+ app._settings_result(values)
3469
+
3470
+ self.assertEqual(saved["commit_prefix_require_pattern"], r"[A-Z]+-\d+:")
3471
+
3429
3472
  def test_cursor_wraps_over_every_row(self):
3430
3473
  dialog = SettingsDialog()
3431
3474
 
@@ -5324,6 +5367,119 @@ class TestCommitFlow(unittest.TestCase):
5324
5367
  self.assertEqual(self._commit_screen(dialogs).prefill, "feature: ")
5325
5368
  notify.assert_not_called()
5326
5369
 
5370
+ def _pattern_app(self, pattern: str, **settings: Any):
5371
+ entry = GitEntry(GitCode.MODIFIED_UNMODIFIED, "pkg/a.py")
5372
+ app, dialogs, _ = self._app([entry], staged={"pkg/a.py"})
5373
+ app._load_settings = MagicMock(
5374
+ return_value={"commit_prefix_require_pattern": pattern, **settings}
5375
+ )
5376
+ app.action_commit_files()
5377
+ return app, dialogs
5378
+
5379
+ def test_a_message_missing_the_required_prefix_is_asked_about(self):
5380
+ app, dialogs = self._pattern_app(r"[A-Z]+-\d+:")
5381
+
5382
+ dialogs._resolve("fix x")
5383
+
5384
+ self.assertEqual(
5385
+ dialogs.prompt(),
5386
+ "Warning: commit message does not start with a prefix matching"
5387
+ r" '\[A-Z]+-\d+:'. Continue?",
5388
+ )
5389
+ self.assertEqual(dialogs.commands(), ["fix x"])
5390
+ app.git.commit.assert_not_called()
5391
+
5392
+ def test_declining_the_required_prefix_warning_reopens_the_message(self):
5393
+ _, dialogs = self._pattern_app(r"\w+-\d+:")
5394
+ dialogs._resolve("fix x")
5395
+
5396
+ dialogs.cancel()
5397
+
5398
+ reopened = dialogs.pushed[-1]
5399
+ self.assertIsInstance(reopened, CommitDialog)
5400
+ self.assertEqual(reopened.prefill, "fix x")
5401
+
5402
+ def test_the_required_pattern_covers_only_the_start_of_the_message(self):
5403
+ """No `.*` needed for the rest of the message, and a match further
5404
+ in doesn't count as a prefix."""
5405
+ for message, accepted in (
5406
+ ("ABC-12: fix x\n\nbody", True),
5407
+ ("ABC-12:", True),
5408
+ ("fix ABC-12: x", False),
5409
+ ("\nABC-12: x", False),
5410
+ ):
5411
+ with self.subTest(message=message):
5412
+ _, dialogs = self._pattern_app(r"[A-Z]+-\d+:")
5413
+
5414
+ dialogs._resolve(message)
5415
+
5416
+ self.assertEqual(dialogs.prompt() == "Commit 1 file(s)?", accepted)
5417
+
5418
+ def test_a_blank_required_pattern_checks_nothing(self):
5419
+ _, dialogs = self._pattern_app(" ")
5420
+
5421
+ dialogs._resolve("fix x")
5422
+
5423
+ self.assertEqual(dialogs.prompt(), "Commit 1 file(s)?")
5424
+
5425
+ def test_an_invalid_required_pattern_is_reported_instead_of_skipped(self):
5426
+ _, dialogs = self._pattern_app("[A-Z")
5427
+
5428
+ dialogs._resolve("fix x")
5429
+
5430
+ self.assertEqual(
5431
+ dialogs.prompt(),
5432
+ r"Warning: commit message can't be checked against invalid pattern '\[A-Z'."
5433
+ " Continue?",
5434
+ )
5435
+
5436
+ def test_a_warned_branch_and_a_missing_prefix_are_reported_together(self):
5437
+ _, dialogs = self._pattern_app(r"\d+:", commit_prefix_warn_branches="master")
5438
+
5439
+ dialogs._resolve("master: fix x")
5440
+
5441
+ self.assertEqual(
5442
+ dialogs.prompt(),
5443
+ "Warning: commit message is prefixed with branch 'master'"
5444
+ r" and does not start with a prefix matching '\d+:'. Continue?",
5445
+ )
5446
+
5447
+ def test_a_prefill_missing_the_required_prefix_is_announced_up_front(self):
5448
+ entry = GitEntry(GitCode.MODIFIED_UNMODIFIED, "pkg/a.py")
5449
+ for branch, warned in (("feature", True), ("ABC-12", False)):
5450
+ with self.subTest(branch=branch):
5451
+ app, _, _ = self._app([entry], staged={"pkg/a.py"}, branch=branch)
5452
+ app._load_settings = MagicMock(
5453
+ return_value={
5454
+ "branch_prefix_in_commits": True,
5455
+ "commit_prefix_require_pattern": r"[A-Z]+-\d+:",
5456
+ }
5457
+ )
5458
+
5459
+ with patch.object(app, "notify") as notify:
5460
+ app.action_commit_files()
5461
+
5462
+ if warned:
5463
+ notify.assert_called_once_with(
5464
+ "Warning: commit message does not start with a prefix matching"
5465
+ r" '\[A-Z]+-\d+:'",
5466
+ severity="warning",
5467
+ )
5468
+ else:
5469
+ notify.assert_not_called()
5470
+
5471
+ def test_an_empty_prefill_is_not_checked_against_the_required_pattern(self):
5472
+ """Nothing has been written yet, so there is no prefix to judge."""
5473
+ entry = GitEntry(GitCode.MODIFIED_UNMODIFIED, "pkg/a.py")
5474
+ app, dialogs, _ = self._app([entry], staged={"pkg/a.py"})
5475
+ app._load_settings = MagicMock(return_value={"commit_prefix_require_pattern": r"\d+:"})
5476
+
5477
+ with patch.object(app, "notify") as notify:
5478
+ app.action_commit_files()
5479
+
5480
+ self.assertEqual(self._commit_screen(dialogs).prefill, "")
5481
+ notify.assert_not_called()
5482
+
5327
5483
  def test_other_prefixes_go_straight_to_the_commit_confirmation(self):
5328
5484
  """Only a whole listed name followed by the colon counts — not a
5329
5485
  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