Repository: beeware/briefcase

Issue:
Idea: Use `git config user.name/email` as default values
### What is the problem or limitation you are having?

During `briefcase new`, the setup process asks for the `Author` and `Author's Email`. The default values are rarely correct (`Jane Developer`, `author.firstname@domain`).

### Describe the solution you'd like

I think it would be nice to use the values of `git config user.name` and `git config user.email` as default values, if available.

### Describe alternatives you've considered

I'm aware that this is only a minor convenience feature, and that the additional complexity might not be worth it. Feel free to reject this idea 🙂

### Additional context

If you are interested in having this, I'm willing to investigate possible implementations, and create a PR if I find a feasible solution.

Hints from issue comments:
@freakboy3742: Thanks for the suggestion. 

We can't necessarily guarantee that these values have been set - but if they have been, they're definitely reasonable defaults for Briefcase to assume - they're certainly more likely to be correct than "Jane Developer" 😄 

An idea related to this one is to add the analog of `git config` to Briefcase - so you could configure your Briefcase install to use your own name and email. There are other values that could be set as defaults (like your default iOS/Android simulator, or default signing credentials). However, that would only serve to add another (and more reliable) default; having a git fallback will always be useful.

Solution patch:
diff --git a/changes/2269.feature.rst b/changes/2269.feature.rst
new file mode 100644
index 000000000..364ca7adf
--- /dev/null
+++ b/changes/2269.feature.rst
@@ -0,0 +1 @@
+When creating a new project with ``briefcase new``, or converting an existing project with ``briefcase convert``, Briefcase will now try to infer the author's name and email address from the git configuration.
diff --git a/src/briefcase/commands/base.py b/src/briefcase/commands/base.py
index 7bb4998f1..4a19b58ce 100644
--- a/src/briefcase/commands/base.py
+++ b/src/briefcase/commands/base.py
@@ -1156,3 +1156,23 @@ def generate_template(
                 output_path=output_path,
                 extra_context=extra_context,
             )
+
+    def get_git_config_value(self, section: str, option: str) -> str | None:
+        """Get the requested git config value, if available.
+
+        :param section: The configuration section.
+        :param option: The configuration option.
+        :returns: The configuration value, or None.
+        """
+        git_config_paths = [
+            self.tools.git.config.get_config_path("system"),
+            self.tools.git.config.get_config_path("global"),
+            self.tools.git.config.get_config_path("user"),
+            ".git/config",
+        ]
+
+        with self.tools.git.config.GitConfigParser(git_config_paths) as git_config:
+            if git_config.has_option(section, option):
+                return git_config.get_value(section, option)
+
+        return None
diff --git a/src/briefcase/commands/convert.py b/src/briefcase/commands/convert.py
index 3b35d9166..bc8cb07f1 100644
--- a/src/briefcase/commands/convert.py
+++ b/src/briefcase/commands/convert.py
@@ -387,11 +387,20 @@ def input_author(self, override_value: str | None) -> str:
             if "name" in author
         ]
 
+        default_author = "Jane Developer"
         if not options or override_value is not None:
+            git_username = self.get_git_config_value("user", "name")
+            if git_username is not None:
+                default_author = git_username
+                intro = (
+                    f"{intro}\n\n"
+                    + f"Based on your git configuration, we believe it could be '{git_username}'."
+                )
+
             return self.console.text_question(
                 intro=intro,
                 description="Author",
-                default="Jane Developer",
+                default=default_author,
                 override_value=override_value,
             )
         elif len(options) > 1:
@@ -421,7 +430,7 @@ def input_author(self, override_value: str | None) -> str:
             author = self.console.text_question(
                 intro="Who do you want to be credited as the author of this application?",
                 description="Author",
-                default="Jane Developer",
+                default=default_author,
                 override_value=None,
             )
 
