git-tide 0.1.5__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.
tide/core.py ADDED
@@ -0,0 +1,821 @@
1
+ from __future__ import absolute_import, print_function, annotations
2
+
3
+ import os
4
+ import subprocess
5
+ import json
6
+
7
+ import click
8
+
9
+ try:
10
+ import tomli as tomllib # noqa: F401
11
+ except ImportError:
12
+ import tomllib # type: ignore[no-redef]
13
+ from pathlib import Path
14
+ from dataclasses import dataclass, field
15
+ from functools import lru_cache
16
+ from urllib.parse import urlparse, urlunparse
17
+ from abc import abstractmethod
18
+ from enum import Enum
19
+ from typing import TYPE_CHECKING, cast
20
+
21
+ from .gitutils import git, checkout, get_tags, branch_exists, join, current_rev, GitRepo
22
+
23
+ if TYPE_CHECKING:
24
+ import gitlab.v4.objects
25
+ import commitizen.providers
26
+ import commitizen.config
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
+ GITLAB_REMOTE = "gitlab_origin"
34
+
35
+ # FIXME: add these to config file
36
+ HOTFIX_MESSAGE = "auto-hotfix into {upstream_branch}: {message}"
37
+ PROMOTION_CYCLE_START_MESSAGE = "starting new {release_id} cycle."
38
+ PROMOTION_MESSAGE = "promoting {upstream_branch} to {branch}!"
39
+
40
+ cache = lru_cache(maxsize=None)
41
+
42
+
43
+ class ReleaseID(str, Enum):
44
+ """Represents semver pre-releases, plus 'stable' (i.e. non-pre-release)"""
45
+
46
+ alpha = "alpha"
47
+ beta = "beta"
48
+ rc = "rc"
49
+ stable = "stable"
50
+
51
+
52
+ def is_url(s: str) -> bool:
53
+ """
54
+ Return whether the string looks like a URL.
55
+ """
56
+ return "://" in s
57
+
58
+
59
+ def load_config(path: str | None = None, verbose: bool = False) -> Config:
60
+ """
61
+ Load and return the GitFlow configuration from the pyproject.toml file.
62
+
63
+ Returns:
64
+ A configuration object
65
+ """
66
+ if path is None:
67
+ path = os.path.join(os.getcwd(), "pyproject.toml")
68
+ if not os.path.isfile(path):
69
+ raise click.ClickException("No pyproject.toml found")
70
+
71
+ with open(path, "rb") as f:
72
+ data = tomllib.load(f)
73
+
74
+ try:
75
+ settings = data["tool"][TOOL_NAME]
76
+ except KeyError:
77
+ raise click.ClickException(f"'tool.{TOOL_NAME}' section missing: {path}")
78
+
79
+ try:
80
+ branches = settings["branches"]
81
+ except KeyError:
82
+ raise click.ClickException(
83
+ f"'tool.{TOOL_NAME}.branches' section missing: {path}"
84
+ )
85
+
86
+ config = Config(verbose=verbose)
87
+
88
+ try:
89
+ config.tag_format = settings["tag_format"]
90
+ except KeyError:
91
+ pass
92
+
93
+ # Loop through branches once and extract all needed information
94
+ for release_id in ReleaseID:
95
+ branch_name = branches.get(release_id.value)
96
+ if branch_name is None:
97
+ continue
98
+
99
+ # Append branch name to CONFIG.branches list
100
+ config.branches.append(branch_name)
101
+
102
+ # Update CONFIG.branch_to_release_id dictionary
103
+ config.branch_to_release_id[branch_name] = release_id
104
+ setattr(config, release_id.value, branch_name)
105
+ return config
106
+
107
+
108
+ @dataclass
109
+ class Config:
110
+ stable: str = "master"
111
+ rc: str | None = None
112
+ beta: str | None = None
113
+ alpha: str | None = None
114
+ # branches in order from most-experimental to stable
115
+ branches: list[str] = field(default_factory=list)
116
+ # branch name to pre-release name (alpha, beta, rc). None for stable.
117
+ branch_to_release_id: dict[str, ReleaseID] = field(default_factory=dict)
118
+ verbose: bool = False
119
+ tag_format: str = "$version"
120
+
121
+ def get_upstream_branch(self, branch: str) -> str | None:
122
+ """
123
+ Determine the upstream branch for a given branch name based on configuration.
124
+
125
+ Args:
126
+ branch: The name of the branch for which to find the upstream branch.
127
+
128
+ Returns:
129
+ The name of the upstream branch, or None if there is no upstream branch.
130
+
131
+ Raises:
132
+ ClickException: If the branch is not found in the configuration.
133
+ """
134
+ try:
135
+ index = self.branches.index(branch)
136
+ except ValueError:
137
+ raise click.ClickException(f"Invalid branch: {branch}")
138
+
139
+ if index > 0:
140
+ return self.branches[index - 1]
141
+ else:
142
+ return None
143
+
144
+ def most_experimental_branch(self) -> str | None:
145
+ """
146
+ Return the most experimental branch.
147
+
148
+ This branch corresponds to the earliest pre-release specified by the config.
149
+ """
150
+ if self.branches[0] == self.stable:
151
+ return None
152
+ else:
153
+ return self.branches[0]
154
+
155
+
156
+ class Runtime:
157
+ """
158
+ Interact with a git repo that is local to the current process
159
+ """
160
+
161
+ def __init__(self, config: Config):
162
+ self.config = config
163
+
164
+ @abstractmethod
165
+ def current_branch(self) -> str:
166
+ """
167
+ Get the current git branch name.
168
+
169
+ Returns:
170
+ The name of the current branch.
171
+
172
+ Raises:
173
+ RuntimeError: If the current branch name is not obtainable
174
+ """
175
+
176
+ @abstractmethod
177
+ def get_base_rev(self) -> str:
178
+ """
179
+ Get the git revision that represents the state of the repo before the changes
180
+ that triggered the current pipeline
181
+
182
+ The files changed after this revision will be used to determine which
183
+ project tags to increment.
184
+ """
185
+
186
+ @abstractmethod
187
+ def get_remote(self) -> str:
188
+ """
189
+ Configure and retrieve the name of a Git remote for use in CI environments.
190
+
191
+ This function configures a Git remote using environment variables
192
+ that should be set in the GitLab CI/CD environment. It configures the git user credentials,
193
+ splits the repository URL to format it with the access token, and adds the remote to the
194
+ local git configuration.
195
+
196
+ Returns:
197
+ The name of the configured remote
198
+ """
199
+
200
+
201
+ class Backend:
202
+ """
203
+ Interact with a remote git backend.
204
+ """
205
+
206
+ def __init__(self, config: Config):
207
+ self.config = config
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=self.config.verbose)
234
+
235
+ if self.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(self.config.branches):
243
+ if branch_exists(branch):
244
+ if branch != self.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, self.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 self.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 = self.config.most_experimental_branch() or self.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": self.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
+ output = subprocess.check_output(
481
+ ["cz"] + list(args),
482
+ text=True,
483
+ cwd=folder,
484
+ )
485
+ return output.strip()
486
+
487
+
488
+ def is_pending_bump(
489
+ config: Config,
490
+ provider: commitizen.providers.ScmProvider,
491
+ remote: str,
492
+ branch: str,
493
+ folder: Path,
494
+ ) -> bool:
495
+ """
496
+ Return whether the given branch and folder combination are awaiting a minor bump.
497
+
498
+ Args:
499
+ remote: The remote repository name
500
+ branch: one of the registered gitflow branches
501
+ folder: folder within the repo that defines commitizen tag rules
502
+
503
+ Returns:
504
+ whether it is pending or not
505
+ """
506
+ if branch != config.most_experimental_branch():
507
+ return False
508
+
509
+ # Find the closest promotion note to the current branch
510
+ promotion_rev = get_promotion_marker(remote)
511
+ if promotion_rev is None:
512
+ if config.verbose:
513
+ click.echo("No promote marker found", err=True)
514
+ return True
515
+
516
+ matcher = provider._tag_format_matcher()
517
+ if config.verbose:
518
+ click.echo(f"Found promotion base rev: {promotion_rev}")
519
+ # List any tags for this project folder between this branch and the promotion note
520
+ all_tags = get_tags(end_rev=promotion_rev)
521
+ tags = [t for t in all_tags if matcher(t)]
522
+ return not tags
523
+
524
+
525
+ def get_promotion_marker(remote: str) -> str | None:
526
+ """
527
+ Get the hash for the most recent promotion commit.
528
+
529
+ Args:
530
+ remote: The remote repository name
531
+ """
532
+ git("fetch", remote, "refs/notes/*:refs/notes/*")
533
+ output = git("log", "--format=%H %N", "-n20", capture=True)
534
+ for line in output.splitlines():
535
+ line = line.strip()
536
+ if not line:
537
+ continue
538
+ parts = line.split(" ", 1)
539
+ if len(parts) == 1:
540
+ continue
541
+ if parts[1] == PROMOTION_BASE_MSG:
542
+ return parts[0]
543
+ return None
544
+
545
+
546
+ def set_promotion_marker(remote: str, branch: str) -> None:
547
+ """
548
+ Store a state for whether the given project on the given branch needs to have a minor bump.
549
+
550
+ If it is true for a given branch, then get_next_tag() will return a minor increment.
551
+ After this, autotag() will set the pending state to False until.
552
+
553
+ This pending state is reset to True after each promotion event.
554
+
555
+ Args:
556
+ remote: The remote repository name
557
+ branch: one of the registered gitflow branches
558
+ """
559
+ git("fetch", remote, "refs/notes/*:refs/notes/*")
560
+ # FIXME: forcing here, because the same commit can be the promotion base more than once. should we skip?
561
+ git("notes", "add", "--force", "-m", PROMOTION_BASE_MSG, branch)
562
+ git("push", remote, "refs/notes/*")
563
+
564
+
565
+ def _get_cz_config(tag_format: str, project_name: str) -> commitizen.config.BaseConfig:
566
+ from commitizen.config.base_config import BaseConfig
567
+ from commitizen.defaults import Settings
568
+
569
+ cz_config = BaseConfig()
570
+ cz_config.update(
571
+ Settings(
572
+ name="cz_conventional_commits",
573
+ tag_format=tag_format.replace("$project", project_name),
574
+ version_scheme="pep440",
575
+ version_provider="scm",
576
+ major_version_zero=False,
577
+ )
578
+ )
579
+ return cz_config
580
+
581
+
582
+ def get_current_version(
583
+ config: Config, project_name: str, folder: Path, as_tag: bool = False
584
+ ) -> str:
585
+ """
586
+ Return the current version.
587
+
588
+ Args:
589
+ project_name: The name of the project, used to look up the commitizen
590
+ configuration
591
+ folder: The folder within the repo that controls the tag.
592
+ as_tag: Whether to format the version based on tool.tide.tag_format
593
+
594
+ Returns:
595
+ The current version or tag
596
+ """
597
+ from commitizen.providers import ScmProvider
598
+ from commitizen.version_schemes import get_version_scheme
599
+ from commitizen import bump
600
+
601
+ cz_config = _get_cz_config(config.tag_format, project_name)
602
+ provider = ScmProvider(cz_config)
603
+ scheme = get_version_scheme(cz_config)
604
+ current_version = provider.get_version()
605
+
606
+ tag_version = bump.normalize_tag(
607
+ current_version,
608
+ tag_format=cz_config.settings["tag_format"] if as_tag else "$version",
609
+ scheme=scheme,
610
+ )
611
+
612
+ return tag_version
613
+
614
+
615
+ def get_next_version(
616
+ config: Config,
617
+ remote: str,
618
+ branch: str,
619
+ project_name: str,
620
+ folder: Path,
621
+ as_tag: bool = False,
622
+ ) -> str:
623
+ """
624
+ Return the next version for a given branch based on the latest changes.
625
+
626
+ Args:
627
+ remote: The remote repository name
628
+ branch: The name of the branch for which to generate the tag.
629
+ project_name: The name of the project, used to look up the commitizen
630
+ configuration
631
+ folder: The folder within the repo that controls the tag.
632
+ as_tag: Whether to format the version based on tool.tide.tag_format
633
+
634
+ Returns:
635
+ The next version or tag to be created
636
+ """
637
+ from commitizen.providers import ScmProvider
638
+ from commitizen.version_schemes import get_version_scheme, Increment
639
+ from commitizen import bump
640
+
641
+ try:
642
+ release_id = config.branch_to_release_id[branch]
643
+ except KeyError:
644
+ raise click.ClickException(
645
+ f"{branch} is not a valid release branch. "
646
+ f"Must be one of {', '.join(config.branches)}"
647
+ )
648
+
649
+ cz_config = _get_cz_config(config.tag_format, project_name)
650
+
651
+ provider = ScmProvider(cz_config)
652
+ scheme = get_version_scheme(cz_config)
653
+ current_version = scheme(provider.get_version())
654
+
655
+ # Only apply minor increment the most experimental branch
656
+ if is_pending_bump(config, provider, remote, branch, folder):
657
+ increment: Increment = "MINOR"
658
+ exact_increment = True
659
+ else:
660
+ increment = "PATCH"
661
+ exact_increment = False
662
+
663
+ if release_id != ReleaseID.stable:
664
+ prerelease = release_id.value
665
+ else:
666
+ prerelease = None
667
+
668
+ new_version = current_version.bump(
669
+ increment,
670
+ prerelease=prerelease,
671
+ exact_increment=exact_increment,
672
+ )
673
+
674
+ return bump.normalize_tag(
675
+ new_version,
676
+ tag_format=cz_config.settings["tag_format"] if as_tag else "$version",
677
+ scheme=scheme,
678
+ )
679
+
680
+
681
+ def get_projects() -> list[tuple[Path, str]]:
682
+ """Get the list of projects within the repo.
683
+
684
+ A project is a folder with a pyproject.toml file with a `tool.tide` section
685
+ and a `tool.tide.project`.
686
+ """
687
+ results = []
688
+ repo = GitRepo(".")
689
+ for path in repo.file_matches(include=("**/pyproject.toml",)):
690
+ with open(path, "rb") as f:
691
+ data = tomllib.load(f)
692
+ try:
693
+ name = data["tool"][TOOL_NAME]["project"]
694
+ except KeyError:
695
+ pass
696
+ else:
697
+ results.append((Path(path).parent, name))
698
+ return sorted(results)
699
+
700
+
701
+ def get_modified_projects(
702
+ base_rev: str, verbose: bool = False
703
+ ) -> list[tuple[Path, str]]:
704
+ """Get the list of projects with changes files.
705
+
706
+ A project is defined as a folder with a pyproject.toml file with a `tool.tide` section.
707
+
708
+ Args:
709
+ base_rev: The Git revision to compare against when identifying changed files
710
+ """
711
+ # Compare the current commit with the branch you want to merge with:
712
+ # FIXME: do not included deleted files
713
+ output = git("diff-tree", "--name-only", "-r", base_rev, "HEAD", capture=True)
714
+ all_files = output.splitlines()
715
+ if verbose:
716
+ if all_files:
717
+ click.echo(f"Modified files between {base_rev} and HEAD:")
718
+ for path in all_files:
719
+ click.echo(f" {path}")
720
+ else:
721
+ click.echo(f"No modified files between {base_rev} and HEAD", err=True)
722
+
723
+ # find the deepest project that the file belongs to
724
+ projects = dict(get_projects())
725
+ project_dirs = list(reversed(projects))
726
+
727
+ results = set()
728
+ for changed_file in all_files:
729
+ for project_dir in project_dirs:
730
+ if project_dir in Path(changed_file).parents:
731
+ results.add(project_dir)
732
+
733
+ return [(project_dir, projects[project_dir]) for project_dir in sorted(results)]
734
+
735
+
736
+ def promote(config: Config, backend: Backend, runtime: Runtime) -> None:
737
+ """
738
+ Promote changes through the branch hierarchy.
739
+
740
+ e.g. from alpha -> beta -> rc -> stable.
741
+ """
742
+ remote = runtime.get_remote()
743
+ if config.verbose:
744
+ click.echo(f"remote = {remote}")
745
+
746
+ local_output = []
747
+
748
+ def promote_branch(branch: str, log_msg_template: str) -> None:
749
+ """
750
+ - Checkout the branch
751
+ - Merge with the upstream branch, if it exists
752
+ - Push, skipping hotfixes
753
+
754
+ The branch is left checked out.
755
+ """
756
+ upstream_branch = config.get_upstream_branch(branch)
757
+ release_id = config.branch_to_release_id[branch]
758
+ log_msg = log_msg_template.format(
759
+ branch=branch, upstream_branch=upstream_branch, release_id=release_id.value
760
+ )
761
+
762
+ click.echo(f"Fetching {remote}/{branch}")
763
+ git("fetch", remote, branch)
764
+
765
+ base_rev = checkout(remote, branch, create=True)
766
+
767
+ if upstream_branch:
768
+ git("fetch", remote, upstream_branch)
769
+ click.echo(f"Merging with upstream branch {remote}/{upstream_branch}")
770
+ git("merge", join(remote, upstream_branch), "-m", f"{log_msg}")
771
+
772
+ variables = {
773
+ f"{ENVVAR_PREFIX}_SKIP_HOTFIX": "true",
774
+ f"{ENVVAR_PREFIX}_AUTOTAG_ANNOTATION": log_msg,
775
+ f"{ENVVAR_PREFIX}_AUTOTAG_BASE_REV": base_rev,
776
+ }
777
+
778
+ # Trigger test/tag jobs for these new versions, but skip auto-hotfix
779
+ # --atomic means if there's a failure to push any of the refs, the entire
780
+ # operation will fail (like a databases). May not be strictly necessary here.
781
+ click.echo("Pushing changes")
782
+ backend.push("--atomic", remote, branch, variables=variables)
783
+
784
+ # FIXME: switch to using push-opts.json
785
+ if (
786
+ isinstance(backend, TestGitlabBackend)
787
+ and upstream_branch
788
+ and base_rev != current_rev()
789
+ ):
790
+ push_info = {
791
+ "annotation": log_msg,
792
+ "base_rev": base_rev,
793
+ "branch": branch,
794
+ }
795
+ local_output.append(push_info)
796
+ click.echo(f"Trigger: {json.dumps(push_info)}")
797
+
798
+ # Promotion time!
799
+
800
+ # loop from stable to most experimental
801
+ for branch in reversed(config.branches):
802
+ # It doesn't matter what the active branch is when promote is run: the next thing
803
+ # that we do is checkout CONFIG.stable.
804
+ if branch == config.most_experimental_branch():
805
+ msg = PROMOTION_CYCLE_START_MESSAGE
806
+ else:
807
+ msg = PROMOTION_MESSAGE
808
+
809
+ promote_branch(branch, msg)
810
+
811
+ if local_output:
812
+ json_file = os.path.join(os.environ["CI_REPOSITORY_URL"], "push-data.json")
813
+ click.echo(f"Writing local output to {json_file}")
814
+ with open(json_file, "w") as f:
815
+ json.dump(local_output, f)
816
+
817
+ # Note: we do not make a tag on our cycle-start branch at this time. Instead, we wait
818
+ # for the first commit on the branch to do so.
819
+ experimental_branch = config.most_experimental_branch()
820
+ if experimental_branch:
821
+ set_promotion_marker(remote, experimental_branch)