Repository: beeware/briefcase

Issue:
Improve Error Handling for Invalid Briefcase Template
### What is the problem or limitation you are having?

The error state for using an invalid remote repo can be improved. While the "invalid repo" example is completely manufactured, I encountered the non-existent repo one with a typo.

A non-existent repo

```
❯ briefcase new -t https://github.com/beeware/beeware-template.git --template-branch linux-system --no-input

Generating a new application 'Hello World'
Using app template: https://github.com/beeware/beeware-template.git, branch linux-system

Log saved to /home/russell/tmp/beeware/logs/briefcase.2023_03_05-16_19_56.new.log

Traceback (most recent call last):
  File "/home/russell/github/beeware/briefcase/venv-3.10-briefcase/bin/briefcase", line 8, in <module>
    sys.exit(main())
  File "/home/russell/github/beeware/briefcase/src/briefcase/__main__.py", line 20, in main
    command(**options)
  File "/home/russell/github/beeware/briefcase/src/briefcase/commands/new.py", line 528, in __call__
    return self.new_app(
  File "/home/russell/github/beeware/briefcase/src/briefcase/commands/new.py", line 477, in new_app
    self.generate_template(
  File "/home/russell/github/beeware/briefcase/src/briefcase/commands/base.py", line 814, in generate_template
    cached_template = self.update_cookiecutter_cache(
  File "/home/russell/github/beeware/briefcase/src/briefcase/commands/base.py", line 758, in update_cookiecutter_cache
    remote = repo.remote(name="origin")
  File "/home/russell/github/beeware/briefcase/venv-3.10-briefcase/lib/python3.10/site-packages/git/repo/base.py", line 414, in remote
    raise ValueError("Remote named '%s' didn't exist" % name)
ValueError: Remote named 'origin' didn't exist
```

An invalid repo

```
❯ briefcase new -t https://example.com --template-branch linux-system --no-input

Generating a new application 'Hello World'
Using app template: https://example.com, branch linux-system

Log saved to /home/russell/tmp/beeware/logs/briefcase.2023_03_05-16_20_46.new.log

Traceback (most recent call last):
  File "/home/russell/github/beeware/briefcase/venv-3.10-briefcase/bin/briefcase", line 8, in <module>
    sys.exit(main())
  File "/home/russell/github/beeware/briefcase/src/briefcase/__main__.py", line 20, in main
    command(**options)
  File "/home/russell/github/beeware/briefcase/src/briefcase/commands/new.py", line 528, in __call__
    return self.new_app(
  File "/home/russell/github/beeware/briefcase/src/briefcase/commands/new.py", line 477, in new_app
    self.generate_template(
  File "/home/russell/github/beeware/briefcase/src/briefcase/commands/base.py", line 820, in generate_template
    self.tools.cookiecutter(
  File "/home/russell/github/beeware/briefcase/venv-3.10-briefcase/lib/python3.10/site-packages/cookiecutter/main.py", line 69, in cookiecutter
    repo_dir, cleanup = determine_repo_dir(
  File "/home/russell/github/beeware/briefcase/venv-3.10-briefcase/lib/python3.10/site-packages/cookiecutter/repository.py", line 106, in determine_repo_dir
    cloned_repo = clone(
  File "/home/russell/github/beeware/briefcase/venv-3.10-briefcase/lib/python3.10/site-packages/cookiecutter/vcs.py", line 72, in clone
    repo_type, repo_url = identify_repo(repo_url)
  File "/home/russell/github/beeware/briefcase/venv-3.10-briefcase/lib/python3.10/site-packages/cookiecutter/vcs.py", line 45, in identify_repo
    raise UnknownRepoType
cookiecutter.exceptions.UnknownRepoType
```


### Describe the solution you'd like

Catch these errors and inform the user to verify the template.

### Describe alternatives you've considered

Interpret the errors myself.

### Additional context

