Skip to content

Commit b4df67e

Browse files
authored
Fix #118: Provide project Git config file (#120)
To create a reproducible build environment, we can't rely on the system or user Git config. Depending on the options, it may let Git behave slightly different than we expect. For this reason, we need to ensure that we use project Git config that is used whenever we call "git".
1 parent b219ba4 commit b4df67e

8 files changed

Lines changed: 111 additions & 10 deletions

File tree

changelog.d/118.bugfix.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Provide a project-defined Git config to prevent issues with user Git config.

pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,9 @@ docbuild = "docbuild.__main__:cli"
9999
package-dir = {"" = "src"}
100100
package-data = { "docbuild" = ["py.typed",
101101
"config/xml/data/*.rnc",
102-
"config/xml/data/*.xsl"]}
102+
"config/xml/data/*.xsl",
103+
"etc/git/*"
104+
]}
103105
include-package-data = true
104106

105107
[tool.setuptools.dynamic]

src/docbuild/constants.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,9 @@
145145
"""The default filename for the environment's config file, typically
146146
used in production."""
147147

148+
GIT_CONFIG_FILENAME = Path(__file__).parent / 'etc/git/gitconfig'
149+
"""The project-specific Git configuration file (relative to this project)"""
150+
148151
# --- State and Logging Constants (Refactored) ---
149152

150153
BASE_STATE_DIR = Path.home() / '.local' / 'state' / APP_NAME

src/docbuild/etc/git/gitconfig

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
[docbuild]
2+
name = docbuild-project

src/docbuild/utils/git.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from pathlib import Path
55
from typing import ClassVar, Self
66

7-
from ..constants import GITLOGGER_NAME
7+
from ..constants import GIT_CONFIG_FILENAME, GITLOGGER_NAME
88
from ..models.repo import Repo
99
from ..utils.shell import execute_git_command
1010

@@ -17,18 +17,24 @@ class ManagedGitRepo:
1717
#: Class variable to indicate the update state of a repo
1818
_is_updated: ClassVar[dict[Repo, bool]] = {}
1919

20-
def __init__(self: Self, remote_url: str, permanent_root: Path) -> None:
20+
def __init__(self: Self,
21+
remote_url: str,
22+
permanent_root: Path,
23+
gitconfig: Path | None = None) -> None:
2124
"""Initialize the managed repository.
2225
2326
:param remote_url: The remote URL of the repository.
2427
:param permanent_root: The root directory for storing permanent bare clones.
28+
:param gitconfig: The path to a separate Git configuration file
29+
(=None, use the default config from etc/gitconfig)
2530
"""
2631
self._repo_model = Repo(remote_url)
2732
self._permanent_root = permanent_root
2833
# The Repo model handles the "sluggification" of the URL
2934
self.bare_repo_path = self._permanent_root / self._repo_model.slug
3035
# Initialize attribute for output:
3136
self.stdout = self.stderr = None
37+
self._gitconfig = gitconfig
3238
# Add repo into class variable
3339
type(self)._is_updated.setdefault(self._repo_model, False)
3440

