Files
Gitea-Tools/docs/canonical-repository-root.md
T
sysadminandClaude Opus 4.8 61c3a57df5 fix(mcp): consume canonical target root in cross-repository operations (Closes #741)
#706 introduced the immutable `canonical_repository_root` and #739/#740 routed
three consumption paths through it. The paths #740 left behind all shared one
shape: the *filesystem* guards had been migrated to the canonical root, but
*repository identity* still bottomed out in `_local_git_remote_url`, which ran
`git remote get-url` with `cwd=PROJECT_ROOT` — always the Gitea-Tools install
checkout. The two halves of a single assessment therefore described two
different repositories.

For a namespace bound to another repository this inverts the guards rather than
merely weakening them: an operation naming the genuinely bound target is
rejected, while one naming Gitea-Tools is accepted.

Central helper
--------------
Adds `_canonical_local_git_root()` as the single place a target-repository root
is resolved: the configured canonical root when one is declared, else
`PROJECT_ROOT`. A configured-but-invalid root is never silently replaced by the
install checkout. Single-repository behaviour is byte-for-byte unchanged.

Defects fixed
-------------
* `_local_git_remote_url` now runs in the canonical target root, which corrects
  every downstream identity consumer at once (`_resolve`, the #530 remote/repo
  guard, the anti-stomp org/repo fill, `_workspace_repository_slug`).
* `_verify_role_mutation_workspace` omitted `configured_canonical_root`, so the
  #274 branches-only / worktree-membership guards validated
  `Gitea-Tools/branches/` instead of the bound target. It now threads the root
  exactly as `_resolve_namespace_mutation_context` does.
* `_resolve` derived omitted coordinates by looking a remote up *by name*. A
  target checkout commonly names its remote `origin` rather than `prgs`, so the
  lookup returned None and the coordinates fell through to the remote-wide
  default target — an unrelated repository. It now prefers
  `_canonical_repository_slug`, which probes candidate remote names.
* Explicit `org`/`repo` short-circuit the #530 match check, so caller
  coordinates bypassed validation entirely. They may now only *confirm* a
  canonical binding, never override it, and fail closed in both directions.
* Reconciler ancestry, fetch, worktree-inventory and cleanup call sites, the
  author worktree derivation in `gitea_lock_issue`/`gitea_create_pr`, and the
  `control_clean` porcelain probe now use the canonical target root.
* `gitea_config`: v2-`environments` silently *dropped* `canonical_repository_root`
  during flattening, so such a namespace fell back to the install root — a
  fail-open. It is now validated and propagated. The v1 path validates it too,
  so all three loaders behave identically.

Preserved as installation-scoped
--------------------------------
Server parity (`master_parity_gate`), workflow/schema/skill loading, self-code
hashing and stale-runtime detection, and `mirror_refs.sh` lookup remain anchored
to `PROJECT_ROOT`. The `server_implementation` vs `target_repository` parity
dimensions from #740 stay separately labelled, and only the server dimension
gates mutations.

Tests
-----
`tests/test_issue_741_canonical_root_consumers.py` (51 tests, 52 subtests) drives
a role-by-operation matrix over author/reviewer/merger/reconciler against:
correct cross-repository target, wrong repository, wrong worktree, explicit
matching and mismatched coordinates, missing/invalid canonical root, immutable
first-bind, and request-override attempts — plus both cross-repository
directions and all three config loaders. Real git repositories, no network, no
branch deletion, no merge.

The module reinstalls the genuine `_local_git_remote_url` per test: an autouse
conftest fixture permanently reassigns it to a working-directory-independent
stub, which is precisely the behaviour under test.

Full suite: 3408 passed, 6 skipped, 2 failed. Both failures
(`test_issue_702_review_findings_f1_f6`, `test_reconciler_supersession_close`)
reproduce identically on clean master c908ed6050 and are pre-existing.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01AYGdWAwuA6UNDc9c3CGipU
2026-07-18 12:02:31 -04:00

6.9 KiB

Installation root vs canonical target repository root

Purpose

This document (tracked as issue #741, building on #706 and #739/#740) explains the two distinct filesystem roots the Gitea-Tools MCP server reasons about, why conflating them silently targets the wrong repository, and which rule applies when you add a new consumer.

It is the repository-scope companion to gitea-execution-profiles.md (the profile model) and gitea-dual-namespace-deployment.md (the per-role namespace model).

The two roots

Installation root Canonical target repository root
What it is The checkout the server code lives in The working root of the repository whose issues/PRs/branches the namespace mutates
How it is derived PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__)) Configured per namespace, then pinned immutably into the session
Configured by Nothing — it follows the script canonical_repository_root profile field, or the GITEA_CANONICAL_REPOSITORY_ROOT environment variable
Changes at runtime? No No — first bind wins for the life of the process
Accessor PROJECT_ROOT _canonical_local_git_root() (filesystem) / _canonical_repository_slug() (identity)

