git-tide 0.1.0__py2.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.
- git_tide-0.1.0.dist-info/METADATA +22 -0
- git_tide-0.1.0.dist-info/RECORD +8 -0
- git_tide-0.1.0.dist-info/WHEEL +4 -0
- git_tide-0.1.0.dist-info/entry_points.txt +3 -0
- tide/__init__.py +1 -0
- tide/__main__.py +4 -0
- tide/core.py +1037 -0
- tide/gitutils.py +340 -0
tide/core.py
ADDED
|
@@ -0,0 +1,1037 @@
|
|
|
1
|
+
from __future__ import absolute_import, print_function, annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import subprocess
|
|
6
|
+
import time
|
|
7
|
+
import json
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
import tomli as tomllib # noqa: F401
|
|
13
|
+
except ImportError:
|
|
14
|
+
import tomllib # type: ignore[no-redef]
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from functools import lru_cache
|
|
18
|
+
from urllib.parse import urlparse, urlunparse
|
|
19
|
+
from abc import abstractmethod
|
|
20
|
+
from enum import Enum
|
|
21
|
+
from typing import TYPE_CHECKING, cast
|
|
22
|
+
|
|
23
|
+
from .gitutils import git, get_tags, branch_exists, checkout, current_rev, join, GitRepo
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
import gitlab.v4.objects
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
TOOL_NAME = "tide"
|
|
30
|
+
ENVVAR_PREFIX = TOOL_NAME.upper()
|
|
31
|
+
PROMOTION_BASE_MSG = "promotion base"
|
|
32
|
+
HERE = os.path.dirname(__file__)
|
|
33
|
+
CONFIG: Config
|
|
34
|
+
GITLAB_REMOTE = "gitlab_origin"
|
|
35
|
+
CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]}
|
|
36
|
+
|
|
37
|
+
# FIXME: add these to config file
|
|
38
|
+
HOTFIX_MESSAGE = "auto-hotfix into {upstream_branch}: {message}"
|
|
39
|
+
PROMOTION_CYCLE_START_MESSAGE = "starting new {release_id} cycle."
|
|
40
|
+
PROMOTION_MESSAGE = "promoting {upstream_branch} to {branch}!"
|
|
41
|
+
|
|
42
|
+
cache = lru_cache(maxsize=None)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ReleaseID(str, Enum):
|
|
46
|
+
"""Represents semver pre-releases, plus 'stable' (i.e. non-pre-release)"""
|
|
47
|
+
|
|
48
|
+
alpha = "alpha"
|
|
49
|
+
beta = "beta"
|
|
50
|
+
rc = "rc"
|
|
51
|
+
stable = "stable"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def is_url(s: str) -> bool:
|
|
55
|
+
"""
|
|
56
|
+
Return whether the string looks like a URL.
|
|
57
|
+
"""
|
|
58
|
+
return "://" in s
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def load_config(path: str | None = None, verbose: bool = False) -> Config:
|
|
62
|
+
"""
|
|
63
|
+
Load and return the GitFlow configuration from the pyproject.toml file.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
A configuration object
|
|
67
|
+
"""
|
|
68
|
+
if path is None:
|
|
69
|
+
path = os.path.join(os.getcwd(), "pyproject.toml")
|
|
70
|
+
if not os.path.isfile(path):
|
|
71
|
+
raise click.ClickException("No pyproject.toml found")
|
|
72
|
+
|
|
73
|
+
with open(path, "rb") as f:
|
|
74
|
+
data = tomllib.load(f)
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
settings = data["tool"][TOOL_NAME]
|
|
78
|
+
except KeyError:
|
|
79
|
+
raise click.ClickException(f"'tool.{TOOL_NAME}' section missing: {path}")
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
branches = settings["branches"]
|
|
83
|
+
except KeyError:
|
|
84
|
+
raise click.ClickException(
|
|
85
|
+
f"'tool.{TOOL_NAME}.branches' section missing: {path}"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
config = Config(verbose=verbose)
|
|
89
|
+
# Loop through branches once and extract all needed information
|
|
90
|
+
for release_id in ReleaseID:
|
|
91
|
+
branch_name = branches.get(release_id.value)
|
|
92
|
+
if branch_name is None:
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
# Append branch name to CONFIG.branches list
|
|
96
|
+
config.branches.append(branch_name)
|
|
97
|
+
|
|
98
|
+
# Update CONFIG.branch_to_release_id dictionary
|
|
99
|
+
config.branch_to_release_id[branch_name] = release_id
|
|
100
|
+
setattr(config, release_id.value, branch_name)
|
|
101
|
+
return config
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def set_config(config: Config) -> Config:
|
|
105
|
+
"""
|
|
106
|
+
Set the global configuration object.
|
|
107
|
+
"""
|
|
108
|
+
global CONFIG
|
|
109
|
+
CONFIG = config
|
|
110
|
+
return config
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass
|
|
114
|
+
class Config:
|
|
115
|
+
stable: str = "master"
|
|
116
|
+
rc: str | None = None
|
|
117
|
+
beta: str | None = None
|
|
118
|
+
alpha: str | None = None
|
|
119
|
+
# branches in order from most-experimental to stable
|
|
120
|
+
branches: list[str] = field(default_factory=list)
|
|
121
|
+
# branch name to pre-release name (alpha, beta, rc). None for stable.
|
|
122
|
+
branch_to_release_id: dict[str, ReleaseID] = field(default_factory=dict)
|
|
123
|
+
verbose: bool = False
|
|
124
|
+
|
|
125
|
+
def most_experimental_branch(self) -> str | None:
|
|
126
|
+
"""
|
|
127
|
+
Return the most experimental branch.
|
|
128
|
+
|
|
129
|
+
This branch corresponds to the earliest pre-release specified by the config.
|
|
130
|
+
"""
|
|
131
|
+
if self.branches[0] == self.stable:
|
|
132
|
+
return None
|
|
133
|
+
else:
|
|
134
|
+
return self.branches[0]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def get_backend(url: str | None = None) -> Backend:
|
|
138
|
+
"""
|
|
139
|
+
Return a Backend corresponding to where the current python process is pushing/pulling.
|
|
140
|
+
"""
|
|
141
|
+
if os.environ.get("GITLAB_CI", "false") == "true" or (url and "gitlab" in url):
|
|
142
|
+
return GitlabBackend()
|
|
143
|
+
else:
|
|
144
|
+
return TestGitlabBackend()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def get_runtime() -> Runtime:
|
|
148
|
+
"""
|
|
149
|
+
Return a Runtime corresponding to where the current python process is *running*
|
|
150
|
+
"""
|
|
151
|
+
print(os.environ.get("GITLAB_CI"))
|
|
152
|
+
if os.environ.get("GITLAB_CI", "false") == "true":
|
|
153
|
+
return GitlabRuntime()
|
|
154
|
+
# gitlab-ci-local and our unittests set this to false as an inidicator that
|
|
155
|
+
# we are testing gitlab, but not IN gitlab.
|
|
156
|
+
elif os.environ.get("GITLAB_CI") == "false":
|
|
157
|
+
return TestGitlabRuntime()
|
|
158
|
+
else:
|
|
159
|
+
return LocalRuntime()
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class Runtime:
|
|
163
|
+
"""
|
|
164
|
+
Interact with a git repo that is local to the current process
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
@abstractmethod
|
|
168
|
+
def current_branch(self) -> str:
|
|
169
|
+
"""
|
|
170
|
+
Get the current git branch name.
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
The name of the current branch.
|
|
174
|
+
|
|
175
|
+
Raises:
|
|
176
|
+
RuntimeError: If the current branch name is not obtainable
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
@abstractmethod
|
|
180
|
+
def get_base_rev(self) -> str:
|
|
181
|
+
"""
|
|
182
|
+
Get the git revision that represents the state of the repo before the changes
|
|
183
|
+
that triggered the current pipeline
|
|
184
|
+
|
|
185
|
+
The files changed after this revision will be used to determine which
|
|
186
|
+
project tags to increment.
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
@abstractmethod
|
|
190
|
+
def get_remote(self) -> str:
|
|
191
|
+
"""
|
|
192
|
+
Configure and retrieve the name of a Git remote for use in CI environments.
|
|
193
|
+
|
|
194
|
+
This function configures a Git remote using environment variables
|
|
195
|
+
that should be set in the GitLab CI/CD environment. It configures the git user credentials,
|
|
196
|
+
splits the repository URL to format it with the access token, and adds the remote to the
|
|
197
|
+
local git configuration.
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
The name of the configured remote
|
|
201
|
+
"""
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class Backend:
|
|
205
|
+
"""
|
|
206
|
+
Interact with a remote git backend.
|
|
207
|
+
"""
|
|
208
|
+
|
|
209
|
+
def push(
|
|
210
|
+
self, *args: str, variables: dict[str, str] | None = None, skip_ci: bool = False
|
|
211
|
+
) -> None:
|
|
212
|
+
opts = []
|
|
213
|
+
if skip_ci:
|
|
214
|
+
opts.extend(["-o", "ci.skip"])
|
|
215
|
+
if variables:
|
|
216
|
+
for key, value in variables.items():
|
|
217
|
+
opts.extend(
|
|
218
|
+
[
|
|
219
|
+
"-o",
|
|
220
|
+
f"ci.variable={key}={value}",
|
|
221
|
+
]
|
|
222
|
+
)
|
|
223
|
+
git("push", *args, *opts)
|
|
224
|
+
|
|
225
|
+
def init_local_repo(self, remote_name: str) -> None:
|
|
226
|
+
"""
|
|
227
|
+
Setup the local repository.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
remote_name: name of the git remote, used to query the url
|
|
231
|
+
"""
|
|
232
|
+
# initialize the local repo
|
|
233
|
+
git("fetch", remote_name, quiet=CONFIG.verbose)
|
|
234
|
+
|
|
235
|
+
if CONFIG.verbose:
|
|
236
|
+
git("branch", "-la")
|
|
237
|
+
git("remote", "-v")
|
|
238
|
+
|
|
239
|
+
# setup branches.
|
|
240
|
+
# loop from stable to pre-release branches, bc we set all release_id branches to
|
|
241
|
+
# the location of stable
|
|
242
|
+
for branch in reversed(CONFIG.branches):
|
|
243
|
+
if branch_exists(branch):
|
|
244
|
+
if branch != CONFIG.stable:
|
|
245
|
+
click.echo(
|
|
246
|
+
f"{branch} already exists. This can potentially cause problems",
|
|
247
|
+
err=True,
|
|
248
|
+
)
|
|
249
|
+
else:
|
|
250
|
+
git("branch", "-f", branch, CONFIG.stable)
|
|
251
|
+
|
|
252
|
+
remote_branch = f"{remote_name}/{branch}"
|
|
253
|
+
if branch_exists(remote_branch):
|
|
254
|
+
git("branch", f"--set-upstream-to={remote_branch}", branch)
|
|
255
|
+
else:
|
|
256
|
+
self.push("--set-upstream", remote_name, branch, skip_ci=True)
|
|
257
|
+
|
|
258
|
+
@abstractmethod
|
|
259
|
+
def init_remote_repo(
|
|
260
|
+
self, remote_url: str, access_token: str, save_token: bool
|
|
261
|
+
) -> None:
|
|
262
|
+
"""
|
|
263
|
+
Setup the remote repository.
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
remote_url: URL of the git remote
|
|
267
|
+
access_token: token used to authenticate changes to the remote.
|
|
268
|
+
save_token: whether to save `access_token` into the remote.
|
|
269
|
+
"""
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
class GitlabRuntime(Runtime):
|
|
273
|
+
def current_branch(self) -> str:
|
|
274
|
+
try:
|
|
275
|
+
return os.environ["CI_COMMIT_BRANCH"]
|
|
276
|
+
except KeyError:
|
|
277
|
+
raise RuntimeError
|
|
278
|
+
|
|
279
|
+
def get_base_rev(self) -> str:
|
|
280
|
+
return os.environ["CI_COMMIT_BEFORE_SHA"]
|
|
281
|
+
|
|
282
|
+
@cache
|
|
283
|
+
def _setup_remote(self, url: str) -> None:
|
|
284
|
+
try:
|
|
285
|
+
access_token = os.environ["ACCESS_TOKEN"]
|
|
286
|
+
except KeyError:
|
|
287
|
+
raise click.ClickException(
|
|
288
|
+
"You must setup a CI variable in the Gitlab process called ACCESS_TOKEN\n"
|
|
289
|
+
"See https://docs.gitlab.com/ee/ci/variables/#for-a-project"
|
|
290
|
+
)
|
|
291
|
+
git("config", "user.email", os.environ["GITLAB_USER_EMAIL"])
|
|
292
|
+
git("config", "user.name", os.environ["GITLAB_USER_NAME"])
|
|
293
|
+
url = url.split("@")[-1]
|
|
294
|
+
git("remote", "add", GITLAB_REMOTE, f"https://oauth2:{access_token}@{url}")
|
|
295
|
+
|
|
296
|
+
def get_remote(self) -> str:
|
|
297
|
+
url = os.environ["CI_REPOSITORY_URL"]
|
|
298
|
+
self._setup_remote(url)
|
|
299
|
+
return GITLAB_REMOTE
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
class GitlabBackend(Backend):
|
|
303
|
+
"""Gitlab-specific behavior"""
|
|
304
|
+
|
|
305
|
+
PROMOTION_SCHEDULED_JOB_NAME = "Promote Gitflow Branches"
|
|
306
|
+
|
|
307
|
+
@cache
|
|
308
|
+
def _conn(self, base_url: str, access_token: str) -> gitlab.Gitlab:
|
|
309
|
+
"""
|
|
310
|
+
Get a cached gitlab connection object.
|
|
311
|
+
"""
|
|
312
|
+
try:
|
|
313
|
+
import gitlab
|
|
314
|
+
except ImportError:
|
|
315
|
+
raise click.ClickException(
|
|
316
|
+
f"To use the init command you must run: pip install {TOOL_NAME}[init]"
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
return gitlab.Gitlab(
|
|
320
|
+
url=base_url,
|
|
321
|
+
private_token=access_token,
|
|
322
|
+
retry_transient_errors=True,
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
def _find_promote_job(
|
|
326
|
+
self, project: gitlab.v4.objects.Project
|
|
327
|
+
) -> gitlab.v4.objects.ProjectPipelineSchedule | None:
|
|
328
|
+
"""
|
|
329
|
+
Find the scheduled job that is used to trigger promotion.
|
|
330
|
+
"""
|
|
331
|
+
schedules = project.pipelineschedules.list(get_all=True)
|
|
332
|
+
for schedule in schedules:
|
|
333
|
+
if schedule.description == self.PROMOTION_SCHEDULED_JOB_NAME:
|
|
334
|
+
return schedule
|
|
335
|
+
return None
|
|
336
|
+
|
|
337
|
+
def init_remote_repo(
|
|
338
|
+
self, remote_url: str, access_token: str, save_token: bool
|
|
339
|
+
) -> None:
|
|
340
|
+
try:
|
|
341
|
+
import gitlab.const
|
|
342
|
+
import gitlab.exceptions
|
|
343
|
+
except ImportError:
|
|
344
|
+
raise click.ClickException(
|
|
345
|
+
f"To use the init command you must run: pip install {TOOL_NAME}[init]"
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
if remote_url.endswith(".git"):
|
|
349
|
+
remote_url = remote_url[:-4]
|
|
350
|
+
|
|
351
|
+
# separate 'https://gitlab.com/groupname/projectname' into
|
|
352
|
+
# 'https://gitlab.com' and 'groupname/projectname'
|
|
353
|
+
url = urlparse(remote_url)
|
|
354
|
+
base_url = urlunparse(url._replace(path=""))
|
|
355
|
+
# remove leading "/"
|
|
356
|
+
project_and_ns = url.path[1:]
|
|
357
|
+
|
|
358
|
+
gl = self._conn(base_url, access_token)
|
|
359
|
+
try:
|
|
360
|
+
project = gl.projects.get(project_and_ns)
|
|
361
|
+
except gitlab.exceptions.GitlabGetError:
|
|
362
|
+
raise click.ClickException(f"Could not find project '{project_and_ns}")
|
|
363
|
+
|
|
364
|
+
if save_token:
|
|
365
|
+
try:
|
|
366
|
+
project.variables.get("ACCESS_TOKEN")
|
|
367
|
+
except gitlab.exceptions.GitlabGetError:
|
|
368
|
+
project.variables.create(
|
|
369
|
+
{
|
|
370
|
+
"key": "ACCESS_TOKEN",
|
|
371
|
+
"value": access_token,
|
|
372
|
+
"protected": True,
|
|
373
|
+
"masked": True,
|
|
374
|
+
}
|
|
375
|
+
)
|
|
376
|
+
click.echo("Created ACCESS_TOKEN project variable")
|
|
377
|
+
else:
|
|
378
|
+
click.echo("ACCESS_TOKEN project variable already exists. Skipping")
|
|
379
|
+
else:
|
|
380
|
+
# FIXME: validate that ACCESS_TOKEN has been set at the project or group level
|
|
381
|
+
pass
|
|
382
|
+
|
|
383
|
+
for branch in CONFIG.branches:
|
|
384
|
+
try:
|
|
385
|
+
p_branch = project.protectedbranches.get(branch)
|
|
386
|
+
except gitlab.exceptions.GitlabGetError:
|
|
387
|
+
project.protectedbranches.create(
|
|
388
|
+
{
|
|
389
|
+
"name": branch,
|
|
390
|
+
"merge_access_level": gitlab.const.AccessLevel.DEVELOPER,
|
|
391
|
+
"push_access_level": gitlab.const.AccessLevel.MAINTAINER,
|
|
392
|
+
"allow_force_push": True,
|
|
393
|
+
}
|
|
394
|
+
)
|
|
395
|
+
else:
|
|
396
|
+
p_branch.allow_force_push = True
|
|
397
|
+
p_branch.save()
|
|
398
|
+
click.echo("Setup protected branches")
|
|
399
|
+
|
|
400
|
+
default_branch = CONFIG.most_experimental_branch() or CONFIG.stable
|
|
401
|
+
gl.projects.update(project.id, {"default_branch": default_branch})
|
|
402
|
+
|
|
403
|
+
if not self._find_promote_job(project):
|
|
404
|
+
# this must happen after the branch has been created in the remote and initial
|
|
405
|
+
# commit pushed
|
|
406
|
+
schedule = project.pipelineschedules.create(
|
|
407
|
+
{
|
|
408
|
+
"ref": CONFIG.stable,
|
|
409
|
+
"description": self.PROMOTION_SCHEDULED_JOB_NAME,
|
|
410
|
+
"cron": "6 6 * * 4",
|
|
411
|
+
"active": False,
|
|
412
|
+
}
|
|
413
|
+
)
|
|
414
|
+
schedule.variables.create({"key": "SCHEDULED_JOB_NAME", "value": "promote"})
|
|
415
|
+
click.echo(
|
|
416
|
+
f"Created '{self.PROMOTION_SCHEDULED_JOB_NAME}' scheduled job, in non-active state"
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
class LocalRuntime(Runtime):
|
|
421
|
+
"""
|
|
422
|
+
Used for processes running in local git repos, not in CI.
|
|
423
|
+
"""
|
|
424
|
+
|
|
425
|
+
def current_branch(self) -> str:
|
|
426
|
+
branch = git("branch", "--show-current", capture=True)
|
|
427
|
+
if not branch:
|
|
428
|
+
raise RuntimeError
|
|
429
|
+
return branch
|
|
430
|
+
|
|
431
|
+
def get_base_rev(self) -> str:
|
|
432
|
+
try:
|
|
433
|
+
return git("rev-parse", "HEAD^", capture=True)
|
|
434
|
+
except subprocess.CalledProcessError:
|
|
435
|
+
return "0000000000000000000000000000000000000000"
|
|
436
|
+
|
|
437
|
+
def get_remote(self) -> str:
|
|
438
|
+
# FIXME: this should probably be configurable somehow, but it's a user pref
|
|
439
|
+
# so it shouldn't live in pyproject.toml
|
|
440
|
+
return "origin"
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
class TestGitlabRuntime(GitlabRuntime):
|
|
444
|
+
@cache
|
|
445
|
+
def _setup_remote(self, url: str) -> None:
|
|
446
|
+
# overridden to prevent adding the oath token to the remote url
|
|
447
|
+
git("config", "user.email", os.environ["GITLAB_USER_EMAIL"])
|
|
448
|
+
git("config", "user.name", os.environ["GITLAB_USER_NAME"])
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
class TestGitlabBackend(GitlabBackend):
|
|
452
|
+
@cache
|
|
453
|
+
def _conn(self, base_url: str, access_token: str) -> gitlab.Gitlab:
|
|
454
|
+
# overridden to return a mocked Gitlab connection object.
|
|
455
|
+
import unittest.mock
|
|
456
|
+
|
|
457
|
+
return cast("gitlab.Gitlab", unittest.mock.MagicMock())
|
|
458
|
+
|
|
459
|
+
def push(
|
|
460
|
+
self, *args: str, variables: dict[str, str] | None = None, skip_ci: bool = False
|
|
461
|
+
) -> None:
|
|
462
|
+
# overridden to write variables to a json object rather than use push options
|
|
463
|
+
# which are not supported by local git repos.
|
|
464
|
+
if variables:
|
|
465
|
+
json_file = os.path.join(os.environ["CI_REPOSITORY_URL"], "push-opts.json")
|
|
466
|
+
click.echo(f"Writing local output to {json_file}")
|
|
467
|
+
if os.path.exists(json_file):
|
|
468
|
+
os.remove(json_file)
|
|
469
|
+
|
|
470
|
+
with open(json_file, "w") as f:
|
|
471
|
+
json.dump(variables, f)
|
|
472
|
+
|
|
473
|
+
git("push", *args)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def cz(*args: str, folder: str | Path | None = None) -> str:
|
|
477
|
+
"""
|
|
478
|
+
Run commitizen in a subprocess.
|
|
479
|
+
"""
|
|
480
|
+
if CONFIG.verbose:
|
|
481
|
+
click.echo(f"running {args} in {folder}")
|
|
482
|
+
output = subprocess.check_output(
|
|
483
|
+
["cz"] + list(args),
|
|
484
|
+
text=True,
|
|
485
|
+
cwd=folder,
|
|
486
|
+
)
|
|
487
|
+
return output
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def get_upstream_branch(branch: str) -> str | None:
|
|
491
|
+
"""
|
|
492
|
+
Determine the upstream branch for a given branch name based on configuration.
|
|
493
|
+
|
|
494
|
+
Args:
|
|
495
|
+
branch: The name of the branch for which to find the upstream branch.
|
|
496
|
+
|
|
497
|
+
Returns:
|
|
498
|
+
The name of the upstream branch, or None if there is no upstream branch.
|
|
499
|
+
|
|
500
|
+
Raises:
|
|
501
|
+
ClickException: If the branch is not found in the configuration.
|
|
502
|
+
"""
|
|
503
|
+
try:
|
|
504
|
+
index = CONFIG.branches.index(branch)
|
|
505
|
+
except ValueError:
|
|
506
|
+
raise click.ClickException(f"Invalid branch: {branch}")
|
|
507
|
+
|
|
508
|
+
if index > 0:
|
|
509
|
+
return CONFIG.branches[index - 1]
|
|
510
|
+
else:
|
|
511
|
+
return None
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def is_pending_bump(remote: str, branch: str, folder: Path) -> bool:
|
|
515
|
+
"""
|
|
516
|
+
Return whether the given branch and folder combination are awaiting a minor bump.
|
|
517
|
+
|
|
518
|
+
Args:
|
|
519
|
+
remote: The remote repository name
|
|
520
|
+
branch: one of the registered gitflow branches
|
|
521
|
+
folder: folder within the repo that defines commitizen tag rules
|
|
522
|
+
|
|
523
|
+
Returns:
|
|
524
|
+
whether it is pending or not
|
|
525
|
+
"""
|
|
526
|
+
from commitizen.config import read_cfg
|
|
527
|
+
from commitizen.providers import ScmProvider
|
|
528
|
+
|
|
529
|
+
if branch != CONFIG.most_experimental_branch():
|
|
530
|
+
return False
|
|
531
|
+
|
|
532
|
+
# Find the closest promotion note to the current branch
|
|
533
|
+
promotion_rev = get_promotion_marker(remote)
|
|
534
|
+
if promotion_rev is None:
|
|
535
|
+
if CONFIG.verbose:
|
|
536
|
+
click.echo("No promote marker found", err=True)
|
|
537
|
+
return True
|
|
538
|
+
|
|
539
|
+
cwd = os.getcwd()
|
|
540
|
+
os.chdir(folder)
|
|
541
|
+
try:
|
|
542
|
+
# Use the matching logic from commitizen, which will read the commitizen
|
|
543
|
+
# config file for us (note: it supports more than just pyproject.toml).
|
|
544
|
+
provider = ScmProvider(read_cfg())
|
|
545
|
+
matcher = provider._tag_format_matcher()
|
|
546
|
+
if CONFIG.verbose:
|
|
547
|
+
click.echo(f"Found promotion base rev: {promotion_rev}")
|
|
548
|
+
# List any tags for this project folder between this branch and the promotion note
|
|
549
|
+
all_tags = get_tags(end_rev=promotion_rev)
|
|
550
|
+
tags = [t for t in all_tags if matcher(t)]
|
|
551
|
+
return not tags
|
|
552
|
+
finally:
|
|
553
|
+
os.chdir(cwd)
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def get_promotion_marker(remote: str) -> str | None:
|
|
557
|
+
"""
|
|
558
|
+
Get the hash for the most recent promotion commit.
|
|
559
|
+
|
|
560
|
+
Args:
|
|
561
|
+
remote: The remote repository name
|
|
562
|
+
"""
|
|
563
|
+
git("fetch", remote, "refs/notes/*:refs/notes/*")
|
|
564
|
+
output = git("log", "--format=%H %N", "-n20", capture=True)
|
|
565
|
+
for line in output.splitlines():
|
|
566
|
+
line = line.strip()
|
|
567
|
+
if not line:
|
|
568
|
+
continue
|
|
569
|
+
parts = line.split(" ", 1)
|
|
570
|
+
if len(parts) == 1:
|
|
571
|
+
continue
|
|
572
|
+
if parts[1] == PROMOTION_BASE_MSG:
|
|
573
|
+
return parts[0]
|
|
574
|
+
return None
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def set_promotion_marker(remote: str, branch: str) -> None:
|
|
578
|
+
"""
|
|
579
|
+
Store a state for whether the given project on the given branch needs to have a minor bump.
|
|
580
|
+
|
|
581
|
+
If it is true for a given branch, then get_tag_for_branch() will return a minor increment.
|
|
582
|
+
After this, autotag() will set the pending state to False until.
|
|
583
|
+
|
|
584
|
+
This pending state is reset to True after each promotion event.
|
|
585
|
+
|
|
586
|
+
Args:
|
|
587
|
+
remote: The remote repository name
|
|
588
|
+
branch: one of the registered gitflow branches
|
|
589
|
+
"""
|
|
590
|
+
git("fetch", remote, "refs/notes/*:refs/notes/*")
|
|
591
|
+
# FIXME: forcing here, because the same commit can be the promotion base more than once. should we skip?
|
|
592
|
+
git("notes", "add", "--force", "-m", PROMOTION_BASE_MSG, branch)
|
|
593
|
+
git("push", remote, "refs/notes/*")
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def get_tag_for_branch(remote: str, branch: str, folder: Path) -> str:
|
|
597
|
+
"""
|
|
598
|
+
Determine the appropriate new tag for a given branch based on the latest changes.
|
|
599
|
+
|
|
600
|
+
This function uses the Commitizen tool to determine the next tag name for a branch, potentially
|
|
601
|
+
adjusting for pre-release tags and minor version increments.
|
|
602
|
+
|
|
603
|
+
Args:
|
|
604
|
+
remote: The remote repository name
|
|
605
|
+
branch: The name of the branch for which to generate the tag.
|
|
606
|
+
folder: The folder within the repo that controls the tag.
|
|
607
|
+
|
|
608
|
+
Returns:
|
|
609
|
+
The new tag to be created
|
|
610
|
+
|
|
611
|
+
Raises:
|
|
612
|
+
RuntimeError: If the command does not generate an output or fails to determine the tag.
|
|
613
|
+
"""
|
|
614
|
+
try:
|
|
615
|
+
release_id = CONFIG.branch_to_release_id[branch]
|
|
616
|
+
except KeyError:
|
|
617
|
+
raise click.ClickException(
|
|
618
|
+
f"{branch} is not a valid release branch. "
|
|
619
|
+
f"Must be one of {', '.join(CONFIG.branches)}"
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
increment = "patch"
|
|
623
|
+
|
|
624
|
+
# Only apply minor increment the most experimental branch
|
|
625
|
+
if is_pending_bump(remote, branch, folder):
|
|
626
|
+
increment = "minor"
|
|
627
|
+
|
|
628
|
+
args = [f"--increment={increment}"]
|
|
629
|
+
if release_id != ReleaseID.stable:
|
|
630
|
+
args += ["--prerelease", release_id.value]
|
|
631
|
+
|
|
632
|
+
if increment == "minor":
|
|
633
|
+
args += ["--increment-mode=exact"]
|
|
634
|
+
|
|
635
|
+
# run this in the project directory so that the pyproject.toml is accessible.
|
|
636
|
+
output = cz(*(["bump"] + list(args) + ["--dry-run", "--yes"]), folder=folder)
|
|
637
|
+
match = re.search("tag to create: (.*)", output)
|
|
638
|
+
|
|
639
|
+
if not match:
|
|
640
|
+
raise click.ClickException(output)
|
|
641
|
+
|
|
642
|
+
tag = match.group(1).strip()
|
|
643
|
+
|
|
644
|
+
return tag
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def get_projects() -> list[tuple[Path, str | None]]:
|
|
648
|
+
"""Get the list of projects within the repo.
|
|
649
|
+
|
|
650
|
+
A project is a folder with a pyproject.toml file with a `tool.commitizen` section.
|
|
651
|
+
"""
|
|
652
|
+
results = []
|
|
653
|
+
repo = GitRepo(".")
|
|
654
|
+
for path in repo.file_matches(include=("**/pyproject.toml",)):
|
|
655
|
+
with open(path, "rb") as f:
|
|
656
|
+
data = tomllib.load(f)
|
|
657
|
+
try:
|
|
658
|
+
data["tool"]["commitizen"]
|
|
659
|
+
except KeyError:
|
|
660
|
+
pass
|
|
661
|
+
else:
|
|
662
|
+
try:
|
|
663
|
+
name = data["project"]["name"]
|
|
664
|
+
except KeyError:
|
|
665
|
+
name = None
|
|
666
|
+
results.append((Path(path).parent, name))
|
|
667
|
+
return sorted(results)
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def get_modified_projects(base_rev: str | None = None) -> list[tuple[Path, str | None]]:
|
|
671
|
+
"""Get the list of projects with changes files.
|
|
672
|
+
|
|
673
|
+
A project is defined as a folder with a pyproject.toml file with a `tool.commitizen` section.
|
|
674
|
+
|
|
675
|
+
Args:
|
|
676
|
+
base_rev: The Git revision to compare against when identifying changed files
|
|
677
|
+
"""
|
|
678
|
+
if not base_rev:
|
|
679
|
+
runtime = get_runtime()
|
|
680
|
+
base_rev = runtime.get_base_rev()
|
|
681
|
+
|
|
682
|
+
# Compare the current commit with the branch you want to merge with:
|
|
683
|
+
# FIXME: do not included deleted files
|
|
684
|
+
output = git("diff-tree", "--name-only", "-r", base_rev, "HEAD", capture=True)
|
|
685
|
+
all_files = output.splitlines()
|
|
686
|
+
if CONFIG.verbose:
|
|
687
|
+
if all_files:
|
|
688
|
+
click.echo(f"Modified files between {base_rev} and HEAD:")
|
|
689
|
+
for path in all_files:
|
|
690
|
+
click.echo(f" {path}")
|
|
691
|
+
else:
|
|
692
|
+
click.echo(f"No modified files between {base_rev} and HEAD", err=True)
|
|
693
|
+
|
|
694
|
+
# find the deepest project that the file belongs to
|
|
695
|
+
projects = dict(get_projects())
|
|
696
|
+
project_dirs = list(reversed(projects))
|
|
697
|
+
|
|
698
|
+
results = set()
|
|
699
|
+
for changed_file in all_files:
|
|
700
|
+
for project_dir in project_dirs:
|
|
701
|
+
if project_dir in Path(changed_file).parents:
|
|
702
|
+
results.add(project_dir)
|
|
703
|
+
|
|
704
|
+
return [(project_dir, projects[project_dir]) for project_dir in sorted(results)]
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
@click.group(context_settings=CONTEXT_SETTINGS)
|
|
708
|
+
@click.option("--config", "-c", "config_path", metavar="CONFIG", type=str)
|
|
709
|
+
@click.option("--verbose", "-v", is_flag=True, default=False)
|
|
710
|
+
def cli(config_path: str, verbose: bool) -> None:
|
|
711
|
+
set_config(load_config(config_path, verbose))
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
@cli.command()
|
|
715
|
+
@click.option(
|
|
716
|
+
"--access-token",
|
|
717
|
+
required=True,
|
|
718
|
+
metavar="TOKEN",
|
|
719
|
+
help="Security token used to authenticate with the remote",
|
|
720
|
+
)
|
|
721
|
+
@click.option(
|
|
722
|
+
"--remote",
|
|
723
|
+
default="origin",
|
|
724
|
+
metavar="REMOTE",
|
|
725
|
+
show_default=True,
|
|
726
|
+
help=(
|
|
727
|
+
"The name of the git remote in the current git repo "
|
|
728
|
+
"(e.g. configured using `git remote`) or the remote URL. "
|
|
729
|
+
"Providing a URL implies --no-local"
|
|
730
|
+
),
|
|
731
|
+
)
|
|
732
|
+
@click.option(
|
|
733
|
+
"--save-token/--no-save-token",
|
|
734
|
+
default=True,
|
|
735
|
+
help="Whether to save the access token into the remote as a reusable "
|
|
736
|
+
"variable. If this is disabled, you must configure the ACCESS_TOKEN "
|
|
737
|
+
"manually.",
|
|
738
|
+
)
|
|
739
|
+
@click.option(
|
|
740
|
+
"--init-local/--no-local",
|
|
741
|
+
default=True,
|
|
742
|
+
help="Whether to initialize the local git repo",
|
|
743
|
+
)
|
|
744
|
+
@click.option(
|
|
745
|
+
"--init-remote/--no-remote",
|
|
746
|
+
default=True,
|
|
747
|
+
help="Whether to initialize the remote git repo (i.e. Gitlab)",
|
|
748
|
+
)
|
|
749
|
+
def init(
|
|
750
|
+
access_token: str,
|
|
751
|
+
remote: str,
|
|
752
|
+
save_token: bool,
|
|
753
|
+
init_local: bool,
|
|
754
|
+
init_remote: bool,
|
|
755
|
+
) -> None:
|
|
756
|
+
"""
|
|
757
|
+
Initialize the current git repo and its associated Gitlab project for use with tide.
|
|
758
|
+
|
|
759
|
+
This command must be run from a git repo, and the repo must have the Gitlab project setup
|
|
760
|
+
as a remote, either by being cloned from it, or via `git remote add`.
|
|
761
|
+
"""
|
|
762
|
+
import tempfile
|
|
763
|
+
|
|
764
|
+
if is_url(remote):
|
|
765
|
+
init_local = False
|
|
766
|
+
remote_url = remote
|
|
767
|
+
else:
|
|
768
|
+
# FIXME: print a user friendly error if we're not in a git repo.
|
|
769
|
+
# FIXME: handle remote not setup correctly
|
|
770
|
+
remote_url = git("remote", "get-url", remote, capture=True)
|
|
771
|
+
|
|
772
|
+
backend = get_backend(remote_url)
|
|
773
|
+
|
|
774
|
+
if init_local:
|
|
775
|
+
backend.init_local_repo(remote)
|
|
776
|
+
else:
|
|
777
|
+
# we may still need to create branches in the remote, so create a dummy clone
|
|
778
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
779
|
+
git("clone", f"--branch={CONFIG.stable}", remote_url, tmpdir)
|
|
780
|
+
pwd = os.getcwd()
|
|
781
|
+
try:
|
|
782
|
+
os.chdir(tmpdir)
|
|
783
|
+
backend.init_local_repo("origin")
|
|
784
|
+
finally:
|
|
785
|
+
os.chdir(pwd)
|
|
786
|
+
|
|
787
|
+
# FIXME: add pyproject.toml section? check if it exists? Hard to do automatically,
|
|
788
|
+
# because in a monorepo there could be many.
|
|
789
|
+
# FIXME: create a stub gitlab-ci.yml file if it doesn't exist?
|
|
790
|
+
if init_remote:
|
|
791
|
+
backend.init_remote_repo(remote_url, access_token, save_token)
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
@cli.command()
|
|
795
|
+
@click.option(
|
|
796
|
+
"--annotation",
|
|
797
|
+
default="automatic change detected",
|
|
798
|
+
show_default=True,
|
|
799
|
+
help="Message to pass for tag annotations.",
|
|
800
|
+
)
|
|
801
|
+
@click.option(
|
|
802
|
+
"--base-rev",
|
|
803
|
+
metavar="SHA",
|
|
804
|
+
help="The Git revision to compare against when identifying changed files.",
|
|
805
|
+
)
|
|
806
|
+
def autotag(annotation: str, base_rev: str | None) -> None:
|
|
807
|
+
"""
|
|
808
|
+
Tag the current branch with a new version number and push the tag to the remote repository.
|
|
809
|
+
"""
|
|
810
|
+
runtime = get_runtime()
|
|
811
|
+
branch = runtime.current_branch()
|
|
812
|
+
remote = runtime.get_remote()
|
|
813
|
+
|
|
814
|
+
backend = get_backend()
|
|
815
|
+
|
|
816
|
+
projects = get_modified_projects(base_rev)
|
|
817
|
+
if projects:
|
|
818
|
+
for project_folder, _ in projects:
|
|
819
|
+
# Auto-tag
|
|
820
|
+
tag = get_tag_for_branch(remote, branch, project_folder)
|
|
821
|
+
|
|
822
|
+
# NOTE: this delay is necessary to create stable sorting of tags
|
|
823
|
+
# because git's time resolution is 1s (same as unix timestamp).
|
|
824
|
+
# https://stackoverflow.com/questions/28237043/what-is-the-resolution-of-gits-commit-date-or-author-date-timestamps
|
|
825
|
+
time.sleep(1.1)
|
|
826
|
+
|
|
827
|
+
click.echo(f"Creating new tag: {tag} on branch: {branch} {time.time()}")
|
|
828
|
+
git("tag", "-a", tag, "-m", annotation)
|
|
829
|
+
|
|
830
|
+
# FIXME: we may want to push all tags at once
|
|
831
|
+
click.echo(f"Pushing {tag} to remote")
|
|
832
|
+
backend.push(remote, tag)
|
|
833
|
+
else:
|
|
834
|
+
click.echo("No tags generated!", err=True)
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
@cli.command()
|
|
838
|
+
def hotfix() -> None:
|
|
839
|
+
"""
|
|
840
|
+
Merge hotfixes from a feature branch back to upstream branches.
|
|
841
|
+
"""
|
|
842
|
+
runtime = get_runtime()
|
|
843
|
+
branch = runtime.current_branch()
|
|
844
|
+
remote = runtime.get_remote()
|
|
845
|
+
upstream_branch = get_upstream_branch(branch)
|
|
846
|
+
if not upstream_branch:
|
|
847
|
+
click.echo(f"No branch upstream from {branch}. Skipping auto-merge")
|
|
848
|
+
return
|
|
849
|
+
|
|
850
|
+
# Auto-merge
|
|
851
|
+
# Record the current state
|
|
852
|
+
message = git("log", "--pretty=format: %s", "-1", capture=True)
|
|
853
|
+
# strip out previous Automerge formatting
|
|
854
|
+
match = re.match(
|
|
855
|
+
HOTFIX_MESSAGE.format(upstream_branch="[^:]+", message="(.*)$"), message
|
|
856
|
+
)
|
|
857
|
+
if match:
|
|
858
|
+
message = match.groups()[0]
|
|
859
|
+
|
|
860
|
+
git("checkout", "-B", f"{branch}_temp")
|
|
861
|
+
|
|
862
|
+
# Fetch the upstream branch
|
|
863
|
+
git("fetch", remote, upstream_branch)
|
|
864
|
+
|
|
865
|
+
checkout(remote, upstream_branch, create=True)
|
|
866
|
+
|
|
867
|
+
msg = HOTFIX_MESSAGE.format(upstream_branch=upstream_branch, message=message)
|
|
868
|
+
click.echo(msg)
|
|
869
|
+
|
|
870
|
+
try:
|
|
871
|
+
git("merge", f"{branch}_temp", "-m", msg)
|
|
872
|
+
except subprocess.CalledProcessError:
|
|
873
|
+
click.echo("Conflicts:", err=True)
|
|
874
|
+
git("diff", "--name-only", "--diff-filter=U")
|
|
875
|
+
raise
|
|
876
|
+
|
|
877
|
+
# this will trigger a full pipeline for upstream_branch, and potentially another auto-merge
|
|
878
|
+
click.echo(f"Pushing {upstream_branch} to {remote}")
|
|
879
|
+
variables = {
|
|
880
|
+
f"{ENVVAR_PREFIX}_AUTOTAG_ANNOTATION": msg,
|
|
881
|
+
}
|
|
882
|
+
get_backend().push(remote, upstream_branch, variables=variables)
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
@cli.command()
|
|
886
|
+
def promote() -> None:
|
|
887
|
+
"""
|
|
888
|
+
Promote changes through the branch hierarchy.
|
|
889
|
+
|
|
890
|
+
e.g. from alpha -> beta -> rc -> stable.
|
|
891
|
+
"""
|
|
892
|
+
backend = get_backend()
|
|
893
|
+
runtime = get_runtime()
|
|
894
|
+
remote = runtime.get_remote()
|
|
895
|
+
if CONFIG.verbose:
|
|
896
|
+
click.echo(f"remote = {remote}")
|
|
897
|
+
|
|
898
|
+
local_output = []
|
|
899
|
+
|
|
900
|
+
def promote_branch(branch: str, log_msg_template: str) -> None:
|
|
901
|
+
"""
|
|
902
|
+
- Checkout the branch
|
|
903
|
+
- Merge with the upstream branch, if it exists
|
|
904
|
+
- Push, skipping hotfixes
|
|
905
|
+
|
|
906
|
+
The branch is left checked out.
|
|
907
|
+
"""
|
|
908
|
+
upstream_branch = get_upstream_branch(branch)
|
|
909
|
+
release_id = CONFIG.branch_to_release_id[branch]
|
|
910
|
+
log_msg = log_msg_template.format(
|
|
911
|
+
branch=branch, upstream_branch=upstream_branch, release_id=release_id.value
|
|
912
|
+
)
|
|
913
|
+
|
|
914
|
+
click.echo(f"Fetching {remote}/{branch}")
|
|
915
|
+
git("fetch", remote, branch)
|
|
916
|
+
|
|
917
|
+
base_rev = checkout(remote, branch, create=True)
|
|
918
|
+
|
|
919
|
+
if upstream_branch:
|
|
920
|
+
git("fetch", remote, upstream_branch)
|
|
921
|
+
click.echo(f"Merging with upstream branch {remote}/{upstream_branch}")
|
|
922
|
+
git("merge", join(remote, upstream_branch), "-m", f"{log_msg}")
|
|
923
|
+
|
|
924
|
+
variables = {
|
|
925
|
+
f"{ENVVAR_PREFIX}_SKIP_HOTFIX": "true",
|
|
926
|
+
f"{ENVVAR_PREFIX}_AUTOTAG_ANNOTATION": log_msg,
|
|
927
|
+
f"{ENVVAR_PREFIX}_AUTOTAG_BASE_REV": base_rev,
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
# Trigger test/tag jobs for these new versions, but skip auto-hotfix
|
|
931
|
+
# --atomic means if there's a failure to push any of the refs, the entire
|
|
932
|
+
# operation will fail (like a databases). May not be strictly necessary here.
|
|
933
|
+
click.echo("Pushing changes")
|
|
934
|
+
backend.push("--atomic", remote, branch, variables=variables)
|
|
935
|
+
|
|
936
|
+
# FIXME: switch to using push-opts.json
|
|
937
|
+
if (
|
|
938
|
+
isinstance(backend, TestGitlabBackend)
|
|
939
|
+
and upstream_branch
|
|
940
|
+
and base_rev != current_rev()
|
|
941
|
+
):
|
|
942
|
+
push_info = {
|
|
943
|
+
"annotation": log_msg,
|
|
944
|
+
"base_rev": base_rev,
|
|
945
|
+
"branch": branch,
|
|
946
|
+
}
|
|
947
|
+
local_output.append(push_info)
|
|
948
|
+
click.echo(f"Trigger: {json.dumps(push_info)}")
|
|
949
|
+
|
|
950
|
+
# Promotion time!
|
|
951
|
+
|
|
952
|
+
# loop from stable to most experimental
|
|
953
|
+
for branch in reversed(CONFIG.branches):
|
|
954
|
+
# It doesn't matter what the active branch is when promote is run: the next thing
|
|
955
|
+
# that we do is checkout CONFIG.stable.
|
|
956
|
+
if branch == CONFIG.most_experimental_branch():
|
|
957
|
+
msg = PROMOTION_CYCLE_START_MESSAGE
|
|
958
|
+
else:
|
|
959
|
+
msg = PROMOTION_MESSAGE
|
|
960
|
+
|
|
961
|
+
promote_branch(branch, msg)
|
|
962
|
+
|
|
963
|
+
if local_output:
|
|
964
|
+
json_file = os.path.join(os.environ["CI_REPOSITORY_URL"], "push-data.json")
|
|
965
|
+
click.echo(f"Writing local output to {json_file}")
|
|
966
|
+
with open(json_file, "w") as f:
|
|
967
|
+
json.dump(local_output, f)
|
|
968
|
+
|
|
969
|
+
# Note: we do not make a tag on our cycle-start branch at this time. Instead, we wait
|
|
970
|
+
# for the first commit on the branch to do so.
|
|
971
|
+
experimental_branch = CONFIG.most_experimental_branch()
|
|
972
|
+
if experimental_branch:
|
|
973
|
+
set_promotion_marker(remote, experimental_branch)
|
|
974
|
+
|
|
975
|
+
|
|
976
|
+
@cli.command
|
|
977
|
+
@click.option("--modified", "-m", is_flag=True, default=False)
|
|
978
|
+
# FIXME: add output format
|
|
979
|
+
# FIXME: add option to write to file
|
|
980
|
+
def projects(modified: bool) -> None:
|
|
981
|
+
"""
|
|
982
|
+
List project paths within the repo.
|
|
983
|
+
|
|
984
|
+
A project is a folder with a pyproject.toml file with a `tool.commitizen` section.
|
|
985
|
+
"""
|
|
986
|
+
|
|
987
|
+
if modified:
|
|
988
|
+
projects_ = get_modified_projects()
|
|
989
|
+
else:
|
|
990
|
+
projects_ = get_projects()
|
|
991
|
+
|
|
992
|
+
for project_dir, project_name in projects_:
|
|
993
|
+
if project_name is None:
|
|
994
|
+
project_name = "[unset]"
|
|
995
|
+
click.echo(f"{project_name} = {project_dir}")
|
|
996
|
+
|
|
997
|
+
|
|
998
|
+
@cli.command(name="next-version")
|
|
999
|
+
@click.option(
|
|
1000
|
+
"--path",
|
|
1001
|
+
default=".",
|
|
1002
|
+
type=click.Path(exists=True, file_okay=False),
|
|
1003
|
+
show_default=True,
|
|
1004
|
+
help="Folder within the repository. A pyproject.toml should reside in the "
|
|
1005
|
+
"specified directory",
|
|
1006
|
+
)
|
|
1007
|
+
@click.option(
|
|
1008
|
+
"--branch",
|
|
1009
|
+
default=None,
|
|
1010
|
+
help=(
|
|
1011
|
+
"The release branch to use to determine the next version. "
|
|
1012
|
+
"Defaults to the current branch."
|
|
1013
|
+
),
|
|
1014
|
+
)
|
|
1015
|
+
@click.option(
|
|
1016
|
+
"--origin",
|
|
1017
|
+
default="origin",
|
|
1018
|
+
show_default=True,
|
|
1019
|
+
help="The git remote to use to when determining the next version.",
|
|
1020
|
+
)
|
|
1021
|
+
def next_tag(path: str, branch: str | None, remote: str) -> None:
|
|
1022
|
+
"""
|
|
1023
|
+
Get the next tag.
|
|
1024
|
+
"""
|
|
1025
|
+
if branch is None:
|
|
1026
|
+
runtime = get_runtime()
|
|
1027
|
+
branch = runtime.current_branch()
|
|
1028
|
+
click.echo(get_tag_for_branch(remote, branch, Path(path)))
|
|
1029
|
+
|
|
1030
|
+
|
|
1031
|
+
def main() -> None:
|
|
1032
|
+
import shutil
|
|
1033
|
+
|
|
1034
|
+
return cli(
|
|
1035
|
+
auto_envvar_prefix=ENVVAR_PREFIX,
|
|
1036
|
+
max_content_width=shutil.get_terminal_size().columns,
|
|
1037
|
+
)
|