gitnc 0.1.2__tar.gz → 0.1.3__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.2 → gitnc-0.1.3}/CLAUDE.md +3 -1
  2. {gitnc-0.1.2 → gitnc-0.1.3}/PKG-INFO +1 -1
  3. {gitnc-0.1.2 → gitnc-0.1.3}/README.md +3 -1
  4. {gitnc-0.1.2 → gitnc-0.1.3}/gitnc/app.py +41 -0
  5. {gitnc-0.1.2 → gitnc-0.1.3}/gitnc/git.py +22 -0
  6. {gitnc-0.1.2 → gitnc-0.1.3}/pyproject.toml +1 -1
  7. {gitnc-0.1.2 → gitnc-0.1.3}/tests/test_app.py +88 -0
  8. {gitnc-0.1.2 → gitnc-0.1.3}/tests/test_git.py +42 -0
  9. {gitnc-0.1.2 → gitnc-0.1.3}/.devcontainer/Dockerfile +0 -0
  10. {gitnc-0.1.2 → gitnc-0.1.3}/.devcontainer/devcontainer.json +0 -0
  11. {gitnc-0.1.2 → gitnc-0.1.3}/.gitignore +0 -0
  12. {gitnc-0.1.2 → gitnc-0.1.3}/LICENSE +0 -0
  13. {gitnc-0.1.2 → gitnc-0.1.3}/gitnc/__init__.py +0 -0
  14. {gitnc-0.1.2 → gitnc-0.1.3}/gitnc/__main__.py +0 -0
  15. {gitnc-0.1.2 → gitnc-0.1.3}/gitnc/terminal.py +0 -0
  16. {gitnc-0.1.2 → gitnc-0.1.3}/gitnc/themes/__init__.py +0 -0
  17. {gitnc-0.1.2 → gitnc-0.1.3}/gitnc/themes/atom-one-dark.yaml +0 -0
  18. {gitnc-0.1.2 → gitnc-0.1.3}/gitnc/themes/atom-one-light.yaml +0 -0
  19. {gitnc-0.1.2 → gitnc-0.1.3}/gitnc/themes/catppuccin-frappe.yaml +0 -0
  20. {gitnc-0.1.2 → gitnc-0.1.3}/gitnc/themes/catppuccin-latte.yaml +0 -0
  21. {gitnc-0.1.2 → gitnc-0.1.3}/gitnc/themes/catppuccin-macchiato.yaml +0 -0
  22. {gitnc-0.1.2 → gitnc-0.1.3}/gitnc/themes/catppuccin-mocha.yaml +0 -0
  23. {gitnc-0.1.2 → gitnc-0.1.3}/gitnc/themes/dracula.yaml +0 -0
  24. {gitnc-0.1.2 → gitnc-0.1.3}/gitnc/themes/flexoki.yaml +0 -0
  25. {gitnc-0.1.2 → gitnc-0.1.3}/gitnc/themes/gruvbox.yaml +0 -0
  26. {gitnc-0.1.2 → gitnc-0.1.3}/gitnc/themes/midnight-commander.yaml +0 -0
  27. {gitnc-0.1.2 → gitnc-0.1.3}/gitnc/themes/monokai.yaml +0 -0
  28. {gitnc-0.1.2 → gitnc-0.1.3}/gitnc/themes/nord.yaml +0 -0
  29. {gitnc-0.1.2 → gitnc-0.1.3}/gitnc/themes/rose-pine-dawn.yaml +0 -0
  30. {gitnc-0.1.2 → gitnc-0.1.3}/gitnc/themes/rose-pine-moon.yaml +0 -0
  31. {gitnc-0.1.2 → gitnc-0.1.3}/gitnc/themes/rose-pine.yaml +0 -0
  32. {gitnc-0.1.2 → gitnc-0.1.3}/gitnc/themes/solarized-dark.yaml +0 -0
  33. {gitnc-0.1.2 → gitnc-0.1.3}/gitnc/themes/solarized-light.yaml +0 -0
  34. {gitnc-0.1.2 → gitnc-0.1.3}/gitnc/themes/textual-ansi.yaml +0 -0
  35. {gitnc-0.1.2 → gitnc-0.1.3}/gitnc/themes/textual-dark.yaml +0 -0
  36. {gitnc-0.1.2 → gitnc-0.1.3}/gitnc/themes/textual-light.yaml +0 -0
  37. {gitnc-0.1.2 → gitnc-0.1.3}/gitnc/themes/tokyo-night.yaml +0 -0
  38. {gitnc-0.1.2 → gitnc-0.1.3}/gitnc/ui_theme.py +0 -0
  39. {gitnc-0.1.2 → gitnc-0.1.3}/tests/__init__.py +0 -0
  40. {gitnc-0.1.2 → gitnc-0.1.3}/tests/gitprocess.py +0 -0
  41. {gitnc-0.1.2 → gitnc-0.1.3}/tests/test_terminal.py +0 -0
  42. {gitnc-0.1.2 → gitnc-0.1.3}/tests/test_themes.py +0 -0