For a single-repository namespace — every Gitea-Tools namespace today — the two roots are the same path, and nothing about the existing behaviour changes. The distinction only becomes observable once a namespace is pointed at a different repository.

Which root does my code need?

Ask what the operation is about, not where the file happens to sit.

Use the installation root (PROJECT_ROOT) when the operation concerns the Gitea-Tools software itself:

  • server implementation / version parity (master_parity_gate, the startup_head vs current_head staleness gate);
  • loading the server's own workflow, schema and skill files;
  • self-code hashing and stale-runtime detection;
  • locating installed scripts such as mirror_refs.sh.

These are intentionally install-scoped. Do not "fix" them.

Use the canonical target root (_canonical_local_git_root()) when the operation concerns the repository being worked on:

  • git remote get-url for repository identity;
  • branch creation, push, and commit;
  • ancestry and merge-base proofs;
  • worktree inventory, cleanup, and branch deletion;
  • any local git subprocess whose result feeds a mutation guard.

If you cannot tell, fail closed. An ambiguous consumer that guesses the install root is the exact defect class #741 exists to eliminate.

Why conflating them inverts the guards

Before #741, _local_git_remote_url() ran git remote get-url with cwd=PROJECT_ROOT unconditionally. Every consumer of repository identity_resolve, the #530 remote/repo guard, the anti-stomp org/repo fill, _workspace_repository_slug — therefore read the Gitea-Tools remote and called it "the workspace", no matter which repository the namespace was bound to.

For a namespace whose canonical root points elsewhere, this inverts the guard rather than merely weakening it:

  • an operation naming the genuinely bound target repository is rejected, because that slug does not appear in the Gitea-Tools remote URL;
  • an operation naming Gitea-Tools is accepted.

The filesystem guards (#274 branches-only and worktree membership) had already been migrated to the canonical root by #706, so the two halves of a single assessment described two different repositories.

A related subtlety: repository identity must not be derived by looking a remote up by name. A target checkout commonly names its remote origin rather than prgs, so a name-keyed lookup returns nothing and the omitted coordinates fall through to the remote-wide default target — an unrelated repository. _canonical_repository_slug() probes candidate remote names against the canonical root instead.

_canonical_local_git_root() is now the one place a target root is resolved. Do not re-derive it; new code that needs a target root calls that helper.

Configuration

Declare the binding on the profile, alongside allowed_repositories:

{
  "profiles": {
    "example-author": {
      "role": "author",
      "canonical_repository_root": "/absolute/path/to/target-repo",
      "allowed_repositories": ["Example-Org/target-repo"]
    }
  }
}

The namespace-scoped environment variable GITEA_CANONICAL_REPOSITORY_ROOT overrides the profile field, and is normally exported next to the server cwd in the MCP client configuration.

Validation is layered, and each layer fails closed:

  1. Config load. The path must be a non-empty absolute string. All supported loaders — v1, v2-environments, and v2-contexts — validate it identically. (Before #741 only the v2-contexts loader validated it, and v2-environments silently dropped the field during flattening, so the namespace fell back to the install root — a fail-open.)
  2. Bind time. The path must exist, be a git repository, and resolve to a repository identity matching the session's authorized slug. A configured but unresolvable root is never replaced by the install identity.
  3. Every mutation. The pinned root is compared against the live configured value; a mismatch is treated as a forged or conflicting binding.

allowed_repositories remains a separate authorization boundary (#714): the canonical root determines which repository is derived, and allowed_repositories determines whether the session may act on it. A root that resolves to a repository outside that list fails closed.

Explicit coordinates confirm, never override

Explicit org/repo arguments may confirm an existing canonical binding. They can never establish, complete, or replace one. A request naming a repository that contradicts the binding fails closed, in both directions:

  • a Gitea-Tools-rooted namespace cannot mutate another repository;
  • a namespace rooted at another repository cannot mutate Gitea-Tools.

This matters because both-explicit coordinates short-circuit the #530 remote/repo match check, so without this rule a caller could name any repository and skip validation entirely.

No request-supplied workspace, remote, owner, repository, or worktree can replace the immutable root.

Parity is reported per dimension

gitea_assess_master_parity reports two separately labelled dimensions:

  • server_implementation — the Gitea-Tools installation checkout. Its startup_head / current_head / stale / restart_required fields keep their original meaning, and only this dimension gates mutations: the running process executes the code it started with, so a merged fix is not live until the daemon restarts.
  • target_repository — the configured canonical target checkout and its last-known remote master.

"In parity" is a statement about one dimension, never about the whole system. Read the dimension you actually care about.