Repository: beeware/briefcase (python)

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.

Pull request:
Use git config values as default author and email
## Summary
This PR improves the default values for "Author" and "Author's Email" in `briefcase new` and `briefcase convert` by reading the user's git configuration.

Fixes #2269 

## Why?
Currently, the default values are:
* `Author`: Jane Developer
* `Author's Email`: firstname@domain (e.g. `niklas@example.org` from Author "Niklas Mertsch" and domain "org.example")

This PR checks for the git config values `user.name` and `user.email`. If they are set, they are used instead of the current default values.

## Details
#### New method `BaseCommand.get_git_config_value(section: str, option: str) -> str | None`
* Read and merge from the following files, if present:
    * system config (`/etc/git/config`)
    * global config (`~/.gitconfig`)
    * user config (`~/.config/git/config`)
    * repo config (`./.git/config`)

#### Updated default values in `NewCommand` and `ConvertCommand`
* If `self.get_git_config_value("user", "name"/"email")` is not `None`:
    * Use it as default value.
    * Add sentence "Based on your git configuration, we believe it could be '{value}'." to the prompt intro (wording copied from `ConvertCommand` email handling).
* Otherwise, use the current default value (e.g. "Jane Developer").

## Comments for reviewers
* I chose to only use the git username if no "PEP 621 'authors'" were found.
* I don't know if `BaseCommand` really is the right place for `get_git_config_value()`.  I chose it because it is the base class of `NewCommand` and `ConvertCommand`, and because it provides `self.tools.git` so we don't have to guard the `git` import in multiple places.
* I chose not to test that `get_git_config_value()` does what it is doing. The implementation is trivial, and I can't find a way to test it without actually just testing mocks.
* I chose to test the new author and email functionality for `NewCommand` together, not in one test for author and one for email. That's because both are set in the same method (`NewCommand.build_app_context()`), so splitting the tests seemed to add too much code duplication. Splitting `build_app_context()` into smaller methods might be the right approach here, but I didn't want to take that decision for you.

## Open questions for reviewers
* The UX improvement is nice to have, but I'm not satisfied with the complexity my PR adds to the repo. What do you think?
* I don't understand if/where the default values from [briefcase-template](briefcase-template) are used. Can I ignore that repo for this change?
* To keep `command.tools.git` out of the general test setup, I check for its presence in `get_git_config_value()`. This check should always be true, so I added `self.console.warning(...)` in that case. Is that the right way to handle this?

## PR Checklist:
- [x] Implement feature
    - [x] Implement `git config` as optional source for default values
    - [x] Tell users where the default values are coming from
- [x] All new features have been tested
    - [x] `get_git_config_value()` reads correct config files
    - [x] `NewCommand` uses git values if present
    - [x] `ConvertCommand` uses git values if present
- [x] All new features have been documented
- [x] I have read the **CONTRIBUTING.md** file
- [x] I will abide by the code of conduct

Patch:
diff --git a/changes/2269.feature.rst b/changes/2269.feature.rst
new file mode 100644
index 00000000..364ca7ad
--- /dev/null
+++ b/changes/2269.feature.rst
@@ -0,0 +1,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 7bb4998f..4a19b58c 100644
--- a/src/briefcase/commands/base.py
+++ b/src/briefcase/commands/base.py
@@ -1156,3 +1156,23 @@ Did you run Briefcase in a project directory that contains {filename.name!r}?"""
                 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 3b35d916..bc8cb07f 100644
--- a/src/briefcase/commands/convert.py
+++ b/src/briefcase/commands/convert.py
@@ -387,11 +387,20 @@ class ConvertCommand(NewCommand):
             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 @@ class ConvertCommand(NewCommand):
             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 @@ class ConvertCommand(NewCommand):
 
         :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 21b89c23..68418f9a 100644
--- a/src/briefcase/commands/new.py
+++ b/src/briefcase/commands/new.py
@@ -365,27 +365,46 @@ class NewCommand(BaseCommand):
             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 00000000..d20752b8
--- /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 9551bf47..d48129c8 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 @@ class DummyConvertCommand(ConvertCommand):
 @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 717e16e0..39b40e25 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 c441a01e..a25ba148 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 30160261..a2daaab5 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 @@ class DummyNewCommand(NewCommand):
 
 @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 c370f707..139fa964 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
+    )