From 1c61841f1ddfda4509e8b92b746a17bc7ef921ac Mon Sep 17 00:00:00 2001 From: GODDiao Date: Sun, 26 Apr 2026 03:25:30 +0800 Subject: [PATCH] Add progress bar for Git clone and fetch operations. (#1858) Git clone and fetch operations now display a live progress bar using the Rich library, showing the stage (counting, receiving, resolving deltas, etc.) and estimated time remaining. This replaces a static log message that made Briefcase appear frozen during template downloads. --- src/briefcase/commands/base.py | 24 +++++-- src/briefcase/git.py | 68 +++++++++++++++++++ .../base/test_update_cookiecutter_cache.py | 11 +-- .../create/test_generate_app_template.py | 10 +-- 4 files changed, 97 insertions(+), 16 deletions(-) create mode 100644 src/briefcase/git.py diff --git a/src/briefcase/commands/base.py b/src/briefcase/commands/base.py index 7158bd206..8f3a80cfa 100644 --- a/src/briefcase/commands/base.py +++ b/src/briefcase/commands/base.py @@ -49,6 +49,7 @@ UnsupportedHostError, UnsupportedPythonVersion, ) +from briefcase.git import GitProgress from briefcase.integrations.base import ToolCache from briefcase.integrations.file import File from briefcase.integrations.subprocess import Subprocess @@ -1178,12 +1179,17 @@ def update_cookiecutter_cache(self, template: str, branch="master"): try: self.console.info(f"Cloning template {template!r}...") cached_template.mkdir(exist_ok=True, parents=True) - repo = self.tools.git.Repo.clone_from( - url=template, - to_path=cached_template, - filter=["blob:none"], - no_checkout=True, - ) + git_progress = GitProgress() + try: + repo = self.tools.git.Repo.clone_from( + url=template, + to_path=cached_template, + filter=["blob:none"], + no_checkout=True, + progress=git_progress, + ) + finally: + git_progress.close() except KeyboardInterrupt: # The user has aborted the initial clone. Git is fairly resilient to # being interrupted, but if the *initial* clone fails, it's very @@ -1228,7 +1234,11 @@ def update_cookiecutter_cache(self, template: str, branch="master"): remote.set_url(new_url=template) try: # Attempt to update the repository - remote.fetch() + git_progress = GitProgress() + try: + remote.fetch(progress=git_progress) + finally: + git_progress.close() except self.tools.git.exc.GitCommandError as e: # We are offline, or otherwise unable to contact the origin git # repo. It's OK to continue; but capture the error in the log and diff --git a/src/briefcase/git.py b/src/briefcase/git.py new file mode 100644 index 000000000..9ebe3c865 --- /dev/null +++ b/src/briefcase/git.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from git.remote import RemoteProgress +from rich.progress import BarColumn, Progress, TextColumn, TimeRemainingColumn + + +class GitProgress(RemoteProgress): + """Report Git clone/fetch progress to a Rich progress bar. + + This class implements GitPython's ``RemoteProgress`` interface to display + real-time progress of Git operations (clone, fetch) using Rich's live + progress display. + """ + + def __init__(self): + super().__init__() + self._progress = Progress( + TextColumn("[progress.description]{task.description}"), + BarColumn(), + TextColumn("[progress.percentage]{task.percentage:>3.0f}%"), + TimeRemainingColumn(), + ) + self._task_id = None + self._progress.start() + + def update(self, op_code, cur_count, max_count=None, message=""): + if self._task_id is None: + stage = self._get_stage_label(op_code) + self._task_id = self._progress.add_task( + description=f"[cyan]{stage}...", + total=max_count or 100, + ) + + if max_count is not None: + self._progress.update( + self._task_id, + completed=cur_count, + total=max_count, + ) + else: + self._progress.update(self._task_id, completed=cur_count) + + if op_code & RemoteProgress.END: + self._progress.stop() + + @staticmethod + def _get_stage_label(op_code): + """Map a Git operation code to a human-readable stage label.""" + if op_code & RemoteProgress.COUNTING: + return "Counting objects" + if op_code & RemoteProgress.COMPRESSION: + return "Compressing objects" + if op_code & RemoteProgress.RECEIVING: + return "Receiving objects" + if op_code & RemoteProgress.RESOLVING: + return "Resolving deltas" + if op_code & RemoteProgress.WRITING: + return "Writing objects" + if op_code & RemoteProgress.CHECKING_OUT: + return "Checking out files" + if op_code & RemoteProgress.FINDING_SOURCES: + return "Finding sources" + return "Cloning" + + def close(self): + """Safely stop the progress display (idempotent).""" + if self._progress is not None: + self._progress.stop() diff --git a/tests/commands/base/test_update_cookiecutter_cache.py b/tests/commands/base/test_update_cookiecutter_cache.py index 1dc0a157f..7ad913fc3 100644 --- a/tests/commands/base/test_update_cookiecutter_cache.py +++ b/tests/commands/base/test_update_cookiecutter_cache.py @@ -45,6 +45,7 @@ def test_new_repo_template(base_command, mock_git): to_path=base_command.data_path / "templates" / "special-template", filter=["blob:none"], no_checkout=True, + progress=mock.ANY, ) @@ -122,6 +123,7 @@ def test_new_repo_invalid_template_url(base_command, mock_git): to_path=base_command.data_path / "templates" / "special-template", filter=["blob:none"], no_checkout=True, + progress=mock.ANY, ) # The template directory should be cleaned up @@ -233,7 +235,7 @@ def test_existing_repo_template(base_command, mock_git): mock_remote.set_url.assert_called_once_with( new_url="https://example.com/magic/special-template.git", ) - mock_remote.fetch.assert_called_once_with() + mock_remote.fetch.assert_called_once_with(progress=mock.ANY) # The right branch was accessed mock_remote.refs.__getitem__.assert_called_once_with("special") @@ -279,6 +281,7 @@ def test_existing_repo_template_corrupted(base_command, mock_git): to_path=base_command.data_path / "templates" / "special-template", filter=["blob:none"], no_checkout=True, + progress=mock.ANY, ) # The old template content has been deleted @@ -320,7 +323,7 @@ def test_existing_repo_template_with_different_url(base_command, mock_git): mock_remote.set_url.assert_called_once_with( new_url="https://example.com/magic/special-template.git", ) - mock_remote.fetch.assert_called_once_with() + mock_remote.fetch.assert_called_once_with(progress=mock.ANY) # The right branch was accessed mock_remote.refs.__getitem__.assert_called_once_with("special") @@ -368,7 +371,7 @@ def test_offline_repo_template(base_command, mock_git, capsys): mock_remote.set_url.assert_called_once_with( new_url="https://example.com/magic/special-template.git", ) - mock_remote.fetch.assert_called_once_with() + mock_remote.fetch.assert_called_once_with(progress=mock.ANY) # The right branch was accessed mock_remote.refs.__getitem__.assert_called_once_with("special") @@ -418,7 +421,7 @@ def test_cached_missing_branch_template(base_command, mock_git): mock_remote.set_url.assert_called_once_with( new_url="https://example.com/magic/special-template.git", ) - mock_remote.fetch.assert_called_once_with() + mock_remote.fetch.assert_called_once_with(progress=mock.ANY) # An attempt to access the branch was made mock_remote.refs.__getitem__.assert_called_once_with("invalid") diff --git a/tests/commands/create/test_generate_app_template.py b/tests/commands/create/test_generate_app_template.py index 8f1a8f853..311fd8232 100644 --- a/tests/commands/create/test_generate_app_template.py +++ b/tests/commands/create/test_generate_app_template.py @@ -566,7 +566,7 @@ def test_cached_template(monkeypatch, create_command, myapp, full_context, tmp_p # The origin of the repo was fetched mock_repo.remote.assert_called_once_with(name="origin") - mock_remote.fetch.assert_called_once_with() + mock_remote.fetch.assert_called_once_with(progress=mock.ANY) # The remote head was checked out. mock_remote_head.checkout.assert_called_once_with() @@ -619,15 +619,15 @@ def test_cached_template_offline( # An attempt to fetch the repo origin was made mock_repo.remote.assert_called_once_with(name="origin") - mock_remote.fetch.assert_called_once_with() + mock_remote.fetch.assert_called_once_with(progress=mock.ANY) + + # The remote head was checked out. + mock_remote_head.checkout.assert_called_once_with() # A warning was raised to the user about the fetch problem output = capsys.readouterr().out assert "** WARNING: Unable to update template" in output - # The remote head was checked out. - mock_remote_head.checkout.assert_called_once_with() - # Cookiecutter was invoked with the path to the *cached* template name create_command.tools.cookiecutter.assert_called_once_with( str(create_command.data_path / "templates/briefcase-Tester-Dummy-template"),