-
Notifications
You must be signed in to change notification settings - Fork 237
/
git_repo.py
395 lines (315 loc) · 13.1 KB
/
git_repo.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING
import pytest
from git import Actor, Repo
from tests.const import (
COMMIT_MESSAGE,
EXAMPLE_HVCS_DOMAIN,
EXAMPLE_REPO_NAME,
EXAMPLE_REPO_OWNER,
TODAY_DATE_STR,
)
from tests.util import (
add_text_to_file,
copy_dir_tree,
shortuid,
temporary_working_directory,
)
if TYPE_CHECKING:
from typing import Generator, Literal, Protocol, TypedDict, Union
from semantic_release.hvcs import HvcsBase
from tests.conftest import TeardownCachedDirFn
from tests.fixtures.example_project import (
ExProjectDir,
UpdatePyprojectTomlFn,
UseHvcsFn,
UseParserFn,
)
CommitConvention = Literal["angular", "emoji", "scipy", "tag"]
VersionStr = str
CommitMsg = str
ChangelogTypeHeading = str
TomlSerializableTypes = Union[dict, set, list, tuple, int, float, bool, str]
class RepoVersionDef(TypedDict):
"""
A reduced common repo definition, that is specific to a type of commit conventions
Used for builder functions that only need to know about a single commit convention type
"""
changelog_sections: list[ChangelogTypeHeadingDef]
commits: list[CommitMsg]
class ChangelogTypeHeadingDef(TypedDict):
section: ChangelogTypeHeading
i_commits: list[int]
"""List of indexes values to match to the commits list in the RepoVersionDef"""
class BaseRepoVersionDef(TypedDict):
"""A Common Repo definition for a get_commits_repo_*() fixture with all commit convention types"""
changelog_sections: dict[CommitConvention, list[ChangelogTypeHeadingDef]]
commits: list[dict[CommitConvention, CommitMsg]]
class BuildRepoFn(Protocol):
def __call__(
self,
dest_dir: Path | str,
commit_type: CommitConvention = ...,
hvcs_client_name: str = ...,
hvcs_domain: str = ...,
tag_format_str: str | None = None,
extra_configs: dict[str, TomlSerializableTypes] | None = None,
) -> tuple[Path, HvcsBase]:
...
class CommitNReturnChangelogEntryFn(Protocol):
def __call__(self, git_repo: Repo, commit_msg: str, hvcs: HvcsBase) -> str:
...
class SimulateChangeCommitsNReturnChangelogEntryFn(Protocol):
def __call__(
self, git_repo: Repo, commit_msgs: list[CommitMsg], hvcs: HvcsBase
) -> list[CommitMsg]:
...
class CreateReleaseFn(Protocol):
def __call__(self, git_repo: Repo, version: str, tag_format: str = ...) -> None:
...
class ExProjectGitRepoFn(Protocol):
def __call__(self) -> Repo:
...
class GetVersionStringsFn(Protocol):
def __call__(self) -> list[VersionStr]:
...
RepoDefinition = dict[VersionStr, RepoVersionDef]
"""
A Type alias to define a repositories versions, commits, and changelog sections
for a specific commit convention
"""
class GetRepoDefinitionFn(Protocol):
def __call__(self, commit_type: CommitConvention = "angular") -> RepoDefinition:
...
class SimulateDefaultChangelogCreationFn(Protocol):
def __call__(
self,
repo_definition: RepoDefinition,
dest_file: Path | None = None,
) -> str:
...
@pytest.fixture(scope="session")
def commit_author():
return Actor(name="semantic release testing", email="not_a_real@email.com")
@pytest.fixture(scope="session")
def default_tag_format_str() -> str:
return "v{version}"
@pytest.fixture(scope="session")
def file_in_repo():
return f"file-{shortuid()}.txt"
@pytest.fixture(scope="session")
def example_git_ssh_url():
return f"git@{EXAMPLE_HVCS_DOMAIN}:{EXAMPLE_REPO_OWNER}/{EXAMPLE_REPO_NAME}.git"
@pytest.fixture(scope="session")
def example_git_https_url():
return f"https://{EXAMPLE_HVCS_DOMAIN}/{EXAMPLE_REPO_OWNER}/{EXAMPLE_REPO_NAME}.git"
@pytest.fixture(scope="session")
def create_release_tagged_commit(
update_pyproject_toml: UpdatePyprojectTomlFn,
default_tag_format_str: str,
) -> CreateReleaseFn:
def _mimic_semantic_release_commit(
git_repo: Repo,
version: str,
tag_format: str = default_tag_format_str,
) -> None:
# stamp version into pyproject.toml
update_pyproject_toml("tool.poetry.version", version)
# commit --all files with version number commit message
git_repo.git.commit(a=True, m=COMMIT_MESSAGE.format(version=version))
# tag commit with version number
tag_str = tag_format.format(version=version)
git_repo.git.tag(tag_str, m=tag_str)
return _mimic_semantic_release_commit
@pytest.fixture(scope="session")
def commit_n_rtn_changelog_entry() -> CommitNReturnChangelogEntryFn:
def _commit_n_rtn_changelog_entry(
git_repo: Repo, commit_msg: str, hvcs: HvcsBase
) -> str:
# make commit with --all files
git_repo.git.commit(a=True, m=commit_msg)
# log commit in changelog format after commit action
commit_sha = git_repo.head.commit.hexsha
return str.join(
" ",
[
str(git_repo.head.commit.message).strip(),
f"([`{commit_sha[:7]}`]({hvcs.commit_hash_url(commit_sha)}))",
],
)
return _commit_n_rtn_changelog_entry
@pytest.fixture(scope="session")
def simulate_change_commits_n_rtn_changelog_entry(
commit_n_rtn_changelog_entry: CommitNReturnChangelogEntryFn,
file_in_repo: str,
) -> SimulateChangeCommitsNReturnChangelogEntryFn:
def _simulate_change_commits_n_rtn_changelog_entry(
git_repo: Repo, commit_msgs: list[str], hvcs: HvcsBase
) -> list[str]:
changelog_entries = []
for commit_msg in commit_msgs:
add_text_to_file(git_repo, file_in_repo)
changelog_entries.append(
commit_n_rtn_changelog_entry(git_repo, commit_msg, hvcs)
)
return changelog_entries
return _simulate_change_commits_n_rtn_changelog_entry
@pytest.fixture(scope="session")
def cached_example_git_project(
cached_files_dir: Path,
teardown_cached_dir: TeardownCachedDirFn,
cached_example_project: Path,
example_git_https_url: str,
commit_author: Actor,
) -> Path:
"""
Initializes an example project with git repo. DO NOT USE DIRECTLY.
Use a `repo_*` fixture instead. This creates a default
base repository, all settings can be changed later through from the
example_project_git_repo fixture's return object and manual adjustment.
"""
if not cached_example_project.exists():
raise RuntimeError("Unable to find cached project files")
cached_git_proj_path = (cached_files_dir / "example_git_project").resolve()
# make a copy of the example project as a base
copy_dir_tree(cached_example_project, cached_git_proj_path)
# initialize git repo (open and close)
# NOTE: We don't want to hold the repo object open for the entire test session,
# the implementation on Windows holds some file descriptors open until close is called.
with Repo.init(cached_git_proj_path) as repo:
# Without this the global config may set it to "master", we want consistency
repo.git.branch("-M", "main")
with repo.config_writer("repository") as config:
config.set_value("user", "name", commit_author.name)
config.set_value("user", "email", commit_author.email)
config.set_value("commit", "gpgsign", False)
repo.create_remote(name="origin", url=example_git_https_url)
# make sure all base files are in index to enable initial commit
repo.index.add(("*", ".gitignore"))
# TODO: initial commit!
# trigger automatic cleanup of cache directory during teardown
return teardown_cached_dir(cached_git_proj_path)
@pytest.fixture(scope="session")
def build_configured_base_repo(
cached_example_git_project: Path,
use_github_hvcs: UseHvcsFn,
use_gitlab_hvcs: UseHvcsFn,
use_gitea_hvcs: UseHvcsFn,
use_bitbucket_hvcs: UseHvcsFn,
use_angular_parser: UseParserFn,
use_emoji_parser: UseParserFn,
use_scipy_parser: UseParserFn,
use_tag_parser: UseParserFn,
example_git_https_url: str,
update_pyproject_toml: UpdatePyprojectTomlFn,
) -> BuildRepoFn:
"""
This fixture is intended to simplify repo scenario building by initially
creating the repo but also configuring semantic_release in the pyproject.toml
for when the test executes semantic_release. It returns a function so that
derivative fixtures can call this fixture with individual parameters.
"""
def _build_configured_base_repo(
dest_dir: Path | str,
commit_type: str = "angular",
hvcs_client_name: str = "github",
hvcs_domain: str = EXAMPLE_HVCS_DOMAIN,
tag_format_str: str | None = None,
extra_configs: dict[str, TomlSerializableTypes] | None = None,
) -> tuple[Path, HvcsBase]:
if not cached_example_git_project.exists():
raise RuntimeError("Unable to find cached git project files!")
# Copy the cached git project the dest directory
copy_dir_tree(cached_example_git_project, dest_dir)
# Make sure we are in the dest directory
with temporary_working_directory(dest_dir):
# Set parser configuration
if commit_type == "angular":
use_angular_parser()
elif commit_type == "emoji":
use_emoji_parser()
elif commit_type == "scipy":
use_scipy_parser()
elif commit_type == "tag":
use_tag_parser()
else:
raise ValueError(f"Unknown parser name: {commit_type}")
# Set HVCS configuration
if hvcs_client_name == "github":
hvcs_class = use_github_hvcs(hvcs_domain)
elif hvcs_client_name == "gitlab":
hvcs_class = use_gitlab_hvcs(hvcs_domain)
elif hvcs_client_name == "gitea":
hvcs_class = use_gitea_hvcs(hvcs_domain)
elif hvcs_client_name == "bitbucket":
hvcs_class = use_bitbucket_hvcs(hvcs_domain)
else:
raise ValueError(f"Unknown HVCS client name: {hvcs_client_name}")
# Create HVCS Client instance
hvcs = hvcs_class(example_git_https_url, hvcs_domain)
# Set tag format in configuration
if tag_format_str is not None:
update_pyproject_toml(
"tool.semantic_release.tag_format", tag_format_str
)
# Apply configurations to pyproject.toml
if extra_configs is not None:
for key, value in extra_configs.items():
update_pyproject_toml(key, value)
return Path(dest_dir), hvcs
return _build_configured_base_repo
@pytest.fixture(scope="session")
def simulate_default_changelog_creation() -> SimulateDefaultChangelogCreationFn:
def build_version_entry(version: VersionStr, version_def: RepoVersionDef) -> str:
version_entry = []
if version == "Unreleased":
version_entry.append(f"## {version}\n")
else:
version_entry.append(
# TODO: artificial newline in front due to template when no Unreleased changes exist
f"\n## v{version} ({TODAY_DATE_STR})\n"
)
for section_def in version_def["changelog_sections"]:
version_entry.append(f"### {section_def['section']}\n")
for i in section_def["i_commits"]:
version_entry.append(f"* {version_def['commits'][i]}\n")
return str.join("\n", version_entry)
def _mimic_semantic_release_default_changelog(
repo_definition: RepoDefinition,
dest_file: Path | None = None,
) -> str:
header = "# CHANGELOG"
version_entries = []
for version, version_def in repo_definition.items():
# prepend entries to force reverse ordering
version_entries.insert(
0, build_version_entry(version, version_def)
)
changelog_content = str.join("\n" * 3, [
header,
str.join("\n", [
entry for entry in version_entries
])
]).rstrip() + '\n'
if dest_file is not None:
dest_file.write_text(changelog_content)
return changelog_content
return _mimic_semantic_release_default_changelog
@pytest.fixture
def example_project_git_repo(
example_project_dir: ExProjectDir,
) -> Generator[ExProjectGitRepoFn, None, None]:
repos: list[Repo] = []
# Must be a callable function to ensure files exist before repo is opened
def _example_project_git_repo() -> Repo:
if not example_project_dir.exists():
raise RuntimeError("Unable to find example git project!")
repo = Repo(example_project_dir)
repos.append(repo)
return repo
try:
yield _example_project_git_repo
finally:
for repo in repos:
repo.close()