releez 0.2.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- releez/__init__.py +1 -0
- releez/artifact_version.py +111 -0
- releez/cli.py +559 -0
- releez/cliff.py +186 -0
- releez/errors.py +231 -0
- releez/git_repo.py +232 -0
- releez/github.py +145 -0
- releez/process.py +52 -0
- releez/release.py +241 -0
- releez/settings.py +101 -0
- releez/subapps/__init__.py +5 -0
- releez/subapps/changelog.py +87 -0
- releez/utils.py +44 -0
- releez/version_tags.py +69 -0
- releez-0.2.2.dist-info/METADATA +225 -0
- releez-0.2.2.dist-info/RECORD +18 -0
- releez-0.2.2.dist-info/WHEEL +4 -0
- releez-0.2.2.dist-info/entry_points.txt +3 -0
releez/cliff.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import sysconfig
|
|
6
|
+
import tempfile
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Literal
|
|
10
|
+
|
|
11
|
+
from releez.errors import GitCliffVersionComputeError, MissingCliError
|
|
12
|
+
from releez.process import run_checked
|
|
13
|
+
|
|
14
|
+
GIT_CLIFF_BIN = 'git-cliff'
|
|
15
|
+
GIT_CLIFF_TAG_PATTERN = '^[0-9]+\\.[0-9]+\\.[0-9]+$'
|
|
16
|
+
|
|
17
|
+
GitCliffBump = Literal['major', 'minor', 'patch', 'auto']
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class ReleaseNotes:
|
|
22
|
+
"""Generated release notes from git-cliff."""
|
|
23
|
+
|
|
24
|
+
version: str
|
|
25
|
+
markdown: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _git_cliff_base_cmd() -> list[str]:
|
|
29
|
+
scripts_dir = sysconfig.get_path('scripts')
|
|
30
|
+
if scripts_dir:
|
|
31
|
+
scripts_path = Path(scripts_dir)
|
|
32
|
+
candidates = [GIT_CLIFF_BIN]
|
|
33
|
+
if os.name == 'nt': # pragma: no cover
|
|
34
|
+
candidates = [
|
|
35
|
+
f'{GIT_CLIFF_BIN}.exe',
|
|
36
|
+
f'{GIT_CLIFF_BIN}.cmd',
|
|
37
|
+
f'{GIT_CLIFF_BIN}.bat',
|
|
38
|
+
GIT_CLIFF_BIN,
|
|
39
|
+
]
|
|
40
|
+
for name in candidates:
|
|
41
|
+
exe = scripts_path / name
|
|
42
|
+
if exe.is_file():
|
|
43
|
+
return [str(exe)]
|
|
44
|
+
|
|
45
|
+
if shutil.which(GIT_CLIFF_BIN):
|
|
46
|
+
return [GIT_CLIFF_BIN]
|
|
47
|
+
raise MissingCliError(GIT_CLIFF_BIN)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _bump_args(bump: GitCliffBump) -> list[str]:
|
|
51
|
+
if bump == 'auto':
|
|
52
|
+
return ['--bump']
|
|
53
|
+
return ['--bump', bump]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class GitCliff:
|
|
57
|
+
"""Typed wrapper around the git-cliff CLI."""
|
|
58
|
+
|
|
59
|
+
def __init__(self, *, repo_root: Path) -> None:
|
|
60
|
+
self._repo_root = repo_root
|
|
61
|
+
self._cmd = _git_cliff_base_cmd()
|
|
62
|
+
|
|
63
|
+
def compute_next_version(self, *, bump: GitCliffBump) -> str:
|
|
64
|
+
"""Compute the next version using git-cliff.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
bump: The bump mode for git-cliff.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
The computed next version.
|
|
71
|
+
|
|
72
|
+
Raises:
|
|
73
|
+
MissingCliError: If `git-cliff` is not available.
|
|
74
|
+
ExternalCommandError: If git-cliff fails.
|
|
75
|
+
GitCliffVersionComputeError: If git-cliff returns an empty version.
|
|
76
|
+
"""
|
|
77
|
+
version = run_checked(
|
|
78
|
+
[
|
|
79
|
+
*self._cmd,
|
|
80
|
+
'--unreleased',
|
|
81
|
+
'--bumped-version',
|
|
82
|
+
'--tag-pattern',
|
|
83
|
+
GIT_CLIFF_TAG_PATTERN,
|
|
84
|
+
*_bump_args(bump),
|
|
85
|
+
],
|
|
86
|
+
cwd=self._repo_root,
|
|
87
|
+
).strip()
|
|
88
|
+
if not version:
|
|
89
|
+
raise GitCliffVersionComputeError
|
|
90
|
+
return version
|
|
91
|
+
|
|
92
|
+
def generate_unreleased_notes(
|
|
93
|
+
self,
|
|
94
|
+
*,
|
|
95
|
+
version: str,
|
|
96
|
+
) -> str:
|
|
97
|
+
"""Generate the unreleased section as markdown.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
version: The version to tag the release notes.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
The generated markdown content.
|
|
104
|
+
|
|
105
|
+
Raises:
|
|
106
|
+
MissingCliError: If `git-cliff` is not available.
|
|
107
|
+
ExternalCommandError: If git-cliff fails.
|
|
108
|
+
"""
|
|
109
|
+
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
110
|
+
out_path = Path(tmp_dir) / 'RELEASE_NOTES.md'
|
|
111
|
+
run_checked(
|
|
112
|
+
[
|
|
113
|
+
*self._cmd,
|
|
114
|
+
'--unreleased',
|
|
115
|
+
'--strip',
|
|
116
|
+
'all',
|
|
117
|
+
'--tag',
|
|
118
|
+
version,
|
|
119
|
+
'--tag-pattern',
|
|
120
|
+
GIT_CLIFF_TAG_PATTERN,
|
|
121
|
+
'--output',
|
|
122
|
+
str(out_path),
|
|
123
|
+
],
|
|
124
|
+
cwd=self._repo_root,
|
|
125
|
+
capture_stdout=False,
|
|
126
|
+
)
|
|
127
|
+
return out_path.read_text(encoding='utf-8')
|
|
128
|
+
|
|
129
|
+
def prepend_to_changelog(
|
|
130
|
+
self,
|
|
131
|
+
*,
|
|
132
|
+
version: str,
|
|
133
|
+
changelog_path: Path,
|
|
134
|
+
) -> None:
|
|
135
|
+
"""Prepend the unreleased section to the changelog file.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
version: The version to tag the release notes.
|
|
139
|
+
changelog_path: The path to the changelog file.
|
|
140
|
+
|
|
141
|
+
Raises:
|
|
142
|
+
MissingCliError: If `git-cliff` is not available.
|
|
143
|
+
ExternalCommandError: If git-cliff fails.
|
|
144
|
+
"""
|
|
145
|
+
run_checked(
|
|
146
|
+
[
|
|
147
|
+
*self._cmd,
|
|
148
|
+
'-v',
|
|
149
|
+
'--unreleased',
|
|
150
|
+
'--tag',
|
|
151
|
+
version,
|
|
152
|
+
'--tag-pattern',
|
|
153
|
+
GIT_CLIFF_TAG_PATTERN,
|
|
154
|
+
'--prepend',
|
|
155
|
+
str(changelog_path),
|
|
156
|
+
],
|
|
157
|
+
cwd=self._repo_root,
|
|
158
|
+
capture_stdout=False,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
def regenerate_changelog(
|
|
162
|
+
self,
|
|
163
|
+
*,
|
|
164
|
+
changelog_path: Path,
|
|
165
|
+
) -> None:
|
|
166
|
+
"""Regenerate the full changelog file from git history.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
changelog_path: The path to the changelog file.
|
|
170
|
+
|
|
171
|
+
Raises:
|
|
172
|
+
MissingCliError: If `git-cliff` is not available.
|
|
173
|
+
ExternalCommandError: If git-cliff fails.
|
|
174
|
+
"""
|
|
175
|
+
run_checked(
|
|
176
|
+
[
|
|
177
|
+
*self._cmd,
|
|
178
|
+
'-v',
|
|
179
|
+
'--tag-pattern',
|
|
180
|
+
GIT_CLIFF_TAG_PATTERN,
|
|
181
|
+
'--output',
|
|
182
|
+
str(changelog_path),
|
|
183
|
+
],
|
|
184
|
+
cwd=self._repo_root,
|
|
185
|
+
capture_stdout=False,
|
|
186
|
+
)
|
releez/errors.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ReleezError(RuntimeError):
|
|
11
|
+
"""Base error for Releez."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class MissingCliError(ReleezError):
|
|
15
|
+
"""Raised when a required CLI executable is missing."""
|
|
16
|
+
|
|
17
|
+
cli_names: list[str]
|
|
18
|
+
|
|
19
|
+
def __init__(self, cli_names: str | Sequence[str]) -> None:
|
|
20
|
+
self.cli_names = [cli_names] if isinstance(cli_names, str) else list(cli_names)
|
|
21
|
+
if len(self.cli_names) == 1:
|
|
22
|
+
message = f'Required CLI {self.cli_names[0]!r} is not installed or not on PATH.'
|
|
23
|
+
else:
|
|
24
|
+
joined = ', '.join(repr(name) for name in self.cli_names)
|
|
25
|
+
message = f'Required CLIs {joined} are not installed or not on PATH.'
|
|
26
|
+
super().__init__(message)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ExternalCommandError(ReleezError):
|
|
30
|
+
"""Raised when an external command returns a non-zero status."""
|
|
31
|
+
|
|
32
|
+
cmd_args: list[str]
|
|
33
|
+
returncode: int
|
|
34
|
+
stderr: str
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
*,
|
|
39
|
+
cmd_args: Sequence[str],
|
|
40
|
+
returncode: int,
|
|
41
|
+
stderr: str | None = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
self.cmd_args = list(cmd_args)
|
|
44
|
+
self.returncode = returncode
|
|
45
|
+
self.stderr = (stderr or '').strip()
|
|
46
|
+
|
|
47
|
+
cmd = ' '.join(self.cmd_args)
|
|
48
|
+
message = f'Command failed ({self.returncode}): {cmd}'
|
|
49
|
+
if self.stderr:
|
|
50
|
+
message = f'{message}\n{self.stderr}'
|
|
51
|
+
super().__init__(message)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class GitRepoRootResolveError(ReleezError):
|
|
55
|
+
"""Raised when the git repository root cannot be determined."""
|
|
56
|
+
|
|
57
|
+
def __init__(self) -> None:
|
|
58
|
+
super().__init__('Failed to resolve git repository root.')
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class DirtyWorkingTreeError(ReleezError):
|
|
62
|
+
"""Raised when the working tree is not clean."""
|
|
63
|
+
|
|
64
|
+
def __init__(self) -> None:
|
|
65
|
+
super().__init__(
|
|
66
|
+
'Working tree is not clean. Commit or stash changes before running.',
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class GitRemoteNotFoundError(ReleezError):
|
|
71
|
+
"""Raised when a configured git remote does not exist."""
|
|
72
|
+
|
|
73
|
+
remote_name: str
|
|
74
|
+
|
|
75
|
+
def __init__(self, remote_name: str) -> None:
|
|
76
|
+
self.remote_name = remote_name
|
|
77
|
+
super().__init__(f'Remote {remote_name!r} does not exist.')
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class GitRemoteBranchNotFoundError(ReleezError):
|
|
81
|
+
"""Raised when a remote branch does not exist."""
|
|
82
|
+
|
|
83
|
+
ref: str
|
|
84
|
+
|
|
85
|
+
def __init__(self, *, remote_name: str, branch: str) -> None:
|
|
86
|
+
self.ref = f'{remote_name}/{branch}'
|
|
87
|
+
super().__init__(f'Remote branch {self.ref!r} does not exist.')
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class GitBranchExistsError(ReleezError):
|
|
91
|
+
"""Raised when attempting to create a branch that already exists."""
|
|
92
|
+
|
|
93
|
+
branch: str
|
|
94
|
+
|
|
95
|
+
def __init__(self, branch: str) -> None:
|
|
96
|
+
self.branch = branch
|
|
97
|
+
super().__init__(f'Local branch {branch!r} already exists.')
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class GitCliffVersionComputeError(ReleezError):
|
|
101
|
+
"""Raised when git-cliff cannot compute the next version."""
|
|
102
|
+
|
|
103
|
+
def __init__(self) -> None:
|
|
104
|
+
super().__init__('Failed to compute next version via git-cliff.')
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class ChangelogNotFoundError(ReleezError):
|
|
108
|
+
"""Raised when the changelog file is missing."""
|
|
109
|
+
|
|
110
|
+
changelog_path: Path
|
|
111
|
+
|
|
112
|
+
def __init__(self, changelog_path: Path) -> None:
|
|
113
|
+
self.changelog_path = changelog_path
|
|
114
|
+
super().__init__(f'Changelog file does not exist: {changelog_path}')
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class ChangelogFormatCommandRequiredError(ReleezError):
|
|
118
|
+
"""Raised when changelog formatting is requested but not configured."""
|
|
119
|
+
|
|
120
|
+
def __init__(self) -> None:
|
|
121
|
+
super().__init__(
|
|
122
|
+
'Changelog formatting was requested, but no format command is configured.\n'
|
|
123
|
+
'Configure it via `releez.toml`:\n'
|
|
124
|
+
' [hooks]\n'
|
|
125
|
+
' changelog_format = ["dprint", "fmt", "{changelog}"]\n'
|
|
126
|
+
'Or via `pyproject.toml`:\n'
|
|
127
|
+
' [tool.releez.hooks]\n'
|
|
128
|
+
' changelog_format = ["dprint", "fmt", "{changelog}"]\n'
|
|
129
|
+
'Or pass `--changelog-format-cmd` (repeatable) on the CLI.',
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class GitHubTokenRequiredError(ReleezError):
|
|
134
|
+
"""Raised when a GitHub token is required but not provided."""
|
|
135
|
+
|
|
136
|
+
def __init__(self) -> None:
|
|
137
|
+
super().__init__(
|
|
138
|
+
'GitHub token is required to create a PR; pass --github-token or set GITHUB_TOKEN.',
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class GitRemoteUrlRequiredError(ReleezError):
|
|
143
|
+
"""Raised when a remote URL is needed but missing."""
|
|
144
|
+
|
|
145
|
+
remote_name: str
|
|
146
|
+
|
|
147
|
+
def __init__(self, remote_name: str) -> None:
|
|
148
|
+
self.remote_name = remote_name
|
|
149
|
+
super().__init__(
|
|
150
|
+
f'Remote URL is required to create a PR; ensure remote {remote_name!r} exists.',
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class InvalidGitHubRemoteError(ReleezError):
|
|
155
|
+
"""Raised when a remote URL cannot be mapped to a GitHub repo."""
|
|
156
|
+
|
|
157
|
+
remote_url: str
|
|
158
|
+
|
|
159
|
+
def __init__(self, remote_url: str) -> None:
|
|
160
|
+
self.remote_url = remote_url
|
|
161
|
+
super().__init__(
|
|
162
|
+
f'Could not infer GitHub repo from remote URL: {remote_url}\n'
|
|
163
|
+
'Use an origin remote pointing at GitHub (SSH or HTTPS).\n'
|
|
164
|
+
'If using GitHub Enterprise Server, set GITHUB_SERVER_URL (and optionally GITHUB_API_URL).',
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class MissingGitHubDependencyError(ReleezError):
|
|
169
|
+
"""Raised when PyGithub is not available but PR creation was requested."""
|
|
170
|
+
|
|
171
|
+
def __init__(self) -> None:
|
|
172
|
+
super().__init__(
|
|
173
|
+
'PyGithub is required for PR creation but is not available.',
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class BuildNumberRequiredError(ReleezError):
|
|
178
|
+
"""Raised when a prerelease build is missing a build number."""
|
|
179
|
+
|
|
180
|
+
def __init__(self) -> None:
|
|
181
|
+
super().__init__(
|
|
182
|
+
'Build number is required for prerelease builds; pass --build-number or set RELEEZ_BUILD_NUMBER.',
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class PrereleaseNumberRequiredError(ReleezError):
|
|
187
|
+
"""Raised when a prerelease build is missing a prerelease number."""
|
|
188
|
+
|
|
189
|
+
def __init__(self) -> None:
|
|
190
|
+
super().__init__(
|
|
191
|
+
'Prerelease number is required for prerelease builds; '
|
|
192
|
+
'pass --prerelease-number or set RELEEZ_PRERELEASE_NUMBER.',
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class InvalidReleaseVersionError(ReleezError):
|
|
197
|
+
"""Raised when a version is not a full release `x.y.z`."""
|
|
198
|
+
|
|
199
|
+
version: str
|
|
200
|
+
|
|
201
|
+
def __init__(self, version: str) -> None:
|
|
202
|
+
self.version = version
|
|
203
|
+
super().__init__(
|
|
204
|
+
f'Expected a full release version like `2.3.4`; got {version!r}.',
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class GitTagExistsError(ReleezError):
|
|
209
|
+
"""Raised when attempting to create a tag that already exists."""
|
|
210
|
+
|
|
211
|
+
tag: str
|
|
212
|
+
|
|
213
|
+
def __init__(self, tag: str) -> None:
|
|
214
|
+
self.tag = tag
|
|
215
|
+
super().__init__(
|
|
216
|
+
f'Git tag already exists: {tag!r} (use --force to update).',
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class InvalidPrereleaseTypeError(ReleezError):
|
|
221
|
+
"""Raised when a prerelease type is invalid for the chosen scheme."""
|
|
222
|
+
|
|
223
|
+
prerelease_type: str
|
|
224
|
+
scheme: str
|
|
225
|
+
|
|
226
|
+
def __init__(self, prerelease_type: str, *, scheme: str) -> None:
|
|
227
|
+
self.prerelease_type = prerelease_type
|
|
228
|
+
self.scheme = scheme
|
|
229
|
+
super().__init__(
|
|
230
|
+
f'Prerelease type {prerelease_type!r} is not supported for scheme {scheme!r}.',
|
|
231
|
+
)
|
releez/git_repo.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from contextlib import suppress
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from git import Repo
|
|
8
|
+
from git.exc import GitCommandError, GitCommandNotFound
|
|
9
|
+
|
|
10
|
+
from releez.errors import (
|
|
11
|
+
DirtyWorkingTreeError,
|
|
12
|
+
GitBranchExistsError,
|
|
13
|
+
GitRemoteBranchNotFoundError,
|
|
14
|
+
GitRemoteNotFoundError,
|
|
15
|
+
GitRepoRootResolveError,
|
|
16
|
+
GitTagExistsError,
|
|
17
|
+
MissingCliError,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
GIT_BIN = 'git'
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class RepoInfo:
|
|
25
|
+
"""Information about a Git repository.
|
|
26
|
+
|
|
27
|
+
Attributes:
|
|
28
|
+
root: The root path of the repository.
|
|
29
|
+
remote_url: The URL of the 'origin' remote.
|
|
30
|
+
active_branch: The name of the currently active branch, or None if in
|
|
31
|
+
detached HEAD state.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
root: Path
|
|
35
|
+
remote_url: str
|
|
36
|
+
active_branch: str | None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def open_repo(*, cwd: Path | None = None) -> tuple[Repo, RepoInfo]:
|
|
40
|
+
"""Open a Git repository and gather information about it.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
cwd: The working directory to start searching for the repository.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
A tuple of the Repo object and RepoInfo dataclass.
|
|
47
|
+
|
|
48
|
+
Raises:
|
|
49
|
+
MissingCliError: If the `git` CLI is not available.
|
|
50
|
+
GitRepoRootResolveError: If the repository root cannot be determined.
|
|
51
|
+
"""
|
|
52
|
+
repo = Repo(cwd or Path.cwd(), search_parent_directories=True)
|
|
53
|
+
try:
|
|
54
|
+
root = Path(
|
|
55
|
+
repo.working_tree_dir or repo.git.rev_parse('--show-toplevel'),
|
|
56
|
+
)
|
|
57
|
+
except GitCommandNotFound as exc: # pragma: no cover
|
|
58
|
+
raise MissingCliError(GIT_BIN) from exc
|
|
59
|
+
except GitCommandError as exc: # pragma: no cover
|
|
60
|
+
raise GitRepoRootResolveError from exc
|
|
61
|
+
|
|
62
|
+
remote_url = ''
|
|
63
|
+
with suppress(AttributeError, IndexError):
|
|
64
|
+
remote_url = repo.remotes.origin.url
|
|
65
|
+
|
|
66
|
+
active_branch: str | None
|
|
67
|
+
try:
|
|
68
|
+
active_branch = repo.active_branch.name
|
|
69
|
+
except TypeError:
|
|
70
|
+
active_branch = None # detached HEAD
|
|
71
|
+
|
|
72
|
+
return repo, RepoInfo(
|
|
73
|
+
root=root,
|
|
74
|
+
remote_url=remote_url,
|
|
75
|
+
active_branch=active_branch,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def ensure_clean(repo: Repo) -> None:
|
|
80
|
+
"""Ensure the repository working tree is clean.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
repo: The Git repository.
|
|
84
|
+
|
|
85
|
+
Raises:
|
|
86
|
+
DirtyWorkingTreeError: If the repository has uncommitted changes.
|
|
87
|
+
"""
|
|
88
|
+
if repo.is_dirty(untracked_files=True):
|
|
89
|
+
raise DirtyWorkingTreeError
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def fetch(repo: Repo, *, remote_name: str) -> None:
|
|
93
|
+
"""Fetch updates from the remote (including tags).
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
repo: The Git repository.
|
|
97
|
+
remote_name: The remote name to fetch from.
|
|
98
|
+
|
|
99
|
+
Raises:
|
|
100
|
+
GitRemoteNotFoundError: If the remote does not exist.
|
|
101
|
+
"""
|
|
102
|
+
try:
|
|
103
|
+
_ = repo.remotes[remote_name]
|
|
104
|
+
except IndexError as exc:
|
|
105
|
+
raise GitRemoteNotFoundError(remote_name) from exc
|
|
106
|
+
repo.git.fetch(remote_name, '--tags', '--prune')
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def checkout_remote_branch(
|
|
110
|
+
repo: Repo,
|
|
111
|
+
*,
|
|
112
|
+
remote_name: str,
|
|
113
|
+
branch: str,
|
|
114
|
+
) -> None:
|
|
115
|
+
"""Check out the given remote branch as a detached HEAD.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
repo: The Git repository.
|
|
119
|
+
remote_name: The remote name.
|
|
120
|
+
branch: The branch name on the remote.
|
|
121
|
+
|
|
122
|
+
Raises:
|
|
123
|
+
MissingCliError: If the `git` CLI is not available.
|
|
124
|
+
GitRemoteBranchNotFoundError: If the remote branch does not exist.
|
|
125
|
+
"""
|
|
126
|
+
ref = f'{remote_name}/{branch}'
|
|
127
|
+
try:
|
|
128
|
+
repo.git.rev_parse('--verify', ref)
|
|
129
|
+
except GitCommandNotFound as exc: # pragma: no cover
|
|
130
|
+
raise MissingCliError(GIT_BIN) from exc
|
|
131
|
+
except GitCommandError as exc:
|
|
132
|
+
raise GitRemoteBranchNotFoundError(
|
|
133
|
+
remote_name=remote_name,
|
|
134
|
+
branch=branch,
|
|
135
|
+
) from exc
|
|
136
|
+
repo.git.checkout(ref)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def create_and_checkout_branch(repo: Repo, *, name: str) -> None:
|
|
140
|
+
"""Create and check out a new local branch.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
repo: The Git repository.
|
|
144
|
+
name: The new branch name.
|
|
145
|
+
|
|
146
|
+
Raises:
|
|
147
|
+
MissingCliError: If the `git` CLI is not available.
|
|
148
|
+
GitBranchExistsError: If the local branch already exists.
|
|
149
|
+
"""
|
|
150
|
+
try:
|
|
151
|
+
repo.git.rev_parse('--verify', name)
|
|
152
|
+
except GitCommandNotFound as exc: # pragma: no cover
|
|
153
|
+
raise MissingCliError(GIT_BIN) from exc
|
|
154
|
+
except GitCommandError:
|
|
155
|
+
repo.git.checkout('-b', name)
|
|
156
|
+
return
|
|
157
|
+
|
|
158
|
+
raise GitBranchExistsError(name)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def commit_file(repo: Repo, *, path: Path, message: str) -> None:
|
|
162
|
+
"""Stage and commit a file with the given message.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
repo: The Git repository.
|
|
166
|
+
path: The path to the file to stage and commit.
|
|
167
|
+
message: The commit message.
|
|
168
|
+
"""
|
|
169
|
+
root = Path(repo.working_tree_dir or '.').resolve()
|
|
170
|
+
abs_path = path.resolve()
|
|
171
|
+
try:
|
|
172
|
+
rel_path = abs_path.relative_to(root)
|
|
173
|
+
pathspec = str(rel_path)
|
|
174
|
+
except ValueError:
|
|
175
|
+
pathspec = str(abs_path)
|
|
176
|
+
repo.index.add([pathspec])
|
|
177
|
+
repo.index.commit(message)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def push_set_upstream(repo: Repo, *, remote_name: str, branch: str) -> None:
|
|
181
|
+
"""Push a branch and set upstream on the remote.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
repo: The Git repository.
|
|
185
|
+
remote_name: The remote name to push to.
|
|
186
|
+
branch: The branch to push.
|
|
187
|
+
"""
|
|
188
|
+
repo.git.push('-u', remote_name, branch)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def create_tags(repo: Repo, *, tags: list[str], force: bool) -> None:
|
|
192
|
+
"""Create git tags pointing at HEAD.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
repo: The Git repository.
|
|
196
|
+
tags: The tag names to create.
|
|
197
|
+
force: If true, overwrite existing tags.
|
|
198
|
+
|
|
199
|
+
Raises:
|
|
200
|
+
GitTagExistsError: If a tag exists and force is false.
|
|
201
|
+
"""
|
|
202
|
+
existing = {t.name for t in repo.tags}
|
|
203
|
+
for tag in tags:
|
|
204
|
+
if tag in existing and not force:
|
|
205
|
+
raise GitTagExistsError(tag)
|
|
206
|
+
if force:
|
|
207
|
+
repo.git.tag('-f', tag)
|
|
208
|
+
else:
|
|
209
|
+
repo.create_tag(tag)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def push_tags(
|
|
213
|
+
repo: Repo,
|
|
214
|
+
*,
|
|
215
|
+
remote_name: str,
|
|
216
|
+
tags: list[str],
|
|
217
|
+
force: bool,
|
|
218
|
+
) -> None:
|
|
219
|
+
"""Push git tags to a remote.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
repo: The Git repository.
|
|
223
|
+
remote_name: The remote to push to.
|
|
224
|
+
tags: The tag names to push.
|
|
225
|
+
force: If true, force-update tags on the remote.
|
|
226
|
+
"""
|
|
227
|
+
if not tags:
|
|
228
|
+
return
|
|
229
|
+
if force:
|
|
230
|
+
repo.git.push('--force', remote_name, *tags)
|
|
231
|
+
return
|
|
232
|
+
repo.git.push(remote_name, *tags)
|