@@ -50,11 +50,13 @@ 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
- **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 is the only reason `on_cancel` exists; every other caller wants the default, which hands focus back to the active view.
59
+ **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 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.
58
60
 
59
61
  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
62
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: gitnc
3
- Version: 0.1.2
3
+ Version: 0.1.3
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
@@ -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
@@ -2804,10 +2807,48 @@ class GitNightCommanderApp(App):
2804
2807
 
2805
2808
  def on_mount(self) -> None:
2806
2809
  self._load_saved_theme()
2810
+ self._offer_top_level_repository(then=self._start)
2811
+
2812
+ def _start(self) -> None:
2807
2813
  self._load_status()
2808
2814
  self._load_commits()
2809
2815
  self._warn_if_started_from_midnight_commander()
2810
2816
 
2817
+ def _offer_top_level_repository(self, then: Callable[[], None]) -> None:
2818
+ """Ask to move to the outermost superproject when started in a submodule.
2819
+
2820
+ Runs *then* once the question is answered (or at once, when there is
2821
+ nothing to ask), so the first `git status` reads the repository the
2822
+ user settled on rather than one it is about to leave. The chain is a
2823
+ `git rev-parse` per nesting level, which doesn't grow with the repo,
2824
+ so it runs inline.
2825
+ """
2826
+ chain = self.git.repository_chain()
2827
+ if len(chain) < 2:
2828
+ then()
2829
+ return
2830
+ rows = [f"submodule {path}" for path in chain[:-1]]
2831
+ rows.append(f"top-level {chain[-1]}")
2832
+
2833
+ def confirmed() -> None:
2834
+ self._switch_repository(chain[-1])
2835
+ then()
2836
+
2837
+ self._show_confirm(SUPERPROJECT_PROMPT, rows, on_confirm=confirmed, on_cancel=then)
2838
+
2839
+ def _switch_repository(self, path: str) -> None:
2840
+ """Point the app at *path*; the caller reloads the views.
2841
+
2842
+ The process moves there too, so the `Ctrl+O` shell and the tools run
2843
+ from the previous screen start where the file list is.
2844
+ """
2845
+ self.repo_path = path
2846
+ self.git = GIT(path)
2847
+ try:
2848
+ os.chdir(path)
2849
+ except OSError:
2850
+ pass
2851
+
2811
2852
  def _warn_if_started_from_midnight_commander(self) -> None:
2812
2853
  if not started_from_midnight_commander():
2813
2854
  return
@@ -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()
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "gitnc"
7
- version = "0.1.2"
7
+ version = "0.1.3"
8
8
  description = "GitNightCommander — a TUI git log browser built with Textual"
9
9
  requires-python = ">=3.12"
