git-acta 1.0.0__tar.gz → 1.1.0__tar.gz
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_acta-1.1.0/CLAUDE.md +19 -0
- git_acta-1.1.0/CONTRIBUTING.md +44 -0
- {git_acta-1.0.0 → git_acta-1.1.0}/PKG-INFO +1 -1
- git_acta-1.1.0/RELEASING.md +11 -0
- {git_acta-1.0.0 → git_acta-1.1.0}/pyproject.toml +1 -1
- {git_acta-1.0.0 → git_acta-1.1.0}/scripts/release.py +26 -6
- {git_acta-1.0.0 → git_acta-1.1.0}/src/acta/cli/__init__.py +2 -0
- {git_acta-1.0.0 → git_acta-1.1.0}/src/acta/cli/board.py +2 -0
- git_acta-1.1.0/src/acta/cli/branch.py +25 -0
- {git_acta-1.0.0 → git_acta-1.1.0}/src/acta/cli/commit.py +6 -2
- {git_acta-1.0.0 → git_acta-1.1.0}/src/acta/cli/issue.py +12 -3
- {git_acta-1.0.0 → git_acta-1.1.0}/src/acta/cli/milestone.py +9 -2
- {git_acta-1.0.0 → git_acta-1.1.0}/src/acta/cli/pr.py +12 -3
- {git_acta-1.0.0 → git_acta-1.1.0}/src/acta/cli/release.py +2 -0
- git_acta-1.1.0/src/acta/cli/shared.py +86 -0
- {git_acta-1.0.0 → git_acta-1.1.0}/src/acta/git/__init__.py +3 -0
- git_acta-1.1.0/src/acta/git/branch.py +123 -0
- git_acta-1.1.0/src/acta/git/commit.py +26 -0
- {git_acta-1.0.0 → git_acta-1.1.0}/src/acta/git/config.py +5 -0
- {git_acta-1.0.0 → git_acta-1.1.0}/src/acta/git/tag.py +83 -3
- {git_acta-1.0.0 → git_acta-1.1.0}/src/acta/github/__init__.py +31 -0
- {git_acta-1.0.0 → git_acta-1.1.0}/src/acta/github/issue.py +29 -0
- {git_acta-1.0.0 → git_acta-1.1.0}/src/acta/github/label.py +7 -0
- {git_acta-1.0.0 → git_acta-1.1.0}/src/acta/github/milestone.py +26 -1
- {git_acta-1.0.0 → git_acta-1.1.0}/src/acta/github/pr.py +21 -0
- {git_acta-1.0.0 → git_acta-1.1.0}/tests/integration/test_cli_branch.py +18 -0
- {git_acta-1.0.0 → git_acta-1.1.0}/tests/integration/test_cli_commit.py +8 -5
- {git_acta-1.0.0 → git_acta-1.1.0}/tests/integration/test_cli_pr.py +16 -2
- git_acta-1.1.0/tests/unit/test_cli_shared.py +77 -0
- {git_acta-1.0.0 → git_acta-1.1.0}/uv.lock +1 -1
- git_acta-1.0.0/RELEASING.md +0 -49
- git_acta-1.0.0/src/acta/cli/branch.py +0 -17
- git_acta-1.0.0/src/acta/cli/shared.py +0 -45
- git_acta-1.0.0/src/acta/git/branch.py +0 -77
- git_acta-1.0.0/src/acta/git/commit.py +0 -20
- git_acta-1.0.0/tests/unit/test_cli_shared.py +0 -46
- {git_acta-1.0.0 → git_acta-1.1.0}/.github/workflows/publish.yml +0 -0
- {git_acta-1.0.0 → git_acta-1.1.0}/.github/workflows/test.yml +0 -0
- {git_acta-1.0.0 → git_acta-1.1.0}/.gitignore +0 -0
- {git_acta-1.0.0 → git_acta-1.1.0}/LICENSE +0 -0
- {git_acta-1.0.0 → git_acta-1.1.0}/README.md +0 -0
- {git_acta-1.0.0 → git_acta-1.1.0}/src/acta/__init__.py +0 -0
- {git_acta-1.0.0 → git_acta-1.1.0}/tests/integration/conftest.py +0 -0
- {git_acta-1.0.0 → git_acta-1.1.0}/tests/integration/test_cli_board.py +0 -0
- {git_acta-1.0.0 → git_acta-1.1.0}/tests/integration/test_cli_issue.py +0 -0
- {git_acta-1.0.0 → git_acta-1.1.0}/tests/integration/test_cli_milestone.py +0 -0
- {git_acta-1.0.0 → git_acta-1.1.0}/tests/integration/test_cli_release.py +0 -0
- {git_acta-1.0.0 → git_acta-1.1.0}/tests/unit/conftest.py +0 -0
- {git_acta-1.0.0 → git_acta-1.1.0}/tests/unit/test_cli_issue_slug.py +0 -0
- {git_acta-1.0.0 → git_acta-1.1.0}/tests/unit/test_git_branch.py +0 -0
- {git_acta-1.0.0 → git_acta-1.1.0}/tests/unit/test_git_tag.py +0 -0
- {git_acta-1.0.0 → git_acta-1.1.0}/tests/unit/test_github.py +0 -0
- {git_acta-1.0.0 → git_acta-1.1.0}/tests/unit/test_github_milestone.py +0 -0
- {git_acta-1.0.0 → git_acta-1.1.0}/tests/unit/test_github_pr.py +0 -0
git_acta-1.1.0/CLAUDE.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# git-acta
|
|
2
|
+
|
|
3
|
+
Structured git workflow CLI: conventional commits, trunk-based branches, GitHub PR lifecycle. Thin Click CLI (`src/acta/cli/`) over git (`src/acta/git/`) and GitHub (`src/acta/github/`) helpers; every subprocess call routes through the `git()`/`gh()` wrappers.
|
|
4
|
+
|
|
5
|
+
## MUST DO — enforce on every task without exception
|
|
6
|
+
|
|
7
|
+
**MUST BE OPINIONATED, NOT SYCOPHANTIC.** Push back on bad ideas; explain the better path instead of silently complying.
|
|
8
|
+
|
|
9
|
+
**MUST DOGFOOD `acta` PER `CONTRIBUTING.md`.** Drive every branch/commit/PR/ship through `acta`, never raw `git`/`gh` for those steps.
|
|
10
|
+
|
|
11
|
+
**MUST APPLY DESIGN PRINCIPLES ON EVERY CHANGE.** KISS, YAGNI, DRY, less-is-more, Unix do-one-thing.
|
|
12
|
+
|
|
13
|
+
**MUST DOCSTRING EVERY FUNCTION (Google style).** One line minimum; full Args/Returns/Raises plus a behavioral example for non-obvious ones. A wrapper's docstring states what its underlying `git`/`gh` command does and why.
|
|
14
|
+
|
|
15
|
+
**MUST TYPE EVERYTHING AND KEEP PYRIGHT-STRICT CLEAN.** Full annotations, no implicit `Any`.
|
|
16
|
+
|
|
17
|
+
**MUST TEST EVERY BEHAVIOR CHANGE.** pytest with `pytest-subprocess`: real `git` against a temp repo, `gh` faked via `fp.register`. Mirror the existing `tests/` patterns. Use `uv run pytest` to run the tests.
|
|
18
|
+
|
|
19
|
+
**LINT AND TYPES RUN ON COMMIT** (ruff + pyright via the pre-commit hook) — rely on the hook, don't run them manually.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
git-acta is developed with itself — branches, commits, and PRs all go through `acta`.
|
|
4
|
+
|
|
5
|
+
## Workflow
|
|
6
|
+
|
|
7
|
+
### Baseline
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
acta branch type/scope
|
|
11
|
+
acta commit -A "Description" -b "Context for why." # -A stages everything before committing
|
|
12
|
+
acta pr "PR title" -b "What changed and why."
|
|
13
|
+
acta ship -y
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Descriptions are not optional, they keep the repo self-documenting. The PR title
|
|
17
|
+
and body are what land on `main` and feed the release notes. Commit bodies get
|
|
18
|
+
squashed away on merge, so skip them by default.
|
|
19
|
+
|
|
20
|
+
### Variations
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
git add path/a path/b && acta commit "Title" # stage selectively (-A stages everything)
|
|
24
|
+
acta commit -AP "Title" # stage all + commit + push (follow-up on an open PR)
|
|
25
|
+
acta commit -t chore "Title" # override the type inferred from the branch
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## PR body template
|
|
29
|
+
|
|
30
|
+
Base PR bodies on this structure. The `## Breaking` section is optional — include
|
|
31
|
+
it only for breaking changes:
|
|
32
|
+
|
|
33
|
+
```markdown
|
|
34
|
+
One-line summary of the change.
|
|
35
|
+
|
|
36
|
+
## Changes
|
|
37
|
+
- What changed, point by point
|
|
38
|
+
|
|
39
|
+
## Why
|
|
40
|
+
The motivation and context behind the change.
|
|
41
|
+
|
|
42
|
+
## Breaking
|
|
43
|
+
What breaks and the step users must take. (Omit for non-breaking changes.)
|
|
44
|
+
```
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: git-acta
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.1.0
|
|
4
4
|
Summary: Structured git workflow CLI: conventional commits, trunk-based branches, GitHub PR lifecycle
|
|
5
5
|
Project-URL: Homepage, https://github.com/nicobc/git-acta
|
|
6
6
|
Project-URL: Source, https://github.com/nicobc/git-acta
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Releasing
|
|
2
|
+
|
|
3
|
+
git-acta publishes SemVer-tagged releases to PyPI. Pushing a `v*.*.*` tag fires the publish workflow. Git tags are the source of truth for the version; `pyproject.toml` mirrors the latest tag.
|
|
4
|
+
|
|
5
|
+
## Release script
|
|
6
|
+
|
|
7
|
+
Once ready to tag a new release, run
|
|
8
|
+
```sh
|
|
9
|
+
uv run scripts/release.py # version derived from the commits since the last tag
|
|
10
|
+
uv run scripts/release.py --stable # one-time promotion of a 0.x project to v1.0.0
|
|
11
|
+
```
|
|
@@ -18,7 +18,8 @@ import subprocess
|
|
|
18
18
|
import time
|
|
19
19
|
from pathlib import Path
|
|
20
20
|
|
|
21
|
-
from acta.git.
|
|
21
|
+
from acta.git.commit import get_commit_subjects
|
|
22
|
+
from acta.git.tag import fetch_tags, latest_semver_tag, list_tags, next_release_tag
|
|
22
23
|
|
|
23
24
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
24
25
|
PYPROJECT = REPO_ROOT / "pyproject.toml"
|
|
@@ -78,6 +79,19 @@ def wait_for_publish_run(tag: str, commit_sha: str) -> str:
|
|
|
78
79
|
)
|
|
79
80
|
|
|
80
81
|
|
|
82
|
+
def _describe_bump(latest: str | None, new_tag: str) -> str:
|
|
83
|
+
"""Describe the jump from ``latest`` to ``new_tag`` as major/minor/patch."""
|
|
84
|
+
if latest is None:
|
|
85
|
+
return "initial release"
|
|
86
|
+
old_major, old_minor, _ = (int(part) for part in latest[1:].split("."))
|
|
87
|
+
new_major, new_minor, _ = (int(part) for part in new_tag[1:].split("."))
|
|
88
|
+
if new_major > old_major:
|
|
89
|
+
return "major"
|
|
90
|
+
if new_minor > old_minor:
|
|
91
|
+
return "minor"
|
|
92
|
+
return "patch"
|
|
93
|
+
|
|
94
|
+
|
|
81
95
|
def main() -> None:
|
|
82
96
|
parser = argparse.ArgumentParser(description="Derive the version, ship, tag, and publish.")
|
|
83
97
|
parser.add_argument(
|
|
@@ -93,18 +107,24 @@ def main() -> None:
|
|
|
93
107
|
dry_run: bool = args.dry_run
|
|
94
108
|
|
|
95
109
|
fetch_tags()
|
|
110
|
+
existing_tags = list_tags()
|
|
96
111
|
try:
|
|
97
|
-
new_tag = next_release_tag(
|
|
112
|
+
new_tag = next_release_tag(existing_tags, stable)
|
|
98
113
|
except ValueError as error:
|
|
99
114
|
raise SystemExit(f"release: {error}")
|
|
100
115
|
new_version = new_tag.removeprefix("v")
|
|
101
116
|
title = f"bump version to {new_version}"
|
|
102
117
|
|
|
103
118
|
if dry_run:
|
|
104
|
-
|
|
105
|
-
print(
|
|
106
|
-
print(f
|
|
107
|
-
|
|
119
|
+
latest = latest_semver_tag(existing_tags)
|
|
120
|
+
print("release: dry run — no changes made")
|
|
121
|
+
print(f" current version: {latest or '(none)'}")
|
|
122
|
+
if latest is not None:
|
|
123
|
+
print(f" commits since {latest}:")
|
|
124
|
+
for subject in get_commit_subjects(f"{latest}..origin/main"):
|
|
125
|
+
print(f" {subject}")
|
|
126
|
+
print(f" bump: {_describe_bump(latest, new_tag)} → {new_tag}")
|
|
127
|
+
print(f" on a real run: ship the bump, push {new_tag}, and publish to PyPI")
|
|
108
128
|
return
|
|
109
129
|
|
|
110
130
|
write_pyproject_version(new_version)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""``acta branch`` — create a type/scope branch from origin/main."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from acta.git.branch import fetch_origin, switch_new_branch
|
|
6
|
+
from acta.git.branch import parse as parse_branch
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@click.command()
|
|
10
|
+
@click.argument("name", metavar="TYPE/SCOPE")
|
|
11
|
+
def branch(name: str) -> None:
|
|
12
|
+
"""Create a `type/scope` branch from the latest origin/main.
|
|
13
|
+
|
|
14
|
+
Fetches origin, then branches off origin/main. The name sets the
|
|
15
|
+
conventional-commit type and scope that `acta commit` and `acta pr` reuse —
|
|
16
|
+
`feat/auth` produces `feat(auth): ...` messages. An optional third segment
|
|
17
|
+
(`type/scope/topic`) adds a human-readable topic without changing type or scope.
|
|
18
|
+
"""
|
|
19
|
+
try:
|
|
20
|
+
parse_branch(name)
|
|
21
|
+
except ValueError as error:
|
|
22
|
+
raise click.ClickException(str(error))
|
|
23
|
+
fetch_origin()
|
|
24
|
+
switch_new_branch(name)
|
|
25
|
+
click.echo(f"Branched {name} from origin/main.")
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
"""``acta commit`` — create a conventional commit from the branch name."""
|
|
2
|
+
|
|
1
3
|
import click
|
|
2
4
|
|
|
3
|
-
from acta.cli.shared import TYPE_CHOICE, open_editor
|
|
5
|
+
from acta.cli.shared import TYPE_CHOICE, open_editor, strip_type_prefix
|
|
4
6
|
from acta.git.branch import get_current_branch
|
|
5
7
|
from acta.git.branch import parse as parse_branch
|
|
6
8
|
from acta.git.commit import add_all, push_head
|
|
@@ -53,7 +55,9 @@ def commit(
|
|
|
53
55
|
type_, scope = parse_branch(branch_name)
|
|
54
56
|
except ValueError as error:
|
|
55
57
|
raise click.ClickException(str(error))
|
|
56
|
-
header =
|
|
58
|
+
header = (
|
|
59
|
+
f"{type_override or type_}({scope_override or scope}): {strip_type_prefix(description)}"
|
|
60
|
+
)
|
|
57
61
|
if edit_body:
|
|
58
62
|
body = open_editor(header)
|
|
59
63
|
if stage_all:
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
"""``acta issue`` — create, list, start, and discard GitHub issues."""
|
|
2
|
+
|
|
1
3
|
import re
|
|
2
4
|
|
|
3
5
|
import click
|
|
@@ -47,7 +49,7 @@ def format_issue_lines(
|
|
|
47
49
|
|
|
48
50
|
@click.group(cls=CLIGroup)
|
|
49
51
|
def issue() -> None:
|
|
50
|
-
"""
|
|
52
|
+
"""Create, list, start, and discard issues on the GitHub board."""
|
|
51
53
|
|
|
52
54
|
|
|
53
55
|
@issue.command(name="new")
|
|
@@ -70,7 +72,11 @@ def new_issue(
|
|
|
70
72
|
body: str | None,
|
|
71
73
|
edit_body: bool,
|
|
72
74
|
) -> None:
|
|
73
|
-
"""Create a
|
|
75
|
+
"""Create a GitHub issue with a type label and optional milestone.
|
|
76
|
+
|
|
77
|
+
The --type becomes a `type: ...` label; when you later run `acta issue start`,
|
|
78
|
+
that label sets the new branch's conventional-commit type.
|
|
79
|
+
"""
|
|
74
80
|
if body and edit_body:
|
|
75
81
|
raise click.UsageError("--body and --edit are mutually exclusive")
|
|
76
82
|
if edit_body:
|
|
@@ -89,7 +95,10 @@ def new_issue(
|
|
|
89
95
|
help="Filter by milestone number.",
|
|
90
96
|
)
|
|
91
97
|
def list_issues(milestone_number: int | None) -> None:
|
|
92
|
-
"""List open issues.
|
|
98
|
+
"""List open issues, grouped by milestone.
|
|
99
|
+
|
|
100
|
+
With --milestone, lists only that milestone's issues as a flat list.
|
|
101
|
+
"""
|
|
93
102
|
issues = issue_list(milestone_number)
|
|
94
103
|
if not issues:
|
|
95
104
|
click.echo("No open issues.")
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
"""``acta milestone`` — create, list, and reopen GitHub milestones."""
|
|
2
|
+
|
|
1
3
|
import click
|
|
2
4
|
|
|
3
5
|
from acta.cli.shared import CLIGroup, open_editor
|
|
@@ -11,10 +13,12 @@ from acta.github.milestone import (
|
|
|
11
13
|
|
|
12
14
|
|
|
13
15
|
def get_repo_name() -> str:
|
|
16
|
+
"""Return just the repository name (the ``repo`` half of ``owner/repo``)."""
|
|
14
17
|
return get_repo().split("/")[-1]
|
|
15
18
|
|
|
16
19
|
|
|
17
20
|
def format_milestone_line(milestone_list_item: MilestoneListItem) -> str:
|
|
21
|
+
"""Render a milestone as a one-line summary with its open (and closed) issue counts."""
|
|
18
22
|
noun = "issue" if milestone_list_item.open_issues == 1 else "issues"
|
|
19
23
|
closed = (
|
|
20
24
|
f", {milestone_list_item.closed_issues} closed" if milestone_list_item.closed_issues else ""
|
|
@@ -27,7 +31,7 @@ def format_milestone_line(milestone_list_item: MilestoneListItem) -> str:
|
|
|
27
31
|
|
|
28
32
|
@click.group(cls=CLIGroup)
|
|
29
33
|
def milestone() -> None:
|
|
30
|
-
"""
|
|
34
|
+
"""Create, list, and reopen milestones on the GitHub board."""
|
|
31
35
|
|
|
32
36
|
|
|
33
37
|
@milestone.command(name="new")
|
|
@@ -42,7 +46,10 @@ def milestone() -> None:
|
|
|
42
46
|
)
|
|
43
47
|
@click.option("-e", "--edit", "edit_body", is_flag=True, help="Open $EDITOR for the description.")
|
|
44
48
|
def new_milestone(title: str, scope: str, description: str | None, edit_body: bool) -> None:
|
|
45
|
-
"""Create a
|
|
49
|
+
"""Create a milestone whose --scope sets the branch scope for its issues.
|
|
50
|
+
|
|
51
|
+
Each issue started under it branches as `type/<scope>/N-title`.
|
|
52
|
+
"""
|
|
46
53
|
if description and edit_body:
|
|
47
54
|
raise click.UsageError("--description and --edit are mutually exclusive")
|
|
48
55
|
if edit_body:
|
|
@@ -1,10 +1,13 @@
|
|
|
1
|
+
"""``acta pr``, ``ship``, and ``watch`` — the pull-request lifecycle."""
|
|
2
|
+
|
|
1
3
|
import click
|
|
2
4
|
|
|
3
|
-
from acta.cli.shared import TYPE_CHOICE, open_editor
|
|
5
|
+
from acta.cli.shared import TYPE_CHOICE, open_editor, strip_type_prefix
|
|
4
6
|
from acta.git.branch import (
|
|
5
7
|
delete_branch,
|
|
6
8
|
get_current_branch,
|
|
7
9
|
merge_origin_main,
|
|
10
|
+
prune_origin,
|
|
8
11
|
pull_origin_main,
|
|
9
12
|
switch_branch,
|
|
10
13
|
switch_main,
|
|
@@ -63,7 +66,10 @@ def pr(
|
|
|
63
66
|
except ValueError as error:
|
|
64
67
|
raise click.ClickException(str(error))
|
|
65
68
|
breaking_marker = "!" if breaking else ""
|
|
66
|
-
pr_title =
|
|
69
|
+
pr_title = (
|
|
70
|
+
f"{type_override or type_}({scope_override or scope}){breaking_marker}: "
|
|
71
|
+
f"{strip_type_prefix(title)}"
|
|
72
|
+
)
|
|
67
73
|
if edit_body:
|
|
68
74
|
body = open_editor(f"{pr_title} ({branch_name})")
|
|
69
75
|
active_issue_number = get_active_issue()
|
|
@@ -117,7 +123,10 @@ def ship(update_branch: str | None, confirmed: bool) -> None:
|
|
|
117
123
|
switch_main()
|
|
118
124
|
pull_origin_main()
|
|
119
125
|
delete_branch(branch_name)
|
|
120
|
-
|
|
126
|
+
prune_origin()
|
|
127
|
+
click.echo(
|
|
128
|
+
f"Switched to main, pulled origin/main, deleted {branch_name}, and pruned stale refs."
|
|
129
|
+
)
|
|
121
130
|
if update_branch:
|
|
122
131
|
switch_branch(update_branch)
|
|
123
132
|
merge_origin_main()
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Shared CLI helpers: error handling, editor input, and title/body utilities."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from acta.git.branch import TYPES
|
|
10
|
+
|
|
11
|
+
# A leading conventional-commit prefix: type, optional (scope), optional !, colon.
|
|
12
|
+
_TYPE_PREFIX_RE = re.compile(rf"^(?:{'|'.join(sorted(TYPES))})(?:\([^)]*\))?!?:\s*")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CLIGroup(click.Group):
|
|
16
|
+
"""Click group that turns subprocess and runtime failures into clean CLI errors."""
|
|
17
|
+
|
|
18
|
+
def invoke(self, ctx: click.Context) -> object:
|
|
19
|
+
"""Run the subcommand, mapping failures to tidy exit codes and messages.
|
|
20
|
+
|
|
21
|
+
A failed `git`/`gh` subprocess exits with its own return code, echoing its
|
|
22
|
+
stderr; other RuntimeErrors become a one-line ClickException. Click's own
|
|
23
|
+
--help/abort control flow is re-raised untouched.
|
|
24
|
+
"""
|
|
25
|
+
try:
|
|
26
|
+
return super().invoke(ctx)
|
|
27
|
+
except (click.exceptions.Exit, click.exceptions.Abort):
|
|
28
|
+
raise # Click's own control flow (--help, ctrl-C); both subclass RuntimeError
|
|
29
|
+
except subprocess.CalledProcessError as error:
|
|
30
|
+
if error.stderr:
|
|
31
|
+
click.echo(error.stderr, err=True, nl=False)
|
|
32
|
+
sys.exit(error.returncode)
|
|
33
|
+
except RuntimeError as error:
|
|
34
|
+
raise click.ClickException(str(error)) from error
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def strip_comments(text: str) -> str:
|
|
38
|
+
"""Drop HTML-comment hint lines from editor input and collapse blank-line runs.
|
|
39
|
+
|
|
40
|
+
Used to clean text returned from $EDITOR: removes full-line ``<!-- ... -->``
|
|
41
|
+
hints and squeezes consecutive blank lines, leaving a trimmed body. ``#`` is
|
|
42
|
+
deliberately not the marker — it is a Markdown heading, and these bodies
|
|
43
|
+
render as Markdown on GitHub.
|
|
44
|
+
"""
|
|
45
|
+
lines = [line for line in text.splitlines() if not line.startswith("<!--")]
|
|
46
|
+
collapsed: list[str] = []
|
|
47
|
+
prev_blank = False
|
|
48
|
+
for line in lines:
|
|
49
|
+
blank = not line.strip()
|
|
50
|
+
if blank and prev_blank:
|
|
51
|
+
continue
|
|
52
|
+
collapsed.append(line)
|
|
53
|
+
prev_blank = blank
|
|
54
|
+
return "\n".join(collapsed).strip()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def open_editor(hint: str) -> str:
|
|
58
|
+
"""Open $EDITOR with a hint header and return the entered body, comments stripped.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
hint: Shown as a leading ``<!-- hint -->`` comment to orient the writer.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
The cleaned body text.
|
|
65
|
+
|
|
66
|
+
Raises:
|
|
67
|
+
click.Abort: If the body is empty after stripping comments.
|
|
68
|
+
"""
|
|
69
|
+
template = f"<!-- {hint} -->\n<!-- HTML comments are ignored. -->\n\n"
|
|
70
|
+
edited_text = click.edit(template)
|
|
71
|
+
body_text = strip_comments(edited_text or "")
|
|
72
|
+
if not body_text:
|
|
73
|
+
raise click.Abort()
|
|
74
|
+
return body_text
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def strip_type_prefix(title: str) -> str:
|
|
78
|
+
"""Drop a redundant leading conventional-commit prefix from a caller title.
|
|
79
|
+
|
|
80
|
+
acta derives type(scope) from the branch and prepends it, so a prefix the
|
|
81
|
+
caller typed (e.g. 'fix: x' or 'fix(auth)!: x') would otherwise double up.
|
|
82
|
+
"""
|
|
83
|
+
return _TYPE_PREFIX_RE.sub("", title, count=1)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
TYPE_CHOICE = click.Choice(sorted(TYPES))
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
"""Thin wrappers around the git command-line tool."""
|
|
2
|
+
|
|
1
3
|
import subprocess
|
|
2
4
|
|
|
3
5
|
|
|
@@ -24,4 +26,5 @@ def git(*args: str, capture: bool = False, quiet: bool = False) -> str:
|
|
|
24
26
|
|
|
25
27
|
|
|
26
28
|
def get_remote_url(remote_name: str) -> str:
|
|
29
|
+
"""Return the configured URL of the named remote (e.g. ``origin``)."""
|
|
27
30
|
return git("remote", "get-url", remote_name, capture=True)
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
from acta.git import git
|
|
4
|
+
|
|
5
|
+
TYPES = frozenset(
|
|
6
|
+
[
|
|
7
|
+
"build",
|
|
8
|
+
"chore",
|
|
9
|
+
"ci",
|
|
10
|
+
"docs",
|
|
11
|
+
"feat",
|
|
12
|
+
"fix",
|
|
13
|
+
"perf",
|
|
14
|
+
"refactor",
|
|
15
|
+
"revert",
|
|
16
|
+
"style",
|
|
17
|
+
"test",
|
|
18
|
+
]
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
# type/scope, with an optional third descriptive segment (e.g. an issue topic);
|
|
22
|
+
# the conventional-commit type and scope are derived from the first two.
|
|
23
|
+
_BRANCH_RE = re.compile(r"([^/]+)/([^/]+)(?:/.+)?")
|
|
24
|
+
_SCOPE_RE = re.compile(r"[a-z0-9][a-z0-9_-]*", re.IGNORECASE)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def parse(branch: str) -> tuple[str, str]:
|
|
28
|
+
"""Extract the conventional-commit type and scope from a branch name.
|
|
29
|
+
|
|
30
|
+
Branch names follow ``type/scope`` with an optional third descriptive
|
|
31
|
+
segment (``type/scope/topic``); only the first two segments are significant.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
branch: Branch name to parse, e.g. ``fix/auth`` or ``feat/auth/sso``.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
The ``(type, scope)`` pair.
|
|
38
|
+
|
|
39
|
+
Raises:
|
|
40
|
+
ValueError: If the name doesn't match ``type/scope``, the type is not a
|
|
41
|
+
conventional-commit type, or the scope has invalid characters.
|
|
42
|
+
|
|
43
|
+
Example:
|
|
44
|
+
>>> parse("feat/auth/sso")
|
|
45
|
+
('feat', 'auth')
|
|
46
|
+
"""
|
|
47
|
+
branch_match = _BRANCH_RE.fullmatch(branch)
|
|
48
|
+
if not branch_match:
|
|
49
|
+
raise ValueError(f"Branch '{branch}' does not follow type/scope convention")
|
|
50
|
+
type_ = branch_match.group(1)
|
|
51
|
+
if type_ not in TYPES:
|
|
52
|
+
raise ValueError(
|
|
53
|
+
f"'{type_}' is not a conventional commit type. Use one of: {', '.join(sorted(TYPES))}"
|
|
54
|
+
)
|
|
55
|
+
scope = branch_match.group(2)
|
|
56
|
+
if not _SCOPE_RE.fullmatch(scope):
|
|
57
|
+
raise ValueError(
|
|
58
|
+
f"'{scope}' is not a valid scope. Use letters, digits, hyphens, and underscores."
|
|
59
|
+
)
|
|
60
|
+
return type_, scope
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def get_current_branch() -> str:
|
|
64
|
+
"""Return the name of the currently checked-out branch."""
|
|
65
|
+
return git("branch", "--show-current", capture=True)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def fetch_origin() -> None:
|
|
69
|
+
"""Download the latest state from origin and clean up stale branch pointers.
|
|
70
|
+
|
|
71
|
+
Git keeps a local read-only pointer for each branch on origin, named
|
|
72
|
+
``origin/<branch>`` (a "remote-tracking ref") — its snapshot of where that
|
|
73
|
+
branch was at the last fetch. ``--prune`` deletes the pointers for branches
|
|
74
|
+
that have since been removed on origin (for example after their PR merged),
|
|
75
|
+
so they don't pile up and shadow names a new branch may want to reuse.
|
|
76
|
+
"""
|
|
77
|
+
git("fetch", "--prune", "origin", quiet=True)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def prune_origin() -> None:
|
|
81
|
+
"""Delete local pointers to branches that no longer exist on origin.
|
|
82
|
+
|
|
83
|
+
Like the prune half of ``fetch_origin`` but without downloading anything:
|
|
84
|
+
it only removes the local ``origin/<branch>`` pointers whose branch is gone
|
|
85
|
+
on origin. Used right after merging a PR (whose branch was deleted) to keep
|
|
86
|
+
the local view in sync.
|
|
87
|
+
"""
|
|
88
|
+
git("remote", "prune", "origin", quiet=True)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def switch_new_branch(name: str) -> None:
|
|
92
|
+
"""Create branch ``name`` from origin/main and check it out."""
|
|
93
|
+
git("switch", "-c", name, "origin/main", quiet=True)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def switch_main() -> None:
|
|
97
|
+
"""Check out the main branch."""
|
|
98
|
+
git("switch", "main", quiet=True)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def pull_origin_main() -> None:
|
|
102
|
+
"""Update the current branch with the latest commits from origin/main."""
|
|
103
|
+
git("pull", "origin", "main", quiet=True)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def branch_exists(name: str) -> bool:
|
|
107
|
+
"""Return True if a local branch named ``name`` exists."""
|
|
108
|
+
return bool(git("branch", "--list", name, capture=True))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def delete_branch(name: str) -> None:
|
|
112
|
+
"""Force-delete the local branch ``name``, discarding unmerged commits."""
|
|
113
|
+
git("branch", "-D", name, quiet=True)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def switch_branch(name: str) -> None:
|
|
117
|
+
"""Check out the existing branch ``name``."""
|
|
118
|
+
git("switch", name, quiet=True)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def merge_origin_main() -> None:
|
|
122
|
+
"""Merge origin/main into the current branch."""
|
|
123
|
+
git("merge", "origin/main", quiet=True)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Staging, committing, and pushing helpers."""
|
|
2
|
+
|
|
3
|
+
from acta.git import git
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def add_all() -> None:
|
|
7
|
+
"""Stage every change in the working tree (tracked, new, and deleted)."""
|
|
8
|
+
git("add", "-A")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def commit(header: str, body: str | None = None) -> None:
|
|
12
|
+
"""Create a commit with ``header`` as the subject and an optional ``body`` paragraph."""
|
|
13
|
+
commit_args = ["commit", "-m", header]
|
|
14
|
+
if body:
|
|
15
|
+
commit_args += ["-m", body]
|
|
16
|
+
git(*commit_args)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def push_head() -> None:
|
|
20
|
+
"""Push the current branch to origin, setting it as the upstream to track."""
|
|
21
|
+
git("push", "--set-upstream", "origin", "HEAD", quiet=True)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_commit_subjects(rev_range: str) -> list[str]:
|
|
25
|
+
"""Return the subject line of each commit in ``rev_range`` (e.g. ``v1.0.0..origin/main``)."""
|
|
26
|
+
return git("log", rev_range, "--format=%s", capture=True).splitlines()
|
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
"""Track the active issue in local git config (key ``clerk.active-issue``)."""
|
|
2
|
+
|
|
1
3
|
import subprocess
|
|
2
4
|
|
|
3
5
|
from acta.git import git
|
|
4
6
|
|
|
5
7
|
|
|
6
8
|
def get_active_issue() -> int | None:
|
|
9
|
+
"""Return the issue number recorded for this repo, or None if none is set."""
|
|
7
10
|
try:
|
|
8
11
|
config_value = git("config", "--get", "clerk.active-issue", capture=True)
|
|
9
12
|
return int(config_value) if config_value else None
|
|
@@ -12,10 +15,12 @@ def get_active_issue() -> int | None:
|
|
|
12
15
|
|
|
13
16
|
|
|
14
17
|
def set_active_issue(number: int) -> None:
|
|
18
|
+
"""Record ``number`` as the active issue, so ``acta pr`` can append ``Closes #N``."""
|
|
15
19
|
git("config", "clerk.active-issue", str(number))
|
|
16
20
|
|
|
17
21
|
|
|
18
22
|
def clear_active_issue() -> None:
|
|
23
|
+
"""Remove the active-issue record; a no-op if none is set."""
|
|
19
24
|
try:
|
|
20
25
|
git("config", "--unset", "clerk.active-issue")
|
|
21
26
|
except subprocess.CalledProcessError:
|