feat: align claim/lock gates with branches-only worktrees (#275)
Allow issue lock from base-equivalent branches/ worktrees instead of requiring the literal master/main branch name. Runtime preflight and mark_issue/lock_issue now inspect the declared active task workspace so dirty control-checkout state does not block clean task worktrees. Closes #275 Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
+81
-14
@@ -14,7 +14,7 @@ import subprocess
|
||||
from reviewer_worktree import parse_dirty_tracked_files
|
||||
|
||||
AUTHOR_WORKTREE_ENV = "GITEA_AUTHOR_WORKTREE"
|
||||
BASE_BRANCHES = frozenset({"master", "main"})
|
||||
BASE_BRANCHES = frozenset({"master", "main", "dev"})
|
||||
|
||||
|
||||
def resolve_author_worktree_path(
|
||||
@@ -50,9 +50,28 @@ def read_worktree_git_state(worktree_path: str) -> dict:
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
root_res = subprocess.run(
|
||||
["git", "-C", path, "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
head_res = subprocess.run(
|
||||
["git", "-C", path, "rev-parse", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
head_sha = (head_res.stdout or "").strip() if head_res.returncode == 0 else None
|
||||
base_branch, base_sha = _find_matching_base_ref(path, head_sha)
|
||||
return {
|
||||
"current_branch": current_branch,
|
||||
"porcelain_status": status_res.stdout or "",
|
||||
"inspected_git_root": (root_res.stdout or "").strip() if root_res.returncode == 0 else None,
|
||||
"head_sha": head_sha,
|
||||
"base_branch": base_branch,
|
||||
"base_sha": base_sha,
|
||||
"base_equivalent": bool(head_sha and base_sha and head_sha == base_sha),
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +80,9 @@ def assess_issue_lock_worktree(
|
||||
worktree_path: str,
|
||||
current_branch: str | None,
|
||||
porcelain_status: str,
|
||||
base_equivalent: bool | None = None,
|
||||
inspected_git_root: str | None = None,
|
||||
base_branch: str | None = None,
|
||||
base_branches: frozenset[str] | None = None,
|
||||
) -> dict:
|
||||
"""Fail closed when lock preconditions are not met on the declared worktree."""
|
||||
@@ -77,21 +99,39 @@ def assess_issue_lock_worktree(
|
||||
if dirty_files:
|
||||
reasons.append(
|
||||
"tracked file edits exist before issue lock; "
|
||||
"lock must precede implementation work"
|
||||
)
|
||||
if not branch:
|
||||
reasons.append(
|
||||
"current branch unknown (detached HEAD?); issue lock must be taken "
|
||||
f"from base branch ({_base_list(bases)})"
|
||||
)
|
||||
elif branch not in bases:
|
||||
reasons.append(
|
||||
f"issue lock must be taken from base branch ({_base_list(bases)}), "
|
||||
f"not '{branch}'"
|
||||
f"lock must precede implementation work in '{path}' "
|
||||
f"(dirty files: {', '.join(dirty_files)})"
|
||||
)
|
||||
|
||||
if base_equivalent is False:
|
||||
reasons.append(
|
||||
"issue lock worktree must be base-equivalent to one of "
|
||||
f"{_base_list(bases)} before implementation work; inspected "
|
||||
f"branch '{branch or '(detached)'}' at '{path}'"
|
||||
)
|
||||
elif base_equivalent is None:
|
||||
if not branch:
|
||||
reasons.append(
|
||||
"current branch unknown (detached HEAD?); issue lock base-equivalence "
|
||||
f"to {_base_list(bases)} could not be proven"
|
||||
)
|
||||
elif branch not in bases:
|
||||
reasons.append(
|
||||
"issue lock worktree base-equivalence could not be proven; "
|
||||
f"branch '{branch}' is not {_base_list(bases)}"
|
||||
)
|
||||
|
||||
proven = not reasons
|
||||
return _assessment(proven, reasons, path, branch or None, dirty_files)
|
||||
return _assessment(
|
||||
proven,
|
||||
reasons,
|
||||
path,
|
||||
branch or None,
|
||||
dirty_files,
|
||||
inspected_git_root=inspected_git_root,
|
||||
base_branch=base_branch,
|
||||
base_equivalent=base_equivalent,
|
||||
)
|
||||
|
||||
|
||||
def format_issue_lock_worktree_error(assessment: dict) -> str:
|
||||
@@ -145,12 +185,39 @@ def _assessment(
|
||||
worktree_path: str,
|
||||
current_branch: str | None,
|
||||
dirty_files: list[str],
|
||||
*,
|
||||
inspected_git_root: str | None = None,
|
||||
base_branch: str | None = None,
|
||||
base_equivalent: bool | None = None,
|
||||
) -> dict:
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": reasons,
|
||||
"worktree_path": worktree_path or None,
|
||||
"inspected_git_root": inspected_git_root,
|
||||
"current_branch": current_branch,
|
||||
"dirty_files": dirty_files,
|
||||
}
|
||||
"base_branch": base_branch,
|
||||
"base_equivalent": base_equivalent,
|
||||
}
|
||||
|
||||
|
||||
def _find_matching_base_ref(path: str, head_sha: str | None) -> tuple[str | None, str | None]:
|
||||
"""Return the stable branch ref whose commit matches HEAD, if any."""
|
||||
if not head_sha:
|
||||
return None, None
|
||||
candidates: list[str] = []
|
||||
for branch in sorted(BASE_BRANCHES):
|
||||
candidates.extend((f"origin/{branch}", branch))
|
||||
for ref in candidates:
|
||||
res = subprocess.run(
|
||||
["git", "-C", path, "rev-parse", "--verify", ref],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
sha = (res.stdout or "").strip()
|
||||
if res.returncode == 0 and sha == head_sha:
|
||||
return ref, sha
|
||||
return None, None
|
||||
|
||||
Reference in New Issue
Block a user