Skip to content

Commit aad95e8

Browse files
authored
Use git config values as default author and email (#2271)
Use git configuration as a source for author name and email hints, where possible.
1 parent eb7d18c commit aad95e8

File tree

10 files changed

+223
-20
lines changed

10 files changed

+223
-20
lines changed

changes/2269.feature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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.

src/briefcase/commands/base.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1156,3 +1156,23 @@ def generate_template(
11561156
output_path=output_path,
11571157
extra_context=extra_context,
11581158
)
1159+
1160+
def get_git_config_value(self, section: str, option: str) -> str | None:
1161+
"""Get the requested git config value, if available.
1162+
1163+
:param section: The configuration section.
1164+
:param option: The configuration option.
1165+
:returns: The configuration value, or None.
1166+
"""
1167+
git_config_paths = [
1168+
self.tools.git.config.get_config_path("system"),
1169+
self.tools.git.config.get_config_path("global"),
1170+
self.tools.git.config.get_config_path("user"),
1171+
".git/config",
1172+
]
1173+
1174+
with self.tools.git.config.GitConfigParser(git_config_paths) as git_config:
1175+
if git_config.has_option(section, option):
1176+
return git_config.get_value(section, option)
1177+
1178+
return None

src/briefcase/commands/convert.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -387,11 +387,20 @@ def input_author(self, override_value: str | None) -> str:
387387
if "name" in author
388388
]
389389

390+
default_author = "Jane Developer"
390391
if not options or override_value is not None:
392+
git_username = self.get_git_config_value("user", "name")
393+
if git_username is not None:
394+
default_author = git_username
395+
intro = (
396+
f"{intro}\n\n"
397+
+ f"Based on your git configuration, we believe it could be '{git_username}'."
398+
)
399+
391400
return self.console.text_question(
392401
intro=intro,
393402
description="Author",
394-
default="Jane Developer",
403+
default=default_author,
395404
override_value=override_value,
396405
)
397406
elif len(options) > 1:
@@ -421,7 +430,7 @@ def input_author(self, override_value: str | None) -> str:
421430
author = self.console.text_question(
422431
intro="Who do you want to be credited as the author of this application?",
423432
description="Author",
424-
default="Jane Developer",
433+
default=default_author,
425434
override_value=None,
426435
)
427436