@@ -71,6 +77,7 @@ async def _initial_clone(self: Self) -> bool:
7177
str(url),
7278
str(self.bare_repo_path),
7379
cwd=self._permanent_root,
80+
gitconfig=self._gitconfig,
7481
)
7582
log.info("Cloned '%s' successfully", url)
7683
return True
@@ -133,7 +140,8 @@ async def create_worktree(
133140
clone_args.extend([str(self.bare_repo_path), str(target_dir)])
134141

135142
self.stdout, self.stderr = await execute_git_command(
136-
*clone_args, cwd=target_dir.parent
143+
*clone_args, cwd=target_dir.parent,
144+
gitconfig=self._gitconfig,
137145
)
138146

139147
async def fetch_updates(self: Self) -> bool:
@@ -151,7 +159,9 @@ async def fetch_updates(self: Self) -> bool:
151159
log.info("Fetching updates for '%s'", self.slug)
152160
try:
153161
self.stdout, self.stderr = await execute_git_command(
154-
'fetch', '--all', cwd=self.bare_repo_path
162+
'fetch', '--all',
163+
cwd=self.bare_repo_path,
164+
gitconfig=self._gitconfig
155165
)
156166
log.info("Successfully fetched updates for '%s'", self.slug)
157167
return True

src/docbuild/utils/shell.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import logging
55
from pathlib import Path
66

7-
from ..constants import GITLOGGER_NAME
7+
from ..constants import GIT_CONFIG_FILENAME, GITLOGGER_NAME
88

99
log = logging.getLogger(GITLOGGER_NAME)
1010

@@ -42,23 +42,29 @@ async def execute_git_command(
4242
*args: str,
4343
cwd: Path | None = None,
4444
extra_env: dict[str, str] | None = None,
45+
gitconfig: Path | None = None,
4546
) -> tuple[str, str]:
4647
"""Execute a Git command asynchronously in a given directory.
4748
4849
:param args: Command arguments for Git.
4950
:param cwd: The working directory for the Git command. If None, the
5051
current working directory is used.
5152
:param extra_env: Additional environment variables to set for the command.
53+
:param gitconfig: The path to a separate Git configuration file. If None,
54+
the default config from etc/git/gitconfig is used.
5255
:return: A tuple containing the decoded stdout and stderr of the command.
5356
:raises RuntimeError: If the command fails.
5457
:raises FileNotFoundError: If `cwd` is specified but does not exist.
5558
"""
5659
if cwd and not cwd.is_dir():
5760
raise FileNotFoundError(f'Git working directory not found: {cwd}')
5861

62+
# Determine which config file to use
63+
gconfig = gitconfig if gitconfig else GIT_CONFIG_FILENAME
64+
5965
# Default Git arguments for consistent behavior
60-
default_git_args = ('-c', 'color.ui=never')
61-
command = ('git', *default_git_args, *args)
66+
git_config_args = ('-c', 'color.ui=never')
67+
command = ('git', *git_config_args, *args)
6268
log.debug('Executing Git command: %s in %s', ' '.join(command), cwd)
6369

6470
# Default environment for secure and non-interactive execution
@@ -67,6 +73,9 @@ async def execute_git_command(
6773
'LC_ALL': 'C',
6874
'GIT_TERMINAL_PROMPT': '0',
6975
'GIT_PROGRESS_FORCE': '1',
76+
'GIT_CONFIG_SYSTEM': '/dev/null',
77+
# 'GIT_CONFIG_NOSYSTEM': '1', # For older Git versions
78+
'GIT_CONFIG_GLOBAL': str(gconfig),
7079
}
7180

7281
# Merge environments, allowing kwargs to override defaults

tests/utils/test_git.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ async def test_managed_repo_clone_bare_new(
6060
'http://a.b/c.git',
6161
str(repo.bare_repo_path),
6262
cwd=tmp_path,
63+
gitconfig=None,
6364
)
6465

6566

@@ -73,7 +74,12 @@ async def test_managed_repo_clone_bare_exists(
7374

7475
result = await repo.clone_bare()
7576

76-
mock_execute_git.assert_awaited_once_with('fetch', '--all', cwd=repo.bare_repo_path)
77+
mock_execute_git.assert_awaited_once_with(
78+
'fetch',
79+
'--all',
80+
cwd=repo.bare_repo_path,
81+
gitconfig=None,
82+
)
7783

7884

7985
async def test_managed_repo_clone_bare_failure(
@@ -109,6 +115,7 @@ async def test_managed_repo_create_worktree_success(
109115
str(repo.bare_repo_path),
110116
str(target_dir),
111117
cwd=target_dir.parent,
118+
gitconfig=None,
112119
)
113120

114121

@@ -133,6 +140,7 @@ async def test_managed_repo_create_worktree_with_options(
133140
str(repo.bare_repo_path),
134141
str(target_dir),
135142
cwd=target_dir.parent,
143+
gitconfig=None,
136144
)
137145

138146

@@ -170,6 +178,7 @@ async def test_managed_repo_create_worktree_not_local(
170178
str(repo.bare_repo_path),
171179
str(target_dir),
172180
cwd=target_dir.parent,
181+
gitconfig=None,
173182
)
174183

175184

@@ -222,7 +231,9 @@ async def test_fetch_updates_success(
222231
result = await repo.fetch_updates()
223232

224233
assert result is True
225-
mock_execute_git.assert_awaited_once_with('fetch', '--all', cwd=repo.bare_repo_path)
234+
mock_execute_git.assert_awaited_once_with(
235+
'fetch', '--all', cwd=repo.bare_repo_path, gitconfig=None
236+
)
226237

227238

228239
async def test_fetch_updates_no_repo(
@@ -272,6 +283,7 @@ async def test_managed_repo_clone_bare_already_processed(
272283
'http://a.b/c.git',
273284
str(repo.bare_repo_path),
274285
cwd=tmp_path,
286+
gitconfig=None,
275287
)
276288

277289
# Second call, should do nothing because it's already processed

tests/utils/test_shell.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"""Tests for shell command utilities."""
2+
3+
from pathlib import Path
4+
5+
import pytest
6+
7+
from docbuild.constants import GIT_CONFIG_FILENAME
8+
from docbuild.utils.shell import execute_git_command
9+
10+
11+
async def test_execute_git_command_with_gitconfig(tmp_path):
12+
"""Verify that execute_git_command uses the config file provided in the
13+
`gitconfig` parameter to replace the user's configuration.
14+
"""
15+
# The tmp_path fixture provides a temporary directory as a Path object
16+
repo_path = tmp_path
17+
18+
# Initialize a Git repository in the temporary directory
19+
await execute_git_command("init", cwd=repo_path)
20+
21+
# Create a project-specific gitconfig file
22+
config_content = "[user]\n name = Test User From Project Config\n"
23+
project_config_path = repo_path / ".gitconfig"
24+
project_config_path.write_text(config_content)
25+
26+
# Execute 'git config' to read the value, passing the project config
27+
stdout, _ = await execute_git_command(
28+
"config", "--get", "user.name", cwd=repo_path, gitconfig=project_config_path
29+
)
30+
31+
# Assert that the output matches the value from our project-specific config
32+
assert stdout == "Test User From Project Config"
33+
34+
35+
async def test_execute_git_command_without_gitconfig(tmp_path):
36+
"""
37+
Verify that execute_git_command falls back to the default GIT_CONFIG_FILENAME
38+
when the `gitconfig` parameter is not provided.
39+
"""
40+
repo_path = tmp_path
41+
42+
# Execute 'git config' to read the value from our default config file.
43+
# We call it without the `gitconfig` parameter to test the default behavior.
44+
stdout, _ = await execute_git_command(
45+
"config", "--get", "docbuild.name", cwd=repo_path
46+
)
47+
48+
# This asserts that the value from 'etc/git/gitconfig' is read correctly.
49+
assert stdout == "docbuild-project"
50+
51+
52+
async def test_execute_git_command_with_nonexistent_cwd():
53+
with pytest.raises(FileNotFoundError):
54+
stdout, _ = await execute_git_command(
55+
'config', '--get', 'docbuild.name', cwd=Path("does-not-exist")
56+
)
57+
58+
async def test_execute_git_command_with_failed_command():
59+
with pytest.raises(RuntimeError):
60+
stdout, _ = await execute_git_command(
61+
'foo', # wrong git command
62+
)

0 commit comments

Comments
 (0)