@@ -433,8 +442,14 @@ def input_email(self, author: str, bundle: str, override_value: str | None) -> s
 
         :returns: author email
         """
-        default = self.make_author_email(author, bundle)
-        default_source = "the author name and bundle"
+        git_email = self.get_git_config_value("user", "email")
+        if git_email is None:
+            default = self.make_author_email(author, bundle)
+            default_source = "the author name and bundle"
+        else:
+            default = git_email
+            default_source = "your git configuration"
+
         for author_info in self.pep621_data.get("authors", []):
             if author_info.get("name") == author and author_info.get("email"):
                 default = author_info["email"]
diff --git a/src/briefcase/commands/new.py b/src/briefcase/commands/new.py
index 21b89c239..68418f9a6 100644
--- a/src/briefcase/commands/new.py
+++ b/src/briefcase/commands/new.py
@@ -365,27 +365,46 @@ def build_app_context(self, project_overrides: dict[str, str]) -> dict[str, str]
             override_value=project_overrides.pop("description", None),
         )
 
+        author_intro = (
+            "Who do you want to be credited as the author of this application?\n"
+            "\n"
+            "This could be your own name, or the name of your company you work for."
+        )
+        default_author = "Jane Developer"
+        git_username = self.get_git_config_value("user", "name")
+        if git_username is not None:
+            default_author = git_username
+            author_intro = (
+                f"{author_intro}\n\n"
+                f"Based on your git configuration, we believe it could be '{git_username}'."
+            )
         author = self.console.text_question(
-            intro=(
-                "Who do you want to be credited as the author of this application?\n"
-                "\n"
-                "This could be your own name, or the name of your company you work for."
-            ),
+            intro=author_intro,
             description="Author",
-            default="Jane Developer",
+            default=default_author,
             override_value=project_overrides.pop("author", None),
         )
 
+        author_email_intro = (
+            "What email address should people use to contact the developers of "
+            "this application?\n"
+            "\n"
+            "This might be your own email address, or a generic contact address "
+            "you set up specifically for this application."
+        )
+        git_email = self.get_git_config_value("user", "email")
+        if git_email is None:
+            default_author_email = self.make_author_email(author, bundle)
+        else:
+            default_author_email = git_email
+            author_email_intro = (
+                f"{author_email_intro}\n\n"
+                f"Based on your git configuration, we believe it could be '{git_email}'."
+            )
         author_email = self.console.text_question(
-            intro=(
-                "What email address should people use to contact the developers of "
-                "this application?\n"
-                "\n"
-                "This might be your own email address, or a generic contact address "
-                "you set up specifically for this application."
-            ),
+            intro=author_email_intro,
             description="Author's Email",
-            default=self.make_author_email(author, bundle),
+            default=default_author_email,
             validator=self.validate_email,
             override_value=project_overrides.pop("author_email", None),
         )


Test patch:
diff --git a/tests/commands/base/test_get_git_config_value.py b/tests/commands/base/test_get_git_config_value.py
new file mode 100644
index 000000000..d20752b87
--- /dev/null
+++ b/tests/commands/base/test_get_git_config_value.py
@@ -0,0 +1,48 @@
+import itertools
+from unittest import mock
+from unittest.mock import MagicMock
+
+
+def test_all_config_files_are_read(base_command, mock_git):
+    """All git config files are read (system, global, user, repo)."""
+    base_command.tools.git = mock_git
+    mock_git.config.get_config_path.side_effect = ["file1", "file2", "file3"]
+
+    base_command.get_git_config_value("test-section", "test-option")
+
+    assert mock_git.config.get_config_path.call_args_list == [
+        mock.call("system"),
+        mock.call("global"),
+        mock.call("user"),
+    ]
+    expected_config_files = ["file1", "file2", "file3", ".git/config"]
+    mock_git.config.GitConfigParser.assert_called_once_with(expected_config_files)
+
+
+def test_config_values_are_parsed(base_command, tmp_path, monkeypatch):
+    """If the requested value exists in one of the config files, it shall be returned."""
+    import git
+
+    # use 'real' gitpython library (no mock)
+    base_command.tools.git = git
+
+    # mock `git.config.get_config_path` to always provide the same three local files
+    mock_config_paths = ["missing-file-1", "config-1", "missing-file-2"]
+    git.config.get_config_path = MagicMock()
+    git.config.get_config_path.side_effect = itertools.cycle(mock_config_paths)
+
+    # create local two config files
+    monkeypatch.chdir(tmp_path)
+    (tmp_path / "config-1").write_text("[user]\n\tname = Some User\n", encoding="utf-8")
+    (tmp_path / ".git").mkdir()
+    (tmp_path / ".git" / "config").write_text(
+        "[user]\n\temail = my@email.com\n", encoding="utf-8"
+    )
+
+    # expect values are parsed from all existing config files
+    assert base_command.get_git_config_value("user", "name") == "Some User"
+    assert base_command.get_git_config_value("user", "email") == "my@email.com"
+
+    # expect that missing sections and options are handled
+    assert base_command.get_git_config_value("user", "something") is None
+    assert base_command.get_git_config_value("something", "something") is None
diff --git a/tests/commands/convert/conftest.py b/tests/commands/convert/conftest.py
index 9551bf475..d48129c80 100644
--- a/tests/commands/convert/conftest.py
+++ b/tests/commands/convert/conftest.py
@@ -1,4 +1,5 @@
 from tempfile import TemporaryDirectory
+from unittest.mock import MagicMock
 
 import pytest
 
@@ -54,7 +55,9 @@ def convert_app(self, **kwargs):
 @pytest.fixture
 def convert_command(tmp_path):
     (tmp_path / "project").mkdir()
-    return DummyConvertCommand(base_path=tmp_path / "project")
+    command = DummyConvertCommand(base_path=tmp_path / "project")
+    command.get_git_config_value = MagicMock(return_value=None)
+    return command
 
 
 @pytest.fixture
diff --git a/tests/commands/convert/test_input_author.py b/tests/commands/convert/test_input_author.py
index 717e16e00..39b40e259 100644
--- a/tests/commands/convert/test_input_author.py
+++ b/tests/commands/convert/test_input_author.py
@@ -209,3 +209,18 @@ def test_prompted_author_with_pyproject_other(convert_command):
     )
     convert_command.console.values = ["5", "Some Author"]
     assert convert_command.input_author(None) == "Some Author"
+
+
+def test_default_author_from_git_config(convert_command, monkeypatch, capsys):
+    """If git integration is configured, and a config value 'user.name' is available,
+    use that value as default."""
+    convert_command.tools.git = object()
+    convert_command.get_git_config_value = MagicMock(return_value="Some Author")
+    convert_command.console.values = [""]
+
+    assert convert_command.input_author(None) == "Some Author"
+    convert_command.get_git_config_value.assert_called_once_with("user", "name")
+
+    # RichConsole wraps long lines, so we have to unwrap before we check
+    stdout = capsys.readouterr().out.replace("\n", " ")
+    assert stdout == PartialMatchString("Based on your git configuration")
diff --git a/tests/commands/convert/test_input_email.py b/tests/commands/convert/test_input_email.py
index c441a01e5..a25ba1481 100644
--- a/tests/commands/convert/test_input_email.py
+++ b/tests/commands/convert/test_input_email.py
@@ -94,3 +94,21 @@ def test_prompted_email(convert_command):
         convert_command.input_email("Some name", "com.some.bundle", None)
         == "my@email.com"
     )
+
+
+def test_default_email_from_git_config(convert_command, monkeypatch, capsys):
+    """If git integration is configured, and a config value 'user.email' is available,
+    use that value as default."""
+    convert_command.tools.git = object()
+    convert_command.get_git_config_value = MagicMock(return_value="my@email.com")
+    convert_command.console.values = [""]
+
+    assert (
+        convert_command.input_email("Some name", "com.some.bundle", None)
+        == "my@email.com"
+    )
+    convert_command.get_git_config_value.assert_called_once_with("user", "email")
+
+    # RichConsole wraps long lines, so we have to unwrap before we check
+    stdout = capsys.readouterr().out.replace("\n", " ")
+    assert stdout == PartialMatchString("Based on your git configuration")
diff --git a/tests/commands/new/conftest.py b/tests/commands/new/conftest.py
index 30160261a..a2daaab59 100644
--- a/tests/commands/new/conftest.py
+++ b/tests/commands/new/conftest.py
@@ -1,3 +1,5 @@
+from unittest.mock import MagicMock
+
 import pytest
 
 from briefcase.commands import NewCommand
@@ -42,4 +44,6 @@ def new_app(self, **kwargs):
 
 @pytest.fixture
 def new_command(tmp_path):
-    return DummyNewCommand(base_path=tmp_path)
+    command = DummyNewCommand(base_path=tmp_path)
+    command.get_git_config_value = MagicMock(return_value=None)
+    return command
diff --git a/tests/commands/new/test_build_app_context.py b/tests/commands/new/test_build_app_context.py
index c370f7070..139fa9642 100644
--- a/tests/commands/new/test_build_app_context.py
+++ b/tests/commands/new/test_build_app_context.py
@@ -1,3 +1,8 @@
+from unittest import mock
+
+from ...utils import PartialMatchString
+
+
 def test_question_sequence(new_command):
     """Questions are asked, a context is constructed."""
 
@@ -133,3 +138,58 @@ def test_question_sequence_with_no_user_input(new_command):
         project_name="Hello World",
         url="https://example.com/helloworld",
     )
+
+
+def test_author_and_email_use_git_config_as_fallback(new_command):
+    """If no user input is provided, git config values 'git.user' and 'git.email' are used if
+    available."""
+    new_command.tools.git = object()
+    new_command.get_git_config_value = mock.MagicMock()
+    new_command.get_git_config_value.side_effect = ["Some Author", "my@email.com"]
+
+    new_command.console.input_enabled = False
+
+    context = new_command.build_app_context(project_overrides={})
+
+    assert context["author"] == "Some Author"
+    assert context["author_email"] == "my@email.com"
+    assert new_command.get_git_config_value.call_args_list == [
+        mock.call("user", "name"),
+        mock.call("user", "email"),
+    ]
+
+
+def test_git_config_is_mentioned_as_source(new_command, monkeypatch):
+    """If git config is used as default value, this shall be mentioned to the user."""
+    new_command.tools.git = object()
+    new_command.get_git_config_value = mock.MagicMock()
+    new_command.get_git_config_value.side_effect = ["Some Author", "my@email.com"]
+
+    new_command.console.input_enabled = False
+
+    mock_text_question = mock.MagicMock()
+    mock_text_question.side_effect = lambda *args, **kwargs: kwargs["default"]
+    monkeypatch.setattr(new_command.console, "text_question", mock_text_question)
+
+    new_command.build_app_context(project_overrides={})
+
+    assert (
+        mock.call(
+            intro=PartialMatchString("Based on your git configuration"),
+            description="Author",
+            default="Some Author",
+            override_value=None,
+        )
+        in mock_text_question.call_args_list
+    )
+
+    assert (
+        mock.call(
+            intro=PartialMatchString("Based on your git configuration"),
+            description="Author's Email",
+            default="my@email.com",
+            override_value=None,
+            validator=new_command.validate_email,
+        )
+        in mock_text_question.call_args_list
+    )