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.
Files changed (54) hide show
  1. git_acta-1.1.0/CLAUDE.md +19 -0
  2. git_acta-1.1.0/CONTRIBUTING.md +44 -0
  3. {git_acta-1.0.0 → git_acta-1.1.0}/PKG-INFO +1 -1
  4. git_acta-1.1.0/RELEASING.md +11 -0
  5. {git_acta-1.0.0 → git_acta-1.1.0}/pyproject.toml +1 -1
  6. {git_acta-1.0.0 → git_acta-1.1.0}/scripts/release.py +26 -6
  7. {git_acta-1.0.0 → git_acta-1.1.0}/src/acta/cli/__init__.py +2 -0
  8. {git_acta-1.0.0 → git_acta-1.1.0}/src/acta/cli/board.py +2 -0
  9. git_acta-1.1.0/src/acta/cli/branch.py +25 -0
  10. {git_acta-1.0.0 → git_acta-1.1.0}/src/acta/cli/commit.py +6 -2
  11. {git_acta-1.0.0 → git_acta-1.1.0}/src/acta/cli/issue.py +12 -3
  12. {git_acta-1.0.0 → git_acta-1.1.0}/src/acta/cli/milestone.py +9 -2
  13. {git_acta-1.0.0 → git_acta-1.1.0}/src/acta/cli/pr.py +12 -3
  14. {git_acta-1.0.0 → git_acta-1.1.0}/src/acta/cli/release.py +2 -0
  15. git_acta-1.1.0/src/acta/cli/shared.py +86 -0
  16. {git_acta-1.0.0 → git_acta-1.1.0}/src/acta/git/__init__.py +3 -0
  17. git_acta-1.1.0/src/acta/git/branch.py +123 -0
  18. git_acta-1.1.0/src/acta/git/commit.py +26 -0
  19. {git_acta-1.0.0 → git_acta-1.1.0}/src/acta/git/config.py +5 -0
  20. {git_acta-1.0.0 → git_acta-1.1.0}/src/acta/git/tag.py +83 -3
  21. {git_acta-1.0.0 → git_acta-1.1.0}/src/acta/github/__init__.py +31 -0
  22. {git_acta-1.0.0 → git_acta-1.1.0}/src/acta/github/issue.py +29 -0
  23. {git_acta-1.0.0 → git_acta-1.1.0}/src/acta/github/label.py +7 -0
  24. {git_acta-1.0.0 → git_acta-1.1.0}/src/acta/github/milestone.py +26 -1
  25. {git_acta-1.0.0 → git_acta-1.1.0}/src/acta/github/pr.py +21 -0
  26. {git_acta-1.0.0 → git_acta-1.1.0}/tests/integration/test_cli_branch.py +18 -0
  27. {git_acta-1.0.0 → git_acta-1.1.0}/tests/integration/test_cli_commit.py +8 -5
  28. {git_acta-1.0.0 → git_acta-1.1.0}/tests/integration/test_cli_pr.py +16 -2
  29. git_acta-1.1.0/tests/unit/test_cli_shared.py +77 -0
  30. {git_acta-1.0.0 → git_acta-1.1.0}/uv.lock +1 -1
  31. git_acta-1.0.0/RELEASING.md +0 -49
  32. git_acta-1.0.0/src/acta/cli/branch.py +0 -17
  33. git_acta-1.0.0/src/acta/cli/shared.py +0 -45
  34. git_acta-1.0.0/src/acta/git/branch.py +0 -77
  35. git_acta-1.0.0/src/acta/git/commit.py +0 -20
  36. git_acta-1.0.0/tests/unit/test_cli_shared.py +0 -46
  37. {git_acta-1.0.0 → git_acta-1.1.0}/.github/workflows/publish.yml +0 -0
  38. {git_acta-1.0.0 → git_acta-1.1.0}/.github/workflows/test.yml +0 -0
  39. {git_acta-1.0.0 → git_acta-1.1.0}/.gitignore +0 -0
  40. {git_acta-1.0.0 → git_acta-1.1.0}/LICENSE +0 -0
  41. {git_acta-1.0.0 → git_acta-1.1.0}/README.md +0 -0
  42. {git_acta-1.0.0 → git_acta-1.1.0}/src/acta/__init__.py +0 -0
  43. {git_acta-1.0.0 → git_acta-1.1.0}/tests/integration/conftest.py +0 -0
  44. {git_acta-1.0.0 → git_acta-1.1.0}/tests/integration/test_cli_board.py +0 -0
  45. {git_acta-1.0.0 → git_acta-1.1.0}/tests/integration/test_cli_issue.py +0 -0
  46. {git_acta-1.0.0 → git_acta-1.1.0}/tests/integration/test_cli_milestone.py +0 -0
  47. {git_acta-1.0.0 → git_acta-1.1.0}/tests/integration/test_cli_release.py +0 -0
  48. {git_acta-1.0.0 → git_acta-1.1.0}/tests/unit/conftest.py +0 -0
  49. {git_acta-1.0.0 → git_acta-1.1.0}/tests/unit/test_cli_issue_slug.py +0 -0
  50. {git_acta-1.0.0 → git_acta-1.1.0}/tests/unit/test_git_branch.py +0 -0
  51. {git_acta-1.0.0 → git_acta-1.1.0}/tests/unit/test_git_tag.py +0 -0
  52. {git_acta-1.0.0 → git_acta-1.1.0}/tests/unit/test_github.py +0 -0
  53. {git_acta-1.0.0 → git_acta-1.1.0}/tests/unit/test_github_milestone.py +0 -0
  54. {git_acta-1.0.0 → git_acta-1.1.0}/tests/unit/test_github_pr.py +0 -0
@@ -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.0.0
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
+ ```
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "git-acta"
7
- version = "1.0.0"
7
+ version = "1.1.0"
8
8
  description = "Structured git workflow CLI: conventional commits, trunk-based branches, GitHub PR lifecycle"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -18,7 +18,8 @@ import subprocess
18
18
  import time
19
19
  from pathlib import Path
20
20
 
21
- from acta.git.tag import fetch_tags, list_tags, next_release_tag
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(list_tags(), stable)
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
- print(f"release: would tag {new_tag}")
105
- print(f" set pyproject.toml + uv.lock to {new_version}")
106
- print(f' acta branch/commit/pr/ship for "{title}"')
107
- print(f" acta release{' --stable' if stable else ''} -y, then watch the publish job")
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)
@@ -1,3 +1,5 @@
1
+ """The ``acta`` command group and subcommand registration."""
2
+
1
3
  import click
2
4
 
3
5
  from acta.cli.board import board
@@ -1,3 +1,5 @@
1
+ """``acta board`` — a session snapshot of active work and milestones."""
2
+
1
3
  import click
2
4
 
3
5
  from acta.cli.issue import format_issue_lines
@@ -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 = f"{type_override or type_}({scope_override or scope}): {description}"
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
- """Manage issues."""
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 new issue."""
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
- """Manage milestones."""
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 new milestone."""
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 = f"{type_override or type_}({scope_override or scope}){breaking_marker}: {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
- click.echo(f"Switched to main, pulled origin/main, deleted {branch_name}.")
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()
@@ -1,3 +1,5 @@
1
+ """``acta release`` — compute and push the next version tag."""
2
+
1
3
  from datetime import date
2
4
 
3
5
  import click
@@ -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: