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.
- {gitnc-0.1.2 → gitnc-0.1.3}/CLAUDE.md +3 -1
- {gitnc-0.1.2 → gitnc-0.1.3}/PKG-INFO +1 -1
- {gitnc-0.1.2 → gitnc-0.1.3}/README.md +3 -1
- {gitnc-0.1.2 → gitnc-0.1.3}/gitnc/app.py +41 -0
- {gitnc-0.1.2 → gitnc-0.1.3}/gitnc/git.py +22 -0
- {gitnc-0.1.2 → gitnc-0.1.3}/pyproject.toml +1 -1
- {gitnc-0.1.2 → gitnc-0.1.3}/tests/test_app.py +88 -0
- {gitnc-0.1.2 → gitnc-0.1.3}/tests/test_git.py +42 -0
- {gitnc-0.1.2 → gitnc-0.1.3}/.devcontainer/Dockerfile +0 -0
- {gitnc-0.1.2 → gitnc-0.1.3}/.devcontainer/devcontainer.json +0 -0
- {gitnc-0.1.2 → gitnc-0.1.3}/.gitignore +0 -0
- {gitnc-0.1.2 → gitnc-0.1.3}/LICENSE +0 -0
- {gitnc-0.1.2 → gitnc-0.1.3}/gitnc/__init__.py +0 -0
- {gitnc-0.1.2 → gitnc-0.1.3}/gitnc/__main__.py +0 -0
- {gitnc-0.1.2 → gitnc-0.1.3}/gitnc/terminal.py +0 -0
- {gitnc-0.1.2 → gitnc-0.1.3}/gitnc/themes/__init__.py +0 -0
- {gitnc-0.1.2 → gitnc-0.1.3}/gitnc/themes/atom-one-dark.yaml +0 -0
- {gitnc-0.1.2 → gitnc-0.1.3}/gitnc/themes/atom-one-light.yaml +0 -0
- {gitnc-0.1.2 → gitnc-0.1.3}/gitnc/themes/catppuccin-frappe.yaml +0 -0
- {gitnc-0.1.2 → gitnc-0.1.3}/gitnc/themes/catppuccin-latte.yaml +0 -0
- {gitnc-0.1.2 → gitnc-0.1.3}/gitnc/themes/catppuccin-macchiato.yaml +0 -0
- {gitnc-0.1.2 → gitnc-0.1.3}/gitnc/themes/catppuccin-mocha.yaml +0 -0
- {gitnc-0.1.2 → gitnc-0.1.3}/gitnc/themes/dracula.yaml +0 -0
- {gitnc-0.1.2 → gitnc-0.1.3}/gitnc/themes/flexoki.yaml +0 -0
- {gitnc-0.1.2 → gitnc-0.1.3}/gitnc/themes/gruvbox.yaml +0 -0
- {gitnc-0.1.2 → gitnc-0.1.3}/gitnc/themes/midnight-commander.yaml +0 -0
- {gitnc-0.1.2 → gitnc-0.1.3}/gitnc/themes/monokai.yaml +0 -0
- {gitnc-0.1.2 → gitnc-0.1.3}/gitnc/themes/nord.yaml +0 -0
- {gitnc-0.1.2 → gitnc-0.1.3}/gitnc/themes/rose-pine-dawn.yaml +0 -0
- {gitnc-0.1.2 → gitnc-0.1.3}/gitnc/themes/rose-pine-moon.yaml +0 -0
- {gitnc-0.1.2 → gitnc-0.1.3}/gitnc/themes/rose-pine.yaml +0 -0
- {gitnc-0.1.2 → gitnc-0.1.3}/gitnc/themes/solarized-dark.yaml +0 -0
- {gitnc-0.1.2 → gitnc-0.1.3}/gitnc/themes/solarized-light.yaml +0 -0
- {gitnc-0.1.2 → gitnc-0.1.3}/gitnc/themes/textual-ansi.yaml +0 -0
- {gitnc-0.1.2 → gitnc-0.1.3}/gitnc/themes/textual-dark.yaml +0 -0
- {gitnc-0.1.2 → gitnc-0.1.3}/gitnc/themes/textual-light.yaml +0 -0
- {gitnc-0.1.2 → gitnc-0.1.3}/gitnc/themes/tokyo-night.yaml +0 -0
- {gitnc-0.1.2 → gitnc-0.1.3}/gitnc/ui_theme.py +0 -0
- {gitnc-0.1.2 → gitnc-0.1.3}/tests/__init__.py +0 -0
- {gitnc-0.1.2 → gitnc-0.1.3}/tests/gitprocess.py +0 -0
- {gitnc-0.1.2 → gitnc-0.1.3}/tests/test_terminal.py +0 -0
- {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
|
|
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.
|
|
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()
|
|
@@ -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
|
|
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
|