Skip to content
1 change: 1 addition & 0 deletions changes/2269.feature.rst
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 20 additions & 0 deletions src/briefcase/commands/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
23 changes: 19 additions & 4 deletions src/briefcase/commands/convert.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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,
)

Expand All @@ -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"]
Expand Down
47 changes: 33 additions & 14 deletions src/briefcase/commands/new.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
)
Expand Down
48 changes: 48 additions & 0 deletions tests/commands/base/test_get_git_config_value.py
Original file line number Diff line number Diff line change
@@ -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
5 changes: 4 additions & 1 deletion tests/commands/convert/conftest.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from tempfile import TemporaryDirectory
from unittest.mock import MagicMock

import pytest

Expand Down Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions tests/commands/convert/test_input_author.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
18 changes: 18 additions & 0 deletions tests/commands/convert/test_input_email.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
6 changes: 5 additions & 1 deletion tests/commands/new/conftest.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from unittest.mock import MagicMock

import pytest

from briefcase.commands import NewCommand
Expand Down Expand Up @@ -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
60 changes: 60 additions & 0 deletions tests/commands/new/test_build_app_context.py
Original file line number Diff line number Diff line change
@@ -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."""

Expand Down Expand Up @@ -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
)