Normal author work is unchanged: gitea_lock_issue still requires the worktree
to be base-equivalent to master/main/dev, and gitea_create_pr still targets a
normal base branch. A *stacked* PR (based on another unmerged PR's branch) is a
new explicit, proof-backed path — it never bypasses the issue lock.
- stacked_pr_support.py: pure policy. Approve a non-master base only when it is
declared AND owned by a live OPEN PR whose number matches; reject arbitrary,
mismatched, or stale (merged/closed) branches; require the PR body to document
the stack (base branch, stacked-on PR, merge ordering).
- issue_lock_worktree.py: read_worktree_git_state / _find_matching_base_ref gain
an opt-in extra_bases arg so an approved stacked base can anchor
base-equivalence in addition to master/main/dev (empty by default = unchanged).
- gitea_mcp_server.py:
- gitea_lock_issue gains stacked_base_branch + stacked_base_pr. When declared,
it verifies the open PR owns the branch, anchors base-equivalence to it, and
records approved_stacked_base = {branch, pr_number, verified_open} on the lock.
- gitea_create_pr validates a non-master base against the lock's approved
stacked base, re-checks the dependency PR is still open, and enforces the
stacked-PR body fields. Master-based path untouched.
- Docs: llm-workflow-runbooks.md and work-issue.md document when stacked PRs are
allowed and the required PR-body wording.
Tests: test_stacked_pr_support.py (18 policy cases), stacked base-equivalence in
test_issue_lock_worktree.py, approved_stacked_base persistence round-trip in
test_issue_lock_store.py. Existing lock/create_pr regressions still pass.
The #482 -> #479/#478 case motivates this: create_pr previously could not open a
stacked PR because the lock required a master-equivalent worktree.
Closes #484.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
178 lines
6.5 KiB
Python
178 lines
6.5 KiB
Python
"""Tests for issue-lock worktree validation (#249)."""
|
|
|
|
import os
|
|
import sys
|
|
import unittest
|
|
from unittest.mock import patch
|
|
|
|
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
|
|
|
import issue_lock_worktree # noqa: E402
|
|
|
|
|
|
class TestIssueLockWorktreeAssessment(unittest.TestCase):
|
|
def test_clean_base_branch_passes(self):
|
|
result = issue_lock_worktree.assess_issue_lock_worktree(
|
|
worktree_path="/scratch/wt",
|
|
current_branch="master",
|
|
porcelain_status="",
|
|
)
|
|
self.assertTrue(result["proven"])
|
|
self.assertFalse(result["block"])
|
|
|
|
def test_dirty_tracked_files_fail(self):
|
|
result = issue_lock_worktree.assess_issue_lock_worktree(
|
|
worktree_path="/scratch/wt",
|
|
current_branch="master",
|
|
porcelain_status=" M gitea_mcp_server.py\n",
|
|
)
|
|
self.assertFalse(result["proven"])
|
|
self.assertIn("tracked file edits exist before issue lock", result["reasons"][0])
|
|
|
|
def test_feature_branch_fails(self):
|
|
result = issue_lock_worktree.assess_issue_lock_worktree(
|
|
worktree_path="/scratch/wt",
|
|
current_branch="feat/issue-243-forbidden-git-gaps",
|
|
porcelain_status="",
|
|
)
|
|
self.assertFalse(result["proven"])
|
|
self.assertIn("base-equivalence could not be proven", result["reasons"][0])
|
|
|
|
def test_base_equivalent_feature_branch_passes(self):
|
|
result = issue_lock_worktree.assess_issue_lock_worktree(
|
|
worktree_path="/repo/branches/issue-275",
|
|
current_branch="feat/issue-275-claim-lock-branches-worktree",
|
|
porcelain_status="",
|
|
base_equivalent=True,
|
|
inspected_git_root="/repo/branches/issue-275",
|
|
base_branch="origin/master",
|
|
)
|
|
self.assertTrue(result["proven"])
|
|
self.assertFalse(result["block"])
|
|
self.assertEqual(result["base_branch"], "origin/master")
|
|
|
|
def test_non_base_equivalent_branch_fails(self):
|
|
result = issue_lock_worktree.assess_issue_lock_worktree(
|
|
worktree_path="/repo/branches/issue-275",
|
|
current_branch="feat/issue-275-claim-lock-branches-worktree",
|
|
porcelain_status="",
|
|
base_equivalent=False,
|
|
inspected_git_root="/repo/branches/issue-275",
|
|
base_branch=None,
|
|
)
|
|
self.assertFalse(result["proven"])
|
|
self.assertIn("must be base-equivalent", result["reasons"][0])
|
|
|
|
def test_untracked_files_do_not_block(self):
|
|
result = issue_lock_worktree.assess_issue_lock_worktree(
|
|
worktree_path="/scratch/wt",
|
|
current_branch="main",
|
|
porcelain_status="?? notes.txt\n",
|
|
)
|
|
self.assertTrue(result["proven"])
|
|
|
|
|
|
class TestStackedBaseEquivalence(unittest.TestCase):
|
|
"""extra_bases (an approved stacked base) can anchor base-equivalence (#484)."""
|
|
|
|
def _git(self, *args):
|
|
import subprocess
|
|
|
|
subprocess.run(
|
|
["git", "-C", self.repo, *args],
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
def setUp(self):
|
|
import subprocess
|
|
import tempfile
|
|
|
|
self.tmp = tempfile.TemporaryDirectory()
|
|
self.repo = self.tmp.name
|
|
subprocess.run(["git", "init", "-q", self.repo], check=True, capture_output=True)
|
|
self._git("config", "user.email", "t@t")
|
|
self._git("config", "user.name", "t")
|
|
self._git("commit", "--allow-empty", "-q", "-m", "base")
|
|
# Rename the default branch away from master/main/dev so no *base* branch
|
|
# exists at HEAD — otherwise HEAD would be base-equivalent for free.
|
|
self._git("branch", "-m", "trunk")
|
|
# Create a non-master "dependency" branch at the same commit, then a
|
|
# feature branch off it — mirrors a stacked worktree.
|
|
self._git("branch", "feat/issue-100-dep")
|
|
self._git("checkout", "-q", "-b", "feat/issue-101-stacked")
|
|
|
|
def tearDown(self):
|
|
self.tmp.cleanup()
|
|
|
|
def test_stacked_base_not_equivalent_without_extra_bases(self):
|
|
state = issue_lock_worktree.read_worktree_git_state(self.repo)
|
|
# HEAD does not match master/main/dev, so base-equivalence is False.
|
|
self.assertFalse(state["base_equivalent"])
|
|
|
|
def test_stacked_base_equivalent_with_extra_bases(self):
|
|
state = issue_lock_worktree.read_worktree_git_state(
|
|
self.repo, extra_bases=("feat/issue-100-dep",)
|
|
)
|
|
self.assertTrue(state["base_equivalent"])
|
|
self.assertEqual(state["base_branch"], "feat/issue-100-dep")
|
|
|
|
def test_unrelated_extra_base_does_not_anchor(self):
|
|
state = issue_lock_worktree.read_worktree_git_state(
|
|
self.repo, extra_bases=("feat/does-not-exist",)
|
|
)
|
|
self.assertFalse(state["base_equivalent"])
|
|
|
|
|
|
class TestIssueLockWorktreeResolution(unittest.TestCase):
|
|
def test_explicit_path_wins(self):
|
|
resolved = issue_lock_worktree.resolve_author_worktree_path(
|
|
"/tmp/scratch/wt", "/shared/dev"
|
|
)
|
|
self.assertEqual(resolved, os.path.realpath("/tmp/scratch/wt"))
|
|
|
|
def test_env_var_when_explicit_missing(self):
|
|
with patch.dict(os.environ, {"GITEA_AUTHOR_WORKTREE": "/tmp/from-env"}):
|
|
resolved = issue_lock_worktree.resolve_author_worktree_path(
|
|
None, "/shared/dev"
|
|
)
|
|
self.assertEqual(resolved, os.path.realpath("/tmp/from-env"))
|
|
|
|
def test_project_root_fallback(self):
|
|
resolved = issue_lock_worktree.resolve_author_worktree_path(
|
|
None, "/shared/dev"
|
|
)
|
|
self.assertEqual(resolved, os.path.realpath("/shared/dev"))
|
|
|
|
|
|
class TestPrWorktreeMatch(unittest.TestCase):
|
|
def test_matching_paths_pass(self):
|
|
result = issue_lock_worktree.verify_pr_worktree_matches_lock(
|
|
"/tmp/scratch/wt",
|
|
"/tmp/scratch/wt",
|
|
"/shared/dev",
|
|
)
|
|
self.assertTrue(result["proven"])
|
|
|
|
def test_mismatch_fails(self):
|
|
result = issue_lock_worktree.verify_pr_worktree_matches_lock(
|
|
"/tmp/scratch/wt",
|
|
"/shared/dev",
|
|
"/shared/dev",
|
|
)
|
|
self.assertFalse(result["proven"])
|
|
self.assertIn("does not match locked worktree", result["reasons"][0])
|
|
|
|
def test_missing_locked_path_skips_check(self):
|
|
result = issue_lock_worktree.verify_pr_worktree_matches_lock(
|
|
None,
|
|
"/any/path",
|
|
"/shared/dev",
|
|
)
|
|
self.assertTrue(result["proven"])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|