@@ -433,8 +442,14 @@ def input_email(self, author: str, bundle: str, override_value: str | None) -> s
433442
434443
:returns: author email
435444
"""
436-
default = self.make_author_email(author, bundle)
437-
default_source = "the author name and bundle"
445+
git_email = self.get_git_config_value("user", "email")
446+
if git_email is None:
447+
default = self.make_author_email(author, bundle)
448+
default_source = "the author name and bundle"
449+
else:
450+
default = git_email
451+
default_source = "your git configuration"
452+
438453
for author_info in self.pep621_data.get("authors", []):
439454
if author_info.get("name") == author and author_info.get("email"):
440455
default = author_info["email"]

src/briefcase/commands/new.py

Lines changed: 33 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -365,27 +365,46 @@ def build_app_context(self, project_overrides: dict[str, str]) -> dict[str, str]
365365
override_value=project_overrides.pop("description", None),
366366
)
367367

368+
author_intro = (
369+
"Who do you want to be credited as the author of this application?\n"
370+
"\n"
371+
"This could be your own name, or the name of your company you work for."
372+
)
373+
default_author = "Jane Developer"
374+
git_username = self.get_git_config_value("user", "name")
375+
if git_username is not None:
376+
default_author = git_username
377+
author_intro = (
378+
f"{author_intro}\n\n"
379+
f"Based on your git configuration, we believe it could be '{git_username}'."
380+
)
368381
author = self.console.text_question(
369-
intro=(
370-
"Who do you want to be credited as the author of this application?\n"
371-
"\n"
372-
"This could be your own name, or the name of your company you work for."
373-
),
382+
intro=author_intro,
374383
description="Author",
375-
default="Jane Developer",
384+
default=default_author,
376385
override_value=project_overrides.pop("author", None),
377386
)
378387

388+
author_email_intro = (
389+
"What email address should people use to contact the developers of "
390+
"this application?\n"
391+
"\n"
392+
"This might be your own email address, or a generic contact address "
393+
"you set up specifically for this application."
394+
)
395+
git_email = self.get_git_config_value("user", "email")
396+
if git_email is None:
397+
default_author_email = self.make_author_email(author, bundle)
398+
else:
399+
default_author_email = git_email
400+
author_email_intro = (
401+
f"{author_email_intro}\n\n"
402+
f"Based on your git configuration, we believe it could be '{git_email}'."
403+
)
379404
author_email = self.console.text_question(
380-
intro=(
381-
"What email address should people use to contact the developers of "
382-
"this application?\n"
383-
"\n"
384-
"This might be your own email address, or a generic contact address "
385-
"you set up specifically for this application."
386-
),
405+
intro=author_email_intro,
387406
description="Author's Email",
388-
default=self.make_author_email(author, bundle),
407+
default=default_author_email,
389408
validator=self.validate_email,
390409
override_value=project_overrides.pop("author_email", None),
391410
)
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import itertools
2+
from unittest import mock
3+
from unittest.mock import MagicMock
4+
5+
6+
def test_all_config_files_are_read(base_command, mock_git):
7+
"""All git config files are read (system, global, user, repo)."""
8+
base_command.tools.git = mock_git
9+
mock_git.config.get_config_path.side_effect = ["file1", "file2", "file3"]
10+
11+
base_command.get_git_config_value("test-section", "test-option")
12+
13+
assert mock_git.config.get_config_path.call_args_list == [
14+
mock.call("system"),
15+
mock.call("global"),
16+
mock.call("user"),
17+
]
18+
expected_config_files = ["file1", "file2", "file3", ".git/config"]
19+
mock_git.config.GitConfigParser.assert_called_once_with(expected_config_files)
20+
21+
22+
def test_config_values_are_parsed(base_command, tmp_path, monkeypatch):
23+
"""If the requested value exists in one of the config files, it shall be returned."""
24+
import git
25+
26+
# use 'real' gitpython library (no mock)
27+
base_command.tools.git = git
28+
29+
# mock `git.config.get_config_path` to always provide the same three local files
30+
mock_config_paths = ["missing-file-1", "config-1", "missing-file-2"]
31+
git.config.get_config_path = MagicMock()
32+
git.config.get_config_path.side_effect = itertools.cycle(mock_config_paths)
33+
34+
# create local two config files
35+
monkeypatch.chdir(tmp_path)
36+
(tmp_path / "config-1").write_text("[user]\n\tname = Some User\n", encoding="utf-8")
37+
(tmp_path / ".git").mkdir()
38+
(tmp_path / ".git" / "config").write_text(
39+
"[user]\n\temail = [email protected]\n", encoding="utf-8"
40+
)
41+
42+
# expect values are parsed from all existing config files
43+
assert base_command.get_git_config_value("user", "name") == "Some User"
44+
assert base_command.get_git_config_value("user", "email") == "[email protected]"
45+
46+
# expect that missing sections and options are handled
47+
assert base_command.get_git_config_value("user", "something") is None
48+
assert base_command.get_git_config_value("something", "something") is None

tests/commands/convert/conftest.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from tempfile import TemporaryDirectory
2+
from unittest.mock import MagicMock
23

34
import pytest
45

@@ -54,7 +55,9 @@ def convert_app(self, **kwargs):
5455
@pytest.fixture
5556
def convert_command(tmp_path):
5657
(tmp_path / "project").mkdir()
57-
return DummyConvertCommand(base_path=tmp_path / "project")
58+
command = DummyConvertCommand(base_path=tmp_path / "project")
59+
command.get_git_config_value = MagicMock(return_value=None)
60+
return command
5861

5962

6063
@pytest.fixture

tests/commands/convert/test_input_author.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,3 +209,18 @@ def test_prompted_author_with_pyproject_other(convert_command):
209209
)
210210
convert_command.console.values = ["5", "Some Author"]
211211
assert convert_command.input_author(None) == "Some Author"
212+
213+
214+
def test_default_author_from_git_config(convert_command, monkeypatch, capsys):
215+
"""If git integration is configured, and a config value 'user.name' is available,
216+
use that value as default."""
217+
convert_command.tools.git = object()
218+
convert_command.get_git_config_value = MagicMock(return_value="Some Author")
219+
convert_command.console.values = [""]
220+
221+
assert convert_command.input_author(None) == "Some Author"
222+
convert_command.get_git_config_value.assert_called_once_with("user", "name")
223+
224+
# RichConsole wraps long lines, so we have to unwrap before we check
225+
stdout = capsys.readouterr().out.replace("\n", " ")
226+
assert stdout == PartialMatchString("Based on your git configuration")

tests/commands/convert/test_input_email.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,3 +94,21 @@ def test_prompted_email(convert_command):
9494
convert_command.input_email("Some name", "com.some.bundle", None)
9595
9696
)
97+
98+
99+
def test_default_email_from_git_config(convert_command, monkeypatch, capsys):
100+
"""If git integration is configured, and a config value 'user.email' is available,
101+
use that value as default."""
102+
convert_command.tools.git = object()
103+
convert_command.get_git_config_value = MagicMock(return_value="[email protected]")
104+
convert_command.console.values = [""]
105+
106+
assert (
107+
convert_command.input_email("Some name", "com.some.bundle", None)
108+
109+
)
110+
convert_command.get_git_config_value.assert_called_once_with("user", "email")
111+
112+
# RichConsole wraps long lines, so we have to unwrap before we check
113+
stdout = capsys.readouterr().out.replace("\n", " ")
114+
assert stdout == PartialMatchString("Based on your git configuration")

tests/commands/new/conftest.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from unittest.mock import MagicMock
2+
13
import pytest
24

35
from briefcase.commands import NewCommand
@@ -42,4 +44,6 @@ def new_app(self, **kwargs):
4244

4345
@pytest.fixture
4446
def new_command(tmp_path):
45-
return DummyNewCommand(base_path=tmp_path)
47+
command = DummyNewCommand(base_path=tmp_path)
48+
command.get_git_config_value = MagicMock(return_value=None)
49+
return command

tests/commands/new/test_build_app_context.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
from unittest import mock
2+
3+
from ...utils import PartialMatchString
4+
5+
16
def test_question_sequence(new_command):
27
"""Questions are asked, a context is constructed."""
38

@@ -133,3 +138,58 @@ def test_question_sequence_with_no_user_input(new_command):
133138
project_name="Hello World",
134139
url="https://example.com/helloworld",
135140
)
141+
142+
143+
def test_author_and_email_use_git_config_as_fallback(new_command):
144+
"""If no user input is provided, git config values 'git.user' and 'git.email' are used if
145+
available."""
146+
new_command.tools.git = object()
147+
new_command.get_git_config_value = mock.MagicMock()
148+
new_command.get_git_config_value.side_effect = ["Some Author", "[email protected]"]
149+
150+
new_command.console.input_enabled = False
151+
152+
context = new_command.build_app_context(project_overrides={})
153+
154+
assert context["author"] == "Some Author"
155+
assert context["author_email"] == "[email protected]"
156+
assert new_command.get_git_config_value.call_args_list == [
157+
mock.call("user", "name"),
158+
mock.call("user", "email"),
159+
]
160+
161+
162+
def test_git_config_is_mentioned_as_source(new_command, monkeypatch):
163+
"""If git config is used as default value, this shall be mentioned to the user."""
164+
new_command.tools.git = object()
165+
new_command.get_git_config_value = mock.MagicMock()
166+
new_command.get_git_config_value.side_effect = ["Some Author", "[email protected]"]
167+
168+
new_command.console.input_enabled = False
169+
170+
mock_text_question = mock.MagicMock()
171+
mock_text_question.side_effect = lambda *args, **kwargs: kwargs["default"]
172+
monkeypatch.setattr(new_command.console, "text_question", mock_text_question)
173+
174+
new_command.build_app_context(project_overrides={})
175+
176+
assert (
177+
mock.call(
178+
intro=PartialMatchString("Based on your git configuration"),
179+
description="Author",
180+
default="Some Author",
181+
override_value=None,
182+
)
183+
in mock_text_question.call_args_list
184+
)
185+
186+
assert (
187+
mock.call(
188+
intro=PartialMatchString("Based on your git configuration"),
189+
description="Author's Email",
190+
default="[email protected]",
191+
override_value=None,
192+
validator=new_command.validate_email,
193+
)
194+
in mock_text_question.call_args_list
195+
)

0 commit comments

Comments
 (0)