#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
154 lines
6.9 KiB
Markdown
154 lines
6.9 KiB
Markdown
# 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`](gitea-execution-profiles.md) (the profile model)
|
|
and [`gitea-dual-namespace-deployment.md`](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`:
|
|
|
|
```json
|
|
{
|
|
"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.
|