[briefcase.2023_03_05-16_19_56.new.log](https://github.com/beeware/briefcase/files/10892997/briefcase.2023_03_05-16_19_56.new.log)
[briefcase.2023_03_05-16_20_46.new.log](https://github.com/beeware/briefcase/files/10892998/briefcase.2023_03_05-16_20_46.new.log)


Discussion:

Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7b4b4338..d79ea6bf 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -60,23 +60,26 @@ jobs:
     name: Unit tests
     needs: [ pre-commit, towncrier, package ]
     runs-on: ${{ matrix.platform }}
-    continue-on-error: ${{ matrix.experimental || false }}
+    continue-on-error: ${{ matrix.experimental }}
     strategy:
       fail-fast: false
       matrix:
-        platform: [ "macos-13", "macos-latest", "windows-latest", "ubuntu-latest" ]
-        python-version: [ "3.9", "3.13" ]
+        platform: [ "macos-12", "macos-14", "windows-latest", "ubuntu-latest" ]
+        python-version: [ "3.9", "3.12", "3.13-dev" ]
         include:
-          # Ensure the Python versions between min and max are tested
+          - experimental: false
+          - platform: "ubuntu-latest"
+            python-version: "3.9"
+            experimental: false
           - platform: "ubuntu-latest"
             python-version: "3.10"
+            experimental: false
           - platform: "ubuntu-latest"
             python-version: "3.11"
-          - platform: "ubuntu-latest"
-            python-version: "3.12"
-          # # Allow dev Python to fail without failing entire job
-          # - python-version: "3.14"
-          #   experimental: true
+            experimental: false
+          # Allow dev Python to fail without failing entire job
+          - python-version: "3.13-dev"
+            experimental: true
     steps:
     - name: Checkout
       uses: actions/checkout@v4.2.1
@@ -87,7 +90,6 @@ jobs:
       uses: actions/setup-python@v5.2.0
       with:
         python-version: ${{ matrix.python-version }}
-        allow-prereleases: true
 
     - name: Get Packages
       uses: actions/download-artifact@v4.1.8
@@ -183,26 +185,23 @@ jobs:
       fail-fast: false
       matrix:
         framework: [ "toga", "pyside6", "pygame", "console" ]
-        runner-os: [ "macos-13", "macos-latest", "ubuntu-latest", "windows-latest" ]
+        runner-os: [ "macos-12", "macos-14", "ubuntu-22.04", "windows-latest" ]
 
   verify-apps:
     name: Build app
     needs: [ package, package-automation, unit-tests ]
     uses: beeware/.github/.github/workflows/app-build-verify.yml@main
     with:
-      python-version: ${{ matrix.python-version }}
+      # This *must* be the version of Python that is the system Python on the
+      # Ubuntu version used to run Linux tests. We use a fixed ubuntu-22.04
+      # rather than `-latest` because at some point, `-latest` will become
+      # `-24.04`, but it will be a soft changeover, which will cause havoc with
+      # the hard Python version requirement for local system packages.
+      python-version: "3.10"
       runner-os: ${{ matrix.runner-os }}
       framework: ${{ matrix.framework }}
     strategy:
       fail-fast: false
       matrix:
         framework: [ "toga", "pyside6", "pygame", "console" ]
-        runner-os: [ "macos-13", "macos-latest", "ubuntu-latest", "windows-latest" ]
-
-        include:
-          # A version of Python that is supported by all the GUI toolkits.
-          - python-version: "3.12"
-
-          # Ubuntu must always use the system Python
-          - runner-os: ubuntu-latest
-            python-version: system
+        runner-os: [ "macos-12", "macos-14", "ubuntu-22.04", "windows-latest" ]
diff --git a/changes/1118.feature.rst b/changes/1118.feature.rst
new file mode 100644
index 00000000..1fe3b822
--- /dev/null
+++ b/changes/1118.feature.rst
@@ -0,0 +1,1 @@
+Briefcase will now surface git's error messages if an error occurs when cloning a template repository.
diff --git a/changes/2032.misc.rst b/changes/2032.misc.rst
deleted file mode 100644
index 46a936b4..00000000
--- a/changes/2032.misc.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-CI configurations were updated to reflect the stable status of 3.13, and the "system" option for app-build-verify.
diff --git a/src/briefcase/commands/base.py b/src/briefcase/commands/base.py
index cddd3726..94e34bd7 100644
--- a/src/briefcase/commands/base.py
+++ b/src/briefcase/commands/base.py
@@ -6,6 +6,7 @@ import importlib.metadata
 import inspect
 import os
 import platform
+import re
 import subprocess
 import sys
 from abc import ABC, abstractmethod
@@ -31,10 +32,10 @@ from briefcase.console import MAX_TEXT_WIDTH, Console, Log
 from briefcase.exceptions import (
     BriefcaseCommandError,
     BriefcaseConfigError,
+    InvalidTemplateBranch,
     InvalidTemplateRepository,
     MissingAppMetadata,
     NetworkFailure,
-    TemplateUnsupportedVersion,
     UnsupportedHostError,
     UnsupportedPythonVersion,
 )
@@ -977,11 +978,23 @@ Did you run Briefcase in a project directory that contains {filename.name!r}?"""
                     # any partial remnants of this initial clone.
                     # If we're getting a GitError, we know the directory must exist.
                     self.tools.shutil.rmtree(cached_template)
+                    git_fatal_message = re.findall(r"(?<=fatal: ).*?$", e.stderr, re.S)
+                    if git_fatal_message:
+                        # GitError captures stderr with single quotes. Because the regex above
+                        # takes everything after git's "fatal" message, we need to strip that final single quote.
+                        hint = git_fatal_message[0].rstrip("'").strip()
+
+                        # git is inconsistent with capitalisation of the first word of the message
+                        # and about periods at the end of the message.
+                        hint = f"{hint[0].upper()}{hint[1:]}{'' if hint[-1] == '.' else '.'}"
+                    else:
+                        hint = (
+                            "This may be because your computer is offline, or "
+                            "because the repository URL is incorrect."
+                        )
+
                     raise BriefcaseCommandError(
-                        f"Unable to clone repository {template!r}.\n"
-                        "\n"
-                        "This may be because your computer is offline, or "
-                        "because the repository URL is incorrect."
+                        f"Unable to clone repository {template!r}.\n\n{hint}"
                     ) from e
 
             try:
@@ -1024,7 +1037,7 @@ Did you run Briefcase in a project directory that contains {filename.name!r}?"""
                     head.checkout()
                 except IndexError as e:
                     # No branch exists for the requested version.
-                    raise TemplateUnsupportedVersion(branch) from e
+                    raise InvalidTemplateBranch(template, branch) from e
             except (ValueError, self.tools.git.exc.GitError) as e:
                 raise BriefcaseCommandError(
                     "Unable to check out template branch.\n"
@@ -1081,7 +1094,7 @@ Did you run Briefcase in a project directory that contains {filename.name!r}?"""
             raise InvalidTemplateRepository(template) from e
         except cookiecutter_exceptions.RepositoryCloneFailed as e:
             # Branch does not exist.
-            raise TemplateUnsupportedVersion(branch) from e
+            raise InvalidTemplateBranch(template, branch) from e
         except cookiecutter_exceptions.UndefinedVariableInTemplate as e:
             raise BriefcaseConfigError(e.message) from e
 
@@ -1122,7 +1135,7 @@ Did you run Briefcase in a project directory that contains {filename.name!r}?"""
                 output_path=output_path,
                 extra_context=extra_context,
             )
-        except TemplateUnsupportedVersion:
+        except InvalidTemplateBranch:
             # Only use the main template if we're on a development branch of briefcase
             # and the user didn't explicitly specify which branch to use.
             if version.dev is None or branch is not None:
diff --git a/src/briefcase/exceptions.py b/src/briefcase/exceptions.py
index c5356a15..4b67d0e7 100644
--- a/src/briefcase/exceptions.py
+++ b/src/briefcase/exceptions.py
@@ -137,11 +137,12 @@ class UpgradeToolError(BriefcaseCommandError):
         super().__init__(msg=error_msg, skip_logfile=True)
 
 
-class TemplateUnsupportedVersion(BriefcaseCommandError):
-    def __init__(self, briefcase_version):
-        self.briefcase_version = briefcase_version
+class InvalidTemplateBranch(BriefcaseCommandError):
+    def __init__(self, template_repo, branch):
+        self.template_repo = template_repo
+        self.branch = branch
         super().__init__(
-            f"Could not find a template branch for Briefcase {briefcase_version}."
+            f"Could not find a branch named {branch!r} in template repository {template_repo!r}."
         )
 
 


Test patch:
diff --git a/tests/commands/base/test_update_cookiecutter_cache.py b/tests/commands/base/test_update_cookiecutter_cache.py
index 0c6937fd..052f0f50 100644
--- a/tests/commands/base/test_update_cookiecutter_cache.py
+++ b/tests/commands/base/test_update_cookiecutter_cache.py
@@ -1,10 +1,11 @@
+import re
 import shutil
 from unittest import mock
 
 import pytest
 from git import exc as git_exceptions
 
-from briefcase.exceptions import BriefcaseCommandError, TemplateUnsupportedVersion
+from briefcase.exceptions import BriefcaseCommandError, InvalidTemplateBranch
 
 from ...utils import create_file
 
@@ -126,6 +127,65 @@ def test_new_repo_invalid_template_url(base_command, mock_git):
     assert not (base_command.data_path / "templates" / "special-template").exists()
 
 
+@pytest.mark.parametrize(
+    ("stderr_string", "error_message"),
+    (
+        pytest.param(
+            "\n    stderr: '\nfatal: could not clone repository 'https://example.com' \n'",
+            "Could not clone repository 'https://example.com'.",
+            id="tailing-whitespace-no-caps-no-period",
+        ),
+        pytest.param(
+            (
+                "\n    stderr: '\nfatal: Could not read from remote repository.\n\n"
+                "Please make sure you have the correct access rights\nand the repository exists. \n'"
+            ),
+            (
+                "Could not read from remote repository.\n\nPlease make sure "
+                "you have the correct access rights\nand the repository exists."
+            ),
+            id="tailing-whitespace-has-caps-has-period",
+        ),
+        pytest.param(
+            (
+                "\n    stderr: '\nfatal: unable to access 'https://example.com/': "
+                "OpenSSL/3.2.2: error:0A000438:SSL routines::tlsv1 alert internal error'"
+            ),
+            (
+                "Unable to access 'https://example.com/': OpenSSL/3.2.2: "
+                "error:0A000438:SSL routines::tlsv1 alert internal error."
+            ),
+            id="no-tailing-whitespace-no-caps-no-period",
+        ),
+        pytest.param(
+            "\n stderr: 'Mysterious git clone edge case with no fatal error.",
+            "This may be because your computer is offline",
+            id="fallback-hint",
+        ),
+    ),
+)
+def test_repo_clone_error(stderr_string, error_message, base_command, mock_git):
+    """If git emits error information when cloning, Briefcase provides that to the user.
+    If git does not emit a 'fatal' error, then fallback to a generic hint."""
+    base_command.tools.git = mock_git
+
+    base_command.tools.git.Repo.clone_from.side_effect = git_exceptions.GitCommandError(
+        "git", 128, stderr_string
+    )
+
+    repository = "https://example.com"
+
+    with pytest.raises(
+        BriefcaseCommandError,
+        # The error message should not retain the "fatal:" prefix; it isn't useful information to the user.
+        match=f"Unable to clone repository '{re.escape(repository)}'.\n\n?(?<!fatal: ){re.escape(error_message)}",
+    ):
+        base_command.update_cookiecutter_cache(
+            template=repository,
+            branch="briefcase-template",
+        )
+
+
 def test_existing_repo_template(base_command, mock_git):
     """If a previously known URL template is specified it is used."""
     base_command.tools.git = mock_git
@@ -334,7 +394,7 @@ def test_cached_missing_branch_template(base_command, mock_git):
     cached_path.mkdir(parents=True)
 
     # Generating the template under there conditions raises an error
-    with pytest.raises(TemplateUnsupportedVersion):
+    with pytest.raises(InvalidTemplateBranch):
         base_command.update_cookiecutter_cache(
             template="https://example.com/magic/special-template.git",
             branch="invalid",
diff --git a/tests/commands/create/test_generate_app_template.py b/tests/commands/create/test_generate_app_template.py
index 9e5c9c28..8c05bae0 100644
--- a/tests/commands/create/test_generate_app_template.py
+++ b/tests/commands/create/test_generate_app_template.py
@@ -12,8 +12,8 @@ import briefcase
 from briefcase.exceptions import (
     BriefcaseCommandError,
     BriefcaseConfigError,
+    InvalidTemplateBranch,
     NetworkFailure,
-    TemplateUnsupportedVersion,
 )
 
 
@@ -243,7 +243,7 @@ def test_default_template_dev_explicit_invalid_branch(
     )
 
     # Generate the template.
-    with pytest.raises(TemplateUnsupportedVersion):
+    with pytest.raises(InvalidTemplateBranch):
         create_command.generate_app_template(myapp)
 
     # Cookiecutter was invoked (once) with the expected template name and context.
@@ -651,7 +651,7 @@ def test_cached_missing_branch_template(monkeypatch, create_command, myapp):
     mock_remote.refs.__getitem__.side_effect = IndexError
 
     # Generating the template under there conditions raises an error
-    with pytest.raises(TemplateUnsupportedVersion):
+    with pytest.raises(InvalidTemplateBranch):
         create_command.generate_app_template(myapp)
 
 
diff --git a/tests/commands/new/test_new_app.py b/tests/commands/new/test_new_app.py
index 1748a6b0..d389d792 100644
--- a/tests/commands/new/test_new_app.py
+++ b/tests/commands/new/test_new_app.py
@@ -10,8 +10,8 @@ from briefcase.commands import NewCommand
 from briefcase.console import Console, Log
 from briefcase.exceptions import (
     BriefcaseCommandError,
+    InvalidTemplateBranch,
     InvalidTemplateRepository,
-    TemplateUnsupportedVersion,
 )
 
 
@@ -123,7 +123,7 @@ def test_new_app_missing_template(monkeypatch, new_command, tmp_path):
     )
 
     # Create the new app, using the default template. This raises an error.
-    with pytest.raises(TemplateUnsupportedVersion):
+    with pytest.raises(InvalidTemplateBranch):
         new_command.new_app()
 
     # App context is constructed
@@ -397,7 +397,7 @@ def test_new_app_with_invalid_template_branch(monkeypatch, new_command, tmp_path
     )
 
     # Create a new app, with a specific template; this raises an error
-    with pytest.raises(TemplateUnsupportedVersion):
+    with pytest.raises(InvalidTemplateBranch):
         new_command.new_app(template="https://example.com/other.git")
 
     # App context is constructed