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.
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
+ )