diff --git a/anti_stomp_preflight.py b/anti_stomp_preflight.py index f025f13..532db08 100644 --- a/anti_stomp_preflight.py +++ b/anti_stomp_preflight.py @@ -87,6 +87,7 @@ MUTATION_TASKS = frozenset({ "edit_pr", "commit_files", "gitea_commit_files", + "publish_unpublished_branch", "delete_branch", "cleanup_merged_pr_branch", "cleanup_stale_claims", diff --git a/branch_publish.py b/branch_publish.py new file mode 100644 index 0000000..a6d3025 --- /dev/null +++ b/branch_publish.py @@ -0,0 +1,591 @@ +"""Publish an unpublished local commit on a registered issue worktree (#812 AC20). + +Entry point B of #812 is the state where an author's work has already advanced +to a local commit: the worktree is registered, clean, on the issue branch, and +carries the only copy of the implementation, but the branch has never been +published. That state deadlocks, because two individually correct predicates +close a cycle: + +* ``issue_lock_renewal.assess_exact_owner_lease_renewal`` refuses to renew an + expired lease without an observable remote head — an unpublished branch has + none. +* Every publication path (``gitea_commit_files``, ``gitea_create_pr``) derives + its workspace from the author issue lock under #618, so nothing can create + that remote head without first holding the lock. + +This module supplies the missing operation: it publishes an *already committed* +local head to the remote branch, so exact-owner renewal has the evidence it +requires. It deliberately does **not** renew, reclaim, rebind, or clear any +lock. Publication is the whole of its authority. + +Why this is not a lock bypass +----------------------------- +The operation can only publish a branch whose **durable issue-lock record +already names the caller as claimant**. Ownership is read from the lock file on +disk (``issue_lock_store``), never from a caller-supplied flag, so the tool +cannot manufacture a claim it does not already hold. Nothing here weakens the +#510/#618/#713 guards: a dirty tree, an unregistered worktree, a foreign +claimant, a changed HEAD, or a divergent remote head each refuse, exactly as +they do today. The only thing this adds is the ability to make an existing, +owned, committed, clean branch observable on the remote. + +Separation of records (#812 AC23) +--------------------------------- +The durable **issue-lock file** and the control-plane **workflow lease** are +distinct records. This module reads the former as ownership evidence and writes +neither. Publishing changes remote git state only; no lock is renewed, +abandoned, reclaimed, or generation-bumped here. + +Process evidence (#812 AC24) +---------------------------- +Liveness of the lock's recorded pid is **not consulted**. That is deliberate: +the recorded pid routinely belongs to the long-running MCP daemon rather than to +an active author client, and the existing reclaim predicate +(``assess_expired_lock_reclaim``) can never be satisfied while that daemon runs. +Publication does not require the recording process to be dead, so this module +never asserts, infers, or depends on a process being dead. Ownership is proven +by identity and profile match against the recorded claimant instead. +""" + +from __future__ import annotations + +import hashlib +import os +import re +import subprocess + +from reviewer_worktree import parse_dirty_tracked_files +from stable_branch_push_guard import is_stable_ref, redact_command + +# Assessment outcomes. +PUBLISH_SANCTIONED = "publish_sanctioned" +ALREADY_PUBLISHED = "already_published" +REFUSED = "refused" + +#: Implementation branches must stay traceable to their issue (#713 lineage). +ISSUE_BRANCH_RE = re.compile(r"^(fix|feat|docs|chore)/issue-(\d+)-.+$") + +_SHA_RE = re.compile(r"^[0-9a-f]{40}$") + + +def _text(value: object) -> str: + return value.strip() if isinstance(value, str) else "" + + +def _realpath(value: str | None) -> str | None: + path = _text(value) + return os.path.realpath(path) if path else None + + +def parse_untracked_files(porcelain_status: str) -> list[str]: + """Return untracked paths from ``git status --porcelain`` output. + + ``reviewer_worktree.parse_dirty_tracked_files`` deliberately skips ``??`` + entries. Publication needs both halves: an untracked file in the worktree is + unpublished content that the commit does not carry, so publishing would + silently leave it behind. + """ + untracked: list[str] = [] + for line in (porcelain_status or "").splitlines(): + if not line.startswith("??"): + continue + path = line[2:].strip() + if path: + untracked.append(path) + return untracked + + +def hash_worktree_files(worktree_path: str, paths) -> dict[str, str | None]: + """SHA-256 each path under *worktree_path*; ``None`` when unreadable.""" + root = _text(worktree_path) + hashes: dict[str, str | None] = {} + for rel in paths or (): + rel_text = _text(rel) + if not rel_text: + continue + full = os.path.join(root, rel_text) + try: + with open(full, "rb") as handle: + digest = hashlib.sha256() + for chunk in iter(lambda: handle.read(65536), b""): + digest.update(chunk) + hashes[rel_text] = digest.hexdigest() + except OSError: + hashes[rel_text] = None + return hashes + + +def read_remote_branch_head( + worktree_path: str, remote_name: str, branch_name: str +) -> dict: + """Observe the remote head for *branch_name*, read-only. + + ``probe_ok`` False means git could not answer at all. That is kept distinct + from "the branch does not exist": an unobservable remote must fail closed + rather than be mistaken for an absent branch, because the two lead to + opposite dispositions. + """ + path = _text(worktree_path) + remote = _text(remote_name) + branch = _text(branch_name) + result: dict = { + "probe_ok": False, + "remote_branch_exists": False, + "remote_head_sha": None, + "reasons": [], + } + if not (path and remote and branch): + result["reasons"].append( + "remote head probe requires a worktree path, remote name, and branch" + ) + return result + try: + res = subprocess.run( + ["git", "-C", path, "ls-remote", remote, f"refs/heads/{branch}"], + capture_output=True, + text=True, + check=False, + ) + except OSError as exc: # git unavailable — fail closed, never assume absent + result["reasons"].append(f"remote head probe could not run: {exc}") + return result + if res.returncode != 0: + result["reasons"].append( + f"remote head probe failed for '{branch}' on remote '{remote}'" + ) + return result + + result["probe_ok"] = True + for line in (res.stdout or "").splitlines(): + parts = line.split() + if len(parts) >= 2 and parts[1] == f"refs/heads/{branch}": + result["remote_branch_exists"] = True + result["remote_head_sha"] = parts[0].strip() + break + return result + + +def read_is_ancestor( + worktree_path: str, ancestor_sha: str, descendant_sha: str +) -> dict: + """Observe whether *ancestor_sha* is an ancestor of *descendant_sha*.""" + path = _text(worktree_path) + ancestor = _text(ancestor_sha) + descendant = _text(descendant_sha) + result: dict = {"probe_ok": False, "is_ancestor": False, "reasons": []} + if not (path and ancestor and descendant): + result["reasons"].append( + "ancestry probe requires a worktree path and both commit SHAs" + ) + return result + try: + present = subprocess.run( + ["git", "-C", path, "rev-parse", "--verify", "--quiet", + f"{ancestor}^{{commit}}"], + capture_output=True, text=True, check=False, + ) + if present.returncode != 0: + result["reasons"].append( + f"remote head {ancestor} is not present locally, so it cannot be " + "proven an ancestor of the commit being published" + ) + return result + res = subprocess.run( + ["git", "-C", path, "merge-base", "--is-ancestor", ancestor, descendant], + capture_output=True, text=True, check=False, + ) + except OSError as exc: + result["reasons"].append(f"ancestry probe could not run: {exc}") + return result + result["probe_ok"] = res.returncode in (0, 1) + result["is_ancestor"] = res.returncode == 0 + return result + + +def assess_unpublished_commit_publication( + existing_lock, + *, + issue_number: int, + branch_name: str, + worktree_path: str, + expected_head: str, + remote: str, + org: str, + repo: str, + identity: str | None, + profile: str | None, + worktree_state, + worktree_registered: bool | None = None, + remote_probe=None, + ancestry=None, + competing_open_prs=(), + expected_file_hashes=None, + observed_file_hashes=None, +) -> dict: + """Decide whether an unpublished local commit may be published (#812 AC20). + + Pure predicate. Every input is either a caller-declared expectation that + must be *matched* against observation, or a server-side observation. No + caller-supplied boolean is accepted as proof of ownership, liveness, or + eligibility: ``existing_lock`` comes from the durable lock file and the + git/PR state is observed by the server. + + The single mutating disposition it can return is "publish this exact commit + to this exact branch". It never sanctions renewal, reclamation, force + updates, history rewriting, or publication of uncommitted content. + """ + reasons: list[str] = [] + branch = _text(branch_name) + head = _text(expected_head).lower() + workspace = _realpath(worktree_path) + state = worktree_state if isinstance(worktree_state, dict) else {} + lock = existing_lock if isinstance(existing_lock, dict) else None + + evidence: dict = { + "issue_number": issue_number, + "branch_name": branch or None, + "worktree_path": workspace, + "expected_head": head or None, + "remote": _text(remote) or None, + "org": _text(org) or None, + "repo": _text(repo) or None, + "identity": _text(identity) or None, + "profile": _text(profile) or None, + "lock_record_present": lock is not None, + "recorded_claimant": None, + "recorded_branch": None, + "recorded_worktree": None, + "lock_generation": None, + "local_head_sha": _text(state.get("head_sha")) or None, + "current_branch": _text(state.get("current_branch")) or None, + "dirty_tracked_files": [], + "untracked_files": [], + "worktree_registered": worktree_registered, + "remote_branch_exists": None, + "remote_head_sha": None, + "fast_forward_from_remote": None, + "competing_open_prs": [], + "file_hashes_verified": None, + "hash_mismatches": [], + # Recorded explicitly so no reader mistakes silence for a liveness + # claim, and so the audit shows which records were left alone (AC23/AC24). + "owner_pid_liveness_consulted": False, + "workflow_lease_touched": False, + "issue_lock_record_mutated": False, + } + + # ── declared shape ──────────────────────────────────────────────────── + if not branch: + reasons.append("branch name not declared; fail closed") + if not head: + reasons.append( + "expected_head not declared; publication must name the exact commit" + ) + elif not _SHA_RE.match(head): + reasons.append( + f"expected_head '{head}' is not a full 40-character commit SHA; " + "abbreviated or symbolic revisions are refused" + ) + if not workspace: + reasons.append("worktree path not declared; fail closed") + + if branch: + match = ISSUE_BRANCH_RE.match(branch) + if not match: + reasons.append( + f"branch '{branch}' is not an issue-linked implementation branch " + "((fix|feat|docs|chore)/issue--); fail closed" + ) + elif int(match.group(2)) != int(issue_number): + reasons.append( + f"branch '{branch}' does not carry issue number {issue_number}; " + "fail closed" + ) + if is_stable_ref(branch): + reasons.append( + f"refusing to publish stable branch '{branch}'; this operation " + "publishes issue branches only" + ) + + # ── ownership: durable issue-lock record only (AC8, AC20, AC24) ─────── + if lock is None: + reasons.append( + "no durable issue-lock record for this issue; publication requires an " + "existing recorded claim naming the caller, so this operation cannot " + "be used to bypass the author lock" + ) + else: + lease = lock.get("work_lease") + lease = lease if isinstance(lease, dict) else {} + claimant = lease.get("claimant") + claimant = claimant if isinstance(claimant, dict) else {} + recorded_user = _text(claimant.get("username")) + recorded_profile = _text(claimant.get("profile")) + recorded_branch = _text(lock.get("branch_name")) or _text(lease.get("branch")) + recorded_worktree = _realpath( + _text(lock.get("worktree_path")) or _text(lease.get("worktree_path")) + ) + evidence["recorded_claimant"] = { + "username": recorded_user or None, + "profile": recorded_profile or None, + } + evidence["recorded_branch"] = recorded_branch or None + evidence["recorded_worktree"] = recorded_worktree + try: + evidence["lock_generation"] = int(lock.get("lock_generation") or 0) + except (TypeError, ValueError): + evidence["lock_generation"] = 0 + + try: + recorded_issue = int(lock.get("issue_number") or 0) + except (TypeError, ValueError): + recorded_issue = 0 + if recorded_issue != int(issue_number): + reasons.append( + f"durable lock records issue {lock.get('issue_number')}, not " + f"{issue_number}; ambiguous ownership, fail closed" + ) + for field, declared in ( + ("remote", _text(remote)), + ("org", _text(org)), + ("repo", _text(repo)), + ): + recorded = _text(lock.get(field)) + if recorded and declared and recorded != declared: + reasons.append( + f"durable lock records {field} '{recorded}' but the request " + f"declares '{declared}'; repository mismatch, fail closed" + ) + if recorded_branch and branch and recorded_branch != branch: + reasons.append( + f"durable lock records branch '{recorded_branch}' but the request " + f"declares '{branch}'; fail closed" + ) + if recorded_worktree and workspace and recorded_worktree != workspace: + reasons.append( + f"durable lock records worktree '{recorded_worktree}' but the " + f"request declares '{workspace}'; fail closed" + ) + if not recorded_user or not recorded_profile: + reasons.append( + "durable lock does not record a claimant username and profile; " + "ownership cannot be proven, fail closed" + ) + else: + if recorded_user != _text(identity): + reasons.append( + f"durable lock claimant '{recorded_user}' is not the acting " + f"identity '{_text(identity) or '(unknown)'}'; foreign claim, " + "fail closed" + ) + if recorded_profile != _text(profile): + reasons.append( + f"durable lock claimant profile '{recorded_profile}' is not " + f"the active profile '{_text(profile) or '(unknown)'}'; " + "fail closed" + ) + + # ── worktree: registered, on-branch, clean, at the expected commit ──── + if worktree_registered is False: + reasons.append( + f"worktree '{workspace}' is not listed in git worktree list; #713 " + "requires a genuinely registered worktree, fail closed" + ) + + current_branch = _text(state.get("current_branch")) + if not current_branch: + reasons.append("worktree branch could not be observed; fail closed") + elif branch and current_branch != branch: + reasons.append( + f"worktree is on branch '{current_branch}', not '{branch}'; fail closed" + ) + + porcelain = state.get("porcelain_status") or "" + dirty_tracked = parse_dirty_tracked_files(porcelain) + untracked = parse_untracked_files(porcelain) + evidence["dirty_tracked_files"] = dirty_tracked + evidence["untracked_files"] = untracked + if dirty_tracked: + reasons.append( + "worktree has dirty tracked files, so the commit is not the whole of " + f"the work: {', '.join(dirty_tracked)}. This operation publishes an " + "existing clean commit only; uncommitted content is out of scope" + ) + if untracked: + reasons.append( + "worktree has untracked files that the commit does not carry: " + f"{', '.join(untracked)}. Publishing would silently leave them " + "behind; fail closed" + ) + + local_head = _text(state.get("head_sha")).lower() + if not local_head: + reasons.append("local HEAD could not be observed; fail closed") + elif head and local_head != head: + reasons.append( + f"worktree HEAD is {local_head} but the request declares {head}; the " + "local commit changed since it was recorded, fail closed" + ) + + # ── remote state ────────────────────────────────────────────────────── + probe = remote_probe if isinstance(remote_probe, dict) else {} + already_published = False + if not probe.get("probe_ok"): + reasons.append( + "remote branch head could not be observed; publication must not " + "proceed against an unknown remote state, fail closed" + ) + reasons.extend(probe.get("reasons") or []) + else: + remote_exists = bool(probe.get("remote_branch_exists")) + remote_head = _text(probe.get("remote_head_sha")).lower() or None + evidence["remote_branch_exists"] = remote_exists + evidence["remote_head_sha"] = remote_head + if remote_exists and remote_head and head: + if remote_head == head: + already_published = True + evidence["fast_forward_from_remote"] = True + else: + anc = ancestry if isinstance(ancestry, dict) else {} + is_anc = bool(anc.get("probe_ok")) and bool(anc.get("is_ancestor")) + evidence["fast_forward_from_remote"] = is_anc + if not is_anc: + reasons.append( + f"remote branch '{branch}' already exists at {remote_head}, " + f"which is not an ancestor of {head}; publishing would " + "discard or rewrite published history, fail closed" + ) + reasons.extend(anc.get("reasons") or []) + elif remote_exists and not remote_head: + reasons.append( + f"remote branch '{branch}' exists but its head could not be read; " + "fail closed" + ) + + # ── competing claims ────────────────────────────────────────────────── + competing = [p for p in (competing_open_prs or ()) if p] + evidence["competing_open_prs"] = list(competing) + if competing: + reasons.append( + f"open pull request(s) {competing} already claim issue {issue_number} " + "or this branch; ambiguous ownership, fail closed" + ) + + # ── content verification before publication ─────────────────────────── + if expected_file_hashes: + observed = ( + observed_file_hashes if isinstance(observed_file_hashes, dict) else {} + ) + mismatches: list[str] = [] + for path, expected_digest in dict(expected_file_hashes).items(): + actual = observed.get(path) + if actual is None: + mismatches.append(f"{path}: missing or unreadable in the worktree") + elif _text(actual).lower() != _text(expected_digest).lower(): + mismatches.append( + f"{path}: expected {expected_digest}, observed {actual}" + ) + evidence["hash_mismatches"] = mismatches + evidence["file_hashes_verified"] = not mismatches + if mismatches: + reasons.append( + "declared content hashes do not match the worktree: " + + "; ".join(mismatches) + + ". Refusing to publish content that is not what was recorded" + ) + + if reasons: + return { + "outcome": REFUSED, + "publish_sanctioned": False, + "already_published": False, + "reasons": reasons, + "evidence": evidence, + } + return { + "outcome": ALREADY_PUBLISHED if already_published else PUBLISH_SANCTIONED, + # Idempotent retry: a remote head that already equals the assessed commit + # needs no second push, so the caller verifies instead of acting. + "publish_sanctioned": not already_published, + "already_published": already_published, + "reasons": [], + "evidence": evidence, + } + + +def publish_commit_to_remote_branch( + *, + worktree_path: str, + remote_name: str, + branch_name: str, + expected_head: str, +) -> dict: + """Send exactly *expected_head* to ``refs/heads/``. + + The refspec names the commit SHA explicitly rather than ``HEAD`` or the + local branch, so what lands is the commit that was assessed and nothing + else. No force, no lease, no ``+`` prefix: a non-fast-forward is rejected by + git itself, the last of several independent guards against overwriting + published history. + """ + path = _text(worktree_path) + remote = _text(remote_name) + branch = _text(branch_name) + head = _text(expected_head) + result: dict = { + "success": False, + "pushed_ref": f"refs/heads/{branch}" if branch else None, + "pushed_sha": head or None, + "stderr": None, + "reasons": [], + } + if not (path and remote and branch and head): + result["reasons"].append( + "publication requires a worktree path, remote, branch, and commit SHA" + ) + return result + + refspec = f"{head}:refs/heads/{branch}" + try: + res = subprocess.run( + ["git", "-C", path, "push", remote, refspec], + capture_output=True, + text=True, + check=False, + ) + except OSError as exc: + result["reasons"].append(f"publication could not run: {exc}") + return result + + if res.returncode != 0: + # Redact before surfacing: failures can echo credentialed remote URLs. + result["stderr"] = redact_command(res.stderr or "") + result["reasons"].append( + f"publication of {head} to '{branch}' on remote '{remote}' failed" + ) + return result + + result["success"] = True + return result + + +def verify_published_head( + *, worktree_path: str, remote_name: str, branch_name: str, expected_head: str +) -> dict: + """Read-after-write: confirm the remote head equals *expected_head* (AC20).""" + probe = read_remote_branch_head(worktree_path, remote_name, branch_name) + head = _text(expected_head).lower() + observed = _text(probe.get("remote_head_sha")).lower() or None + verified = bool(head) and bool(probe.get("probe_ok")) and observed == head + reasons: list[str] = list(probe.get("reasons") or []) + if probe.get("probe_ok") and not verified: + reasons.append( + "read-after-write verification failed: remote head is " + f"{observed or '(absent)'}, expected {head}" + ) + return { + "verified": verified, + "remote_head_sha": observed, + "expected_head": head or None, + "reasons": reasons, + } diff --git a/docs/mcp-tool-inventory.md b/docs/mcp-tool-inventory.md index a44a6c7..8cb865b 100644 --- a/docs/mcp-tool-inventory.md +++ b/docs/mcp-tool-inventory.md @@ -120,6 +120,7 @@ that gates each call, not which tools exist. - `gitea_observability_list_projects` - `gitea_observability_reconcile_incident` - `gitea_post_heartbeat` +- `gitea_publish_unpublished_issue_branch` - `gitea_quarantine_contaminated_review` - `gitea_reclaim_expired_workflow_lease` - `gitea_reconcile_already_landed_pr` diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py index 8223f4e..f71360b 100644 --- a/gitea_mcp_server.py +++ b/gitea_mcp_server.py @@ -2024,6 +2024,7 @@ import review_quarantine # noqa: E402 # #695 contaminated formal-review quaran import mcp_daemon_guard # noqa: E402 # #695 native transport provenance import already_landed_reconcile # noqa: E402 import author_mutation_worktree # noqa: E402 +import branch_publish # noqa: E402 # #812 AC20 unpublished-commit publication import root_checkout_guard # noqa: E402 import workflow_scope_guard # noqa: E402 # #683 production scope / force-on guards import stable_branch_push_guard # noqa: E402 @@ -9082,6 +9083,237 @@ def gitea_commit_files( } +def _publication_block(reasons: list[str], **extra) -> dict: + """Uniform fail-closed shape for publication refusals (#812 AC20).""" + payload = { + "success": False, + "performed": False, + "published": False, + "verified": False, + "outcome": branch_publish.REFUSED, + "reasons": reasons, + # State every record this operation left alone, so a refusal can never + # be misread as a lock mutation (#812 AC23). + "issue_lock_record_mutated": False, + "workflow_lease_touched": False, + } + payload.update(extra) + return payload + + +@mcp.tool() +def gitea_publish_unpublished_issue_branch( + issue_number: int, + branch_name: str, + worktree_path: str, + expected_head: str, + remote: str = "dadeschools", + host: str | None = None, + org: str | None = None, + repo: str | None = None, + git_remote_name: str | None = None, + expected_file_hashes: dict | None = None, + dry_run: bool = False, +) -> dict: + """Publish an already-committed, unpublished issue branch (#812 AC20). + + Creates the remote head for a branch whose work is *already* a local commit + on a registered, clean worktree, so exact-owner lease renewal + (``issue_lock_renewal``) has the published head its evidence model requires. + This is the one step of the entry point B deadlock that no existing tool can + perform: publication is otherwise lock-derived under #618, and the lock + itself is withheld until a remote head exists. + + Not a lock bypass. The branch's **durable issue-lock record must already + name the caller as claimant** — ownership is read from the lock file, never + asserted by the caller — so this can only publish work the caller already + owns. It refuses a dirty or untracked-carrying worktree, an unregistered + worktree, a changed local HEAD, a remote head that is not an ancestor of the + commit, a competing open PR on another branch for the same issue, and any + declared-hash mismatch. It renews, reclaims, and clears nothing: the durable + issue-lock file and the control-plane workflow lease are both left untouched + (#812 AC23), and the recorded owner pid's liveness is never consulted or + asserted (#812 AC24). + + Args: + issue_number: The issue whose recorded claim authorizes publication. + branch_name: Issue branch to publish, ``(fix|feat|docs|chore)/issue-N-…``. + worktree_path: Registered worktree holding the commit. + expected_head: Full 40-character SHA of the commit to publish. Required: + publication names the exact commit, and a mismatch fails closed. + remote: Known instance — 'dadeschools' or 'prgs'. + host: Override the Gitea host. + org: Override the owner/organization. + repo: Override the repository name. + git_remote_name: Git remote to publish to; defaults to *remote*. + expected_file_hashes: Optional ``{path: sha256}`` verified against the + worktree before publication. Any mismatch or missing file refuses. + dry_run: Report the decision and evidence, mutate nothing. + + Returns: + dict with 'success', 'performed', 'published', 'verified', 'outcome', + 'remote_head_sha', 'reasons', and 'evidence'. + """ + task = "publish_unpublished_branch" + ok, block_reasons = role_session_router.check_author_mutation_after_reviewer_stop( + task + ) + if not ok: + return _publication_block(block_reasons) + + blocked = _namespace_mutation_block(task, remote=remote) + if blocked: + return blocked + blocked = _profile_permission_block( + task_capability_map.required_permission(task), + remote=remote, host=host, org=org, repo=repo, + org_explicit=org is not None, + repo_explicit=repo is not None, + ) + if blocked: + return blocked + + verify_preflight_purity(remote, task=task, org=org, repo=repo) + + h, o, r = _resolve(remote, host, org, repo) + git_remote = (git_remote_name or remote or "").strip() + workspace = os.path.realpath(os.path.abspath((worktree_path or "").strip() or ".")) + + existing_lock = issue_lock_store.load_issue_lock( + remote=remote, org=o, repo=r, issue_number=int(issue_number) + ) + git_state = issue_lock_worktree.read_worktree_git_state(workspace) + registered = author_mutation_worktree.path_in_git_worktree_list( + workspace, PROJECT_ROOT + ) + + remote_probe = branch_publish.read_remote_branch_head( + workspace, git_remote, branch_name + ) + ancestry = None + probe_head = (remote_probe.get("remote_head_sha") or "").strip() + if probe_head and probe_head.lower() != (expected_head or "").strip().lower(): + ancestry = branch_publish.read_is_ancestor(workspace, probe_head, expected_head) + + # A PR on this very branch is this work's own PR, not a rival claim. Only an + # open PR for the same issue on a *different* branch is a competing claim. + competing: list = [] + for pull in _list_open_pulls(h, o, r, _auth(h)): + ref = str((pull.get("head") or {}).get("ref") or "") + if not ref or ref == branch_name: + continue + if issue_lock_adoption.branch_carries_issue_marker(ref, int(issue_number)): + competing.append(pull.get("number")) + + observed_hashes = None + if expected_file_hashes: + observed_hashes = branch_publish.hash_worktree_files( + workspace, list(expected_file_hashes.keys()) + ) + + claimant = _work_lease_claimant(h) + assessment = branch_publish.assess_unpublished_commit_publication( + existing_lock, + issue_number=int(issue_number), + branch_name=branch_name, + worktree_path=workspace, + expected_head=expected_head, + remote=remote, + org=o, + repo=r, + identity=claimant.get("username"), + profile=claimant.get("profile"), + worktree_state=git_state, + worktree_registered=registered, + remote_probe=remote_probe, + ancestry=ancestry, + competing_open_prs=competing, + expected_file_hashes=expected_file_hashes, + observed_file_hashes=observed_hashes, + ) + + if assessment["outcome"] == branch_publish.REFUSED: + return _publication_block( + assessment["reasons"], evidence=assessment["evidence"] + ) + + if dry_run: + return { + "success": True, + "performed": False, + "published": False, + "verified": False, + "dry_run": True, + "outcome": assessment["outcome"], + "would_publish": assessment["publish_sanctioned"], + "remote_head_sha": assessment["evidence"].get("remote_head_sha"), + "reasons": [], + "evidence": assessment["evidence"], + "issue_lock_record_mutated": False, + "workflow_lease_touched": False, + } + + performed = False + if assessment["publish_sanctioned"]: + with _audited( + task, host=h, remote=remote, org=o, repo=r, + target_branch=branch_name, + request_metadata={ + "issue_number": int(issue_number), + "expected_head": expected_head, + "git_remote": git_remote, + }, + ): + push = branch_publish.publish_commit_to_remote_branch( + worktree_path=workspace, + remote_name=git_remote, + branch_name=branch_name, + expected_head=expected_head, + ) + if not push.get("success"): + return _publication_block( + push.get("reasons") or ["publication failed"], + evidence=assessment["evidence"], + stderr=push.get("stderr"), + ) + performed = True + + verification = branch_publish.verify_published_head( + worktree_path=workspace, + remote_name=git_remote, + branch_name=branch_name, + expected_head=expected_head, + ) + if not verification.get("verified"): + return _publication_block( + verification.get("reasons") or ["read-after-write verification failed"], + evidence=assessment["evidence"], + performed=performed, + published=performed, + remote_head_sha=verification.get("remote_head_sha"), + ) + + return { + "success": True, + "performed": performed, + "published": True, + "verified": True, + "outcome": assessment["outcome"], + "remote_head_sha": verification.get("remote_head_sha"), + "reasons": [], + "evidence": assessment["evidence"], + # Publication is the whole of this operation's authority (#812 AC23). + "issue_lock_record_mutated": False, + "workflow_lease_touched": False, + "exact_next_action": ( + f"Remote head for '{branch_name}' is now observable. Call " + f"gitea_lock_issue(issue_number={int(issue_number)}, " + f"branch_name='{branch_name}', worktree_path='{workspace}') to renew " + "the exact-owner lease, then gitea_create_pr." + ), + } + + # Merge methods supported by the Gitea merge API. _MERGE_METHODS = ("merge", "squash", "rebase") diff --git a/task_capability_map.py b/task_capability_map.py index 23409e0..0a21063 100644 --- a/task_capability_map.py +++ b/task_capability_map.py @@ -62,6 +62,14 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = { "permission": "gitea.branch.push", "role": "author", }, + # #812 AC20: publish an already-committed, unpublished local head so + # exact-owner lease renewal has an observable remote head to reason about. + # Same authority as any other author push — deliberately not a new + # operation name, so it cannot widen an already-configured author profile. + "publish_unpublished_branch": { + "permission": "gitea.branch.push", + "role": "author", + }, "create_pr": { "permission": "gitea.pr.create", "role": "author", @@ -500,6 +508,7 @@ ROLE_EXCLUSIVE_TASKS: frozenset[str] = frozenset( "gitea_release_merger_pr_lease", "create_branch", "push_branch", + "publish_unpublished_branch", "create_pr", "commit_files", "gitea_commit_files", diff --git a/tests/test_issue_812_publish_unpublished_commit.py b/tests/test_issue_812_publish_unpublished_commit.py new file mode 100644 index 0000000..80b8d54 --- /dev/null +++ b/tests/test_issue_812_publish_unpublished_commit.py @@ -0,0 +1,650 @@ +"""Publication of an unpublished local commit (#812 AC20). + +Entry point B of #812: a registered worktree, clean, on its issue branch, +holding a local commit that has never been published. Exact-owner lease renewal +refuses such a claim for want of an observable remote head, and every existing +publication path is lock-derived, so the two predicates close a cycle around +work that is otherwise complete. + +These tests exercise the disposition through its *evidence*, never through any +particular issue number: every case uses an arbitrary issue number against a +synthetic repository, and the same assertions hold for any other. Nothing here +reads, writes, or references the live protected worktree named in #812 AC17 — +that content is preserved evidence for the duration of this work, so the +fixtures below build their own repositories from scratch. + +The remote is a local bare repository, so publication and read-after-write +verification are genuinely executed rather than mocked. +""" + +from __future__ import annotations + +import os +import subprocess +import sys +import tempfile +import unittest +from datetime import datetime, timedelta, timezone +from unittest.mock import patch + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import branch_publish # noqa: E402 +import issue_lock_provenance # noqa: E402 +import issue_lock_renewal # noqa: E402 +import issue_lock_store # noqa: E402 +import mcp_server # noqa: E402 +from mutation_profile_fixture import shared_mutation_env # noqa: E402 + +ISSUE = 9812 +BRANCH = f"feat/issue-{ISSUE}-publish-fixture" +IDENTITY = "example-user" +PROFILE = "test-author-prgs" +ORG = "Scaled-Tech-Consulting" +REPO = "Gitea-Tools" +GIT_REMOTE = "prgs" + + +def _ts(hours: int) -> str: + return ( + (datetime.now(timezone.utc) + timedelta(hours=hours)) + .isoformat() + .replace("+00:00", "Z") + ) + + +class _PublishBase(unittest.TestCase): + """Real git repo + real bare remote + durable lock naming the caller. + + The recorded owner pid is deliberately **this live process**. That mirrors + the production shape #812 documents, where the pid belongs to a long-running + MCP daemon rather than to a dead author client, and it proves publication + never depends on a dead process (#812 AC24). + """ + + def setUp(self): + self.lock_dir = tempfile.TemporaryDirectory() + self.addCleanup(self.lock_dir.cleanup) + self.origin = tempfile.mkdtemp(prefix="issue812-origin-") + self.repo = tempfile.mkdtemp(prefix="issue812-work-") + for path in (self.origin, self.repo): + self.addCleanup( + lambda p=path: subprocess.run(["rm", "-rf", p], check=False) + ) + self._init_repos() + self.remotes = patch.dict( + mcp_server.REMOTES, + {"prgs": {"host": "gitea.prgs.cc", "org": ORG, "repo": REPO}}, + ) + self.remotes.start() + self.addCleanup(patch.stopall) + mcp_server._IDENTITY_CACHE.clear() + + # ── fixture construction ───────────────────────────────────────────── + def _git(self, *args, cwd=None): + return subprocess.run( + ["git", "-C", cwd or self.repo, *args], + capture_output=True, + text=True, + check=True, + ) + + def _init_repos(self): + subprocess.run( + ["git", "init", "-q", "--bare", "-b", "master", self.origin], check=True + ) + self._git("init", "-q", "-b", "master") + self._git("config", "user.email", "test@example.com") + self._git("config", "user.name", "Test") + self._git("remote", "add", GIT_REMOTE, self.origin) + + with open(os.path.join(self.repo, "seed.txt"), "w") as fh: + fh.write("seed\n") + self._git("add", "seed.txt") + self._git("commit", "-q", "-m", "seed") + self.base_sha = self._git("rev-parse", "HEAD").stdout.strip() + self._git("push", "-q", GIT_REMOTE, "master") + + self._git("checkout", "-q", "-b", BRANCH) + with open(os.path.join(self.repo, "work.txt"), "w") as fh: + fh.write("unpublished implementation\n") + self._git("add", "work.txt") + self._git("commit", "-q", "-m", "unpublished implementation") + self.head_sha = self._git("rev-parse", "HEAD").stdout.strip() + self.worktree = os.path.realpath(self.repo) + + def lock_path(self): + return issue_lock_store.lock_file_path( + remote="prgs", org=ORG, repo=REPO, issue_number=ISSUE, + lock_dir=self.lock_dir.name, + ) + + def write_lock(self, **overrides): + path = self.lock_path() + claimant = overrides.pop( + "claimant", {"username": IDENTITY, "profile": PROFILE} + ) + pid = overrides.pop("session_pid", os.getpid()) + lease = { + "operation_type": issue_lock_store.AUTHOR_ISSUE_WORK_LEASE, + "issue_number": ISSUE, + "pr_number": None, + "branch": overrides.get("branch_name", BRANCH), + "worktree_path": overrides.get("worktree_path", self.worktree), + "claimant": claimant, + "created_at": _ts(-2), + "last_heartbeat_at": _ts(-2), + # Expired: entry point B's lease has lapsed, which is precisely why + # renewal — and therefore a published head — is needed. + "expires_at": _ts(-1), + } + lease.update(overrides.pop("work_lease", {})) + data = { + "issue_number": ISSUE, + "branch_name": BRANCH, + "remote": "prgs", + "org": ORG, + "repo": REPO, + "worktree_path": self.worktree, + "session_pid": pid, + "pid": pid, + "lock_generation": 1, + "work_lease": lease, + "lock_provenance": issue_lock_provenance.build_sanctioned_lock_provenance( + tool="gitea_lock_issue", claimant=claimant + ), + } + data.update(overrides) + data["lock_file_path"] = path + issue_lock_store.save_lock_file(path, data) + return path + + def _tool_env(self): + env = shared_mutation_env( + PROFILE, include_example_repo=True, + GITEA_ISSUE_LOCK_DIR=self.lock_dir.name, + ) + env["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir.name + # These tests repoint PROJECT_ROOT at a synthetic repository so the + # registered-worktree proof runs for real. Pin the parity gate to the + # server's own startup head so that repointing does not read as a stale + # daemon; the gate itself stays live and enforced. + startup_head = mcp_server._STARTUP_PARITY.get("startup_head") or "" + env["GITEA_TEST_CURRENT_HEAD"] = startup_head + env["GITEA_TEST_LIVE_REMOTE_HEAD"] = startup_head + return env + + # ── tool driver ────────────────────────────────────────────────────── + def run_publish(self, *, open_prs=None, expected_head=None, **kwargs): + """Drive the public publication tool against the synthetic fixture.""" + env = self._tool_env() + with patch( + "mcp_server._list_open_pulls", return_value=list(open_prs or []) + ), patch( + "mcp_server._auth", return_value="token x" + ), patch( + "mcp_server.get_auth_header", return_value="token x" + ), patch( + "mcp_server._work_lease_claimant", + return_value={"username": IDENTITY, "profile": PROFILE}, + ), patch.object( + mcp_server, "PROJECT_ROOT", self.repo + ), patch.dict(os.environ, env, clear=True): + os.environ["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir.name + return mcp_server.gitea_publish_unpublished_issue_branch( + issue_number=kwargs.pop("issue_number", ISSUE), + branch_name=kwargs.pop("branch_name", BRANCH), + worktree_path=kwargs.pop("worktree_path", self.worktree), + expected_head=expected_head or self.head_sha, + remote="prgs", + git_remote_name=kwargs.pop("git_remote_name", GIT_REMOTE), + **kwargs, + ) + + def remote_head(self, branch=BRANCH): + res = subprocess.run( + ["git", "-C", self.origin, "rev-parse", "--verify", "--quiet", branch], + capture_output=True, text=True, check=False, + ) + return (res.stdout or "").strip() or None + + +class TestSuccessfulPublication(_PublishBase): + """AC20 — the branch becomes observable and is verified after the write.""" + + def test_publishes_clean_unpublished_commit(self): + self.write_lock() + self.assertIsNone(self.remote_head(), "fixture must start unpublished") + + result = self.run_publish() + + self.assertTrue(result["success"], result.get("reasons")) + self.assertTrue(result["performed"]) + self.assertTrue(result["published"]) + self.assertTrue(result["verified"], "read-after-write must be proven") + self.assertEqual(result["remote_head_sha"], self.head_sha) + self.assertEqual(self.remote_head(), self.head_sha) + + def test_publication_does_not_rewrite_the_commit(self): + self.write_lock() + self.run_publish() + # The published object is the same commit, not a copy or a rewrite. + self.assertEqual(self.remote_head(), self.head_sha) + self.assertEqual( + self._git("rev-parse", "HEAD").stdout.strip(), self.head_sha + ) + + def test_exact_next_action_names_the_lock_call(self): + self.write_lock() + result = self.run_publish() + self.assertIn("gitea_lock_issue", result["exact_next_action"]) + + +class TestFailsClosed(_PublishBase): + """AC20/AC9 — each refusal reason, exercised independently.""" + + def test_changed_local_head_refuses(self): + self.write_lock() + stale = self.base_sha # a real commit, but not the declared head + result = self.run_publish(expected_head=stale) + self.assertFalse(result["success"]) + self.assertTrue( + any("local commit changed" in r for r in result["reasons"]), + result["reasons"], + ) + self.assertIsNone(self.remote_head(), "refusal must not publish") + + def test_abbreviated_sha_refuses(self): + self.write_lock() + result = self.run_publish(expected_head=self.head_sha[:8]) + self.assertFalse(result["success"]) + self.assertTrue( + any("40-character" in r for r in result["reasons"]), result["reasons"] + ) + + def test_dirty_tracked_worktree_refuses(self): + self.write_lock() + with open(os.path.join(self.repo, "work.txt"), "a") as fh: + fh.write("uncommitted edit\n") + + result = self.run_publish() + + self.assertFalse(result["success"]) + self.assertTrue( + any("dirty tracked files" in r for r in result["reasons"]), + result["reasons"], + ) + self.assertIn("work.txt", result["evidence"]["dirty_tracked_files"]) + self.assertIsNone(self.remote_head()) + + def test_untracked_file_refuses(self): + self.write_lock() + with open(os.path.join(self.repo, "stray.txt"), "w") as fh: + fh.write("not committed\n") + + result = self.run_publish() + + self.assertFalse(result["success"]) + self.assertTrue( + any("untracked files" in r for r in result["reasons"]), result["reasons"] + ) + self.assertIn("stray.txt", result["evidence"]["untracked_files"]) + self.assertIsNone(self.remote_head()) + + def test_unexpected_remote_head_refuses(self): + """A remote head that is not an ancestor must never be overwritten.""" + self.write_lock() + # Publish a divergent commit to the branch from a separate line. + self._git("checkout", "-q", "-b", "divergent", self.base_sha) + with open(os.path.join(self.repo, "other.txt"), "w") as fh: + fh.write("someone else's work\n") + self._git("add", "other.txt") + self._git("commit", "-q", "-m", "divergent") + divergent = self._git("rev-parse", "HEAD").stdout.strip() + self._git("push", "-q", GIT_REMOTE, f"{divergent}:refs/heads/{BRANCH}") + self._git("checkout", "-q", BRANCH) + + result = self.run_publish() + + self.assertFalse(result["success"]) + self.assertTrue( + any("not an ancestor" in r for r in result["reasons"]), result["reasons"] + ) + self.assertEqual( + self.remote_head(), divergent, "the other head must survive intact" + ) + + def test_fast_forward_remote_head_is_allowed(self): + """An ancestor head is an honest fast-forward, not a conflict.""" + self.write_lock() + self._git("push", "-q", GIT_REMOTE, f"{self.base_sha}:refs/heads/{BRANCH}") + + result = self.run_publish() + + self.assertTrue(result["success"], result.get("reasons")) + self.assertTrue(result["evidence"]["fast_forward_from_remote"]) + self.assertEqual(self.remote_head(), self.head_sha) + + def test_content_hash_mismatch_refuses(self): + self.write_lock() + wrong = {"work.txt": "0" * 64} + + result = self.run_publish(expected_file_hashes=wrong) + + self.assertFalse(result["success"]) + self.assertTrue( + any("declared content hashes" in r for r in result["reasons"]), + result["reasons"], + ) + self.assertFalse(result["evidence"]["file_hashes_verified"]) + self.assertIsNone(self.remote_head()) + + def test_matching_content_hashes_publish(self): + self.write_lock() + digests = branch_publish.hash_worktree_files(self.worktree, ["work.txt"]) + + result = self.run_publish(expected_file_hashes=digests) + + self.assertTrue(result["success"], result.get("reasons")) + self.assertTrue(result["evidence"]["file_hashes_verified"]) + + def test_missing_declared_file_refuses(self): + self.write_lock() + result = self.run_publish(expected_file_hashes={"absent.txt": "0" * 64}) + self.assertFalse(result["success"]) + self.assertTrue( + any("missing or unreadable" in r for r in result["reasons"]), + result["reasons"], + ) + + def test_foreign_claimant_refuses(self): + """Ownership comes from the durable record, not from the caller.""" + self.write_lock(claimant={"username": "someone-else", "profile": PROFILE}) + + result = self.run_publish() + + self.assertFalse(result["success"]) + self.assertTrue( + any("foreign claim" in r for r in result["reasons"]), result["reasons"] + ) + self.assertIsNone(self.remote_head()) + + def test_foreign_profile_refuses(self): + self.write_lock( + claimant={"username": IDENTITY, "profile": "test-reviewer-prgs"} + ) + result = self.run_publish() + self.assertFalse(result["success"]) + self.assertTrue( + any("claimant profile" in r for r in result["reasons"]), result["reasons"] + ) + + def test_absent_lock_record_refuses(self): + """No recorded claim means this cannot be used to bypass the lock.""" + result = self.run_publish() # no write_lock() + + self.assertFalse(result["success"]) + self.assertTrue( + any("no durable issue-lock record" in r for r in result["reasons"]), + result["reasons"], + ) + self.assertIsNone(self.remote_head()) + + def test_branch_mismatch_against_lock_refuses(self): + self.write_lock(branch_name=f"feat/issue-{ISSUE}-different") + result = self.run_publish() + self.assertFalse(result["success"]) + self.assertTrue( + any("records branch" in r for r in result["reasons"]), result["reasons"] + ) + + def test_worktree_mismatch_against_lock_refuses(self): + self.write_lock(worktree_path="/tmp/some/other/worktree") + result = self.run_publish() + self.assertFalse(result["success"]) + self.assertTrue( + any("records worktree" in r for r in result["reasons"]), result["reasons"] + ) + + def test_competing_open_pr_on_another_branch_refuses(self): + self.write_lock() + competing = [{"number": 4242, "head": {"ref": f"fix/issue-{ISSUE}-rival"}}] + + result = self.run_publish(open_prs=competing) + + self.assertFalse(result["success"]) + self.assertTrue( + any("already claim issue" in r for r in result["reasons"]), + result["reasons"], + ) + self.assertIsNone(self.remote_head()) + + def test_open_pr_on_the_same_branch_is_not_competing(self): + """This branch's own PR is not a rival claim against itself.""" + self.write_lock() + own = [{"number": 77, "head": {"ref": BRANCH}}] + + result = self.run_publish(open_prs=own) + + self.assertTrue(result["success"], result.get("reasons")) + + +class TestGuardStrictnessPreserved(_PublishBase): + """AC15 — publication is an operation, never a weakening of the guards.""" + + def test_non_issue_branch_refuses(self): + self._git("checkout", "-q", "-b", "scratch/not-issue-linked") + self.write_lock(branch_name="scratch/not-issue-linked") + + result = self.run_publish(branch_name="scratch/not-issue-linked") + + self.assertFalse(result["success"]) + self.assertTrue( + any("issue-linked" in r for r in result["reasons"]), result["reasons"] + ) + + def test_stable_branch_refuses(self): + self.write_lock(branch_name="master") + result = self.run_publish(branch_name="master") + self.assertFalse(result["success"]) + self.assertTrue( + any("issue-linked" in r or "stable branch" in r for r in result["reasons"]), + result["reasons"], + ) + + def test_branch_number_must_match_the_issue(self): + other = "feat/issue-7777-mismatched" + self._git("checkout", "-q", "-b", other) + self.write_lock(branch_name=other) + result = self.run_publish(branch_name=other) + self.assertFalse(result["success"]) + self.assertTrue( + any("does not carry issue number" in r for r in result["reasons"]), + result["reasons"], + ) + + def test_unregistered_worktree_refuses(self): + """#713 — an improvised directory is not a registered worktree.""" + path = self.write_lock() + assessment = branch_publish.assess_unpublished_commit_publication( + issue_lock_store.read_lock_file(path), + issue_number=ISSUE, branch_name=BRANCH, worktree_path=self.worktree, + expected_head=self.head_sha, remote="prgs", org=ORG, repo=REPO, + identity=IDENTITY, profile=PROFILE, + worktree_state={ + "current_branch": BRANCH, "porcelain_status": "", + "head_sha": self.head_sha, + }, + worktree_registered=False, + remote_probe={"probe_ok": True, "remote_branch_exists": False}, + ) + self.assertEqual(assessment["outcome"], branch_publish.REFUSED) + self.assertTrue( + any("not listed in git worktree list" in r + for r in assessment["reasons"]), + assessment["reasons"], + ) + + def test_unobservable_remote_refuses(self): + """An unknown remote state must not be mistaken for an absent branch.""" + self.write_lock() + result = self.run_publish(git_remote_name="no-such-remote") + self.assertFalse(result["success"]) + self.assertTrue( + any("could not be observed" in r for r in result["reasons"]), + result["reasons"], + ) + + +class TestRecordSeparation(_PublishBase): + """AC23 — the durable issue lock and the workflow lease are distinct.""" + + def test_publication_leaves_the_issue_lock_byte_identical(self): + path = self.write_lock() + with open(path, "rb") as fh: + before = fh.read() + + result = self.run_publish() + + self.assertTrue(result["success"], result.get("reasons")) + with open(path, "rb") as fh: + after = fh.read() + self.assertEqual(before, after, "publication must not mutate the lock record") + self.assertFalse(result["issue_lock_record_mutated"]) + self.assertFalse(result["workflow_lease_touched"]) + + def test_refusal_also_reports_untouched_records(self): + result = self.run_publish() # refuses: no lock record + self.assertFalse(result["issue_lock_record_mutated"]) + self.assertFalse(result["workflow_lease_touched"]) + + def test_lock_generation_is_not_advanced(self): + path = self.write_lock() + self.run_publish() + lock = issue_lock_store.read_lock_file(path) + self.assertEqual(lock["lock_generation"], 1) + + +class TestTruthfulProcessEvidence(_PublishBase): + """AC24 — a live daemon pid is never represented as a dead process.""" + + def test_live_recorded_pid_does_not_block_publication(self): + # The recorded pid is this live process, standing in for the live MCP + # daemon. Reclaim would refuse here; publication legitimately does not. + path = self.write_lock(session_pid=os.getpid()) + lock = issue_lock_store.read_lock_file(path) + self.assertEqual(lock["pid"], os.getpid()) + + result = self.run_publish() + + self.assertTrue(result["success"], result.get("reasons")) + self.assertEqual(self.remote_head(), self.head_sha) + + def test_liveness_is_not_consulted_as_evidence(self): + self.write_lock(session_pid=os.getpid()) + result = self.run_publish() + self.assertFalse(result["evidence"]["owner_pid_liveness_consulted"]) + + def test_reclaim_still_refuses_for_the_same_live_pid(self): + """Publication does not soften the reclaim predicate it routes around.""" + path = self.write_lock(session_pid=os.getpid()) + lock = issue_lock_store.read_lock_file(path) + reclaim = issue_lock_store.assess_expired_lock_reclaim(lock) + self.assertFalse(reclaim["reclaim_allowed"]) + + +class TestIdempotentRetry(_PublishBase): + """AC20 — retry is safe and read-after-write is proven every time.""" + + def test_second_publication_reports_already_published(self): + self.write_lock() + first = self.run_publish() + self.assertTrue(first["performed"]) + + second = self.run_publish() + + self.assertTrue(second["success"], second.get("reasons")) + self.assertFalse(second["performed"], "no second push is needed") + self.assertTrue(second["published"]) + self.assertTrue(second["verified"]) + self.assertEqual(second["outcome"], branch_publish.ALREADY_PUBLISHED) + self.assertEqual(self.remote_head(), self.head_sha) + + +class TestDryRun(_PublishBase): + """AC12 — dry run reports the decision and mutates nothing.""" + + def test_dry_run_reports_intent_without_publishing(self): + self.write_lock() + + result = self.run_publish(dry_run=True) + + self.assertTrue(result["success"]) + self.assertTrue(result["dry_run"]) + self.assertTrue(result["would_publish"]) + self.assertFalse(result["performed"]) + self.assertIsNone(self.remote_head(), "dry run must not publish") + + def test_dry_run_and_apply_agree_on_a_refusal(self): + """AC11 — the reported decision does not depend on which mode ran.""" + self.write_lock(claimant={"username": "someone-else", "profile": PROFILE}) + + dry = self.run_publish(dry_run=True) + applied = self.run_publish() + + self.assertFalse(dry["success"]) + self.assertFalse(applied["success"]) + self.assertEqual(dry["reasons"], applied["reasons"]) + + +class TestRenewalUnblocked(_PublishBase): + """AC20/AC21 — renewal is permitted only after verified publication.""" + + def _renewal(self, remote_head): + return issue_lock_renewal.assess_exact_owner_lease_renewal( + issue_lock_store.read_lock_file(self.lock_path()), + issue_number=ISSUE, branch_name=BRANCH, worktree_path=self.worktree, + remote="prgs", org=ORG, repo=REPO, + identity=IDENTITY, profile=PROFILE, + current_branch=BRANCH, porcelain_status="", worktree_exists=True, + head_sha=self.head_sha, remote_head_sha=remote_head, + ) + + def test_renewal_refuses_before_publication(self): + self.write_lock() + decision = self._renewal(None) + self.assertFalse(decision["renewal_sanctioned"]) + self.assertTrue( + any("unpublished branch" in r for r in decision["reasons"]), + decision["reasons"], + ) + + def test_renewal_is_sanctioned_after_publication(self): + self.write_lock() + result = self.run_publish() + self.assertTrue(result["verified"], result.get("reasons")) + + decision = self._renewal(self.remote_head()) + + self.assertTrue(decision["renewal_sanctioned"], decision["reasons"]) + + +class TestProtectedAssetUntouched(unittest.TestCase): + """AC17 — no test or fixture may reference the protected worktree.""" + + def test_no_reference_to_the_protected_worktree(self): + here = os.path.dirname(os.path.abspath(__file__)) + root = os.path.dirname(here) + needle = "issue-635-project-registry" + "-api" + for path in ( + os.path.join(here, "test_issue_812_publish_unpublished_commit.py"), + os.path.join(root, "branch_publish.py"), + ): + with open(path, "r", encoding="utf-8") as fh: + body = fh.read() + self.assertNotIn(needle, body) + + +if __name__ == "__main__": # pragma: no cover + unittest.main() diff --git a/tests/test_task_capability_role_invariants.py b/tests/test_task_capability_role_invariants.py index df69e9e..fbba796 100644 --- a/tests/test_task_capability_role_invariants.py +++ b/tests/test_task_capability_role_invariants.py @@ -139,6 +139,9 @@ EXPECTED_ROLE_EXCLUSIVE_TASKS = frozenset( "gitea_release_merger_pr_lease", "create_branch", "push_branch", + # #812 AC20: publishing an unpublished local head is author-only for the + # same reason every other push is — it writes a branch to the remote. + "publish_unpublished_branch", "create_pr", "commit_files", "gitea_commit_files",