10
10
  dependencies = [
@@ -630,6 +630,92 @@ class TestGitNightCommanderApp(unittest.TestCase):
630
630
 
631
631
  _DialogProbe(app).assert_not_shown()
632
632
 
633
+ def test_on_mount_does_not_ask_outside_a_submodule(self):
634
+ app = _mock_app()
635
+ app._load_status = MagicMock()
636
+
637
+ with (
638
+ patch.object(app, "_load_saved_theme"),
639
+ patch.object(_app_module, "started_from_midnight_commander", return_value=False),
640
+ ):
641
+ app.on_mount()
642
+
643
+ _DialogProbe(app).assert_not_shown()
644
+ app._load_status.assert_called_once_with()
645
+
646
+ def test_on_mount_offers_the_outermost_superproject(self):
647
+ app = _mock_app()
648
+ app._load_status = MagicMock()
649
+ app.git.repository_chain.return_value = ["/top/mid/sub", "/top/mid", "/top"]
650
+
651
+ with (
652
+ patch.object(app, "_load_saved_theme"),
653
+ patch.object(_app_module, "started_from_midnight_commander", return_value=False),
654
+ ):
655
+ app.on_mount()
656
+
657
+ _DialogProbe(app).assert_confirm(
658
+ _app_module.SUPERPROJECT_PROMPT,
659
+ ["submodule /top/mid/sub", "submodule /top/mid", "top-level /top"],
660
+ )
661
+ # Nothing is read until the user has picked the repository.
662
+ app._load_status.assert_not_called()
663
+
664
+ def test_superproject_confirm_switches_to_the_top_level_repository(self):
665
+ app = _mock_app()
666
+ app._load_status = MagicMock()
667
+ app.git.repository_chain.return_value = ["/top/sub", "/top"]
668
+
669
+ with (
670
+ patch.object(app, "_load_saved_theme"),
671
+ patch.object(_app_module, "started_from_midnight_commander", return_value=False),
672
+ ):
673
+ app.on_mount()
674
+ with (
675
+ patch.object(_app_module, "GIT") as git_class,
676
+ patch.object(_app_module.os, "chdir") as chdir,
677
+ ):
678
+ _DialogProbe(app).confirm()
679
+
680
+ self.assertEqual(app.repo_path, "/top")
681
+ git_class.assert_called_once_with("/top")
682
+ self.assertIs(app.git, git_class.return_value)
683
+ chdir.assert_called_once_with("/top")
684
+ app._load_status.assert_called_once_with()
685
+
686
+ def test_superproject_cancel_stays_in_the_submodule(self):
687
+ app = _mock_app()
688
+ app._load_status = MagicMock()
689
+ app.git.repository_chain.return_value = ["/top/sub", "/top"]
690
+ git = app.git
691
+
692
+ with (
693
+ patch.object(app, "_load_saved_theme"),
694
+ patch.object(_app_module, "started_from_midnight_commander", return_value=False),
695
+ ):
696
+ app.on_mount()
697
+ with patch.object(_app_module.os, "chdir") as chdir:
698
+ _DialogProbe(app).cancel()
699
+
700
+ self.assertIs(app.git, git)
701
+ self.assertEqual(app.repo_path, ".")
702
+ chdir.assert_not_called()
703
+ app._load_status.assert_called_once_with()
704
+
705
+ def test_midnight_commander_warning_follows_the_superproject_question(self):
706
+ app = _mock_app()
707
+ app.git.repository_chain.return_value = ["/top/sub", "/top"]
708
+
709
+ with (
710
+ patch.object(app, "_load_saved_theme"),
711
+ patch.object(_app_module, "started_from_midnight_commander", return_value=True),
712
+ ):
713
+ app.on_mount()
714
+ self.assertIsInstance(_DialogProbe(app).assert_open(), _app_module.ConfirmDialog)
715
+ _DialogProbe(app).cancel()
716
+
717
+ self.assertIsInstance(_DialogProbe(app).pushed[-1], _app_module.MidnightCommanderDialog)
718
+
633
719
  def test_midnight_commander_dialog_keys(self):
634
720
  dialog_class = _app_module.MidnightCommanderDialog
635
721
  for key, expected in (("c", True), ("enter", True), ("e", False), ("escape", False)):
@@ -852,6 +938,8 @@ def _mock_app() -> Any:
852
938
  app.git = MagicMock()
853
939
  app.git.load_status.return_value = GitFilelist()
854
940
  app.git.load_commits.return_value = []
941
+ # Not a submodule, so on_mount goes straight to the first load.
942
+ app.git.repository_chain.return_value = ["/repo"]
855
943
  # Route entry-aware methods through the legacy filename-keyed mocks so
856
944
  # assertions on stage_file / unstage_file / stage_command / commit / etc.
857
945
  # keep working without per-test rewires. Submodule-specific behaviour is
@@ -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
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes