Files
Gitea-Tools/tests/test_worktrees.py
sysadmin f18cecc998 feat: enforce issue-linked branches + document versioning/tagging policy (#48)
Formalize the branch↔issue relationship and add a release/version-tagging policy.

Branch/issue linkage:
- scripts/worktree-start now validates branch names: implementation branches
  must match (fix|feat|docs|chore)/issue-<number>-<slug>; review branches
  review/pr-<number>-<slug>. Untraceable names are rejected with a clear error
  (exit 2). New --allow-unlinked override for genuine exceptions. --dry-run
  preserved.
- Documented issue → branch → worktree → PR → cleanup traceability in the
  runbook and the portable SKILL, including the claim-comment convention and
  Closes #n / Refs #n PR-body usage.
- Noted that Gitea exposes no native issue→branch API field (only a PR head
  branch), so linkage is enforced via branch name + claim comment + PR body +
  cleanup.

Versioning / tagging policy (docs only; no release automation yet):
- SemVer vMAJOR.MINOR.PATCH (v0.x.y while unstable) with PATCH/MINOR/MAJOR bump
  rules.
- Annotated tags only, from the exact commit on remote master, only after the
  full suite passes, with release notes referencing merged PRs/issues. Never tag
  feature branches, dirty worktrees, unreviewed/self-authored work, or commits
  not on remote master.
- Release runbook in the runbook + SKILL, plus a new
  skills/llm-project-workflow/templates/release-tag.md prompt template.

Tests: worktree-start branch validation — accepts fix/feat/docs/chore/issue-*
and review/pr-*, rejects fix/random-name / my-branch / non-numeric issue,
honors --allow-unlinked, preserves --dry-run. Full suite 291 passed / 0 failures;
bash -n clean; git diff --check clean; no secrets.

Release-tag automation (a scripts/release-tag helper) intentionally deferred to a
later issue to keep this diff narrow and testable.

Closes #48. Refs #38, #39, #46.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 04:08:42 -04:00

132 lines
4.7 KiB
Python

"""Tests for the branch-worktree helper scripts (#38/#39).
Exercises path generation and refuse-to-overwrite via the scripts' ``--dry-run``
mode, so no real worktrees, branches, network, or deletions are involved.
"""
import os
import subprocess
import unittest
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
SCRIPTS = REPO / "scripts"
BRANCHES = REPO / "branches"
def run(script, *args):
proc = subprocess.run(
["bash", str(SCRIPTS / script), *args],
capture_output=True, text=True, cwd=str(REPO),
)
return proc.returncode, proc.stdout, proc.stderr
class TestWorktreeStart(unittest.TestCase):
def test_dry_run_path_generation(self):
rc, out, _ = run("worktree-start", "--dry-run", "fix/issue-123-example")
self.assertEqual(rc, 0)
self.assertIn("branches/fix-issue-123-example", out)
self.assertIn("fix/issue-123-example", out)
self.assertIn("prgs/master", out) # default start-ref
def test_bad_args_exit_2(self):
rc, _, _ = run("worktree-start")
self.assertEqual(rc, 2)
def test_refuses_existing_worktree(self):
branch = f"fix/issue-999-refuse-{os.getpid()}"
slug = branch.replace("/", "-")
target = BRANCHES / slug
target.mkdir(parents=True, exist_ok=True)
try:
rc, _, err = run("worktree-start", "--dry-run", branch)
self.assertEqual(rc, 1)
self.assertIn("Refusing to reuse", err)
finally:
target.rmdir()
# -- issue-linked branch validation (#48) --------------------------------
def test_accepts_issue_linked_impl_branches(self):
for branch in ("fix/issue-123-example", "feat/issue-123-example",
"docs/issue-123-example", "chore/issue-123-example"):
rc, out, err = run("worktree-start", "--dry-run", branch)
self.assertEqual(rc, 0, f"{branch}: {err}")
self.assertIn(f"branches/{branch.replace('/', '-')}", out)
def test_accepts_review_branch(self):
rc, out, _ = run("worktree-start", "--dry-run", "review/pr-456-example")
self.assertEqual(rc, 0)
self.assertIn("branches/review-pr-456-example", out)
def test_rejects_untraceable_branches(self):
for branch in ("fix/random-name", "my-branch", "feat/no-issue-here",
"fix/issue-abc-x"):
rc, _, err = run("worktree-start", "--dry-run", branch)
self.assertEqual(rc, 2, branch)
self.assertIn("Untraceable branch name", err)
def test_allow_unlinked_override(self):
rc, out, _ = run("worktree-start", "--dry-run", "--allow-unlinked", "my-branch")
self.assertEqual(rc, 0)
self.assertIn("branches/my-branch", out)
class TestWorktreeReview(unittest.TestCase):
def test_dry_run_detached_review_path(self):
rc, out, _ = run("worktree-review", "--dry-run", "fix/issue-9-x")
self.assertEqual(rc, 0)
self.assertIn("branches/review-fix-issue-9-x", out)
self.assertIn("--detach", out)
self.assertIn("prgs/fix/issue-9-x", out) # default start-ref
def test_refuses_existing_review_worktree(self):
slug = f"review-zz-refuse-rev-{os.getpid()}"
target = BRANCHES / slug
target.mkdir(parents=True, exist_ok=True)
try:
# branch name maps to the same slug: review-<branch>
rc, _, err = run("worktree-review", "--dry-run",
f"zz-refuse-rev-{os.getpid()}")
self.assertEqual(rc, 1)
self.assertIn("Refusing to reuse", err)
finally:
target.rmdir()
class TestWorktreeClean(unittest.TestCase):
def test_missing_worktree_errors(self):
rc, _, err = run("worktree-clean", "--dry-run", "does-not-exist-xyz")
self.assertEqual(rc, 1)
self.assertIn("No such worktree", err)
def test_dry_run_does_not_delete(self):
slug = f"zz-clean-{os.getpid()}"
target = BRANCHES / slug
target.mkdir(parents=True, exist_ok=True)
try:
rc, out, _ = run("worktree-clean", "--dry-run", slug)
self.assertEqual(rc, 0)
self.assertIn("worktree remove", out)
self.assertTrue(target.is_dir()) # nothing removed in dry-run
finally:
target.rmdir()
def test_dry_run_delete_branch_lists_branch_command(self):
slug = f"zz-clean-b-{os.getpid()}"
target = BRANCHES / slug
target.mkdir(parents=True, exist_ok=True)
try:
rc, out, _ = run("worktree-clean", "--dry-run", "--delete-branch", slug)
self.assertEqual(rc, 0)
self.assertIn("branch -d", out)
finally:
target.rmdir()
if __name__ == "__main__":
unittest.main()