gitnc 0.1.2__tar.gz → 0.1.4__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.2 → gitnc-0.1.4}/CLAUDE.md +7 -1
- {gitnc-0.1.2 → gitnc-0.1.4}/PKG-INFO +1 -1
- {gitnc-0.1.2 → gitnc-0.1.4}/README.md +10 -1
- {gitnc-0.1.2 → gitnc-0.1.4}/gitnc/app.py +132 -9
- {gitnc-0.1.2 → gitnc-0.1.4}/gitnc/git.py +22 -0
- {gitnc-0.1.2 → gitnc-0.1.4}/gitnc/themes/atom-one-dark.yaml +5 -0
- {gitnc-0.1.2 → gitnc-0.1.4}/gitnc/themes/atom-one-light.yaml +5 -0
- {gitnc-0.1.2 → gitnc-0.1.4}/gitnc/themes/catppuccin-frappe.yaml +5 -0
- {gitnc-0.1.2 → gitnc-0.1.4}/gitnc/themes/catppuccin-latte.yaml +5 -0
- {gitnc-0.1.2 → gitnc-0.1.4}/gitnc/themes/catppuccin-macchiato.yaml +5 -0
- {gitnc-0.1.2 → gitnc-0.1.4}/gitnc/themes/catppuccin-mocha.yaml +5 -0
- {gitnc-0.1.2 → gitnc-0.1.4}/gitnc/themes/dracula.yaml +5 -0
- {gitnc-0.1.2 → gitnc-0.1.4}/gitnc/themes/flexoki.yaml +5 -0
- {gitnc-0.1.2 → gitnc-0.1.4}/gitnc/themes/gruvbox.yaml +5 -0
- {gitnc-0.1.2 → gitnc-0.1.4}/gitnc/themes/midnight-commander.yaml +7 -0
- {gitnc-0.1.2 → gitnc-0.1.4}/gitnc/themes/monokai.yaml +5 -0
- {gitnc-0.1.2 → gitnc-0.1.4}/gitnc/themes/nord.yaml +5 -0
- {gitnc-0.1.2 → gitnc-0.1.4}/gitnc/themes/rose-pine-dawn.yaml +5 -0
- {gitnc-0.1.2 → gitnc-0.1.4}/gitnc/themes/rose-pine-moon.yaml +5 -0
- {gitnc-0.1.2 → gitnc-0.1.4}/gitnc/themes/rose-pine.yaml +5 -0
- {gitnc-0.1.2 → gitnc-0.1.4}/gitnc/themes/solarized-dark.yaml +5 -0
- {gitnc-0.1.2 → gitnc-0.1.4}/gitnc/themes/solarized-light.yaml +5 -0
- {gitnc-0.1.2 → gitnc-0.1.4}/gitnc/themes/textual-ansi.yaml +5 -0
- {gitnc-0.1.2 → gitnc-0.1.4}/gitnc/themes/textual-dark.yaml +5 -0
- {gitnc-0.1.2 → gitnc-0.1.4}/gitnc/themes/textual-light.yaml +5 -0
- {gitnc-0.1.2 → gitnc-0.1.4}/gitnc/themes/tokyo-night.yaml +5 -0
- {gitnc-0.1.2 → gitnc-0.1.4}/gitnc/ui_theme.py +8 -0
- {gitnc-0.1.2 → gitnc-0.1.4}/pyproject.toml +2 -1
- {gitnc-0.1.2 → gitnc-0.1.4}/tests/test_app.py +234 -0
- {gitnc-0.1.2 → gitnc-0.1.4}/tests/test_git.py +42 -0
- {gitnc-0.1.2 → gitnc-0.1.4}/.devcontainer/Dockerfile +0 -0
- {gitnc-0.1.2 → gitnc-0.1.4}/.devcontainer/devcontainer.json +0 -0
- {gitnc-0.1.2 → gitnc-0.1.4}/.gitignore +0 -0
- {gitnc-0.1.2 → gitnc-0.1.4}/LICENSE +0 -0
- {gitnc-0.1.2 → gitnc-0.1.4}/gitnc/__init__.py +0 -0
- {gitnc-0.1.2 → gitnc-0.1.4}/gitnc/__main__.py +0 -0
- {gitnc-0.1.2 → gitnc-0.1.4}/gitnc/terminal.py +0 -0
- {gitnc-0.1.2 → gitnc-0.1.4}/gitnc/themes/__init__.py +0 -0
- {gitnc-0.1.2 → gitnc-0.1.4}/tests/__init__.py +0 -0
- {gitnc-0.1.2 → gitnc-0.1.4}/tests/gitprocess.py +0 -0
- {gitnc-0.1.2 → gitnc-0.1.4}/tests/test_terminal.py +0 -0
- {gitnc-0.1.2 → gitnc-0.1.4}/tests/test_themes.py +0 -0
|
@@ -50,11 +50,17 @@ The two File menu entries (`action_short_file_list` / `action_long_file_list`) a
|
|
|
50
50
|
|
|
51
51
|
Menu chrome is composed once and stays mounted, so `action_set_theme` / `_load_saved_theme` call `_restyle_menu_mnemonics()` to re-render the labels; otherwise they would keep the accelerator style of the theme that was active at compose time.
|
|
52
52
|
|
|
53
|
+
**Starting inside a submodule:** `on_mount` loads nothing until `_offer_top_level_repository()` has settled which repository to read. `GIT.repository_chain()` returns this repo's top level followed by each superproject (`git rev-parse --show-superproject-working-tree`, asked again from every repository it finds), so nesting of any depth ends at the outermost one; a chain of one means not a submodule and the app starts at once. Otherwise a `_show_confirm()` lists the chain and asks to start in the last entry: Y goes through `_switch_repository()` — a new `GIT`, `repo_path`, and `os.chdir()` so the `Ctrl+O` shell lands there too — N keeps the submodule. Either answer then runs `_start()` (first loads, then the Midnight Commander warning), so the two startup questions come one after the other rather than stacked. The walk is a `rev-parse` per level, which doesn't grow with the repo, so it runs inline.
|
|
54
|
+
|
|
53
55
|
**Chrome colors:** the menu bar and the footer are painted from four `UITheme` fields — `menu_bar_background` / `menu_bar_foreground` / `footer_background` / `footer_foreground` — which `css_variables()` emits as `$menu-bar-background`, `$menu-bar-foreground`, `$footer-background` and `$footer-foreground` (the last two are Textual's own Footer variables, so the app carries no `Footer` CSS at all). The defaults are `$panel` / `$foreground`, i.e. Textual's Header/Footer palette, so a theme that says nothing about the chrome gets the bars every other Textual app has; `midnight-commander.yaml` overrides all four to `$warning` / `$background` for the MC look. Don't tie the bars to a palette role in CSS (they used to read `background: $warning`): "warning" means cyan in one theme and yellow in the next, so the bar changed color for reasons that had nothing to do with chrome. `MenuLabel`'s hover and `-active` states derive from those two variables — a 15% tint of the bar's own text color, and a full reverse — so they stay legible on any palette.
|
|
54
56
|
|
|
55
57
|
**Text selection colors:** selected text in the commit message box (Shift+arrows in the `TextArea`) is painted from `UITheme.text_selection_background` / `text_selection_foreground`, which `css_variables()` emits as Textual's own `$input-selection-background` / `$input-selection-foreground` — the variables `TextArea` and `Input` already read, so there is no selection CSS in the app. Unlike the chrome fields these are optional: an empty value emits nothing and leaves Textual's derived default (`$primary` lightened, at 40% over the background), because that default is fine in most themes and can't be written as a `$ref` `_resolve_refs()` understands. It is not fine in midnight-commander, where primary and background are both blue and a selection all but vanished; that theme sets `$warning` / `$background`, mcedit's dark-blue-on-cyan marked block. Set the selection through these fields, not through `variables`: `get_theme()` lets a `variables` entry win, so a second copy there would silently override the field.
|
|
56
58
|
|
|
57
|
-
**
|
|
59
|
+
**Warning notification color:** a `notify(..., severity="warning")` toast — today only the up-front branch-prefix warning (see *Committing*) — takes its text and left bar from `UITheme.warning_notification`, emitted as `$warning-notification` and applied by the `Toast.-warning` rules in the app `CSS`. It is a field of its own for the same reason the chrome colors are: Textual paints that bar in `$warning`, which is midnight-commander's cyan menu bar color and doesn't read as a warning there, so that theme sets a red (`#FF5555`) while every other theme keeps the default `$warning`.
|
|
60
|
+
|
|
61
|
+
**Committing:** `action_commit_files()` asks twice, and the two questions are different. The first — only when a target still has unstaged changes — is `Stage N file(s) for commit?`, listing the `git add` commands that would run. The second always comes, after the message dialog: `_confirm_commit()` shows `Commit N file(s)?` over the commands from `GIT.commit_with_selection_commands()`, which is the last point at which the files going into the commit are visible before `commit_with_selection` walks the submodules and commits. The command list comes from that method rather than being rebuilt in `app.py` so the popup and the executor can't drift apart, and the message inside it is cut to `_commit_message_subject()` — its first line, marked with `TRUNCATION_MARKER` when there is more — because the popup renders one command per row and a multi-line `-m` argument would break that. N on it doesn't cancel: `_show_confirm()`'s `on_cancel` reopens the message editor — `CommitDialog`, or the external editor the settings name — on the message just written, so backing out of the confirmation costs nothing. That, the prefix warning below, which reopens the message the same way, and the startup submodule question (see *Starting inside a submodule*), whose N still has to start the app, are the only callers of `on_cancel`; every other caller wants the default, which hands focus back to the active view.
|
|
62
|
+
|
|
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.
|
|
58
64
|
|
|
59
65
|
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.
|
|
60
66
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: gitnc
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.4
|
|
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
|
|
@@ -10,7 +10,9 @@ Main features:
|
|
|
10
10
|
|
|
11
11
|
- **Easy handling of commits in projects that use git submodules** — staging
|
|
12
12
|
and committing walk the submodules for you, and pull/push warn when a
|
|
13
|
-
submodule sits on a branch of its own.
|
|
13
|
+
submodule sits on a branch of its own. Started inside a submodule — at any
|
|
14
|
+
depth of nesting — the app offers to open the top-level repository instead
|
|
15
|
+
(`Y` / `N`).
|
|
14
16
|
- **`Ctrl+O` switches to the command line** — drops the app to a `$SHELL` in
|
|
15
17
|
the same terminal; press `Ctrl+O` again to switch back with the TUI right
|
|
16
18
|
where you left it. Started from Midnight Commander's command line, mc keeps
|
|
@@ -90,6 +92,7 @@ first use, so no manual venv activation is needed for the commands below.
|
|
|
90
92
|
|
|
91
93
|
```sh
|
|
92
94
|
hatch build # sdist + wheel into dist/
|
|
95
|
+
hatch run rebuild # same, removing the old artifacts from dist/ first
|
|
93
96
|
```
|
|
94
97
|
|
|
95
98
|
The artifacts land in `dist/` as `gitnc-<version>-py3-none-any.whl` and
|
|
@@ -270,6 +273,12 @@ Options -> Settings configures the things it uses:
|
|
|
270
273
|
- **Use branch name as prefix in commit messages** — prefills the message with
|
|
271
274
|
`<branch>: `, and puts that prefix in front of a drafted message that does not
|
|
272
275
|
already start with the branch name.
|
|
276
|
+
- **Warn on prefix** — branch names, separated by spaces or commas, that should
|
|
277
|
+
not end up in front of a commit message (e.g. `master main`). A message that
|
|
278
|
+
starts with `<name>:` is asked about in a confirmation of its own before the
|
|
279
|
+
`Commit N file(s)?` one: `Y` continues, `N` puts you back in the message to
|
|
280
|
+
change it. When the branch prefix setting prefills such a name, the same
|
|
281
|
+
warning also pops up as the message editor opens.
|
|
273
282
|
- **Commit message editor** — where the message is written:
|
|
274
283
|
- **Built-in editor** (the default) is the commit dialog described above.
|
|
275
284
|
- **$EDITOR** runs the editor named by the `EDITOR` environment variable;
|
|
@@ -10,7 +10,7 @@ import subprocess
|
|
|
10
10
|
import tempfile
|
|
11
11
|
import textwrap
|
|
12
12
|
import threading
|
|
13
|
-
from collections.abc import Callable, Mapping
|
|
13
|
+
from collections.abc import Callable, Iterable, Mapping
|
|
14
14
|
from contextlib import ExitStack
|
|
15
15
|
from dataclasses import dataclass
|
|
16
16
|
from pathlib import Path
|
|
@@ -132,6 +132,9 @@ MIDNIGHT_COMMANDER_WARNING = (
|
|
|
132
132
|
)
|
|
133
133
|
# Wide enough for the warning to wrap into a few readable lines.
|
|
134
134
|
MIDNIGHT_COMMANDER_DIALOG_WIDTH = 60
|
|
135
|
+
# Asked at startup when the repository is a submodule, however deeply nested:
|
|
136
|
+
# the question is always about the outermost repository.
|
|
137
|
+
SUPERPROJECT_PROMPT = "This repository is a submodule. Start in the top-level repository?"
|
|
135
138
|
|
|
136
139
|
# Where the commit message is written: the app's own dialog, the editor named
|
|
137
140
|
# by $EDITOR, or a command line typed into Options -> Settings. The last two
|
|
@@ -812,6 +815,22 @@ def _caret_markup(text: str, *, column: int, style: str) -> str:
|
|
|
812
815
|
return f"{_escape_markup(head)}[{style}]{_escape_markup(cell)}[/{style}]{_escape_markup(tail)}"
|
|
813
816
|
|
|
814
817
|
|
|
818
|
+
def _branch_names(text: str) -> list[str]:
|
|
819
|
+
"""The branch names in a settings value that lists several, separated by
|
|
820
|
+
spaces or commas — neither of which a branch name can contain."""
|
|
821
|
+
return [name for name in re.split(r"[\s,]+", text) if name]
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
def _warned_commit_prefix(message: str, branches: Iterable[str]) -> str:
|
|
825
|
+
"""Whichever of `branches` the message is prefixed with, or "".
|
|
826
|
+
|
|
827
|
+
The message is what is checked, not the branch that is checked out: the
|
|
828
|
+
prefill is editable, so the prefix can have been removed since — or typed
|
|
829
|
+
in by hand with the prefix setting off.
|
|
830
|
+
"""
|
|
831
|
+
return next((branch for branch in branches if message.startswith(f"{branch}:")), "")
|
|
832
|
+
|
|
833
|
+
|
|
815
834
|
def _setting_str(settings: Mapping[str, object], key: str, default: str) -> str:
|
|
816
835
|
"""`settings[key]` when it holds a string, else `default`.
|
|
817
836
|
|
|
@@ -894,6 +913,7 @@ class SettingsValues(NamedTuple):
|
|
|
894
913
|
parse_suggestions: bool = False
|
|
895
914
|
editor: str = COMMIT_EDITOR_BUILTIN
|
|
896
915
|
editor_command: str = ""
|
|
916
|
+
prefix_warn_branches: str = ""
|
|
897
917
|
|
|
898
918
|
|
|
899
919
|
# Rows of the settings dialog, in the order they are drawn. The checkbox rows
|
|
@@ -903,14 +923,15 @@ class SettingsValues(NamedTuple):
|
|
|
903
923
|
# Experimental heading.
|
|
904
924
|
SETTINGS_ROW_BRANCH = 0
|
|
905
925
|
SETTINGS_ROW_PREFIX = 1
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
926
|
+
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
|
|
914
935
|
|
|
915
936
|
# Which editor each radio row selects.
|
|
916
937
|
SETTINGS_EDITOR_ROWS: dict[int, str] = {
|
|
@@ -1281,6 +1302,7 @@ class SettingsDialog(ModalScreen["SettingsValues | None"]):
|
|
|
1281
1302
|
# rows differ only by their label and the value they carry.
|
|
1282
1303
|
TEXT_ROWS: ClassVar[dict[int, str]] = {
|
|
1283
1304
|
SETTINGS_ROW_BRANCH: "_default_branch",
|
|
1305
|
+
SETTINGS_ROW_PREFIX_WARN: "_prefix_warn_branches",
|
|
1284
1306
|
SETTINGS_ROW_EDITOR_COMMAND_LINE: "_editor_command",
|
|
1285
1307
|
SETTINGS_ROW_COMMAND: "_draft_command",
|
|
1286
1308
|
SETTINGS_ROW_PROMPT: "_draft_prompt",
|
|
@@ -1300,11 +1322,13 @@ class SettingsDialog(ModalScreen["SettingsValues | None"]):
|
|
|
1300
1322
|
parse_suggestions: bool = False,
|
|
1301
1323
|
editor: str = COMMIT_EDITOR_BUILTIN,
|
|
1302
1324
|
editor_command: str = "",
|
|
1325
|
+
prefix_warn_branches: str = "",
|
|
1303
1326
|
) -> None:
|
|
1304
1327
|
super().__init__()
|
|
1305
1328
|
self._cursor: int = 0
|
|
1306
1329
|
self._default_branch: str = default_branch
|
|
1307
1330
|
self._branch_prefix: bool = branch_prefix
|
|
1331
|
+
self._prefix_warn_branches: str = prefix_warn_branches
|
|
1308
1332
|
self._draft_command: str = draft_command
|
|
1309
1333
|
self._draft_prompt: str = draft_prompt
|
|
1310
1334
|
self._parse_suggestions: bool = parse_suggestions
|
|
@@ -1519,6 +1543,12 @@ class SettingsDialog(ModalScreen["SettingsValues | None"]):
|
|
|
1519
1543
|
"Use branch name as prefix in commit messages",
|
|
1520
1544
|
)
|
|
1521
1545
|
)
|
|
1546
|
+
lines.append(self._text_row(SETTINGS_ROW_PREFIX_WARN, " Warn on prefix: "))
|
|
1547
|
+
lines.append(
|
|
1548
|
+
f"[{theme.dialog_hint}]"
|
|
1549
|
+
" (branch names, separated by spaces, e.g. master main)"
|
|
1550
|
+
f"[/{theme.dialog_hint}]"
|
|
1551
|
+
)
|
|
1522
1552
|
lines.append("")
|
|
1523
1553
|
lines.append(self._section_header("Commit message editor"))
|
|
1524
1554
|
lines.append("")
|
|
@@ -1597,6 +1627,7 @@ class SettingsDialog(ModalScreen["SettingsValues | None"]):
|
|
|
1597
1627
|
parse_suggestions=self._parse_suggestions,
|
|
1598
1628
|
editor=self._editor,
|
|
1599
1629
|
editor_command=self._editor_command,
|
|
1630
|
+
prefix_warn_branches=self._prefix_warn_branches,
|
|
1600
1631
|
)
|
|
1601
1632
|
|
|
1602
1633
|
|
|
@@ -2638,6 +2669,15 @@ class GitNightCommanderApp(App):
|
|
|
2638
2669
|
]
|
|
2639
2670
|
|
|
2640
2671
|
CSS = """
|
|
2672
|
+
/* Warning notifications take the theme's own color for them instead of
|
|
2673
|
+
$warning, which a theme may have spent on its chrome. */
|
|
2674
|
+
Toast.-warning {
|
|
2675
|
+
border-left: outer $warning-notification;
|
|
2676
|
+
color: $warning-notification;
|
|
2677
|
+
}
|
|
2678
|
+
Toast.-warning .toast--title {
|
|
2679
|
+
color: $warning-notification;
|
|
2680
|
+
}
|
|
2641
2681
|
Screen {
|
|
2642
2682
|
layout: horizontal;
|
|
2643
2683
|
layers: default overlay;
|
|
@@ -2804,10 +2844,48 @@ class GitNightCommanderApp(App):
|
|
|
2804
2844
|
|
|
2805
2845
|
def on_mount(self) -> None:
|
|
2806
2846
|
self._load_saved_theme()
|
|
2847
|
+
self._offer_top_level_repository(then=self._start)
|
|
2848
|
+
|
|
2849
|
+
def _start(self) -> None:
|
|
2807
2850
|
self._load_status()
|
|
2808
2851
|
self._load_commits()
|
|
2809
2852
|
self._warn_if_started_from_midnight_commander()
|
|
2810
2853
|
|
|
2854
|
+
def _offer_top_level_repository(self, then: Callable[[], None]) -> None:
|
|
2855
|
+
"""Ask to move to the outermost superproject when started in a submodule.
|
|
2856
|
+
|
|
2857
|
+
Runs *then* once the question is answered (or at once, when there is
|
|
2858
|
+
nothing to ask), so the first `git status` reads the repository the
|
|
2859
|
+
user settled on rather than one it is about to leave. The chain is a
|
|
2860
|
+
`git rev-parse` per nesting level, which doesn't grow with the repo,
|
|
2861
|
+
so it runs inline.
|
|
2862
|
+
"""
|
|
2863
|
+
chain = self.git.repository_chain()
|
|
2864
|
+
if len(chain) < 2:
|
|
2865
|
+
then()
|
|
2866
|
+
return
|
|
2867
|
+
rows = [f"submodule {path}" for path in chain[:-1]]
|
|
2868
|
+
rows.append(f"top-level {chain[-1]}")
|
|
2869
|
+
|
|
2870
|
+
def confirmed() -> None:
|
|
2871
|
+
self._switch_repository(chain[-1])
|
|
2872
|
+
then()
|
|
2873
|
+
|
|
2874
|
+
self._show_confirm(SUPERPROJECT_PROMPT, rows, on_confirm=confirmed, on_cancel=then)
|
|
2875
|
+
|
|
2876
|
+
def _switch_repository(self, path: str) -> None:
|
|
2877
|
+
"""Point the app at *path*; the caller reloads the views.
|
|
2878
|
+
|
|
2879
|
+
The process moves there too, so the `Ctrl+O` shell and the tools run
|
|
2880
|
+
from the previous screen start where the file list is.
|
|
2881
|
+
"""
|
|
2882
|
+
self.repo_path = path
|
|
2883
|
+
self.git = GIT(path)
|
|
2884
|
+
try:
|
|
2885
|
+
os.chdir(path)
|
|
2886
|
+
except OSError:
|
|
2887
|
+
pass
|
|
2888
|
+
|
|
2811
2889
|
def _warn_if_started_from_midnight_commander(self) -> None:
|
|
2812
2890
|
if not started_from_midnight_commander():
|
|
2813
2891
|
return
|
|
@@ -3973,6 +4051,12 @@ class GitNightCommanderApp(App):
|
|
|
3973
4051
|
self._pending_commit_targets = list(targets)
|
|
3974
4052
|
branch = self._commit_branch_prefix()
|
|
3975
4053
|
prefill = f"{branch}: " if branch else ""
|
|
4054
|
+
warning = self._commit_prefix_warning(prefill)
|
|
4055
|
+
if warning:
|
|
4056
|
+
# Up front as well as in the question asked after the message:
|
|
4057
|
+
# the prefix is already known here, and this is before the
|
|
4058
|
+
# message has been written around it.
|
|
4059
|
+
self.notify(warning, severity="warning")
|
|
3976
4060
|
self._edit_commit_message(prefill)
|
|
3977
4061
|
|
|
3978
4062
|
def _edit_commit_message(self, message: str) -> None:
|
|
@@ -4246,7 +4330,29 @@ class GitNightCommanderApp(App):
|
|
|
4246
4330
|
if message is None:
|
|
4247
4331
|
self._commit_cancel()
|
|
4248
4332
|
else:
|
|
4333
|
+
self._confirm_commit_prefix(message)
|
|
4334
|
+
|
|
4335
|
+
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.
|
|
4338
|
+
|
|
4339
|
+
A question of its own has to be answered; a row under a list of
|
|
4340
|
+
commands only has to be overlooked. It comes here rather than on the
|
|
4341
|
+
message dialog because this is where the message is final, whichever
|
|
4342
|
+
editor wrote it. Y goes on to the commit confirmation, N reopens the
|
|
4343
|
+
message — the same way back that confirmation offers — and a message
|
|
4344
|
+
without such a prefix goes straight through.
|
|
4345
|
+
"""
|
|
4346
|
+
warning = self._commit_prefix_warning(message)
|
|
4347
|
+
if not warning:
|
|
4249
4348
|
self._confirm_commit(message)
|
|
4349
|
+
return
|
|
4350
|
+
self._show_confirm(
|
|
4351
|
+
f"{warning}. Continue?",
|
|
4352
|
+
[_commit_message_subject(message)],
|
|
4353
|
+
lambda: self._confirm_commit(message),
|
|
4354
|
+
lambda: self._edit_commit_message(message),
|
|
4355
|
+
)
|
|
4250
4356
|
|
|
4251
4357
|
def _confirm_commit(self, message: str) -> None:
|
|
4252
4358
|
"""Show what the commit will run and ask before running it.
|
|
@@ -4274,6 +4380,21 @@ class GitNightCommanderApp(App):
|
|
|
4274
4380
|
fill_height=True,
|
|
4275
4381
|
)
|
|
4276
4382
|
|
|
4383
|
+
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.
|
|
4386
|
+
|
|
4387
|
+
One text for the notification the message editor opens with and the
|
|
4388
|
+
question asked once the message is written, so the two can't drift.
|
|
4389
|
+
"""
|
|
4390
|
+
branches = _branch_names(
|
|
4391
|
+
_setting_str(self._load_settings(), "commit_prefix_warn_branches", "")
|
|
4392
|
+
)
|
|
4393
|
+
branch = _warned_commit_prefix(message, branches)
|
|
4394
|
+
if not branch:
|
|
4395
|
+
return ""
|
|
4396
|
+
return f"Warning: commit message is prefixed with branch '{branch}'"
|
|
4397
|
+
|
|
4277
4398
|
def _commit_submit(self, message: str) -> None:
|
|
4278
4399
|
result = self.git.commit_with_selection(self._pending_commit_targets, message)
|
|
4279
4400
|
self._pending_commit_targets = []
|
|
@@ -4534,6 +4655,7 @@ class GitNightCommanderApp(App):
|
|
|
4534
4655
|
parse_suggestions=bool(settings.get("commit_draft_parse_suggestions", False)),
|
|
4535
4656
|
editor=_setting_str(settings, "commit_editor", COMMIT_EDITOR_BUILTIN),
|
|
4536
4657
|
editor_command=_setting_str(settings, "commit_editor_command", ""),
|
|
4658
|
+
prefix_warn_branches=_setting_str(settings, "commit_prefix_warn_branches", ""),
|
|
4537
4659
|
),
|
|
4538
4660
|
callback=self._settings_result,
|
|
4539
4661
|
)
|
|
@@ -4551,6 +4673,7 @@ class GitNightCommanderApp(App):
|
|
|
4551
4673
|
settings["commit_draft_parse_suggestions"] = values.parse_suggestions
|
|
4552
4674
|
settings["commit_editor"] = values.editor
|
|
4553
4675
|
settings["commit_editor_command"] = values.editor_command
|
|
4676
|
+
settings["commit_prefix_warn_branches"] = values.prefix_warn_branches
|
|
4554
4677
|
if not self._save_settings(settings):
|
|
4555
4678
|
self.notify("Settings saved (could not write to disk)")
|
|
4556
4679
|
else:
|
|
@@ -705,6 +705,28 @@ class GIT:
|
|
|
705
705
|
name = (result.stdout or "").strip()
|
|
706
706
|
return name or None
|
|
707
707
|
|
|
708
|
+
def repository_chain(self) -> list[str]:
|
|
709
|
+
"""Return the working trees from this repository out to the top-level one.
|
|
710
|
+
|
|
711
|
+
The first entry is this repository's own top level, and each one after
|
|
712
|
+
it is the superproject the previous one is a submodule of, so the last
|
|
713
|
+
is the repository nothing contains. A repository that isn't a submodule
|
|
714
|
+
yields a single entry; a path outside any repository yields none.
|
|
715
|
+
"""
|
|
716
|
+
result = self._run("rev-parse", "--show-toplevel")
|
|
717
|
+
toplevel = (result.stdout or "").strip()
|
|
718
|
+
if result.returncode != 0 or not toplevel:
|
|
719
|
+
return []
|
|
720
|
+
chain = [toplevel]
|
|
721
|
+
while True:
|
|
722
|
+
result = self._run_in(chain[-1], "rev-parse", "--show-superproject-working-tree")
|
|
723
|
+
parent = (result.stdout or "").strip()
|
|
724
|
+
# The membership check stops a misconfigured gitlink that points
|
|
725
|
+
# back into the chain from walking in circles.
|
|
726
|
+
if result.returncode != 0 or not parent or parent in chain:
|
|
727
|
+
return chain
|
|
728
|
+
chain.append(parent)
|
|
729
|
+
|
|
708
730
|
def _branch_name_in(self, cwd: str) -> str | None:
|
|
709
731
|
result = self._run_in(cwd, "branch", "--show-current")
|
|
710
732
|
name = (result.stdout or "").strip()
|
|
@@ -89,6 +89,11 @@ menu_bar_foreground: $foreground
|
|
|
89
89
|
footer_background: $panel
|
|
90
90
|
footer_foreground: $foreground
|
|
91
91
|
|
|
92
|
+
# Warning notifications (the toast that says a commit message is about to be
|
|
93
|
+
# prefixed with a branch the settings warn against): its text and the bar on
|
|
94
|
+
# its left edge.
|
|
95
|
+
warning_notification: $warning
|
|
96
|
+
|
|
92
97
|
# CSS variables emitted for the file-list cursor and multi-selection.
|
|
93
98
|
# Values may reference Textual palette variables with `$name` (e.g. `$primary`).
|
|
94
99
|
# References are resolved against this theme's own fields when the CSS is generated.
|
|
@@ -89,6 +89,11 @@ menu_bar_foreground: $foreground
|
|
|
89
89
|
footer_background: $panel
|
|
90
90
|
footer_foreground: $foreground
|
|
91
91
|
|
|
92
|
+
# Warning notifications (the toast that says a commit message is about to be
|
|
93
|
+
# prefixed with a branch the settings warn against): its text and the bar on
|
|
94
|
+
# its left edge.
|
|
95
|
+
warning_notification: $warning
|
|
96
|
+
|
|
92
97
|
# CSS variables emitted for the file-list cursor and multi-selection.
|
|
93
98
|
# Values may reference Textual palette variables with `$name` (e.g. `$primary`).
|
|
94
99
|
# References are resolved against this theme's own fields when the CSS is generated.
|
|
@@ -97,6 +97,11 @@ menu_bar_foreground: $foreground
|
|
|
97
97
|
footer_background: $panel
|
|
98
98
|
footer_foreground: $foreground
|
|
99
99
|
|
|
100
|
+
# Warning notifications (the toast that says a commit message is about to be
|
|
101
|
+
# prefixed with a branch the settings warn against): its text and the bar on
|
|
102
|
+
# its left edge.
|
|
103
|
+
warning_notification: $warning
|
|
104
|
+
|
|
100
105
|
# CSS variables emitted for the file-list cursor and multi-selection.
|
|
101
106
|
# Values may reference Textual palette variables with `$name` (e.g. `$primary`).
|
|
102
107
|
# References are resolved against this theme's own fields when the CSS is generated.
|
|
@@ -90,6 +90,11 @@ menu_bar_foreground: $foreground
|
|
|
90
90
|
footer_background: $panel
|
|
91
91
|
footer_foreground: $foreground
|
|
92
92
|
|
|
93
|
+
# Warning notifications (the toast that says a commit message is about to be
|
|
94
|
+
# prefixed with a branch the settings warn against): its text and the bar on
|
|
95
|
+
# its left edge.
|
|
96
|
+
warning_notification: $warning
|
|
97
|
+
|
|
93
98
|
# CSS variables emitted for the file-list cursor and multi-selection.
|
|
94
99
|
# Values may reference Textual palette variables with `$name` (e.g. `$primary`).
|
|
95
100
|
# References are resolved against this theme's own fields when the CSS is generated.
|
|
@@ -97,6 +97,11 @@ menu_bar_foreground: $foreground
|
|
|
97
97
|
footer_background: $panel
|
|
98
98
|
footer_foreground: $foreground
|
|
99
99
|
|
|
100
|
+
# Warning notifications (the toast that says a commit message is about to be
|
|
101
|
+
# prefixed with a branch the settings warn against): its text and the bar on
|
|
102
|
+
# its left edge.
|
|
103
|
+
warning_notification: $warning
|
|
104
|
+
|
|
100
105
|
# CSS variables emitted for the file-list cursor and multi-selection.
|
|
101
106
|
# Values may reference Textual palette variables with `$name` (e.g. `$primary`).
|
|
102
107
|
# References are resolved against this theme's own fields when the CSS is generated.
|
|
@@ -97,6 +97,11 @@ menu_bar_foreground: $foreground
|
|
|
97
97
|
footer_background: $panel
|
|
98
98
|
footer_foreground: $foreground
|
|
99
99
|
|
|
100
|
+
# Warning notifications (the toast that says a commit message is about to be
|
|
101
|
+
# prefixed with a branch the settings warn against): its text and the bar on
|
|
102
|
+
# its left edge.
|
|
103
|
+
warning_notification: $warning
|
|
104
|
+
|
|
100
105
|
# CSS variables emitted for the file-list cursor and multi-selection.
|
|
101
106
|
# Values may reference Textual palette variables with `$name` (e.g. `$primary`).
|
|
102
107
|
# References are resolved against this theme's own fields when the CSS is generated.
|
|
@@ -90,6 +90,11 @@ menu_bar_foreground: $foreground
|
|
|
90
90
|
footer_background: $panel
|
|
91
91
|
footer_foreground: $foreground
|
|
92
92
|
|
|
93
|
+
# Warning notifications (the toast that says a commit message is about to be
|
|
94
|
+
# prefixed with a branch the settings warn against): its text and the bar on
|
|
95
|
+
# its left edge.
|
|
96
|
+
warning_notification: $warning
|
|
97
|
+
|
|
93
98
|
# CSS variables emitted for the file-list cursor and multi-selection.
|
|
94
99
|
# Values may reference Textual palette variables with `$name` (e.g. `$primary`).
|
|
95
100
|
# References are resolved against this theme's own fields when the CSS is generated.
|
|
@@ -92,6 +92,11 @@ menu_bar_foreground: $foreground
|
|
|
92
92
|
footer_background: $panel
|
|
93
93
|
footer_foreground: $foreground
|
|
94
94
|
|
|
95
|
+
# Warning notifications (the toast that says a commit message is about to be
|
|
96
|
+
# prefixed with a branch the settings warn against): its text and the bar on
|
|
97
|
+
# its left edge.
|
|
98
|
+
warning_notification: $warning
|
|
99
|
+
|
|
95
100
|
# CSS variables emitted for the file-list cursor and multi-selection.
|
|
96
101
|
# Values may reference Textual palette variables with `$name` (e.g. `$primary`).
|
|
97
102
|
# References are resolved against this theme's own fields when the CSS is generated.
|
|
@@ -91,6 +91,11 @@ menu_bar_foreground: $foreground
|
|
|
91
91
|
footer_background: $panel
|
|
92
92
|
footer_foreground: $foreground
|
|
93
93
|
|
|
94
|
+
# Warning notifications (the toast that says a commit message is about to be
|
|
95
|
+
# prefixed with a branch the settings warn against): its text and the bar on
|
|
96
|
+
# its left edge.
|
|
97
|
+
warning_notification: $warning
|
|
98
|
+
|
|
94
99
|
# CSS variables emitted for the file-list cursor and multi-selection.
|
|
95
100
|
# Values may reference Textual palette variables with `$name` (e.g. `$primary`).
|
|
96
101
|
# References are resolved against this theme's own fields when the CSS is generated.
|
|
@@ -93,6 +93,13 @@ menu_bar_foreground: $background
|
|
|
93
93
|
footer_background: $warning
|
|
94
94
|
footer_foreground: $background
|
|
95
95
|
|
|
96
|
+
# Warning notifications (the toast that says a commit message is about to be
|
|
97
|
+
# prefixed with a branch the settings warn against): its text and the bar on
|
|
98
|
+
# its left edge. $warning is this theme's cyan — the color of the menu bar,
|
|
99
|
+
# which reads as chrome rather than as a warning — so the toast gets a red
|
|
100
|
+
# of its own.
|
|
101
|
+
warning_notification: '#FF5555'
|
|
102
|
+
|
|
96
103
|
# CSS variables emitted for the file-list cursor and multi-selection.
|
|
97
104
|
# Values may reference Textual palette variables with `$name` (e.g. `$primary`).
|
|
98
105
|
# References are resolved against this theme's own fields when the CSS is generated.
|
|
@@ -91,6 +91,11 @@ menu_bar_foreground: $foreground
|
|
|
91
91
|
footer_background: $panel
|
|
92
92
|
footer_foreground: $foreground
|
|
93
93
|
|
|
94
|
+
# Warning notifications (the toast that says a commit message is about to be
|
|
95
|
+
# prefixed with a branch the settings warn against): its text and the bar on
|
|
96
|
+
# its left edge.
|
|
97
|
+
warning_notification: $warning
|
|
98
|
+
|
|
94
99
|
# CSS variables emitted for the file-list cursor and multi-selection.
|
|
95
100
|
# Values may reference Textual palette variables with `$name` (e.g. `$primary`).
|
|
96
101
|
# References are resolved against this theme's own fields when the CSS is generated.
|
|
@@ -95,6 +95,11 @@ menu_bar_foreground: $foreground
|
|
|
95
95
|
footer_background: $panel
|
|
96
96
|
footer_foreground: $foreground
|
|
97
97
|
|
|
98
|
+
# Warning notifications (the toast that says a commit message is about to be
|
|
99
|
+
# prefixed with a branch the settings warn against): its text and the bar on
|
|
100
|
+
# its left edge.
|
|
101
|
+
warning_notification: $warning
|
|
102
|
+
|
|
98
103
|
# CSS variables emitted for the file-list cursor and multi-selection.
|
|
99
104
|
# Values may reference Textual palette variables with `$name` (e.g. `$primary`).
|
|
100
105
|
# References are resolved against this theme's own fields when the CSS is generated.
|
|
@@ -96,6 +96,11 @@ menu_bar_foreground: $foreground
|
|
|
96
96
|
footer_background: $panel
|
|
97
97
|
footer_foreground: $foreground
|
|
98
98
|
|
|
99
|
+
# Warning notifications (the toast that says a commit message is about to be
|
|
100
|
+
# prefixed with a branch the settings warn against): its text and the bar on
|
|
101
|
+
# its left edge.
|
|
102
|
+
warning_notification: $warning
|
|
103
|
+
|
|
99
104
|
# CSS variables emitted for the file-list cursor and multi-selection.
|
|
100
105
|
# Values may reference Textual palette variables with `$name` (e.g. `$primary`).
|
|
101
106
|
# References are resolved against this theme's own fields when the CSS is generated.
|
|
@@ -96,6 +96,11 @@ menu_bar_foreground: $foreground
|
|
|
96
96
|
footer_background: $panel
|
|
97
97
|
footer_foreground: $foreground
|
|
98
98
|
|
|
99
|
+
# Warning notifications (the toast that says a commit message is about to be
|
|
100
|
+
# prefixed with a branch the settings warn against): its text and the bar on
|
|
101
|
+
# its left edge.
|
|
102
|
+
warning_notification: $warning
|
|
103
|
+
|
|
99
104
|
# CSS variables emitted for the file-list cursor and multi-selection.
|
|
100
105
|
# Values may reference Textual palette variables with `$name` (e.g. `$primary`).
|
|
101
106
|
# References are resolved against this theme's own fields when the CSS is generated.
|
|
@@ -96,6 +96,11 @@ menu_bar_foreground: $foreground
|
|
|
96
96
|
footer_background: $panel
|
|
97
97
|
footer_foreground: $foreground
|
|
98
98
|
|
|
99
|
+
# Warning notifications (the toast that says a commit message is about to be
|
|
100
|
+
# prefixed with a branch the settings warn against): its text and the bar on
|
|
101
|
+
# its left edge.
|
|
102
|
+
warning_notification: $warning
|
|
103
|
+
|
|
99
104
|
# CSS variables emitted for the file-list cursor and multi-selection.
|
|
100
105
|
# Values may reference Textual palette variables with `$name` (e.g. `$primary`).
|
|
101
106
|
# References are resolved against this theme's own fields when the CSS is generated.
|
|
@@ -93,6 +93,11 @@ menu_bar_foreground: $foreground
|
|
|
93
93
|
footer_background: $panel
|
|
94
94
|
footer_foreground: $foreground
|
|
95
95
|
|
|
96
|
+
# Warning notifications (the toast that says a commit message is about to be
|
|
97
|
+
# prefixed with a branch the settings warn against): its text and the bar on
|
|
98
|
+
# its left edge.
|
|
99
|
+
warning_notification: $warning
|
|
100
|
+
|
|
96
101
|
# CSS variables emitted for the file-list cursor and multi-selection.
|
|
97
102
|
# Values may reference Textual palette variables with `$name` (e.g. `$primary`).
|
|
98
103
|
# References are resolved against this theme's own fields when the CSS is generated.
|
|
@@ -93,6 +93,11 @@ menu_bar_foreground: $foreground
|
|
|
93
93
|
footer_background: $panel
|
|
94
94
|
footer_foreground: $foreground
|
|
95
95
|
|
|
96
|
+
# Warning notifications (the toast that says a commit message is about to be
|
|
97
|
+
# prefixed with a branch the settings warn against): its text and the bar on
|
|
98
|
+
# its left edge.
|
|
99
|
+
warning_notification: $warning
|
|
100
|
+
|
|
96
101
|
# CSS variables emitted for the file-list cursor and multi-selection.
|
|
97
102
|
# Values may reference Textual palette variables with `$name` (e.g. `$primary`).
|
|
98
103
|
# References are resolved against this theme's own fields when the CSS is generated.
|
|
@@ -99,6 +99,11 @@ menu_bar_foreground: $foreground
|
|
|
99
99
|
footer_background: $panel
|
|
100
100
|
footer_foreground: $foreground
|
|
101
101
|
|
|
102
|
+
# Warning notifications (the toast that says a commit message is about to be
|
|
103
|
+
# prefixed with a branch the settings warn against): its text and the bar on
|
|
104
|
+
# its left edge.
|
|
105
|
+
warning_notification: $warning
|
|
106
|
+
|
|
102
107
|
# CSS variables emitted for the file-list cursor and multi-selection.
|
|
103
108
|
# Values may reference Textual palette variables with `$name` (e.g. `$primary`).
|
|
104
109
|
# References are resolved against this theme's own fields when the CSS is generated.
|
|
@@ -89,6 +89,11 @@ menu_bar_foreground: $foreground
|
|
|
89
89
|
footer_background: $panel
|
|
90
90
|
footer_foreground: $foreground
|
|
91
91
|
|
|
92
|
+
# Warning notifications (the toast that says a commit message is about to be
|
|
93
|
+
# prefixed with a branch the settings warn against): its text and the bar on
|
|
94
|
+
# its left edge.
|
|
95
|
+
warning_notification: $warning
|
|
96
|
+
|
|
92
97
|
# CSS variables emitted for the file-list cursor and multi-selection.
|
|
93
98
|
# Values may reference Textual palette variables with `$name` (e.g. `$primary`).
|
|
94
99
|
# References are resolved against this theme's own fields when the CSS is generated.
|
|
@@ -90,6 +90,11 @@ menu_bar_foreground: $foreground
|
|
|
90
90
|
footer_background: $panel
|
|
91
91
|
footer_foreground: $foreground
|
|
92
92
|
|
|
93
|
+
# Warning notifications (the toast that says a commit message is about to be
|
|
94
|
+
# prefixed with a branch the settings warn against): its text and the bar on
|
|
95
|
+
# its left edge.
|
|
96
|
+
warning_notification: $warning
|
|
97
|
+
|
|
93
98
|
# CSS variables emitted for the file-list cursor and multi-selection.
|
|
94
99
|
# Values may reference Textual palette variables with `$name` (e.g. `$primary`).
|
|
95
100
|
# References are resolved against this theme's own fields when the CSS is generated.
|
|
@@ -90,6 +90,11 @@ menu_bar_foreground: $foreground
|
|
|
90
90
|
footer_background: $panel
|
|
91
91
|
footer_foreground: $foreground
|
|
92
92
|
|
|
93
|
+
# Warning notifications (the toast that says a commit message is about to be
|
|
94
|
+
# prefixed with a branch the settings warn against): its text and the bar on
|
|
95
|
+
# its left edge.
|
|
96
|
+
warning_notification: $warning
|
|
97
|
+
|
|
93
98
|
# CSS variables emitted for the file-list cursor and multi-selection.
|
|
94
99
|
# Values may reference Textual palette variables with `$name` (e.g. `$primary`).
|
|
95
100
|
# References are resolved against this theme's own fields when the CSS is generated.
|
|
@@ -117,6 +117,13 @@ class UITheme:
|
|
|
117
117
|
footer_background: str = "$panel"
|
|
118
118
|
footer_foreground: str = "$foreground"
|
|
119
119
|
|
|
120
|
+
# CSS-side color of a warning notification: the toast's text and the bar
|
|
121
|
+
# on its left edge. The default is the palette's own warning role, which
|
|
122
|
+
# is what Textual paints that bar in anyway; a theme that has spent
|
|
123
|
+
# $warning on something else (midnight-commander's cyan chrome) names a
|
|
124
|
+
# color that still reads as a warning.
|
|
125
|
+
warning_notification: str = "$warning"
|
|
126
|
+
|
|
120
127
|
# CSS-side palette for the file list cursor + multi-selection.
|
|
121
128
|
cursor_row_background: str = "$primary"
|
|
122
129
|
cursor_row_foreground: str = "$foreground"
|
|
@@ -184,6 +191,7 @@ class UITheme:
|
|
|
184
191
|
"menu-bar-foreground": self._resolve_refs(self.menu_bar_foreground),
|
|
185
192
|
"footer-background": self._resolve_refs(self.footer_background),
|
|
186
193
|
"footer-foreground": self._resolve_refs(self.footer_foreground),
|
|
194
|
+
"warning-notification": self._resolve_refs(self.warning_notification),
|
|
187
195
|
"cursor-row-background": self._resolve_refs(self.cursor_row_background),
|
|
188
196
|
"cursor-row-foreground": self._resolve_refs(self.cursor_row_foreground),
|
|
189
197
|
"selected-file-background": self._resolve_refs(self.selected_file_background),
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "gitnc"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.4"
|
|
8
8
|
description = "GitNightCommander — a TUI git log browser built with Textual"
|
|
9
9
|
requires-python = ">=3.12"
|
|
10
10
|
dependencies = [
|
|
@@ -44,6 +44,7 @@ fmt = [
|
|
|
44
44
|
"ruff format gitnc tests",
|
|
45
45
|
]
|
|
46
46
|
start = "python -m gitnc {args}"
|
|
47
|
+
rebuild = "hatch build --clean {args}"
|
|
47
48
|
|
|
48
49
|
[tool.ruff]
|
|
49
50
|
line-length = 100
|
|
@@ -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_WARN,
|
|
349
350
|
SETTINGS_ROW_PROMPT,
|
|
350
351
|
SETTINGS_SECTION_WIDTH,
|
|
351
352
|
SHORTCUT_ALIASES,
|
|
@@ -441,6 +442,7 @@ except ModuleNotFoundError as exc:
|
|
|
441
442
|
SETTINGS_ROW_EDITOR_COMMAND_LINE,
|
|
442
443
|
SETTINGS_ROW_EDITOR_ENVIRONMENT,
|
|
443
444
|
SETTINGS_ROW_PARSE,
|
|
445
|
+
SETTINGS_ROW_PREFIX_WARN,
|
|
444
446
|
SETTINGS_ROW_PROMPT,
|
|
445
447
|
SETTINGS_SECTION_WIDTH,
|
|
446
448
|
SHORTCUT_ALIASES,
|
|
@@ -630,6 +632,92 @@ class TestGitNightCommanderApp(unittest.TestCase):
|
|
|
630
632
|
|
|
631
633
|
_DialogProbe(app).assert_not_shown()
|
|
632
634
|
|
|
635
|
+
def test_on_mount_does_not_ask_outside_a_submodule(self):
|
|
636
|
+
app = _mock_app()
|
|
637
|
+
app._load_status = MagicMock()
|
|
638
|
+
|
|
639
|
+
with (
|
|
640
|
+
patch.object(app, "_load_saved_theme"),
|
|
641
|
+
patch.object(_app_module, "started_from_midnight_commander", return_value=False),
|
|
642
|
+
):
|
|
643
|
+
app.on_mount()
|
|
644
|
+
|
|
645
|
+
_DialogProbe(app).assert_not_shown()
|
|
646
|
+
app._load_status.assert_called_once_with()
|
|
647
|
+
|
|
648
|
+
def test_on_mount_offers_the_outermost_superproject(self):
|
|
649
|
+
app = _mock_app()
|
|
650
|
+
app._load_status = MagicMock()
|
|
651
|
+
app.git.repository_chain.return_value = ["/top/mid/sub", "/top/mid", "/top"]
|
|
652
|
+
|
|
653
|
+
with (
|
|
654
|
+
patch.object(app, "_load_saved_theme"),
|
|
655
|
+
patch.object(_app_module, "started_from_midnight_commander", return_value=False),
|
|
656
|
+
):
|
|
657
|
+
app.on_mount()
|
|
658
|
+
|
|
659
|
+
_DialogProbe(app).assert_confirm(
|
|
660
|
+
_app_module.SUPERPROJECT_PROMPT,
|
|
661
|
+
["submodule /top/mid/sub", "submodule /top/mid", "top-level /top"],
|
|
662
|
+
)
|
|
663
|
+
# Nothing is read until the user has picked the repository.
|
|
664
|
+
app._load_status.assert_not_called()
|
|
665
|
+
|
|
666
|
+
def test_superproject_confirm_switches_to_the_top_level_repository(self):
|
|
667
|
+
app = _mock_app()
|
|
668
|
+
app._load_status = MagicMock()
|
|
669
|
+
app.git.repository_chain.return_value = ["/top/sub", "/top"]
|
|
670
|
+
|
|
671
|
+
with (
|
|
672
|
+
patch.object(app, "_load_saved_theme"),
|
|
673
|
+
patch.object(_app_module, "started_from_midnight_commander", return_value=False),
|
|
674
|
+
):
|
|
675
|
+
app.on_mount()
|
|
676
|
+
with (
|
|
677
|
+
patch.object(_app_module, "GIT") as git_class,
|
|
678
|
+
patch.object(_app_module.os, "chdir") as chdir,
|
|
679
|
+
):
|
|
680
|
+
_DialogProbe(app).confirm()
|
|
681
|
+
|
|
682
|
+
self.assertEqual(app.repo_path, "/top")
|
|
683
|
+
git_class.assert_called_once_with("/top")
|
|
684
|
+
self.assertIs(app.git, git_class.return_value)
|
|
685
|
+
chdir.assert_called_once_with("/top")
|
|
686
|
+
app._load_status.assert_called_once_with()
|
|
687
|
+
|
|
688
|
+
def test_superproject_cancel_stays_in_the_submodule(self):
|
|
689
|
+
app = _mock_app()
|
|
690
|
+
app._load_status = MagicMock()
|
|
691
|
+
app.git.repository_chain.return_value = ["/top/sub", "/top"]
|
|
692
|
+
git = app.git
|
|
693
|
+
|
|
694
|
+
with (
|
|
695
|
+
patch.object(app, "_load_saved_theme"),
|
|
696
|
+
patch.object(_app_module, "started_from_midnight_commander", return_value=False),
|
|
697
|
+
):
|
|
698
|
+
app.on_mount()
|
|
699
|
+
with patch.object(_app_module.os, "chdir") as chdir:
|
|
700
|
+
_DialogProbe(app).cancel()
|
|
701
|
+
|
|
702
|
+
self.assertIs(app.git, git)
|
|
703
|
+
self.assertEqual(app.repo_path, ".")
|
|
704
|
+
chdir.assert_not_called()
|
|
705
|
+
app._load_status.assert_called_once_with()
|
|
706
|
+
|
|
707
|
+
def test_midnight_commander_warning_follows_the_superproject_question(self):
|
|
708
|
+
app = _mock_app()
|
|
709
|
+
app.git.repository_chain.return_value = ["/top/sub", "/top"]
|
|
710
|
+
|
|
711
|
+
with (
|
|
712
|
+
patch.object(app, "_load_saved_theme"),
|
|
713
|
+
patch.object(_app_module, "started_from_midnight_commander", return_value=True),
|
|
714
|
+
):
|
|
715
|
+
app.on_mount()
|
|
716
|
+
self.assertIsInstance(_DialogProbe(app).assert_open(), _app_module.ConfirmDialog)
|
|
717
|
+
_DialogProbe(app).cancel()
|
|
718
|
+
|
|
719
|
+
self.assertIsInstance(_DialogProbe(app).pushed[-1], _app_module.MidnightCommanderDialog)
|
|
720
|
+
|
|
633
721
|
def test_midnight_commander_dialog_keys(self):
|
|
634
722
|
dialog_class = _app_module.MidnightCommanderDialog
|
|
635
723
|
for key, expected in (("c", True), ("enter", True), ("e", False), ("escape", False)):
|
|
@@ -852,6 +940,8 @@ def _mock_app() -> Any:
|
|
|
852
940
|
app.git = MagicMock()
|
|
853
941
|
app.git.load_status.return_value = GitFilelist()
|
|
854
942
|
app.git.load_commits.return_value = []
|
|
943
|
+
# Not a submodule, so on_mount goes straight to the first load.
|
|
944
|
+
app.git.repository_chain.return_value = ["/repo"]
|
|
855
945
|
# Route entry-aware methods through the legacy filename-keyed mocks so
|
|
856
946
|
# assertions on stage_file / unstage_file / stage_command / commit / etc.
|
|
857
947
|
# keep working without per-test rewires. Submodule-specific behaviour is
|
|
@@ -3306,6 +3396,36 @@ class TestSettingsDialog(unittest.TestCase):
|
|
|
3306
3396
|
self.assertEqual(dialog.get_values().draft_prompt, "a ")
|
|
3307
3397
|
self.assertFalse(dialog.get_values().branch_prefix)
|
|
3308
3398
|
|
|
3399
|
+
def test_the_warned_prefixes_are_typed_under_the_prefix_checkbox(self):
|
|
3400
|
+
dialog = SettingsDialog(prefix_warn_branches="master")
|
|
3401
|
+
for _ in range(SETTINGS_ROW_PREFIX_WARN):
|
|
3402
|
+
dialog.on_key(_key_event("down"))
|
|
3403
|
+
self.assertEqual(dialog._cursor, SETTINGS_ROW_PREFIX_WARN)
|
|
3404
|
+
|
|
3405
|
+
for key, character in (("space", None), ("m", "m")):
|
|
3406
|
+
dialog.on_key(_key_event(key, character=character))
|
|
3407
|
+
|
|
3408
|
+
self.assertEqual(dialog.get_values().prefix_warn_branches, "master m")
|
|
3409
|
+
self.assertFalse(dialog.get_values().branch_prefix)
|
|
3410
|
+
|
|
3411
|
+
def test_the_warned_prefixes_round_trip_through_the_settings_file(self):
|
|
3412
|
+
app = _mock_app()
|
|
3413
|
+
with patch.object(
|
|
3414
|
+
app, "_load_settings", return_value={"commit_prefix_warn_branches": "master main"}
|
|
3415
|
+
):
|
|
3416
|
+
app.action_settings()
|
|
3417
|
+
values = _pushed_screen(app).get_values()
|
|
3418
|
+
self.assertEqual(values.prefix_warn_branches, "master main")
|
|
3419
|
+
|
|
3420
|
+
saved: dict[str, object] = {}
|
|
3421
|
+
with (
|
|
3422
|
+
patch.object(app, "_load_settings", return_value={}),
|
|
3423
|
+
patch.object(app, "_save_settings", side_effect=lambda s: saved.update(s) or True),
|
|
3424
|
+
):
|
|
3425
|
+
app._settings_result(values)
|
|
3426
|
+
|
|
3427
|
+
self.assertEqual(saved["commit_prefix_warn_branches"], "master main")
|
|
3428
|
+
|
|
3309
3429
|
def test_cursor_wraps_over_every_row(self):
|
|
3310
3430
|
dialog = SettingsDialog()
|
|
3311
3431
|
|
|
@@ -4593,6 +4713,7 @@ class TestUIThemeCssVariables(unittest.TestCase):
|
|
|
4593
4713
|
"menu-bar-foreground",
|
|
4594
4714
|
"footer-background",
|
|
4595
4715
|
"footer-foreground",
|
|
4716
|
+
"warning-notification",
|
|
4596
4717
|
"cursor-row-background",
|
|
4597
4718
|
"cursor-row-foreground",
|
|
4598
4719
|
"selected-file-background",
|
|
@@ -4634,6 +4755,28 @@ class TestUIThemeCssVariables(unittest.TestCase):
|
|
|
4634
4755
|
self.assertNotIn("$", variables[field_name])
|
|
4635
4756
|
|
|
4636
4757
|
|
|
4758
|
+
class TestWarningNotificationThemeColor(unittest.TestCase):
|
|
4759
|
+
"""A warning toast is painted from the theme's `warning_notification`
|
|
4760
|
+
field rather than from `$warning`, which a theme may use for chrome."""
|
|
4761
|
+
|
|
4762
|
+
def test_the_default_is_the_palette_warning_role(self):
|
|
4763
|
+
from gitnc.ui_theme import UITheme
|
|
4764
|
+
|
|
4765
|
+
theme = UITheme(name="plain", primary="#123456", warning="#abcdef")
|
|
4766
|
+
|
|
4767
|
+
self.assertEqual(theme.css_variables()["warning-notification"], "#abcdef")
|
|
4768
|
+
|
|
4769
|
+
def test_midnight_commander_warns_in_red_not_in_its_chrome_cyan(self):
|
|
4770
|
+
css_vars = MIDNIGHT_COMMANDER_THEME.css_variables()
|
|
4771
|
+
|
|
4772
|
+
self.assertEqual(css_vars["warning-notification"], "#FF5555")
|
|
4773
|
+
self.assertNotEqual(css_vars["warning-notification"], MIDNIGHT_COMMANDER_THEME.warning)
|
|
4774
|
+
|
|
4775
|
+
def test_the_app_css_paints_warning_toasts_from_the_variable(self):
|
|
4776
|
+
self.assertIn("border-left: outer $warning-notification;", GitNightCommanderApp.CSS)
|
|
4777
|
+
self.assertIn("color: $warning-notification;", GitNightCommanderApp.CSS)
|
|
4778
|
+
|
|
4779
|
+
|
|
4637
4780
|
class TestTextSelectionThemeColors(unittest.TestCase):
|
|
4638
4781
|
"""Selected text in the commit message box takes its colors from the
|
|
4639
4782
|
theme's text selection fields, emitted as Textual's own
|
|
@@ -5107,6 +5250,97 @@ class TestCommitFlow(unittest.TestCase):
|
|
|
5107
5250
|
self.assertTrue(dialogs.pushed[-1].fill_height)
|
|
5108
5251
|
app.git.commit.assert_not_called()
|
|
5109
5252
|
|
|
5253
|
+
def _warned_app(self):
|
|
5254
|
+
entry = GitEntry(GitCode.MODIFIED_UNMODIFIED, "pkg/a.py")
|
|
5255
|
+
app, dialogs, _ = self._app([entry], staged={"pkg/a.py"})
|
|
5256
|
+
app._load_settings = MagicMock(return_value={"commit_prefix_warn_branches": "main, master"})
|
|
5257
|
+
app.action_commit_files()
|
|
5258
|
+
return app, dialogs
|
|
5259
|
+
|
|
5260
|
+
def test_a_warned_prefix_is_asked_about_before_the_commit_confirmation(self):
|
|
5261
|
+
app, dialogs = self._warned_app()
|
|
5262
|
+
|
|
5263
|
+
dialogs._resolve("master: fix x")
|
|
5264
|
+
|
|
5265
|
+
self.assertEqual(
|
|
5266
|
+
dialogs.prompt(),
|
|
5267
|
+
"Warning: commit message is prefixed with branch 'master'. Continue?",
|
|
5268
|
+
)
|
|
5269
|
+
self.assertEqual(dialogs.commands(), ["master: fix x"])
|
|
5270
|
+
app.git.commit.assert_not_called()
|
|
5271
|
+
|
|
5272
|
+
def test_accepting_the_prefix_warning_goes_on_to_the_commit_confirmation(self):
|
|
5273
|
+
app, dialogs = self._warned_app()
|
|
5274
|
+
dialogs._resolve("master: fix x")
|
|
5275
|
+
|
|
5276
|
+
dialogs.confirm()
|
|
5277
|
+
|
|
5278
|
+
self.assertEqual(dialogs.prompt(), "Commit 1 file(s)?")
|
|
5279
|
+
self.assertEqual(dialogs.commands(), ["git commit -m master: fix x"])
|
|
5280
|
+
app.git.commit.assert_not_called()
|
|
5281
|
+
|
|
5282
|
+
def test_declining_the_prefix_warning_reopens_the_message(self):
|
|
5283
|
+
app, dialogs = self._warned_app()
|
|
5284
|
+
dialogs._resolve("master: fix x")
|
|
5285
|
+
|
|
5286
|
+
dialogs.cancel()
|
|
5287
|
+
|
|
5288
|
+
reopened = dialogs.pushed[-1]
|
|
5289
|
+
self.assertIsInstance(reopened, CommitDialog)
|
|
5290
|
+
self.assertEqual(reopened.prefill, "master: fix x")
|
|
5291
|
+
app.git.commit.assert_not_called()
|
|
5292
|
+
|
|
5293
|
+
def test_a_warned_prefix_is_announced_when_the_message_editor_opens(self):
|
|
5294
|
+
entry = GitEntry(GitCode.MODIFIED_UNMODIFIED, "pkg/a.py")
|
|
5295
|
+
app, dialogs, _ = self._app([entry], staged={"pkg/a.py"}, branch="master")
|
|
5296
|
+
app._load_settings = MagicMock(
|
|
5297
|
+
return_value={
|
|
5298
|
+
"branch_prefix_in_commits": True,
|
|
5299
|
+
"commit_prefix_warn_branches": "master",
|
|
5300
|
+
}
|
|
5301
|
+
)
|
|
5302
|
+
|
|
5303
|
+
with patch.object(app, "notify") as notify:
|
|
5304
|
+
app.action_commit_files()
|
|
5305
|
+
|
|
5306
|
+
self.assertEqual(self._commit_screen(dialogs).prefill, "master: ")
|
|
5307
|
+
notify.assert_called_once_with(
|
|
5308
|
+
"Warning: commit message is prefixed with branch 'master'", severity="warning"
|
|
5309
|
+
)
|
|
5310
|
+
|
|
5311
|
+
def test_an_unlisted_prefix_opens_the_message_editor_quietly(self):
|
|
5312
|
+
entry = GitEntry(GitCode.MODIFIED_UNMODIFIED, "pkg/a.py")
|
|
5313
|
+
app, dialogs, _ = self._app([entry], staged={"pkg/a.py"}, branch="feature")
|
|
5314
|
+
app._load_settings = MagicMock(
|
|
5315
|
+
return_value={
|
|
5316
|
+
"branch_prefix_in_commits": True,
|
|
5317
|
+
"commit_prefix_warn_branches": "master",
|
|
5318
|
+
}
|
|
5319
|
+
)
|
|
5320
|
+
|
|
5321
|
+
with patch.object(app, "notify") as notify:
|
|
5322
|
+
app.action_commit_files()
|
|
5323
|
+
|
|
5324
|
+
self.assertEqual(self._commit_screen(dialogs).prefill, "feature: ")
|
|
5325
|
+
notify.assert_not_called()
|
|
5326
|
+
|
|
5327
|
+
def test_other_prefixes_go_straight_to_the_commit_confirmation(self):
|
|
5328
|
+
"""Only a whole listed name followed by the colon counts — not a
|
|
5329
|
+
branch that merely starts with one, and not the name mid-message."""
|
|
5330
|
+
entry = GitEntry(GitCode.MODIFIED_UNMODIFIED, "pkg/a.py")
|
|
5331
|
+
for message in ("master-fix: x", "feature: merge master: x", "masterful change"):
|
|
5332
|
+
with self.subTest(message=message):
|
|
5333
|
+
app, dialogs, _ = self._app([entry], staged={"pkg/a.py"})
|
|
5334
|
+
app._load_settings = MagicMock(
|
|
5335
|
+
return_value={"commit_prefix_warn_branches": "master"}
|
|
5336
|
+
)
|
|
5337
|
+
app.action_commit_files()
|
|
5338
|
+
|
|
5339
|
+
dialogs._resolve(message)
|
|
5340
|
+
|
|
5341
|
+
self.assertEqual(dialogs.prompt(), "Commit 1 file(s)?")
|
|
5342
|
+
self.assertEqual(dialogs.commands(), [f"git commit -m {message}"])
|
|
5343
|
+
|
|
5110
5344
|
def test_the_confirmation_shows_only_the_subject_of_a_long_message(self):
|
|
5111
5345
|
"""The popup renders one command per row, so a multi-line `-m` has to
|
|
5112
5346
|
be cut down to its first line — marked as cut."""
|
|
@@ -734,6 +734,48 @@ class TestShutdown(unittest.TestCase):
|
|
|
734
734
|
self.assertEqual(result.stdout, "")
|
|
735
735
|
|
|
736
736
|
|
|
737
|
+
class TestRepositoryChain(unittest.TestCase):
|
|
738
|
+
"""`GIT.repository_chain()`: this repo's top level out to the outermost superproject."""
|
|
739
|
+
|
|
740
|
+
def test_not_a_submodule_is_a_chain_of_one(self):
|
|
741
|
+
git = GIT("/top/src")
|
|
742
|
+
with fake_git_processes(results=[(0, "/top\n", ""), (0, "", "")]) as processes:
|
|
743
|
+
self.assertEqual(git.repository_chain(), ["/top"])
|
|
744
|
+
|
|
745
|
+
self.assertEqual(
|
|
746
|
+
processes.commands[1][-3:], ["/top", "rev-parse", "--show-superproject-working-tree"]
|
|
747
|
+
)
|
|
748
|
+
|
|
749
|
+
def test_nested_submodules_walk_out_to_the_top_level(self):
|
|
750
|
+
git = GIT("/top/mid/sub/src")
|
|
751
|
+
results = [
|
|
752
|
+
(0, "/top/mid/sub\n", ""),
|
|
753
|
+
(0, "/top/mid\n", ""),
|
|
754
|
+
(0, "/top\n", ""),
|
|
755
|
+
(0, "", ""),
|
|
756
|
+
]
|
|
757
|
+
with fake_git_processes(results=results) as processes:
|
|
758
|
+
chain = git.repository_chain()
|
|
759
|
+
|
|
760
|
+
self.assertEqual(chain, ["/top/mid/sub", "/top/mid", "/top"])
|
|
761
|
+
# Each step asks from the repository the previous step found.
|
|
762
|
+
self.assertEqual(
|
|
763
|
+
[argv[4] for argv in processes.commands[1:]], ["/top/mid/sub", "/top/mid", "/top"]
|
|
764
|
+
)
|
|
765
|
+
|
|
766
|
+
def test_outside_a_repository_is_empty(self):
|
|
767
|
+
git = GIT("/tmp")
|
|
768
|
+
with fake_git_processes(returncode=128, stderr="fatal: not a git repository") as processes:
|
|
769
|
+
self.assertEqual(git.repository_chain(), [])
|
|
770
|
+
|
|
771
|
+
self.assertEqual(processes.call_count, 1)
|
|
772
|
+
|
|
773
|
+
def test_a_cycle_stops_the_walk(self):
|
|
774
|
+
git = GIT("/a")
|
|
775
|
+
with fake_git_processes(results=[(0, "/a\n", ""), (0, "/b\n", ""), (0, "/a\n", "")]):
|
|
776
|
+
self.assertEqual(git.repository_chain(), ["/a", "/b"])
|
|
777
|
+
|
|
778
|
+
|
|
737
779
|
class TestRemoteCommands(unittest.TestCase):
|
|
738
780
|
"""git pull / push: upstream detection and the argv it produces."""
|
|
739
781
|
|
|
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
|