git-clerk 0.1.0__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_clerk/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from importlib.metadata import version
2
+
3
+ __version__ = version("git-clerk")
git_clerk/branch.py ADDED
@@ -0,0 +1,38 @@
1
+ import re
2
+
3
+ TYPES = frozenset(
4
+ [
5
+ "build",
6
+ "chore",
7
+ "ci",
8
+ "docs",
9
+ "feat",
10
+ "fix",
11
+ "perf",
12
+ "refactor",
13
+ "revert",
14
+ "style",
15
+ "test",
16
+ ]
17
+ )
18
+
19
+
20
+ _BRANCH_RE = re.compile(r"([^/]+)/([^/]+)")
21
+ _SCOPE_RE = re.compile(r"[a-z0-9][a-z0-9_-]*", re.IGNORECASE)
22
+
23
+
24
+ def parse(branch: str) -> tuple[str, str]:
25
+ m = _BRANCH_RE.fullmatch(branch)
26
+ if not m:
27
+ raise ValueError(f"Branch '{branch}' does not follow type/scope convention")
28
+ type_ = m.group(1)
29
+ if type_ not in TYPES:
30
+ raise ValueError(
31
+ f"'{type_}' is not a conventional commit type. Use one of: {', '.join(sorted(TYPES))}"
32
+ )
33
+ scope = m.group(2)
34
+ if not _SCOPE_RE.fullmatch(scope):
35
+ raise ValueError(
36
+ f"'{scope}' is not a valid scope. Use letters, digits, hyphens, and underscores."
37
+ )
38
+ return type_, scope
git_clerk/cli.py ADDED
@@ -0,0 +1,259 @@
1
+ import subprocess
2
+ import sys
3
+ from datetime import date
4
+
5
+ import click
6
+
7
+ from git_clerk import git
8
+ from git_clerk import github as gh
9
+ from git_clerk.branch import TYPES
10
+ from git_clerk.branch import parse as parse_branch
11
+ from git_clerk.release import CALVER, SEMVER, Scheme, detect_scheme, next_calver, next_semver
12
+
13
+ TYPE_CHOICE = click.Choice(sorted(TYPES))
14
+
15
+
16
+ class _Group(click.Group):
17
+ def invoke(self, ctx: click.Context) -> object:
18
+ try:
19
+ return super().invoke(ctx)
20
+ except subprocess.CalledProcessError as e:
21
+ sys.exit(e.returncode)
22
+ except RuntimeError as e:
23
+ raise click.ClickException(str(e)) from e
24
+
25
+
26
+ def _strip_comments(text: str) -> str:
27
+ return "\n".join(line for line in text.splitlines() if not line.startswith("#")).strip()
28
+
29
+
30
+ def _open_editor(hint: str) -> str:
31
+ template = f"# {hint}\n# Lines starting with '#' are ignored.\n\n"
32
+ raw = click.edit(template)
33
+ result = _strip_comments(raw or "")
34
+ if not result:
35
+ raise click.Abort()
36
+ return result
37
+
38
+
39
+ @click.group(cls=_Group, invoke_without_command=True)
40
+ @click.version_option(package_name="git-clerk")
41
+ @click.pass_context
42
+ def main(ctx: click.Context) -> None:
43
+ """Structured git workflow: conventional commits, trunk-based branches, GitHub PR lifecycle.
44
+
45
+ Branch names follow the type/scope convention from the conventional commits specification.
46
+ See https://www.conventionalcommits.org for the full spec.
47
+ """
48
+ if ctx.invoked_subcommand is None:
49
+ click.echo(ctx.get_help())
50
+
51
+
52
+ @main.command()
53
+ @click.argument("name", metavar="TYPE/scope")
54
+ def branch(name: str) -> None:
55
+ """Create a branch from origin/main."""
56
+ try:
57
+ parse_branch(name)
58
+ except ValueError as e:
59
+ raise click.ClickException(str(e))
60
+ git.fetch_origin()
61
+ git.switch_new_branch(name)
62
+
63
+
64
+ @main.command()
65
+ @click.option("-A", "stage_all", is_flag=True, help="Stage all changes before committing.")
66
+ @click.option(
67
+ "-t",
68
+ "--type",
69
+ "type_override",
70
+ default=None,
71
+ metavar="TYPE",
72
+ type=TYPE_CHOICE,
73
+ help="Override the commit type inferred from the branch name.",
74
+ )
75
+ @click.option(
76
+ "-s",
77
+ "--scope",
78
+ "scope_override",
79
+ default=None,
80
+ help="Override the commit scope inferred from the branch name.",
81
+ )
82
+ @click.option("-e", "--edit", "edit_body", is_flag=True, help="Open $EDITOR for commit body.")
83
+ @click.argument("description")
84
+ @click.argument("body", required=False, default=None)
85
+ def commit(
86
+ stage_all: bool,
87
+ type_override: str | None,
88
+ scope_override: str | None,
89
+ edit_body: bool,
90
+ description: str,
91
+ body: str | None,
92
+ ) -> None:
93
+ """Create a conventional commit from the branch name.
94
+
95
+ Type and scope are derived from the branch. By default no body is added —
96
+ pass BODY as a second argument for an inline body, or use -e to open $EDITOR.
97
+ BODY and -e are mutually exclusive.
98
+ """
99
+ if body and edit_body:
100
+ raise click.UsageError("BODY and --edit are mutually exclusive")
101
+ br = git.current_branch()
102
+ try:
103
+ type_, scope = parse_branch(br)
104
+ except ValueError as e:
105
+ raise click.ClickException(str(e))
106
+ header = f"{type_override or type_}({scope_override or scope}): {description}"
107
+ if edit_body:
108
+ body = _open_editor(header)
109
+ if stage_all:
110
+ git.add_all()
111
+ git.commit(header, body)
112
+
113
+
114
+ @main.command()
115
+ @click.option(
116
+ "-t",
117
+ "--type",
118
+ "type_override",
119
+ default=None,
120
+ metavar="TYPE",
121
+ type=TYPE_CHOICE,
122
+ help="Override the PR title type inferred from the branch name.",
123
+ )
124
+ @click.option(
125
+ "-s",
126
+ "--scope",
127
+ "scope_override",
128
+ default=None,
129
+ help="Override the PR title scope inferred from the branch name.",
130
+ )
131
+ @click.option("-e", "--edit", "edit_body", is_flag=True, help="Open $EDITOR for the PR body.")
132
+ @click.argument("title")
133
+ @click.argument("body", required=False, default=None)
134
+ def pr(
135
+ type_override: str | None,
136
+ scope_override: str | None,
137
+ edit_body: bool,
138
+ title: str,
139
+ body: str | None,
140
+ ) -> None:
141
+ """Push branch, open a PR against main, and watch CI.
142
+
143
+ By default no body is added — pass BODY as a second argument for an inline
144
+ body, or use -e to open $EDITOR. BODY and -e are mutually exclusive.
145
+ """
146
+ if body and edit_body:
147
+ raise click.UsageError("BODY and --edit are mutually exclusive")
148
+ br = git.current_branch()
149
+ try:
150
+ type_, scope = parse_branch(br)
151
+ except ValueError as e:
152
+ raise click.ClickException(str(e))
153
+ pr_title = f"{type_override or type_}({scope_override or scope}): {title}"
154
+ if edit_body:
155
+ body = _open_editor(f"{pr_title} ({br})")
156
+ git.push_head()
157
+ number, url = gh.pr_create(pr_title, body or "")
158
+ click.echo(url)
159
+ gh.pr_checks_watch(number)
160
+
161
+
162
+ @main.command()
163
+ @click.option(
164
+ "-u",
165
+ "--update",
166
+ "update_branch",
167
+ default=None,
168
+ metavar="BRANCH",
169
+ help="After shipping, switch to BRANCH and merge origin/main.",
170
+ )
171
+ @click.option("-y", "--yes", "confirmed", is_flag=True, help="Skip confirmation prompt.")
172
+ def ship(update_branch: str | None, confirmed: bool) -> None:
173
+ """Ship the PR and return to a clean main.
174
+
175
+ Squash-merges the current branch's PR, deletes the remote branch, switches
176
+ to local main, pulls, and force-deletes the local branch.
177
+ """
178
+ br = git.current_branch()
179
+ if br == "main":
180
+ raise click.ClickException("run 'git clerk ship' from the feature branch, not main")
181
+ number, title = gh.pr_view()
182
+ prompt = f'Ship "{title}" (#{number})'
183
+ if update_branch:
184
+ prompt += f", then update {update_branch}"
185
+ if not confirmed:
186
+ click.confirm(prompt, abort=True)
187
+ if not gh.pr_checks_pass(number):
188
+ raise click.ClickException(
189
+ f"PR #{number} has failing or pending checks — run 'git clerk watch' to monitor"
190
+ )
191
+ gh.pr_merge(number)
192
+ git.switch_main()
193
+ git.pull_origin_main()
194
+ if git.branch_exists(br):
195
+ git.delete_branch(br)
196
+ else:
197
+ click.echo(f"warning: local branch '{br}' not found, skipping delete", err=True)
198
+ if update_branch:
199
+ git.switch_branch(update_branch)
200
+ git.merge_origin_main()
201
+
202
+
203
+ @main.command()
204
+ def watch() -> None:
205
+ """Watch CI checks for the current PR."""
206
+ number, _ = gh.pr_view()
207
+ gh.pr_checks_watch(number)
208
+
209
+
210
+ @main.command()
211
+ @click.option(
212
+ "--calver",
213
+ "scheme",
214
+ flag_value=CALVER,
215
+ default=None,
216
+ help="Use calendar versioning (vYYYY.MM.N).",
217
+ )
218
+ @click.option(
219
+ "--semver",
220
+ "scheme",
221
+ flag_value=SEMVER,
222
+ default=None,
223
+ help="Use semantic versioning (vMAJOR.MINOR.PATCH).",
224
+ )
225
+ @click.option(
226
+ "--bump",
227
+ type=click.Choice(["patch", "minor", "major"]),
228
+ default="patch",
229
+ help="SemVer component to increment (ignored for CalVer).",
230
+ )
231
+ @click.option("-y", "--yes", "confirmed", is_flag=True, help="Skip confirmation prompt.")
232
+ def release(scheme: Scheme | None, bump: str, confirmed: bool) -> None:
233
+ """Tag origin/main and push the tag.
234
+
235
+ Auto-detects CalVer or SemVer from existing tags. Prompts for scheme on
236
+ first use. Pass --calver or --semver to skip the prompt.
237
+ """
238
+ git.fetch_tags()
239
+ existing = git.tags()
240
+ if not scheme:
241
+ try:
242
+ scheme = detect_scheme(existing)
243
+ except ValueError as e:
244
+ raise click.ClickException(str(e))
245
+ if not scheme:
246
+ click.echo("No existing tags found. Choose a versioning scheme:")
247
+ click.echo(
248
+ f" {CALVER} vYYYY.MM.N — calendar versioning, counter resets each month"
249
+ )
250
+ click.echo(f" {SEMVER} vMAJOR.MINOR.PATCH — semantic versioning")
251
+ raw = click.prompt(
252
+ "Scheme", type=click.Choice([CALVER, SEMVER], case_sensitive=False), show_choices=False
253
+ )
254
+ scheme = CALVER if raw == CALVER else SEMVER
255
+ tag = next_calver(existing, date.today()) if scheme == CALVER else next_semver(existing, bump)
256
+ if not confirmed:
257
+ click.confirm(f"Tag and push {tag}", abort=True)
258
+ git.create_tag(tag)
259
+ click.echo(f"Tagged and pushed {tag}")
git_clerk/git.py ADDED
@@ -0,0 +1,89 @@
1
+ import subprocess
2
+
3
+
4
+ def _git(*args: str, capture: bool = False) -> str:
5
+ try:
6
+ result = subprocess.run(
7
+ ["git", *args],
8
+ check=True,
9
+ text=True,
10
+ stdout=subprocess.PIPE if capture else None,
11
+ )
12
+ return result.stdout.strip() if result.stdout else ""
13
+ except FileNotFoundError:
14
+ raise RuntimeError("'git' not found in PATH — install git: https://git-scm.com/install/")
15
+ except subprocess.CalledProcessError:
16
+ raise
17
+
18
+
19
+ def current_branch() -> str:
20
+ return _git("branch", "--show-current", capture=True)
21
+
22
+
23
+ def fetch_origin() -> None:
24
+ _git("fetch", "origin")
25
+
26
+
27
+ def fetch_tags() -> None:
28
+ _git("fetch", "--tags", "origin")
29
+
30
+
31
+ def switch_new_branch(name: str) -> None:
32
+ _git("switch", "-c", name, "origin/main")
33
+
34
+
35
+ def switch_main() -> None:
36
+ _git("switch", "main")
37
+
38
+
39
+ def pull_origin_main() -> None:
40
+ _git("pull", "origin", "main")
41
+
42
+
43
+ def branch_exists(name: str) -> bool:
44
+ return bool(_git("branch", "--list", name, capture=True))
45
+
46
+
47
+ def delete_branch(name: str) -> None:
48
+ _git("branch", "-D", name)
49
+
50
+
51
+ def switch_branch(name: str) -> None:
52
+ _git("switch", name)
53
+
54
+
55
+ def merge_origin_main() -> None:
56
+ _git("merge", "origin/main")
57
+
58
+
59
+ def add_all() -> None:
60
+ _git("add", "-A")
61
+
62
+
63
+ def commit(header: str, body: str | None = None) -> None:
64
+ args = ["commit", "-m", header]
65
+ if body:
66
+ args += ["-m", body]
67
+ _git(*args)
68
+
69
+
70
+ def push_head() -> None:
71
+ _git("push", "--set-upstream", "origin", "HEAD")
72
+
73
+
74
+ def remote_url(name: str) -> str:
75
+ return _git("remote", "get-url", name, capture=True)
76
+
77
+
78
+ def tags(pattern: str = "v*") -> list[str]:
79
+ return [t for t in _git("tag", "--list", pattern, capture=True).splitlines() if t]
80
+
81
+
82
+ def create_tag(tag: str, ref: str = "origin/main") -> None:
83
+ fetch_tags()
84
+ if tag in tags():
85
+ raise RuntimeError(
86
+ f"tag '{tag}' already exists — re-run 'git clerk release' to get the next version"
87
+ )
88
+ _git("tag", tag, ref)
89
+ _git("push", "origin", tag)
git_clerk/github.py ADDED
@@ -0,0 +1,86 @@
1
+ import functools
2
+ import json
3
+ import subprocess
4
+ from urllib.parse import urlparse
5
+
6
+ from git_clerk.git import remote_url
7
+
8
+
9
+ def parse_repo_from_url(url: str) -> str:
10
+ if "://" in url:
11
+ path = urlparse(url).path.lstrip("/")
12
+ else:
13
+ _, _, path = url.partition(":")
14
+ repo = path.removesuffix(".git")
15
+ parts = repo.split("/")
16
+ if len(parts) != 2 or not all(parts):
17
+ raise ValueError(f"cannot parse GitHub repo from remote URL: {url}")
18
+ return repo
19
+
20
+
21
+ def _gh(*args: str, capture: bool = False) -> str:
22
+ try:
23
+ result = subprocess.run(
24
+ ["gh", *args],
25
+ check=True,
26
+ text=True,
27
+ stdout=subprocess.PIPE if capture else None,
28
+ )
29
+ return result.stdout.strip() if result.stdout else ""
30
+ except FileNotFoundError:
31
+ raise RuntimeError(
32
+ "'gh' not found in PATH — install the GitHub CLI: https://cli.github.com"
33
+ )
34
+ except subprocess.CalledProcessError:
35
+ raise
36
+
37
+
38
+ @functools.cache
39
+ def repo() -> str:
40
+ url = remote_url("origin")
41
+ try:
42
+ return parse_repo_from_url(url)
43
+ except ValueError as e:
44
+ raise RuntimeError(str(e)) from e
45
+
46
+
47
+ def pr_create(title: str, body: str, base: str = "main") -> tuple[int, str]:
48
+ out = _gh(
49
+ "pr",
50
+ "create",
51
+ "--base",
52
+ base,
53
+ "--title",
54
+ title,
55
+ "--body",
56
+ body,
57
+ "--repo",
58
+ repo(),
59
+ "--json",
60
+ "number,url",
61
+ capture=True,
62
+ )
63
+ data = json.loads(out)
64
+ return int(data["number"]), str(data["url"])
65
+
66
+
67
+ def pr_view() -> tuple[int, str]:
68
+ out = _gh("pr", "view", "--repo", repo(), "--json", "number,title", capture=True)
69
+ data = json.loads(out)
70
+ return int(data["number"]), str(data["title"])
71
+
72
+
73
+ def pr_checks_pass(pr_number: int) -> bool:
74
+ try:
75
+ _gh("pr", "checks", str(pr_number), "--repo", repo(), capture=True)
76
+ return True
77
+ except subprocess.CalledProcessError:
78
+ return False
79
+
80
+
81
+ def pr_merge(pr_number: int) -> None:
82
+ _gh("pr", "merge", str(pr_number), "--squash", "--delete-branch", "--repo", repo())
83
+
84
+
85
+ def pr_checks_watch(pr_number: int) -> None:
86
+ _gh("pr", "checks", str(pr_number), "--repo", repo(), "--watch")
git_clerk/release.py ADDED
@@ -0,0 +1,48 @@
1
+ import re
2
+ from datetime import date
3
+ from typing import Final, Literal, TypeAlias
4
+
5
+ CALVER: Final = "CalVer"
6
+ SEMVER: Final = "SemVer"
7
+ Scheme: TypeAlias = Literal["CalVer", "SemVer"]
8
+
9
+ _CALVER_RE = re.compile(r"v\d{4}\.\d{2}\.\d+")
10
+ _SEMVER_RE = re.compile(r"v\d+\.\d+\.\d+")
11
+
12
+
13
+ def detect_scheme(tags: list[str]) -> Scheme | None:
14
+ found: set[Scheme] = set()
15
+ for t in tags:
16
+ if _CALVER_RE.fullmatch(t):
17
+ found.add(CALVER)
18
+ elif _SEMVER_RE.fullmatch(t):
19
+ found.add(SEMVER)
20
+ if not found:
21
+ return None
22
+ if len(found) > 1:
23
+ raise ValueError(
24
+ f"mixed {CALVER} and {SEMVER} tags found — pass --calver or --semver to proceed"
25
+ )
26
+ return next(iter(found))
27
+
28
+
29
+ def next_calver(tags: list[str], today: date) -> str:
30
+ prefix = f"v{today.year}.{today.month:02d}."
31
+ existing = [t for t in tags if re.fullmatch(rf"{re.escape(prefix)}\d+", t)]
32
+ last = max((int(t[len(prefix) :]) for t in existing), default=0)
33
+ return f"{prefix}{last + 1}"
34
+
35
+
36
+ def next_semver(tags: list[str], bump: str) -> str:
37
+ semver_tags = sorted(
38
+ [t for t in tags if _SEMVER_RE.fullmatch(t) and not _CALVER_RE.fullmatch(t)],
39
+ key=lambda t: tuple(int(x) for x in t[1:].split(".")),
40
+ )
41
+ if not semver_tags:
42
+ return "v0.1.0"
43
+ major, minor, patch = (int(x) for x in semver_tags[-1][1:].split("."))
44
+ if bump == "major":
45
+ return f"v{major + 1}.0.0"
46
+ if bump == "minor":
47
+ return f"v{major}.{minor + 1}.0"
48
+ return f"v{major}.{minor}.{patch + 1}"
@@ -0,0 +1,344 @@
1
+ Metadata-Version: 2.4
2
+ Name: git-clerk
3
+ Version: 0.1.0
4
+ Summary: Structured git workflow CLI: conventional commits, trunk-based branches, GitHub PR lifecycle
5
+ Project-URL: Homepage, https://github.com/nicobc/git-clerk
6
+ Project-URL: Source, https://github.com/nicobc/git-clerk
7
+ Project-URL: Issues, https://github.com/nicobc/git-clerk/issues
8
+ Author-email: Nicolas Contreras <nicolas.b.contreras@gmail.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Classifier: Environment :: Console
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Software Development :: Version Control :: Git
16
+ Requires-Python: >=3.10
17
+ Requires-Dist: click>=8.0
18
+ Description-Content-Type: text/markdown
19
+
20
+ # git-clerk
21
+
22
+ A structured git workflow CLI for [conventional commits](https://www.conventionalcommits.org/), trunk-based branching, and a clean GitHub PR lifecycle — all from the command line.
23
+
24
+ ## Philosophy
25
+
26
+ git-clerk is built on three practices that reinforce each other: [trunk-based development](https://trunkbaseddevelopment.com/), [conventional commits](https://www.conventionalcommits.org/), and squash-merge-only history. Short-lived branches stay close to `main`. Squash merges keep `main`'s history linear and readable — one commit per feature. Conventional commit types make that history meaningful at a glance. git-clerk connects all three as a unit: you name your branch `feat/user-auth` once, and every commit message, PR title, and release tag follows from that single decision.
27
+
28
+ ## Opinions
29
+
30
+ git-clerk is intentionally opinionated. These constraints are not configurable:
31
+
32
+ - **GitHub only** — PR and release operations rely on `gh`. GitLab and Bitbucket are not supported.
33
+ - **Squash merges** — `ship` always squash-merges to keep `main`'s history linear.
34
+ - **Single trunk** — `main` is the only integration branch. `develop`, `release/*`, and similar long-lived branches are out of scope. The trunk name is not configurable — repositories using a different default branch are not supported.
35
+ - **Conventional commits** — branch names must follow `type/scope` using one of the [11 standard types](https://www.conventionalcommits.org).
36
+
37
+ If your workflow diverges from any of these, git-clerk is not the right tool.
38
+
39
+ ## Prerequisites
40
+
41
+ **Local**
42
+
43
+ - Python 3.10+
44
+ - [uv](https://docs.astral.sh/uv/) for installation
45
+ - [GitHub CLI](https://cli.github.com/) (`gh`) authenticated to your GitHub account
46
+
47
+ **Repository configuration**
48
+
49
+ git-clerk assumes the repository is configured to match its workflow. Without this, the tool still works but its guarantees don't hold — anyone can bypass conventions by using `git` and `gh` directly.
50
+
51
+ Ask your infra or platform team to configure:
52
+
53
+ - **Squash merges only** — disable merge commits and rebase merges so `main` stays linear
54
+ - **Branch protection on `main`** — require pull requests before merging; disallow direct pushes
55
+ - **Required status checks** — require CI to pass before a PR can be merged; this makes `git clerk ship`'s CI gate structural rather than advisory
56
+
57
+ ## Installation
58
+
59
+ ```sh
60
+ uv tool install git-clerk
61
+ ```
62
+
63
+ To use it as `git clerk` (recommended) rather than `git-clerk`, register a git alias:
64
+
65
+ ```sh
66
+ git config --global alias.clerk '!git-clerk'
67
+ ```
68
+
69
+ After this, `git clerk` prints help and `git clerk commit --help` (any subcommand) works as expected. Note that `git clerk --help` will not work — git intercepts `--help` before running the alias and tries to open a man page. Use `git clerk` or `git-clerk --help` for top-level help instead.
70
+
71
+ ## Workflow walkthrough
72
+
73
+ Here is a complete cycle from starting a feature to tagging a release.
74
+
75
+ **1. Create a branch**
76
+
77
+ ```sh
78
+ git clerk branch feat/user-auth
79
+ ```
80
+
81
+ Fetches the latest `origin/main` and creates `feat/user-auth` from it. The branch name is the only thing you decide upfront — type and scope flow into every subsequent command automatically.
82
+
83
+ **2. Do your work, then commit**
84
+
85
+ ```sh
86
+ git clerk commit -A "add login form"
87
+ ```
88
+
89
+ Stages all changes and commits with the message `feat(user-auth): add login form`. The type and scope come from the branch name — you only write the description.
90
+
91
+ For commits that need more context, pass the body as a second argument or open your editor with `-e`:
92
+
93
+ ```sh
94
+ git clerk commit -A "add login form" "Supports email and SSO providers."
95
+ # → commits with inline body (useful in scripts and LLM workflows)
96
+
97
+ git clerk commit -A -e "add login form"
98
+ # → opens $EDITOR for the body, then commits
99
+ ```
100
+
101
+ You can commit as many times as you want. Only the squash commit that lands on `main` is permanent.
102
+
103
+ **3. Open a PR**
104
+
105
+ ```sh
106
+ git clerk pr "Add login form"
107
+ ```
108
+
109
+ Pushes the branch with upstream tracking set, creates the PR against `main`, prints the URL, then watches CI checks until they complete. You can share the URL while CI is still running.
110
+
111
+ By default no body is added. To add one:
112
+
113
+ ```sh
114
+ git clerk pr "Add login form" "Adds email/password and SSO login. Closes #42."
115
+ # inline body
116
+
117
+ git clerk pr -e "Add login form"
118
+ # opens $EDITOR for the body
119
+ ```
120
+
121
+ **4. Ship it**
122
+
123
+ Once CI is green:
124
+
125
+ ```sh
126
+ git clerk ship
127
+ ```
128
+
129
+ Shows you the PR title and number, asks for confirmation, then: squash-merges into `main`, deletes the remote branch, switches to local `main`, pulls, and force-deletes the local branch. You end up on a clean, up-to-date `main` in one step.
130
+
131
+ **5. Tag a release**
132
+
133
+ ```sh
134
+ git clerk release
135
+ ```
136
+
137
+ Detects your versioning scheme from existing tags, computes the next version, shows you the tag, and asks for confirmation before pushing. On a fresh repo with no tags, it prompts you to choose CalVer or SemVer.
138
+
139
+ ## Commands
140
+
141
+ ### `branch TYPE/scope`
142
+
143
+ Fetches the latest `origin/main` and creates a new branch from it.
144
+
145
+ ```sh
146
+ git clerk branch feat/user-auth # → feat/user-auth
147
+ git clerk branch fix/payment-api # → fix/payment-api
148
+ git clerk branch chore/deps # → chore/deps
149
+ ```
150
+
151
+ The branch name is the only decision you make upfront. Every subsequent `commit` and `pr` command reads the type and scope from it — you never repeat yourself.
152
+
153
+ The fetch happens before branch creation, so you always start from the latest `main` regardless of how long ago you last pulled. If the branch already exists locally, git will error — delete it first or choose a different name.
154
+
155
+ ### `commit DESCRIPTION [BODY]`
156
+
157
+ Creates a conventional commit by reading the type and scope from the current branch name. The commit message header is always `type(scope): description` — you only supply the description.
158
+
159
+ ```sh
160
+ git clerk commit "add login form"
161
+ # → feat(user-auth): add login form
162
+ ```
163
+
164
+ **Body**
165
+
166
+ By default there is no body — most commits don't need one. There are three ways to add one:
167
+
168
+ ```sh
169
+ # Inline — pass the body as a second positional argument.
170
+ # Useful in scripts and LLM-driven workflows.
171
+ git clerk commit "add login form" "Supports email and SSO providers."
172
+
173
+ # Interactive — open $EDITOR. Save and exit to use the body; quit without
174
+ # saving to commit with no body.
175
+ git clerk commit -e "add login form"
176
+ ```
177
+
178
+ An empty string passed as BODY is treated the same as no body — only a non-empty string is included in the commit.
179
+
180
+ **Options**
181
+
182
+ | Flag | Description |
183
+ |------|-------------|
184
+ | `-A` | Stage all changes (`git add -A`) before committing |
185
+ | `-e` | Open `$EDITOR` to write the commit body interactively |
186
+ | `-t TYPE` | Override the type inferred from the branch name |
187
+ | `-s SCOPE` | Override the scope inferred from the branch name |
188
+
189
+ The `-t` and `-s` overrides are for cases where the commit type or scope differs from the branch — for example, bumping a lockfile on a `feat` branch:
190
+
191
+ ```sh
192
+ git clerk commit -t chore "update lockfile" # chore(user-auth): update lockfile
193
+ git clerk commit -s auth-core "fix token TTL" # feat(auth-core): fix token TTL
194
+ ```
195
+
196
+ ### `pr TITLE [BODY]`
197
+
198
+ Pushes the current branch to origin (with upstream tracking), creates a GitHub PR against `main` with a conventional title derived from the branch name, prints the PR URL, then watches CI checks until they complete.
199
+
200
+ The PR title is constructed the same way as a commit header: `type(scope): title`. You supply only the human-readable title.
201
+
202
+ **Body**
203
+
204
+ By default no body is added. There are two ways to add one:
205
+
206
+ ```sh
207
+ # Inline — pass the body as a second positional argument.
208
+ # Useful in scripts and LLM-driven workflows.
209
+ git clerk pr "Add login form" "Adds email/password and SSO login. Closes #42."
210
+
211
+ # Interactive — open $EDITOR. Save and exit to use the body;
212
+ # quit without saving to create the PR with no body.
213
+ git clerk pr -e "Add login form"
214
+ ```
215
+
216
+ An empty string passed as BODY is treated the same as no body.
217
+
218
+ **Options**
219
+
220
+ | Flag | Description |
221
+ |------|-------------|
222
+ | `-e` | Open `$EDITOR` to write the PR body interactively |
223
+ | `-t TYPE` | Override the type in the PR title |
224
+ | `-s SCOPE` | Override the scope in the PR title |
225
+
226
+ The PR URL is printed to stdout as soon as the PR is created, before CI checks begin — you can share it while checks are still running. If checks fail, the run ends with a non-zero exit code.
227
+
228
+ ### `ship`
229
+
230
+ Squash-merges the current branch's PR and brings your local environment back to a clean state on `main`. Must be run from the feature branch, not from `main`.
231
+
232
+ ```sh
233
+ git clerk ship
234
+ ```
235
+
236
+ Displays the PR title and number, asks for confirmation, then executes in order:
237
+
238
+ 1. Squash-merges the PR into `main`
239
+ 2. Deletes the remote branch
240
+ 3. Switches to local `main`
241
+ 4. Pulls latest from `origin/main`
242
+ 5. Force-deletes the local branch
243
+
244
+ You end up on a clean, up-to-date `main` in one step. If the local branch doesn't exist — because you're shipping someone else's PR, or because a previous partial run already cleaned it up — step 5 is skipped with a warning rather than erroring.
245
+
246
+ **Options**
247
+
248
+ | Flag | Description |
249
+ |------|-------------|
250
+ | `-y` / `--yes` | Skip the confirmation prompt |
251
+ | `-u BRANCH` | After shipping, switch to BRANCH and merge `origin/main` into it |
252
+
253
+ `-u` is for cases where you paused work on one branch to ship a dependency first:
254
+
255
+ ```sh
256
+ # Shipping fix/tech-debt while feat/user-auth is parked
257
+ git clerk ship -u feat/user-auth
258
+ # → ships fix/tech-debt, then switches to feat/user-auth and merges origin/main
259
+ ```
260
+
261
+ `-y` is useful in automated contexts where you want to ship without interactive confirmation:
262
+
263
+ ```sh
264
+ git clerk ship -y
265
+ ```
266
+
267
+ ### `watch`
268
+
269
+ Re-attaches to CI checks for the current branch's PR. Useful when you want to check in on CI after navigating away from the terminal during a `pr` run.
270
+
271
+ ```sh
272
+ git clerk watch
273
+ ```
274
+
275
+ ### `release`
276
+
277
+ Tags the current tip of `origin/main` and pushes the tag. Supports CalVer and SemVer.
278
+
279
+ git-clerk fetches the latest tags, computes the next version, shows you what it's about to create, and asks for confirmation before pushing anything.
280
+
281
+ ```sh
282
+ git clerk release # auto-detect scheme, bump patch
283
+ git clerk release --semver --bump minor
284
+ git clerk release --semver --bump major
285
+ git clerk release --calver
286
+ ```
287
+
288
+ **Scheme detection**
289
+
290
+ If the repository already has version tags, git-clerk detects the scheme automatically — `--calver` and `--semver` are not needed. If no tags exist yet, git-clerk prompts you to choose interactively. Pass `--calver` or `--semver` to skip the prompt.
291
+
292
+ If both CalVer and SemVer tags are found (e.g. after a scheme migration), git-clerk exits with an error. Pass `--calver` or `--semver` explicitly to proceed.
293
+
294
+ **Options**
295
+
296
+ | Flag | Description |
297
+ |------|-------------|
298
+ | `--calver` | Use calendar versioning |
299
+ | `--semver` | Use semantic versioning |
300
+ | `--bump patch\|minor\|major` | SemVer component to increment (default: `patch`, ignored for CalVer) |
301
+ | `-y` / `--yes` | Skip the confirmation prompt |
302
+
303
+ ## Versioning schemes
304
+
305
+ ### CalVer — `vYYYY.MM.N`
306
+
307
+ Tags are tied to the calendar month, with a sequential counter that resets each month:
308
+
309
+ ```
310
+ v2026.06.1
311
+ v2026.06.2
312
+ v2026.07.1 ← new month, counter resets
313
+ ```
314
+
315
+ Good fit for projects that ship continuously and want version numbers that communicate when something was released.
316
+
317
+ ### SemVer — `vMAJOR.MINOR.PATCH`
318
+
319
+ Standard semantic versioning. The first tag on a repo with no prior tags starts at `v0.1.0`.
320
+
321
+ ```
322
+ v0.1.0 → v0.1.1 (patch)
323
+ v0.1.1 → v0.2.0 (minor)
324
+ v0.2.0 → v1.0.0 (major)
325
+ ```
326
+
327
+ The tag is always placed on `origin/main`, so run `release` after shipping all PRs for the version.
328
+
329
+ ## Branch naming
330
+
331
+ All commands that read from the branch name (`commit`, `pr`) expect the format `type/scope`:
332
+
333
+ ```
334
+ feat/user-auth
335
+ fix/payment-timeout
336
+ chore/upgrade-deps
337
+ docs/api-reference
338
+ ```
339
+
340
+ The type must be one of the standard conventional commit types: `build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, `revert`, `style`, `test`. The scope must start with a letter or digit and may contain letters, digits, hyphens, and underscores — spaces and special characters are not allowed. Both are validated by `branch` before the branch is created, and by `commit` and `pr` when reading the current branch name.
341
+
342
+ ## License
343
+
344
+ MIT
@@ -0,0 +1,11 @@
1
+ git_clerk/__init__.py,sha256=8BtIFmZ7QzCXpAOVOP2WRuwGPff3ys5oDGy40GaeMPs,75
2
+ git_clerk/branch.py,sha256=BspKuAphvFLn9goWtL-ZsRViHdhLLNLZ3HjfgyNoCAY,916
3
+ git_clerk/cli.py,sha256=I8w0UPQBwbLZHbjCGlpu23lOnn9hg4PG0wS6Rozsga0,8094
4
+ git_clerk/git.py,sha256=uF5sgn9wqyxjE6VEq1s9YGncVvAUxcUbfKt9F2X2GUQ,2039
5
+ git_clerk/github.py,sha256=lzAYGSFboxkFPkp4S2PAMCx6tuNaRBMuUXzfElMBZzk,2238
6
+ git_clerk/release.py,sha256=b4njsPuDXVSqbtZqAmiiEZ_sLlqFl0WqAXTzYAR92ZA,1543
7
+ git_clerk-0.1.0.dist-info/METADATA,sha256=1XtEA76QxVguLWL4tZrFu-8hhSX0-XY8uJ4MGjj0B_Q,13093
8
+ git_clerk-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
9
+ git_clerk-0.1.0.dist-info/entry_points.txt,sha256=awNt9zi4cbzw_UuwiHwBozIbLWiv050FymS2dj22B-s,49
10
+ git_clerk-0.1.0.dist-info/licenses/LICENSE,sha256=fXPx6-tt0xoJ3o87wko0BcBptkIprKNhzwrZTek3_7c,1074
11
+ git_clerk-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ git-clerk = git_clerk.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nicolas Contreras
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.