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.
@@ -0,0 +1,157 @@
1
+ Metadata-Version: 2.3
2
+ Name: git-tide
3
+ Version: 0.1.5
4
+ Summary: CLI for automated gitflow-style branching
5
+ Project-URL: Homepage, https://github.com/chadrik/git-tide
6
+ Author: Chad Dombrova, Matt Collie
7
+ Requires-Python: >=3.8
8
+ Requires-Dist: click==8.1.7
9
+ Requires-Dist: commitizen==3.16.0
10
+ Requires-Dist: tomli>=2.0.1; python_version < '3.11'
11
+ Provides-Extra: init
12
+ Requires-Dist: python-gitlab==4.8.0; extra == 'init'
13
+ Description-Content-Type: text/markdown
14
+
15
+
16
+ # Git Tide: Automated gitflow merge cycles with semantic versioning
17
+
18
+ `tide` simplifies [gitflow](https://www.atlassian.com/git/tutorials/comparing-workflows/gitflow-workflow) processes with automatic tagging and hot-fixing and promotion.
19
+
20
+ ## Features
21
+
22
+ - Automatic cascading merging of hotfixes from stable branches into experimental ones, e.g. alpha, beta, rc (this is the "ebb tide")
23
+ - - Automated "promotion" of experimental branches forward to their next branch. e.g. alpha to beta, beta to rc, and so on. (this is the "flood tide" or "flow")
24
+ - Uses [`commitizen`](https://github.com/commitizen-tools/commitizen) to create semver tags
25
+ - Avoids merge conflicts between branches by using tags to store and query the latest version
26
+
27
+
28
+ ### Example flow
29
+
30
+ Here is an overview of the rules that `tide` applies during automation:
31
+
32
+ > NOTE: this document and repository use **develop**, **staging**, and **master** for it's gitflow branches
33
+ > but this behavior is configurable in the **pyproject.toml** and **.gitlab-ci.yml** files.
34
+
35
+ - New features should start as merge requests made against the `develop` branch (this is the default branch)
36
+ - Commits that are merged to `develop` auto-generate a tag with a beta suffix: e.g. `1.0.0b1`
37
+ - Commits that are merged to `staging` auto-generate a tag with a release candidate suffix: e.g. `1.0.0rc1`
38
+ - Hotfixes that are added to `master` automatically merge to `staging`
39
+ - Hotfixes that are added to `staging` automatically merge to `develop`
40
+ - Promoting the release candidate to a new official release can be done two ways:
41
+ - Every pipeline on the `staging` branch has a `promotion` job that can be manually run
42
+ - A scheduled pipeline can be setup to do the same
43
+ - Either way, when the `promotion` job runs it will do 3 things:
44
+ - Move the `master` branch to the tip of `staging` and create a new release, stripping off the `rc` suffix
45
+ - Move the `staging` branch to the tip of `develop` and create a new release, converting `b` to `rc`
46
+ - Restart `develop`, incrementing the minor version, e.g. from `1.1.0b4` to `1.2.0b0`.
47
+
48
+ ### Sample development cycle
49
+
50
+ Below is the Git commit graph demonstrating a sample development cycle using this project's branching strategy,
51
+ including tags and hotfix propagation.
52
+
53
+ ```plaintext
54
+ * auto-hotfix into develop: staging: add hotfix 2 - (HEAD -> develop, tag: 1.2.0rc0, tag: 1.2.0b2, staging)
55
+ |\
56
+ | * staging: add hotfix 2 - (tag: 1.1.0rc2, tag: 1.1.0, master)
57
+ * | auto-hotfix into develop: master: add hotfix - (tag: 1.2.0b1)
58
+ |\|
59
+ | * auto-hotfix into staging: master: add hotfix - (tag: 1.1.0rc1)
60
+ | |\
61
+ | | * master: add hotfix - (tag: 1.0.1)
62
+ * | | develop: add beta feature 2 - (tag: 1.2.0b0)
63
+ |/ /
64
+ * / develop: add feature 1 - (tag: 1.1.0rc0, tag: 1.1.0b0)
65
+ |/
66
+ * master: initial state - (tag: 1.0.0)
67
+
68
+ chronological tag order:
69
+ 1.2.0rc0 promoting develop to staging!
70
+ 1.1.0 promoting staging to master!
71
+ 1.2.0b2 auto-hotfix into develop: staging: add hotfix 2
72
+ 1.1.0rc2 staging: add hotfix 2
73
+ 1.2.0b1 auto-hotfix into develop: master: add hotfix
74
+ 1.1.0rc1 auto-hotfix into staging: master: add hotfix
75
+ 1.0.1 master: add hotfix
76
+ 1.2.0b0 develop: add beta feature 2
77
+ 1.1.0rc0 promoting develop to staging!
78
+ 1.1.0b0 develop: add feature 1
79
+ 1.0.0 master: initial state
80
+ ```
81
+
82
+ ## Setting up a repo
83
+
84
+ In order for `tide` to work its magic, the Gitlab repo needs to be properly configured:
85
+
86
+ - Add a `[tool].tide` section to your `pyproject.toml` file to define the gitflow branch names and prerelease version you wish to use.
87
+ ```toml
88
+ [tool.tide]
89
+ branches.beta = "develop"
90
+ branches.rc = "staging"
91
+ branches.stable = "master"
92
+ ```
93
+ - For each project within your repo that you want to manage tagged releases, add a `project` entry:
94
+ ```toml
95
+ [tool.tide]
96
+ project = "project_name"
97
+ ```
98
+ If there is only one, then combine be sure to put all of your options under a single `[tool.tide]` section.
99
+ - Create a [Project Access Token](https://docs.gitlab.com/ee/user/project/settings/project_access_tokens.html) with the appropriate scope for being able to push tags, changes and create cicd variables.
100
+ - Run `tide init --access-token='YOUR_ACCESS_TOKEN'`
101
+ - Copy and modify the `.gitlab-ci.yml` file ensuring the branch variables match the ones in the `pyproject.toml` (TODO: automated generation of `.gitlab-ci.yml` in `tide init`)
102
+
103
+ ## Development
104
+
105
+ ### Setting up your local development environment
106
+
107
+ - clone the repository using either https or ssh depending on your preference and then run the following commands:
108
+ ```bash
109
+ python -m venv venv
110
+ .\venv\Scripts\Activate
111
+ pip install -r requirements.txt
112
+ pre-commit install
113
+ ```
114
+
115
+ ### Local development info, tips, and tricks
116
+
117
+ `nox` is the primary interface for what we'll be doing day to day and under the hood.
118
+
119
+ Use `nox --list` to see the tasks that are available.
120
+
121
+ ### Running the unit tests
122
+
123
+ ```bash
124
+ nox -s unit_tests
125
+ ```
126
+
127
+ The primary test `tests/test_unit.py::test_dev_cycle` can be run in several modes:
128
+ - local: simulated using local repos
129
+ - remote: integration testing using real gitlab repos
130
+ - gitlab-ci-local: simulated using local repos, but using the `.gitlab-ci.yml` file for entry-point and env var control. Requires installation of https://github.com/firecow/gitlab-ci-local
131
+
132
+ Here's how you run it in remote mode:
133
+ ```bash
134
+ EXEC_MODE="remote" ACCESS_TOKEN="your-access-token-here" nox -s unit_tests -- tests/test_unit.py::test_dev_cycle -vv -s
135
+ ```
136
+
137
+ Here's how you run it using `gitlab-ci-local`:
138
+ ```bash
139
+ EXEC_MODE="gitlab-ci-local" nox -s unit_tests -- tests/test_unit.py::test_dev_cycle -vv -s
140
+ ```
141
+
142
+ ### Serving Documentation on GitLab Pages
143
+
144
+ Our project leverages GitLab Pages to host and serve the documentation generated by MkDocs. This setup ensures that all team members and stakeholders have access to the most current documentation relevant to the entire project.
145
+
146
+ #### How It Works
147
+
148
+ - **Unified Documentation:** We maintain a single, comprehensive documentation system that reflects the latest changes in our codebase.
149
+
150
+ - **Automated Builds:** The documentation is automatically built and updated whenever changes are made. This ensures that our documentation is always up-to-date with the latest codebase.
151
+
152
+ #### Benefits
153
+
154
+ - **Simplified Access:** Provides a single, consistent source of documentation for all stakeholders, simplifying access and reducing potential confusion.
155
+ - **Immediate Updates:** Changes to the documentation are immediately reflected upon updates, ensuring that the documentation always matches the current state of the project.
156
+
157
+ This streamlined approach to documentation allows our team to efficiently maintain and update project details, ensuring consistent and accessible information for everyone involved.
@@ -0,0 +1,9 @@
1
+ tide/__init__.py,sha256=P976fB0eaD6inWZ19CcXgKWlqOQpp_anldhYxqpJqrw,55
2
+ tide/__main__.py,sha256=MSmt_5Xg84uHqzTN38JwgseJK8rsJn_11A8WD99VtEo,61
3
+ tide/cli.py,sha256=Il3OzcMtsvAYx8YMmgMFb54wl3e6PMwzilU0SkodwUo,10182
4
+ tide/core.py,sha256=zG9aQ1FCPSxRiI57JeqOAnX2eRl3P9bCTT-MMoPyxVE,26494
5
+ tide/gitutils.py,sha256=7tRfuGRYPtDImFMUmGCIKd1BLxfpO40IMKLx-rnFhxs,9123
6
+ git_tide-0.1.5.dist-info/METADATA,sha256=rX8ZJ4tPqX6xqAchb7GGfC0-CHbR0x3d0mycCUV65N4,7251
7
+ git_tide-0.1.5.dist-info/WHEEL,sha256=1yFddiXMmvYK7QYTqtRNtX66WJ0Mz8PYEiEUoOUUxRY,87
8
+ git_tide-0.1.5.dist-info/entry_points.txt,sha256=GcxqhKkWJnEGVaoKzpajCFCyeXa2Ga8da-UbInkFUJY,39
9
+ git_tide-0.1.5.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.25.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ tide = tide.cli:main
tide/__init__.py ADDED
@@ -0,0 +1 @@
1
+ from __future__ import absolute_import, print_function
tide/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
tide/cli.py ADDED
@@ -0,0 +1,349 @@
1
+ from __future__ import absolute_import, print_function, annotations
2
+ import click
3
+ import time
4
+ import os
5
+ import subprocess
6
+ import re
7
+ from pathlib import Path
8
+
9
+ from .core import (
10
+ is_url,
11
+ get_next_version,
12
+ get_current_version,
13
+ get_modified_projects,
14
+ get_projects,
15
+ load_config,
16
+ promote as _promote,
17
+ Config,
18
+ Runtime,
19
+ Backend,
20
+ GitlabBackend,
21
+ TestGitlabBackend,
22
+ GitlabRuntime,
23
+ TestGitlabRuntime,
24
+ LocalRuntime,
25
+ ENVVAR_PREFIX,
26
+ HOTFIX_MESSAGE,
27
+ )
28
+ from .gitutils import git, checkout
29
+
30
+ CONFIG: Config
31
+ CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]}
32
+
33
+
34
+ def set_config(config: Config) -> Config:
35
+ """
36
+ Set the global configuration object.
37
+ """
38
+ global CONFIG
39
+ CONFIG = config
40
+ return config
41
+
42
+
43
+ def get_backend(url: str | None = None) -> Backend:
44
+ """
45
+ Return a Backend corresponding to where the current python process is pushing/pulling.
46
+ """
47
+ if os.environ.get("GITLAB_CI", "false") == "true" or (url and "gitlab" in url):
48
+ return GitlabBackend(CONFIG)
49
+ else:
50
+ return TestGitlabBackend(CONFIG)
51
+
52
+
53
+ def get_runtime() -> Runtime:
54
+ """
55
+ Return a Runtime corresponding to where the current python process is *running*
56
+ """
57
+ if os.environ.get("GITLAB_CI", "false") == "true":
58
+ return GitlabRuntime(CONFIG)
59
+ # gitlab-ci-local and our unittests set this to false as an inidicator that
60
+ # we are testing gitlab, but not IN gitlab.
61
+ elif os.environ.get("GITLAB_CI") == "false":
62
+ return TestGitlabRuntime(CONFIG)
63
+ else:
64
+ return LocalRuntime(CONFIG)
65
+
66
+
67
+ @click.group(context_settings=CONTEXT_SETTINGS)
68
+ @click.option("--config", "-c", "config_path", metavar="CONFIG", type=str)
69
+ @click.option("--verbose", "-v", is_flag=True, default=False)
70
+ def cli(config_path: str, verbose: bool) -> None:
71
+ set_config(load_config(config_path, verbose))
72
+
73
+
74
+ @cli.command()
75
+ @click.option(
76
+ "--access-token",
77
+ required=True,
78
+ metavar="TOKEN",
79
+ help="Security token used to authenticate with the remote",
80
+ )
81
+ @click.option(
82
+ "--remote",
83
+ default="origin",
84
+ metavar="REMOTE",
85
+ show_default=True,
86
+ help=(
87
+ "The name of the git remote in the current git repo "
88
+ "(e.g. configured using `git remote`) or the remote URL. "
89
+ "Providing a URL implies --no-local"
90
+ ),
91
+ )
92
+ @click.option(
93
+ "--save-token/--no-save-token",
94
+ default=True,
95
+ help="Whether to save the access token into the remote as a reusable "
96
+ "variable. If this is disabled, you must configure the ACCESS_TOKEN "
97
+ "manually.",
98
+ )
99
+ @click.option(
100
+ "--init-local/--no-local",
101
+ default=True,
102
+ help="Whether to initialize the local git repo",
103
+ )
104
+ @click.option(
105
+ "--init-remote/--no-remote",
106
+ default=True,
107
+ help="Whether to initialize the remote git repo (i.e. Gitlab)",
108
+ )
109
+ def init(
110
+ access_token: str,
111
+ remote: str,
112
+ save_token: bool,
113
+ init_local: bool,
114
+ init_remote: bool,
115
+ ) -> None:
116
+ """
117
+ Initialize the current git repo and its associated Gitlab project for use with tide.
118
+
119
+ This command must be run from a git repo, and the repo must have the Gitlab project setup
120
+ as a remote, either by being cloned from it, or via `git remote add`.
121
+ """
122
+ import tempfile
123
+
124
+ if is_url(remote):
125
+ init_local = False
126
+ remote_url = remote
127
+ else:
128
+ # FIXME: print a user friendly error if we're not in a git repo.
129
+ # FIXME: handle remote not setup correctly
130
+ remote_url = git("remote", "get-url", remote, capture=True)
131
+
132
+ backend = get_backend(remote_url)
133
+
134
+ if init_local:
135
+ backend.init_local_repo(remote)
136
+ else:
137
+ # we may still need to create branches in the remote, so create a dummy clone
138
+ with tempfile.TemporaryDirectory() as tmpdir:
139
+ git("clone", f"--branch={CONFIG.stable}", remote_url, tmpdir)
140
+ pwd = os.getcwd()
141
+ try:
142
+ os.chdir(tmpdir)
143
+ backend.init_local_repo("origin")
144
+ finally:
145
+ os.chdir(pwd)
146
+
147
+ # FIXME: add pyproject.toml section? check if it exists? Hard to do automatically,
148
+ # because in a monorepo there could be many.
149
+ # FIXME: create a stub gitlab-ci.yml file if it doesn't exist?
150
+ if init_remote:
151
+ backend.init_remote_repo(remote_url, access_token, save_token)
152
+
153
+
154
+ @cli.command()
155
+ @click.option(
156
+ "--annotation",
157
+ default="automatic change detected",
158
+ show_default=True,
159
+ help="Message to pass for tag annotations.",
160
+ )
161
+ @click.option(
162
+ "--base-rev",
163
+ metavar="SHA",
164
+ help="The Git revision to compare against when identifying changed files.",
165
+ )
166
+ def autotag(annotation: str, base_rev: str | None) -> None:
167
+ """
168
+ Tag the current branch with a new version number and push the tag to the remote repository.
169
+ """
170
+ runtime = get_runtime()
171
+ branch = runtime.current_branch()
172
+ remote = runtime.get_remote()
173
+
174
+ backend = get_backend()
175
+
176
+ if not base_rev:
177
+ base_rev = runtime.get_base_rev()
178
+
179
+ projects = get_modified_projects(base_rev, verbose=CONFIG.verbose)
180
+ if projects:
181
+ for project_folder, project_name in projects:
182
+ # Auto-tag
183
+ tag = get_next_version(
184
+ CONFIG, remote, branch, project_name, project_folder, as_tag=True
185
+ )
186
+
187
+ # NOTE: this delay is necessary to create stable sorting of tags
188
+ # because git's time resolution is 1s (same as unix timestamp).
189
+ # https://stackoverflow.com/questions/28237043/what-is-the-resolution-of-gits-commit-date-or-author-date-timestamps
190
+ time.sleep(1.1)
191
+
192
+ click.echo(f"Creating new tag: {tag} on branch: {branch} {time.time()}")
193
+ git("tag", "-a", tag, "-m", annotation)
194
+
195
+ # FIXME: we may want to push all tags at once
196
+ click.echo(f"Pushing {tag} to remote")
197
+ backend.push(remote, tag)
198
+ else:
199
+ click.echo("No tags generated!", err=True)
200
+
201
+
202
+ @cli.command()
203
+ def hotfix() -> None:
204
+ """
205
+ Merge hotfixes from a feature branch back to upstream branches.
206
+ """
207
+ runtime = get_runtime()
208
+ branch = runtime.current_branch()
209
+ remote = runtime.get_remote()
210
+ upstream_branch = CONFIG.get_upstream_branch(branch)
211
+ if not upstream_branch:
212
+ click.echo(f"No branch upstream from {branch}. Skipping auto-merge")
213
+ return
214
+
215
+ # Auto-merge
216
+ # Record the current state
217
+ message = git("log", "--pretty=format: %s", "-1", capture=True)
218
+ # strip out previous Automerge formatting
219
+ match = re.match(
220
+ HOTFIX_MESSAGE.format(upstream_branch="[^:]+", message="(.*)$"), message
221
+ )
222
+ if match:
223
+ message = match.groups()[0]
224
+
225
+ git("checkout", "-B", f"{branch}_temp")
226
+
227
+ # Fetch the upstream branch
228
+ git("fetch", remote, upstream_branch)
229
+
230
+ checkout(remote, upstream_branch, create=True)
231
+
232
+ msg = HOTFIX_MESSAGE.format(upstream_branch=upstream_branch, message=message)
233
+ click.echo(msg)
234
+
235
+ try:
236
+ git("merge", f"{branch}_temp", "-m", msg)
237
+ except subprocess.CalledProcessError:
238
+ click.echo("Conflicts:", err=True)
239
+ git("diff", "--name-only", "--diff-filter=U")
240
+ raise
241
+
242
+ # this will trigger a full pipeline for upstream_branch, and potentially another auto-merge
243
+ click.echo(f"Pushing {upstream_branch} to {remote}")
244
+ variables = {
245
+ f"{ENVVAR_PREFIX}_AUTOTAG_ANNOTATION": msg,
246
+ }
247
+ get_backend().push(remote, upstream_branch, variables=variables)
248
+
249
+
250
+ @cli.command()
251
+ def promote() -> None:
252
+ """
253
+ Promote changes through the branch hierarchy.
254
+
255
+ e.g. from alpha -> beta -> rc -> stable.
256
+ """
257
+ backend = get_backend()
258
+ runtime = get_runtime()
259
+ remote = runtime.get_remote()
260
+ if CONFIG.verbose:
261
+ click.echo(f"remote = {remote}")
262
+ _promote(CONFIG, backend, runtime)
263
+
264
+
265
+ @cli.command
266
+ @click.option("--modified", "-m", is_flag=True, default=False)
267
+ # FIXME: add output format
268
+ # FIXME: add option to write to file
269
+ def projects(modified: bool) -> None:
270
+ """
271
+ List project paths within the repo.
272
+
273
+ A project is a folder with a pyproject.toml file with a `tool.commitizen` section.
274
+ """
275
+ if modified:
276
+ runtime = get_runtime()
277
+ projects_ = get_modified_projects(
278
+ runtime.get_base_rev(), verbose=CONFIG.verbose
279
+ )
280
+ else:
281
+ projects_ = get_projects()
282
+
283
+ for project_dir, project_name in projects_:
284
+ if project_name is None:
285
+ project_name = "[unset]"
286
+ click.echo(f"{project_name} = {project_dir}")
287
+
288
+
289
+ @cli.command
290
+ @click.option(
291
+ "--path",
292
+ default=".",
293
+ type=click.Path(exists=True, file_okay=False),
294
+ show_default=True,
295
+ help="Folder within the repository. A pyproject.toml should reside in the "
296
+ "specified directory",
297
+ )
298
+ @click.option(
299
+ "--branch",
300
+ default=None,
301
+ help=(
302
+ "The release branch to use to determine the next version. "
303
+ "Defaults to the current branch."
304
+ ),
305
+ )
306
+ @click.option(
307
+ "--remote",
308
+ default="origin",
309
+ show_default=True,
310
+ help="The git remote to use to when determining the next version.",
311
+ )
312
+ @click.option("--next", "-n", is_flag=True, default=False)
313
+ @click.option("--as-tag", "-t", is_flag=True, default=False)
314
+ def version(
315
+ path: str, branch: str | None, remote: str, next: bool, as_tag: bool
316
+ ) -> None:
317
+ """
318
+ Get the project version
319
+ """
320
+ projects = dict(get_projects())
321
+ folder = Path(path)
322
+ try:
323
+ project_name = projects[folder]
324
+ except KeyError:
325
+ raise click.ClickException(
326
+ f"There is not a project at path={folder}. "
327
+ "Ensure there is a pyproject.toml file with a [tool.tide] section "
328
+ "and a `project` entry"
329
+ )
330
+
331
+ if next:
332
+ if branch is None:
333
+ runtime = get_runtime()
334
+ branch = runtime.current_branch()
335
+
336
+ click.echo(
337
+ get_next_version(CONFIG, remote, branch, project_name, folder, as_tag)
338
+ )
339
+ else:
340
+ click.echo(get_current_version(CONFIG, project_name, folder, as_tag))
341
+
342
+
343
+ def main() -> None:
344
+ import shutil
345
+
346
+ return cli(
347
+ auto_envvar_prefix=ENVVAR_PREFIX,
348
+ max_content_width=shutil.get_terminal_size().columns,
349
+ )