You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@magpie.apache.org by po...@apache.org on 2026/10/14 08:09:22 UTC

(magpie) branch main updated: feat(tools/vcs): implement the Subversion backend (#1609)

This is an automated email from the ASF dual-hosted git repository.

potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/magpie.git


The following commit(s) were added to refs/heads/main by this push:
     new 0de32188 feat(tools/vcs): implement the Subversion backend (#1609)
0de32188 is described below

commit 0de32188bd6d4ce5a10c0d851fb5cf32dd6e7529
Author: Matt Topol <zo...@gmail.com>
AuthorDate: Wed Oct 14 09:09:16 2026 +0100

    feat(tools/vcs): implement the Subversion backend (#1609)
    
    * feat(tools/vcs): implement the Subversion backend (#602)
    
    `magpie-vcs` detected SVN working copies but every operation raised,
    so skills could not drive the svn.apache.org source trees or any other
    SVN checkout through the source-control capability. SubversionBackend
    now binds each abstract operation to `svn`, following the centralized
    model already documented in tools/asf-svn/source-control.md: branches
    are server-side copies under the trunk/branches/tags layout, `stage`
    schedules adds and deletes, `commit` writes every working-copy change
    straight to the server (there is no staging area), `fetch` is a no-op
    because history reads query the server, and `push` refuses because the
    commit already published. `log` normalises to one `r<rev> <subject>`
    line per revision, and `cat` pegs the URL so moved or deleted paths
    resolve. All svn calls run `--non-interactive` so a missing credential
    fails instead of blocking on a prompt.
    
    With no extension-point backend left, `_UnimplementedBackend` is
    removed, and the docs that listed SVN as pending now describe the
    binding.
    
    Generated-by: Claude Code (Opus 5.5)
    
    * fix(tools/vcs): check svn version and validate branch names
    
    The svn backend relies on `info --show-item` and `cleanup
    --remove-unversioned` (Subversion 1.9+); an older svn now reports as
    unavailable and every operation fails with an actionable error instead
    of an opaque command failure. `new-branch` takes one path segment,
    rejects empty, `.` and `..` names, and URL-quotes the name; `switch`
    applies the same segment check to layout-relative refs. The README now
    says commit sends modified and staged paths, not unversioned files.
    
    Generated-by: Claude Code (Opus 5.5)
    
    * fix(tools/vcs): resolve svn layout at the rightmost marker
    
    A project nested under a directory named branches or tags (e.g.
    ^/branches/projects/foo/trunk) was split at the first marker, so
    new-branch and switch targeted the wrong server path. The rightmost
    marker now wins. The README states the supported layout, the
    one-segment branch names and recovery from a partial new-branch, and the
    svn tests skip unless the backend reports svn 1.9+ available.
    
    Generated-by: Claude Code (Opus 5.5)
    
    * fix(tools/vcs): read branches/trunk as a branch; refuse nested switch names
    
    The rightmost layout scan matched `trunk` even when it was the name of
    a branch or tag, so a working copy on branches/trunk reported `trunk`
    and a later new-branch targeted branches/branches/<name>. A segment
    directly after branches/ or tags/ is now always the line name.
    Name-based switch now refuses a nested bare name, matching new-branch
    and the README.
    
    Generated-by: Claude Code (Opus 5.5)
    
    * fix(tools/vcs): report svn branch names decoded
    
    svn info returns relative-url URL-encoded, so current_branch reported
    branches/fix%201 for a branch created as "fix 1", and passing that back
    to switch quoted it again (fix%25201). current_branch now decodes the
    name; URL construction keeps quoting exactly once.
    
    Generated-by: Claude Code (Opus 5.5)
    
    * fix(tools/vcs): address #1609 review comments
    
    - stage: test paths with os.path.lexists, so a versioned symlink whose
      target is missing is not scheduled for deletion.
    - diff --base: the svn backend takes a revision; a line-of-work name
      (trunk, main, a branch) now fails with a clear VCSError instead of
      svn's revision syntax error.
    - README: on svn the change goes public at commit / new-branch, so
      pre-publish gates belong there; -n does not bound the server request
      when log filters are set; CLI callers detect the centralized model
      via `magpie-vcs detect` printing svn.
    
    Generated-by: Claude Code (Opus 5.5)
    
    * fix(tools/vcs): make svn cat follow copy history
    
    cat pegged <url>/<path>@<rev>, so on a branch a revision from before the
    branch existed failed (E200009): the branch directory did not exist at
    that revision. cat now asks svn for the working-copy path at the
    operative revision, which follows copy history back through trunk, and
    falls back to the pegged URL only when the path is no longer in the
    working copy.
    
    Generated-by: Claude Code (Opus 5.5)
    
    * fix(tools/vcs): svn cat follows history for paths deleted on a branch
    
    For a path no longer in the working copy, cat fell back to
    <branch-url>/<path>@<rev>, which cannot reach a revision from before the
    branch existed. It now finds the commit on this line of work that
    deleted the path (or a parent) and reads it through the revision before
    that, so copy history is still followed back through trunk.
    
    Generated-by: Claude Code (Opus 5.5)
    
    * fix(tools/vcs): svn stage schedules deletions under a staged directory
    
    stage only checked whether each argument existed, so `stage pkg` or
    `stage .` left versioned files deleted inside the directory as missing
    (!) instead of scheduling their deletion, unlike `git add <dir>`. stage
    now reads `svn status --xml` for the present paths and runs svn delete
    on every missing entry before svn add.
    
    Generated-by: Claude Code (Opus 5.5)
---
 docs/adapters/registry.md            |   2 +-
 docs/labels-and-capabilities.md      |   2 +-
 docs/vendor-neutrality.md            |  26 ++-
 tools/asf-svn/source-control.md      |  18 +-
 tools/forgejo/source-control.md      |   3 +-
 tools/github/source-control.md       |   3 +-
 tools/spec-loop/specs/adapters.md    |   5 +-
 tools/vcs/README.md                  |  81 ++++++--
 tools/vcs/pyproject.toml             |   2 +-
 tools/vcs/src/magpie_vcs/__init__.py | 348 +++++++++++++++++++++++++++--------
 tools/vcs/tests/test_vcs.py          | 293 ++++++++++++++++++++++++++++-
 11 files changed, 648 insertions(+), 135 deletions(-)

diff --git a/docs/adapters/registry.md b/docs/adapters/registry.md
index 6f76345a..0be51837 100644
--- a/docs/adapters/registry.md
+++ b/docs/adapters/registry.md
@@ -53,7 +53,7 @@ extension point = a documented, labelled slot with a tracking issue.
 | [`tools/mail-source`](../../tools/mail-source/) | mbox, IMAP, [`mailman3`](../../tools/mail-source/mailman3/), [`gmail`](../../tools/gmail/), [`maildir`](../../tools/maildir/), [`ponymail`](../../tools/ponymail/) (ASF) | — |
 | [`tools/forwarder-relay`](../../tools/forwarder-relay/) | ASF-security ([`tools/gmail/asf-relay.md`](../../tools/gmail/asf-relay.md)) | huntr.com, HackerOne, GHSA relay |
 | [`tools/scan-format`](../../tools/scan-format/) | ASVS | other scanner formats |
-| [`tools/vcs`](../../tools/vcs/) | Git, Mercurial, Fossil | Subversion [\#602](https://github.com/apache/magpie/issues/602), Jujutsu [\#603](https://github.com/apache/magpie/issues/603), Perforce [\#605](https://github.com/apache/magpie/issues/605) |
+| [`tools/vcs`](../../tools/vcs/) | Git, Mercurial, Fossil, Subversion | Jujutsu [\#603](https://github.com/apache/magpie/issues/603), Perforce [\#605](https://github.com/apache/magpie/issues/605) |
 | Forge / tracker | [`github`](../../tools/github/), [`forgejo`](../../tools/forgejo/) `partial` foundation, [`jira`](../../tools/jira/), [`bitbucket`](../../tools/bitbucket/) `partial-read-only` foundation, [`sourcehut`](../../tools/sourcehut/), [`fossil`](../../tools/fossil/), [`gitlab`](../../tools/gitlab/) `partial-read-only` foundation | Forgejo/Gitea [\#310](https://github.com/apache/magpie/issues/310), Pagure [\#312](https://github.com/apache/magpie/issues/312), deeper Bitbucket/J [...]
 | [`tools/chat`](../../tools/chat/) | [`chat-slack`](../../tools/chat-slack/), [`chat-discord`](../../tools/chat-discord/) | Matrix [\#309](https://github.com/apache/magpie/issues/309), Zulip [\#308](https://github.com/apache/magpie/issues/308) |
 | Agent harness | Claude Code, [Codex](codex.md) `experimental` ([#313](https://github.com/apache/magpie/issues/313)), [Gemini CLI](gemini.md) `experimental` ([#314](https://github.com/apache/magpie/issues/314)), [Local LLM (Ollama / llama.cpp / vLLM)](local-llm.md) ([#315](https://github.com/apache/magpie/issues/315)), [Cursor](cursor.md) ([#316](https://github.com/apache/magpie/issues/316)), [Goose](goose.md) `guide only` ([#319](https://github.com/apache/magpie/issues/319)), [Aider](a [...]
diff --git a/docs/labels-and-capabilities.md b/docs/labels-and-capabilities.md
index 0d3f7809..c02d2443 100644
--- a/docs/labels-and-capabilities.md
+++ b/docs/labels-and-capabilities.md
@@ -376,7 +376,7 @@ or a contract-free mix of substrates (e.g. `tools/spec-inventory` is
 | [`tools/symlink-lint`](../tools/symlink-lint/) | `substrate:framework-dev` | Self-adoption symlink hygiene — rejects cyclic symlinks, misdirected skill relays (canonical/relay target-correctness), and incomplete self-adoption symlink sets |
 | [`tools/pilot-report-validator`](../tools/pilot-report-validator/) | `substrate:framework-dev` | Adopter pilot-report validator — required frontmatter keys, no unfilled placeholders, valid profile, and required body sections; counterpart to `spec-validator` for `docs/pilot-report-template.md` |
 | [`tools/skill-reconciler-diff`](../tools/skill-reconciler-diff/) | `substrate:framework-dev` | Deterministic structural diff between two skill trees — parses frontmatter, section headings, step inventory, placeholders, support files, and safety-baseline clauses into a JSON diff object for the `skill-reconciler` skill |
-| [`tools/vcs`](../tools/vcs/) | `contract:source-control` | Backend-dispatching implementation of the source-control (VCS) capability ([`tools/github/source-control.md`](../tools/github/source-control.md)); complete Git, Mercurial (Hg), and Fossil backends, plus detected extension point for SVN (#602) |
+| [`tools/vcs`](../tools/vcs/) | `contract:source-control` | Backend-dispatching implementation of the source-control (VCS) capability ([`tools/github/source-control.md`](../tools/github/source-control.md)); complete Git, Mercurial (Hg), Fossil, and Subversion (SVN) backends |
 | [`tools/sourcehut`](../tools/sourcehut/) | `contract:tracker` + `contract:source-control` + `contract:mail-archive` | SourceHut (sr.ht) forge bridge: todo.sr.ht, lists.sr.ht, builds.sr.ht, and git/hg repository reads |
 | [`tools/tracker`](../tools/tracker/) | `contract:tracker` | Interface spec for the tracker contract's read-only contributor-activity queries (issues filed, triaged and commented on; issues created; first maintainer reply). Pure interface spec; GitHub and Jira implement it, and the rest of the contract is described by `tools/github/tool.md` |
 | [`tools/typed-decision`](../tools/typed-decision/) | `contract:typed-decision` | Provider-agnostic typed decision tool contract (Choice / Score / Noul) with TypeSafe Jev API as initial backend |
diff --git a/docs/vendor-neutrality.md b/docs/vendor-neutrality.md
index dc878af8..047757b0 100644
--- a/docs/vendor-neutrality.md
+++ b/docs/vendor-neutrality.md
@@ -163,7 +163,7 @@ with pluggable backends already include:
 | [`tools/mail-source`](../tools/mail-source/) | mbox, IMAP, Gmail API ([`tools/gmail`](../tools/gmail/)), Mailman 3 |
 | [`tools/forwarder-relay`](../tools/forwarder-relay/) | ASF Security relay, huntr.com, HackerOne triagers |
 | [`tools/scan-format`](../tools/scan-format/) | security-scanner report formats (ASVS reference) |
-| [`tools/vcs`](../tools/vcs/) | Git (complete), Mercurial (complete), Subversion, … (extension points) |
+| [`tools/vcs`](../tools/vcs/) | Git, Mercurial, Fossil, Subversion (complete); Jujutsu, Perforce (extension points) |
 | [`tools/typed-decision`](../tools/typed-decision/) | TypeSafe Jev API (cloud), in-tree local provider (Ollama / llama.cpp / vLLM) |
 | [`tools/people`](../tools/people/) | Forge / tracker accounts — GitHub (profile, collaborators, teams), Jira (profile lookup); GitLab, Forgejo, Bitbucket (extension points) |
 | [`tools/tracker`](../tools/tracker/) | Tracker activity queries — GitHub and Jira, both through [`tools/contributor-metrics`](../tools/contributor-metrics/) backends; GitLab, Forgejo, Bitbucket (extension points) |
@@ -435,13 +435,13 @@ fixed that:
   (`magpie-vcs`) runs the *abstract* operation and detects the active
   backend from the working copy.
 
-Today: **Git and Mercurial are complete** (the Git and Mercurial bindings); Subversion
-([#602](https://github.com/apache/magpie/issues/602)) is a real, detected
-extension point that raises an actionable error naming its tracking
-issue until the full binding lands. Adding a backend means replacing one
-`_UnimplementedBackend` with a concrete `VCSBackend` subclass —
-detection, dispatch, the CLI, and every skill that calls `magpie-vcs`
-pick it up automatically. Nothing else changes.
+Today: **Git, Mercurial, Fossil, and Subversion are complete**. The
+Subversion binding ([#602](https://github.com/apache/magpie/issues/602))
+maps the operations onto SVN's centralized model: `commit` writes to the
+server, `fetch` has nothing to sync, and `push` is refused. Adding a
+backend means writing one concrete `VCSBackend` subclass and registering
+it — detection, dispatch, the CLI, and every skill that calls
+`magpie-vcs` pick it up automatically. Nothing else changes.
 
 The **ASF SVN** surface goes beyond the generic VCS binding: the
 [`tools/asf-svn/`](../tools/asf-svn/) adapter packages the SVN
@@ -452,12 +452,8 @@ GitHub-hosted ASF project that uses Git for source control needs
 `tools/asf-svn` to steward its release flow through `dist.apache.org`.
 
 Tracking issues exist, labelled `good first issue`, for the remaining
-non-Git/non-Hg systems:
-[Subversion](https://github.com/apache/magpie/issues/602) (generic VCS
-binding; `tools/asf-svn` covers the full ASF SVN surface including
-`dist.apache.org` and authorization),
-[Jujutsu](https://github.com/apache/magpie/issues/603),
-[Fossil](https://github.com/apache/magpie/issues/604), and
+systems:
+[Jujutsu](https://github.com/apache/magpie/issues/603) and
 [Perforce](https://github.com/apache/magpie/issues/605) — so the
 extension points are public and labelled, not hypothetical. (The
 Bitbucket and SourceHut forges, which carry their own VCS surfaces, are tracked
@@ -526,7 +522,7 @@ coverage without pretending one team can implement an open-ended set.
 | Agentic harness | ✅ by construction (`AGENTS.md` standard) | Claude Code; OpenCode; [Codex adapter](adapters/codex.md) (experimental); [Gemini adapter](adapters/gemini.md) (experimental); community use under Cursor, Copilot, Kiro | Remaining runtime adapters [#314–#322](https://github.com/apache/magpie/issues?q=is%3Aissue+state%3Aopen+adapter+in%3Atitle) |
 | Forge / tracker | ✅ by construction | GitHub, Jira, SourceHut; Bitbucket and GitLab `partial-read-only` foundations excluded from complete-backend counts; CVE/scan/relay via adapter contracts | Forgejo/Gitea [#310](https://github.com/apache/magpie/issues/310), Pagure [#312](https://github.com/apache/magpie/issues/312), full Bitbucket tracker/change-request/Jira coverage [#606](https://github.com/apache/magpie/issues/606), GitLab [#305](https://github.com/apache/magpie/issues/305), Bugz [...]
 | Communication channels | ✅ by construction | PonyMail / mail-archive reads; Mailman 3 / Hyperkitty public-archive reads ([`tools/mail-source/mailman3`](../tools/mail-source/mailman3/)) | mbox [#304](https://github.com/apache/magpie/issues/304), IMAP [#303](https://github.com/apache/magpie/issues/303); Discourse [#307](https://github.com/apache/magpie/issues/307), Zulip [#308](https://github.com/apache/magpie/issues/308), Matrix [#309](https://github.com/apache/magpie/issues/309) |
-| Source control (VCS) | ✅ by construction | **Git (complete)**, **Mercurial (complete)**; ASF SVN surface ([`tools/asf-svn`](../tools/asf-svn/): source control + dist.apache.org + authorization) | Subversion generic VCS binding [\#602](https://github.com/apache/magpie/issues/602) (detected); Jujutsu [\#603](https://github.com/apache/magpie/issues/603), Fossil [\#604](https://github.com/apache/magpie/issues/604), Perforce [\#605](https://github.com/apache/magpie/issues/605) (tracked) |
+| Source control (VCS) | ✅ by construction | **Git**, **Mercurial**, **Fossil**, **Subversion** (complete, [`tools/vcs`](../tools/vcs/)); ASF SVN surface ([`tools/asf-svn`](../tools/asf-svn/): source control + dist.apache.org + authorization) | Jujutsu [\#603](https://github.com/apache/magpie/issues/603), Perforce [\#605](https://github.com/apache/magpie/issues/605) (tracked) |
 | Project governance | ✅ by construction | ASF + non-ASF adopter profiles | Adopter config (modes, thresholds) |
 
 ✅ "by construction" means the workflows carry no vendor assumption;
diff --git a/tools/asf-svn/source-control.md b/tools/asf-svn/source-control.md
index c0eac88a..29c40395 100644
--- a/tools/asf-svn/source-control.md
+++ b/tools/asf-svn/source-control.md
@@ -103,16 +103,14 @@ svn checkout \
 ## `magpie-vcs` integration
 
 The abstract `magpie-vcs` CLI ([`tools/vcs/`](../vcs/README.md))
-detects SVN working copies and dispatches to the SVN backend. Until
-the full SVN binding lands in `magpie-vcs` (tracked in
-[apache/magpie#602](https://github.com/apache/magpie/issues/602)),
-`magpie-vcs detect` correctly identifies SVN working copies and raises
-an actionable error naming the tracking issue.
-
-When the `#602` binding is complete, the `magpie-vcs` CLI becomes the
-preferred interface — a skill can run `magpie-vcs diff` or
-`magpie-vcs log` and the tool dispatches to the correct `svn` command
-without the skill knowing which VCS is in use.
+detects SVN working copies and dispatches to its `svn` backend, which
+implements this binding: a skill runs `magpie-vcs diff` or
+`magpie-vcs log` and the tool runs the matching `svn` command without
+the skill knowing which VCS is in use. How each abstract operation maps
+onto the centralized model (no staging area, `fetch` a no-op, `push`
+refused because `commit` already published) is tabulated in the
+[`magpie-vcs` README](../vcs/README.md#subversion-centralized-model).
+Blame has no abstract operation; run `svn blame` directly.
 
 ## When to replace this capability
 
diff --git a/tools/forgejo/source-control.md b/tools/forgejo/source-control.md
index 213de499..56d50f14 100644
--- a/tools/forgejo/source-control.md
+++ b/tools/forgejo/source-control.md
@@ -86,7 +86,8 @@ backend:
 
 - Mercurial (Hg) — apache/magpie#601 (generic VCS binding;
   [`tools/vcs/`](../vcs/))
-- Apache Subversion (SVN) — apache/magpie#602 (generic VCS binding);
+- Apache Subversion (SVN) — generic VCS binding in
+  [`tools/vcs/`](../vcs/) (apache/magpie#602);
   [`tools/asf-svn/`](../asf-svn/) packages the full ASF SVN surface
   (source control + `dist.apache.org` release distribution +
   authorization) for ASF projects
diff --git a/tools/github/source-control.md b/tools/github/source-control.md
index ba61c3a5..b3bccb48 100644
--- a/tools/github/source-control.md
+++ b/tools/github/source-control.md
@@ -99,7 +99,8 @@ Tracked VCS bridges that implement this capability against a non-Git
 backend:
 
 - Mercurial (Hg) — apache/magpie#601
-- Apache Subversion (SVN) — apache/magpie#602 (generic VCS binding);
+- Apache Subversion (SVN) — generic VCS binding in
+  [`tools/vcs/`](../vcs/) (apache/magpie#602);
   [`tools/asf-svn/`](../asf-svn/) packages the full ASF SVN surface
   (source control + `dist.apache.org` release distribution +
   authorization) for ASF projects
diff --git a/tools/spec-loop/specs/adapters.md b/tools/spec-loop/specs/adapters.md
index 2a70831a..03ab4f2b 100644
--- a/tools/spec-loop/specs/adapters.md
+++ b/tools/spec-loop/specs/adapters.md
@@ -145,8 +145,9 @@ by swapping the adapter, not the skill.
   active VCS backend, so skills call the abstract operation
   and the backend is detected from the working copy or forced with
   `--backend`/`$MAGPIE_VCS`.
-  `git`, `hg`, and `fossil` are complete backends; `svn` is detected but
-  remains an extension point (#602).
+  `git`, `hg`, `fossil`, and `svn` are complete backends; `svn` maps the
+  operations onto the centralized model — no staging area (`commit` records
+  every working-copy change), `fetch` a no-op, `push` refused (#602).
   A Fossil checkout is detected by its `.fslckout` (or `_FOSSIL_`) marker
   (#1494).
 - `tools/fossil/` (`magpie-fossil`) — stdlib-only Fossil SCM forge bridge,
diff --git a/tools/vcs/README.md b/tools/vcs/README.md
index 38514a44..c04f4e5d 100644
--- a/tools/vcs/README.md
+++ b/tools/vcs/README.md
@@ -10,6 +10,7 @@
   - [Why](#why)
   - [The abstraction](#the-abstraction)
   - [Backends](#backends)
+    - [Subversion (centralized model)](#subversion-centralized-model)
     - [Adding a backend](#adding-a-backend)
   - [How to use](#how-to-use)
 
@@ -49,12 +50,17 @@ VCS the project enables under *Tools enabled → Source control*.
 - **Runtime:** Python 3.11+ run via `uv`; stdlib-only (no runtime
   dependencies). The `dev` group pulls `pytest`, `ruff`, `mypy`.
 - **CLIs:** Depends on the active backend — the tool shells out to the
-  underlying VCS binary: `git`, `hg` or `fossil` for the complete
-  backends. `svn` is detected but its binding is not yet implemented.
-- **Credentials / auth:** None of its own; write operations (`fetch`,
-  `push`) inherit whatever auth the underlying VCS/remote needs.
-- **Network:** Local for read and local-write operations; `fetch` / `push`
-  reach the project's remote (e.g. GitHub) over the network.
+  underlying VCS binary: `git`, `hg`, `fossil` or `svn` (Subversion
+  1.9+, for `info --show-item` and `cleanup --remove-unversioned`; an
+  older `svn` is reported as not installed and every operation errors).
+- **Credentials / auth:** None of its own; operations that reach the
+  remote inherit whatever auth the underlying VCS/remote needs. `svn`
+  runs `--non-interactive`, so it uses a cached credential
+  (`~/.subversion/auth/`) and fails rather than prompting.
+- **Network:** For the distributed backends, local for read and
+  local-write operations; `fetch` / `push` reach the project's remote
+  (e.g. GitHub) over the network. For `svn` (centralized), `log`, `cat`,
+  `diff --base`, `new-branch`, `switch` and `commit` reach the server.
 
 ## Configuration
 
@@ -97,6 +103,11 @@ Write operations stay gated on explicit user confirmation **in the
 calling skill**, exactly as the tracker write paths are — the tool does
 not add its own prompt.
 
+On a centralized backend (`svn`) the change becomes public at `commit`
+and `new-branch` (a server-side copy), not at `push`, so whatever a skill
+runs just before publishing (a confirmation naming the remote, a pre-push
+scrub) must gate those operations instead.
+
 ## Backends
 
 | Backend | Status | Notes |
@@ -104,20 +115,56 @@ not add its own prompt.
 | `git` | **complete** | GitHub's native VCS; the default binding |
 | `hg` (Mercurial) | **complete** | Mercurial VCS support |
 | `fossil` | **complete** | Fossil SCM; pairs with the [`tools/fossil`](../fossil/) forge bridge |
-| `svn` (Subversion) | extension point | detected; centralized model (`distributed = False`) → [#602](https://github.com/apache/magpie/issues/602) |
-
-Detection is real for every backend (so `magpie-vcs detect` reports the
-working copy's VCS correctly); the remaining extension-point backends raise an actionable
-`VCSError` naming their tracking issue until the full binding lands.
+| `svn` (Subversion) | **complete** | centralized model (`distributed = False`); see below |
+
+### Subversion (centralized model)
+
+The `svn` backend maps the operations onto SVN's centralized model, as
+documented in [`tools/asf-svn/source-control.md`](../asf-svn/source-control.md#centralized-model-divergences):
+
+| Operation | SVN behaviour |
+|---|---|
+| `branch` | the line of work under the `trunk` / `branches/<name>` / `tags/<name>` layout (e.g. `trunk`); outside that layout, the repository-relative path |
+| `new-branch <name>` | server-side `svn copy` of the current line of work to `branches/<name>`, then `svn switch` onto it; `<name>` is one path segment (not empty, `.` or `..`, no `/`) and is URL-quoted |
+| `switch <ref>` | `trunk`, `branches/<n>`, `tags/<n>`, or a bare branch name (one segment, so `team/fix` is refused); a URL or `^/` path is used as-is |
+| `stage <paths…>` | `svn add` for new paths (and unversioned parents), `svn delete` for versioned paths gone from disk, whether given directly or found under a given directory (as `git add <dir>` stages removals); versioned paths still on disk are a no-op |
+| `commit -m` | `svn commit` of every modified or scheduled (`stage`d) path in the working copy — SVN has no staging area, so unrelated edits go too; unversioned files need `stage` first. Written straight to the server |
+| `diff --cached` | error: no staging area |
+| `diff --base <rev>` | the working copy against a **revision** (a number, `HEAD`, `BASE`, `COMMITTED`, `PREV` or `{DATE}`); a line-of-work name such as `main` or `trunk` is refused |
+| `log` | server history up to `HEAD`, one `r<rev> <subject>` line per revision; `--grep` / `--author` are regexes, `--since` takes an ISO 8601 date. With `--grep` / `--author` / `--since`, `-n` caps the output only: the path's whole history (back to `--since`, if given) is fetched before filtering, which is slow on a large shared repository such as svn.apache.org |
+| `cat <rev> <path>` | the working-copy path at `<rev>`, following copy history (so a revision from before a branch was created resolves through trunk). A path no longer in the working copy is read through the revision before the commit that deleted it on this line of work, which reads the line's log (slow on a large repository) |
+| `fetch` | no-op: refs live on the server and every history read queries it |
+| `push` | error: `commit` already published the change |
+
+A caller using the Python API tells the models apart with
+`VCSBackend.distributed`; a skill using the CLI checks for
+`magpie-vcs detect` printing `svn`.
+
+Supported scope, and what is not:
+
+- **Layout.** Only the conventional `trunk` / `branches/<name>` /
+  `tags/<name>` layout, read from the working copy's URL with the
+  *rightmost* marker winning (`^/branches/projects/foo/trunk` is `foo`'s
+  `trunk`). A checkout of a subdirectory that is itself named `trunk`,
+  `branches` or `tags` is misread, and other repository organisations are
+  not supported: there `branch` reports the repository-relative path and
+  `new-branch` / name-based `switch` refuse (pass a URL or `^/` path to
+  `switch`).
+- **Branch names** are one path segment; nested names (`team/fix`) are
+  refused by `new-branch` and by name-based `switch`. A branch or tag may
+  be named `trunk` (`branches/trunk` is a branch, not the trunk).
+- **Partial `new-branch`.** The server-side copy and the local switch are
+  two steps. If the switch fails after the copy succeeded, the branch
+  exists on the server and the working copy stays where it was; recover
+  with `switch <name>`, not another `new-branch`.
 
 ### Adding a backend
 
-A VCS bridge (e.g. #602 Subversion) implements the full binding by
-replacing that backend's `_UnimplementedBackend` base with a concrete
-`VCSBackend` subclass — `detect()`, the read operations, the write
-
-operations — and nothing else changes: detection, dispatch, the CLI,
-and every skill that calls `magpie-vcs` pick it up automatically.
+A VCS bridge (e.g. #603 Jujutsu) implements a concrete `VCSBackend`
+subclass — `detect()`, the read operations, the write operations — and
+registers it in `BACKENDS`; nothing else changes: detection, dispatch,
+the CLI, and every skill that calls `magpie-vcs` pick it up
+automatically.
 
 ## How to use
 
diff --git a/tools/vcs/pyproject.toml b/tools/vcs/pyproject.toml
index f8197cf3..492967d2 100644
--- a/tools/vcs/pyproject.toml
+++ b/tools/vcs/pyproject.toml
@@ -22,7 +22,7 @@ build-backend = "hatchling.build"
 [project]
 name = "magpie-vcs"
 version = "0.1.0"
-description = "Backend-dispatching implementation of the source-control (VCS) capability documented in tools/github/source-control.md — one abstract interface, a complete Git backend, and explicit extension points for non-Git VCS bridges."
+description = "Backend-dispatching implementation of the source-control (VCS) capability documented in tools/github/source-control.md — one abstract interface with Git, Mercurial, Fossil, and Subversion backends."
 readme = "README.md"
 requires-python = ">=3.11"
 license = { text = "Apache-2.0" }
diff --git a/tools/vcs/src/magpie_vcs/__init__.py b/tools/vcs/src/magpie_vcs/__init__.py
index 6730e5d4..51ded199 100644
--- a/tools/vcs/src/magpie_vcs/__init__.py
+++ b/tools/vcs/src/magpie_vcs/__init__.py
@@ -20,9 +20,8 @@
 This module extracts the abstraction documented in
 ``tools/github/source-control.md`` into runnable code: one abstract
 :class:`VCSBackend` interface listing the operations the dev-loop skills
-need, a complete :class:`GitBackend`, a complete :class:`MercurialBackend`, and explicit extension points for the
-non-Git/non-Hg VCS bridges (Subversion #602, Jujutsu #603,
-Fossil #604, Perforce #605).
+need, and complete Git, Mercurial, Fossil, and Subversion backends. Further
+VCS bridges (Jujutsu #603, Perforce #605) plug in as new backends.
 
 A skill calls the abstract operation (``magpie-vcs diff``) instead of a raw
 ``git`` command; the active backend is detected from the working copy (or
@@ -33,12 +32,17 @@ whatever VCS the project enables under *Tools enabled -> Source control*.
 from __future__ import annotations
 
 import argparse
+import functools
 import os
+import re
 import subprocess
 import sys
+import xml.etree.ElementTree as ET
 from abc import ABC, abstractmethod
 from collections.abc import Sequence
+from datetime import datetime
 from pathlib import Path
+from urllib.parse import quote, unquote
 
 __all__ = [
     "BACKENDS",
@@ -297,75 +301,6 @@ class GitBackend(VCSBackend):
         return _run(["git", "show", f"{rev}:{path}"], self.root)
 
 
-class _UnimplementedBackend(VCSBackend):
-    """Shared base for detected-but-not-yet-implemented non-Git backends.
-
-    The detection logic is real (so ``magpie-vcs detect`` correctly reports
-    the working copy's VCS); every operation raises a clear, actionable
-    :class:`VCSError` pointing at the tracking issue for the full bridge.
-    """
-
-    marker: str = ""
-    issue: str = ""
-
-    @classmethod
-    def detect(cls, start: Path) -> Path | None:
-        for d in (start, *start.parents):
-            if (d / cls.marker).exists():
-                return d
-        return None
-
-    def _unsupported(self, op: str) -> VCSError:
-        return VCSError(
-            f"{self.name}: operation '{op}' is not implemented yet — "
-            f"the {self.name} backend is a tracked extension point ({self.issue}). "
-            f"Implement it by completing this backend in tools/vcs/."
-        )
-
-    def status(self) -> str:
-        raise self._unsupported("status")
-
-    def current_branch(self) -> str:
-        raise self._unsupported("current_branch")
-
-    def diff(self, base: str | None = None, cached: bool = False, paths: Sequence[str] = ()) -> str:
-        raise self._unsupported("diff")
-
-    def log(
-        self,
-        max_count: int | None = None,
-        grep: str | None = None,
-        author: str | None = None,
-        since: str | None = None,
-        paths: Sequence[str] = (),
-    ) -> str:
-        raise self._unsupported("log")
-
-    def create_branch(self, name: str) -> None:
-        raise self._unsupported("create_branch")
-
-    def switch(self, ref: str) -> None:
-        raise self._unsupported("switch")
-
-    def stage(self, paths: Sequence[str]) -> None:
-        raise self._unsupported("stage")
-
-    def commit(self, message: str) -> None:
-        raise self._unsupported("commit")
-
-    def fetch(self, remote: str | None = None, ref: str | None = None) -> None:
-        raise self._unsupported("fetch")
-
-    def push(self, remote: str, ref: str, set_upstream: bool = False) -> None:
-        raise self._unsupported("push")
-
-    def reset_worktree(self) -> None:
-        raise self._unsupported("reset_worktree")
-
-    def cat(self, rev: str, path: str) -> str:
-        raise self._unsupported("cat")
-
-
 class MercurialBackend(VCSBackend):
     """Mercurial (Hg) backend implementation."""
 
@@ -598,13 +533,274 @@ class FossilBackend(VCSBackend):
         return _run(["fossil", "cat", path, "-r", rev], self.root)
 
 
-class SubversionBackend(_UnimplementedBackend):
-    """Apache Subversion (SVN) extension point — see apache/magpie#602."""
+class SubversionBackend(VCSBackend):
+    """Apache Subversion (SVN) binding of the source-control capability.
+
+    SVN is centralized (see ``tools/asf-svn/source-control.md``): ``commit``
+    writes straight to the server, so there is nothing to ``fetch`` or ``push``;
+    there is no staging area, so ``commit`` records every change in the working
+    copy; and a line of work is a server-side directory under the conventional
+    ``trunk`` / ``branches/<name>`` / ``tags/<name>`` layout.
+    """
 
     name = "svn"
     distributed = False
-    marker = ".svn"
-    issue = "apache/magpie#602"
+
+    @classmethod
+    def detect(cls, start: Path) -> Path | None:
+        # SVN 1.7+ keeps a single .svn directory at the working-copy root.
+        for d in (start, *start.parents):
+            if (d / ".svn").exists():
+                return d
+        return None
+
+    #: `svn info --show-item` and `svn cleanup --remove-unversioned` arrived in 1.9.
+    min_version: tuple[int, int] = (1, 9)
+
+    @staticmethod
+    @functools.cache
+    def _version() -> tuple[int, int] | None:
+        try:
+            out = subprocess.run(
+                ["svn", "--version", "--quiet"], capture_output=True, text=True, check=True
+            ).stdout
+        except (OSError, subprocess.CalledProcessError):
+            return None
+        match = re.match(r"(\d+)\.(\d+)", out.strip())
+        return (int(match[1]), int(match[2])) if match else None
+
+    @classmethod
+    def is_available(cls) -> bool:
+        version = cls._version()
+        return version is not None and version >= cls.min_version
+
+    def _svn(self, *args: str, capture: bool = True, check: bool = True) -> str:
+        version = self._version()
+        if version is not None and version < self.min_version:
+            have = ".".join(map(str, version))
+            need = ".".join(map(str, self.min_version))
+            raise VCSError(f"svn {have} is too old: the svn backend needs Subversion {need}+")
+        # --non-interactive: never block on an auth or conflict prompt the caller cannot answer.
+        return _run(["svn", "--non-interactive", *args], self.root, capture=capture, check=check)
+
+    @staticmethod
+    def _relative_ref(ref: str) -> str:
+        """Validate a layout-relative ref (``trunk``, ``feature``, ``branches/a/b``) and URL-quote it."""
+        segments = ref.split("/")
+        if any(seg in ("", ".", "..") for seg in segments):
+            raise VCSError(f"svn: invalid branch or ref name {ref!r} (empty, '.' or '..' path segment)")
+        return quote(ref, safe="/")
+
+    def _url(self) -> str:
+        return self._svn("info", "--show-item", "url").strip()
+
+    def _layout(self) -> tuple[str, str, str] | None:
+        """Split the working copy's URL at its line of work.
+
+        Returns ``(project base URL, line of work, path below the line)`` for a
+        URL under ``trunk`` / ``branches/<name>`` / ``tags/<name>`` — e.g.
+        ``(".../asf/foo", "branches/fix", "sub")`` — or ``None`` outside that layout.
+        The rightmost marker wins, so a project nested under a directory that is
+        itself named ``branches`` (``^/branches/projects/foo/trunk``) resolves to
+        ``foo``'s ``trunk``.
+        """
+        info = ET.fromstring(self._svn("info", "--xml"))
+        root = info.findtext("entry/repository/root", "")
+        rel = info.findtext("entry/relative-url", "").removeprefix("^").strip("/")
+        parts = rel.split("/") if rel else []
+        for i in reversed(range(len(parts))):
+            if i > 0 and parts[i - 1] in ("branches", "tags"):
+                # The segment after branches/ or tags/ is a name, even `trunk`.
+                start, width = i - 1, 2
+            elif parts[i] == "trunk":
+                start, width = i, 1
+            else:
+                continue
+            base = "/".join([root, *parts[:start]])
+            return base, "/".join(parts[start : start + width]), "/".join(parts[start + width :])
+        return None
+
+    def _require_layout(self, op: str) -> tuple[str, str, str]:
+        layout = self._layout()
+        if layout is None:
+            raise VCSError(
+                f"svn {op}: {self._url()} is not under a trunk/branches/tags layout; "
+                f"pass a full URL (or ^/ repository-relative path) instead"
+            )
+        return layout
+
+    def status(self) -> str:
+        return self._svn("status", "--ignore-externals")
+
+    def current_branch(self) -> str:
+        # relative-url is URL-encoded; report the name decoded so it round-trips
+        # through switch / new-branch, which quote exactly once.
+        layout = self._layout()
+        if layout is not None:
+            return unquote(layout[1])
+        # Outside the conventional layout, the repository-relative path is the line of work.
+        return unquote(self._svn("info", "--show-item", "relative-url").strip().removeprefix("^").strip("/"))
+
+    def diff(self, base: str | None = None, cached: bool = False, paths: Sequence[str] = ()) -> str:
+        if cached:
+            raise VCSError("svn does not support staging area/cached diff")
+        args = ["diff"]
+        if base:
+            # svn compares the working copy against a *revision*; a line-of-work
+            # name (`trunk`, a branch) would end in svn's own syntax error.
+            if not re.fullmatch(r"\d+|HEAD|BASE|COMMITTED|PREV|\{[^{}]+\}", base):
+                raise VCSError(
+                    f"svn diff --base takes a revision (a number, HEAD, BASE, COMMITTED, PREV "
+                    f"or {{DATE}}), not {base!r}"
+                )
+            args.extend(["-r", base])
+        return self._svn(*args, "--", *paths)
+
+    def log(
+        self,
+        max_count: int | None = None,
+        grep: str | None = None,
+        author: str | None = None,
+        since: str | None = None,
+        paths: Sequence[str] = (),
+    ) -> str:
+        # History lives on the server: read the line of work up to HEAD. svn log
+        # takes one working-copy target, or a URL plus paths relative to it.
+        targets = [self._url(), *paths] if len(paths) > 1 else list(paths) or ["."]
+        since_dt: datetime | None = None
+        if since:
+            try:
+                since_dt = datetime.fromisoformat(since)
+            except ValueError as exc:
+                raise VCSError(f"svn log --since needs an ISO 8601 date, got {since!r}") from exc
+            if since_dt.tzinfo is None:
+                since_dt = since_dt.astimezone()  # naive dates are local time, as svn's {DATE} is
+            # {DATE} resolves to the last revision *before* the date; the boundary is filtered below.
+            rev_range = f"HEAD:{{{since}}}"
+        else:
+            rev_range = "HEAD:1"
+        filtered = bool(grep or author or since)
+        args = ["log", "--xml", "-r", rev_range]
+        if max_count is not None and not filtered:
+            args.extend(["-l", str(max_count)])
+        try:
+            grep_re = re.compile(grep) if grep else None
+            author_re = re.compile(author) if author else None
+        except re.error as exc:
+            raise VCSError(f"svn log: invalid pattern: {exc}") from exc
+
+        lines: list[str] = []
+        for entry in ET.fromstring(self._svn(*args, "--", *targets)).iter("logentry"):
+            msg = entry.findtext("msg", "")
+            if grep_re and not grep_re.search(msg):
+                continue
+            if author_re and not author_re.search(entry.findtext("author", "")):
+                continue
+            if since_dt and datetime.fromisoformat(entry.findtext("date", "")) < since_dt:
+                continue
+            first_line = msg.splitlines()[0] if msg else ""
+            lines.append(f"r{entry.get('revision')} {first_line}")
+            if max_count is not None and len(lines) >= max_count:
+                break
+        return "".join(f"{line}\n" for line in lines)
+
+    def create_branch(self, name: str) -> None:
+        # A branch is a server-side copy of the current line of work at the
+        # working copy's revision; the working copy then switches onto it,
+        # carrying any local modifications, as `git checkout -b` does.
+        base, line, sub = self._require_layout("new-branch")
+        rev = self._svn("info", "--show-item", "revision").strip()
+        if "/" in name:
+            # The layout reads a line of work as branches/<one segment>.
+            raise VCSError(f"svn: invalid branch or ref name {name!r} (a branch name is one path segment)")
+        target = f"{base}/branches/{self._relative_ref(name)}"
+        self._svn("copy", f"{base}/{line}@{rev}", target, "-m", f"Create branch {name} from {line}@{rev}")
+        self._svn("switch", "/".join(filter(None, [target, sub])), capture=False)
+
+    def switch(self, ref: str) -> None:
+        # A URL or ^/ path is used as-is; otherwise `trunk`, `branches/<n>` and
+        # `tags/<n>` name a line of work, and a bare name is a branch (the name
+        # create_branch was given).
+        if "://" in ref or ref.startswith("^/"):
+            target = ref
+        else:
+            base, _, sub = self._require_layout("switch")
+            if ref == "trunk" or ref.startswith(("branches/", "tags/")):
+                line = ref
+            elif "/" in ref:
+                raise VCSError(f"svn: invalid branch or ref name {ref!r} (a branch name is one path segment)")
+            else:
+                line = f"branches/{ref}"
+            target = "/".join(filter(None, [base, self._relative_ref(line), sub]))
+        self._svn("switch", target, capture=False)
+
+    def stage(self, paths: Sequence[str]) -> None:
+        if not paths:
+            raise VCSError("stage: refusing to stage nothing (pass explicit paths)")
+        # `svn add` schedules new paths (and their unversioned parents) and
+        # skips versioned ones; a versioned path gone from disk, given directly
+        # or found under a given directory (`!` in `svn status`), is scheduled
+        # for deletion, as `git add <dir>` stages removals.
+        # lexists, not exists: a versioned symlink whose target is missing is
+        # still on disk and must not be scheduled for deletion.
+        missing = [p for p in paths if not os.path.lexists(self.root / p)]
+        present = [p for p in paths if p not in missing]
+        if present:
+            status = ET.fromstring(self._svn("status", "--xml", "--", *present))
+            missing += [
+                entry.get("path", "")
+                for entry in status.iter("entry")
+                if entry.find("wc-status[@item='missing']") is not None
+            ]
+        if missing:
+            self._svn("delete", "--", *missing, capture=False)
+        if present:
+            self._svn("add", "--force", "--parents", "--", *present, capture=False)
+
+    def commit(self, message: str) -> None:
+        # No staging area: this writes every change in the working copy to the server.
+        self._svn("commit", "-m", message, capture=False)
+
+    def fetch(self, remote: str | None = None, ref: str | None = None) -> None:
+        # Nothing to sync: refs live on the server and every history read
+        # (log, cat, diff --base) queries it directly. `svn update` would
+        # change the working copy, which fetch must not do.
+        return None
+
+    def push(self, remote: str, ref: str, set_upstream: bool = False) -> None:
+        raise VCSError(
+            "svn is centralized: `commit` already published the change to the server; there is nothing to push"
+        )
+
+    def reset_worktree(self) -> None:
+        self._svn("revert", "--recursive", ".", capture=False)
+        # Removes unversioned files but keeps ignored ones, like `git clean -fd`.
+        self._svn("cleanup", "--remove-unversioned", capture=False)
+
+    def cat(self, rev: str, path: str) -> str:
+        # Operative revision on the working-copy path (pegged at BASE) follows
+        # copy history, so a pre-branch revision resolves through trunk. A
+        # trailing "@" keeps an "@" inside the path from being read as a peg.
+        try:
+            return self._svn("cat", "-r", rev, "--", f"{path}@")
+        except VCSError:
+            pass
+        # The path is no longer in the working copy. Find the revision on this
+        # line of work that deleted it (or a parent) and read it through the
+        # revision before that, so copy history is still followed back past
+        # the branch point.
+        info = ET.fromstring(self._svn("info", "--xml"))
+        root = info.findtext("entry/repository/root", "")
+        line = unquote(info.findtext("entry/relative-url", "").removeprefix("^")).rstrip("/")
+        target = f"{line}/{path.strip('/')}"
+        for entry in ET.fromstring(self._svn("log", "--xml", "-v", "-r", "HEAD:1", ".")).iter("logentry"):
+            for changed in entry.iter("path"):
+                gone = changed.text or ""
+                if changed.get("action") in ("D", "R") and (target == gone or target.startswith(f"{gone}/")):
+                    peg = int(entry.get("revision", "0")) - 1
+                    return self._svn("cat", "-r", rev, f"{root}{quote(target)}@{peg}")
+        # Never deleted on this line: resolve it as it was at rev.
+        return self._svn("cat", f"{root}{quote(target)}@{rev}")
 
 
 # Registry, in detection-priority order. New VCS bridges register here.
diff --git a/tools/vcs/tests/test_vcs.py b/tools/vcs/tests/test_vcs.py
index c3df9680..59a3a35a 100644
--- a/tools/vcs/tests/test_vcs.py
+++ b/tools/vcs/tests/test_vcs.py
@@ -16,6 +16,7 @@
 # under the License.
 
 import os
+import shutil
 import subprocess
 from pathlib import Path
 
@@ -43,6 +44,10 @@ for _var in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR", "GI
 
 git_required = pytest.mark.skipif(not GitBackend.is_available(), reason="git not installed")
 hg_required = pytest.mark.skipif(not MercurialBackend.is_available(), reason="hg not installed")
+svn_required = pytest.mark.skipif(
+    not SubversionBackend.is_available() or shutil.which("svnadmin") is None,
+    reason="svn 1.9+ / svnadmin not installed",
+)
 
 
 def _git(repo: Path, *args: str) -> None:
@@ -53,6 +58,10 @@ def _hg(repo: Path, *args: str) -> None:
     subprocess.run(["hg", *args], cwd=repo, check=True, capture_output=True)
 
 
+def _svn(cwd: Path, *args: str) -> None:
+    subprocess.run(["svn", "--non-interactive", *args], cwd=cwd, check=True, capture_output=True)
+
+
 @pytest.fixture
 def git_repo(tmp_path: Path) -> Path:
     repo = tmp_path / "repo"
@@ -80,6 +89,32 @@ def hg_repo(tmp_path: Path) -> Path:
     return repo
 
 
+@pytest.fixture
+def svn_wc(tmp_path: Path) -> Path:
+    """A trunk working copy of a local repo with the standard trunk/branches/tags layout.
+
+    History: r1 creates the layout, r2 (by ``alice``) adds file.txt, r3 (by ``bob``)
+    adds other.txt — so ``file.txt`` is ``hello`` at r2 and the WC is at r3.
+    """
+    repos = tmp_path / "svnrepo"
+    subprocess.run(["svnadmin", "create", str(repos)], check=True, capture_output=True)
+    url = repos.as_uri()
+    _svn(
+        tmp_path, "mkdir", "--parents", "-m", "layout",
+        f"{url}/proj/trunk", f"{url}/proj/branches", f"{url}/proj/tags",
+    )  # fmt: skip
+    wc = tmp_path / "wc"
+    _svn(tmp_path, "checkout", f"{url}/proj/trunk", str(wc))
+    (wc / "file.txt").write_text("hello\n")
+    _svn(wc, "add", "file.txt")
+    _svn(wc, "commit", "--username", "alice", "-m", "initial commit")
+    (wc / "other.txt").write_text("other\n")
+    _svn(wc, "add", "other.txt")
+    _svn(wc, "commit", "--username", "bob", "-m", "add other.txt")
+    _svn(wc, "update")
+    return wc
+
+
 # -- detection -------------------------------------------------------------
 
 
@@ -207,14 +242,247 @@ def test_git_cat(git_repo: Path) -> None:
     assert backend.cat("HEAD", "file.txt") == "hello\n"
 
 
-# -- unimplemented backends ------------------------------------------------
+# -- svn backend operations ------------------------------------------------
+
+
+@svn_required
+def test_svn_detect_from_subdir(svn_wc: Path) -> None:
+    sub = svn_wc / "sub"
+    sub.mkdir()
+    backend = detect_backend(sub)
+    assert isinstance(backend, SubversionBackend)
+    assert backend.root == svn_wc
+    assert backend.distributed is False  # centralized model flagged
+
+
+@svn_required
+def test_svn_clean_then_dirty(svn_wc: Path) -> None:
+    backend = SubversionBackend(svn_wc)
+    assert backend.is_clean()
+    (svn_wc / "file.txt").write_text("changed\n")
+    (svn_wc / "untracked.txt").write_text("u\n")
+    status = backend.status()
+    assert "M       file.txt" in status
+    assert "?       untracked.txt" in status
+    assert not backend.is_clean()
+
+
+@svn_required
+def test_svn_branch_switch_and_commit(svn_wc: Path) -> None:
+    backend = SubversionBackend(svn_wc)
+    assert backend.current_branch() == "trunk"
+    backend.create_branch("feature")
+    assert backend.current_branch() == "branches/feature"
+    (svn_wc / "pkg").mkdir()
+    (svn_wc / "pkg" / "new.txt").write_text("new\n")
+    backend.stage(["pkg/new.txt"])  # unversioned parent dir is added too
+    backend.commit("add new.txt")
+    assert backend.is_clean()
+    assert "add new.txt" in backend.log(max_count=1)
+    backend.switch("trunk")
+    assert backend.current_branch() == "trunk"
+    assert not (svn_wc / "pkg").exists()
+    backend.switch("feature")  # a name created by create_branch switches back
+    assert (svn_wc / "pkg" / "new.txt").read_text() == "new\n"
+
+
+@svn_required
+def test_svn_stage_modified_and_deleted(svn_wc: Path) -> None:
+    backend = SubversionBackend(svn_wc)
+    (svn_wc / "file.txt").write_text("changed\n")
+    (svn_wc / "other.txt").unlink()
+    backend.stage(["file.txt", "other.txt"])  # versioned: no-op; gone from disk: scheduled delete
+    assert backend.status().splitlines() == ["M       file.txt", "D       other.txt"]
+    backend.commit("edit file.txt, drop other.txt")
+    assert backend.is_clean()
+    with pytest.raises(VCSError, match="refusing to stage nothing"):
+        backend.stage([])
+
+
+@svn_required
+@pytest.mark.parametrize("name", ["", "..", ".", "../escape", "team/fix"])
+def test_svn_create_branch_rejects_bad_names(svn_wc: Path, name: str) -> None:
+    with pytest.raises(VCSError, match="invalid branch or ref name"):
+        SubversionBackend(svn_wc).create_branch(name)
+    assert SubversionBackend(svn_wc).current_branch() == "trunk"
+
+
+@svn_required
+def test_svn_branch_name_is_url_quoted(svn_wc: Path) -> None:
+    backend = SubversionBackend(svn_wc)
+    backend.create_branch("fix 1")
+    assert backend._url().endswith("/branches/fix%201")
+    assert backend.current_branch() == "branches/fix 1"  # decoded, not branches/fix%201
+    branch = backend.current_branch()
+    backend.switch("trunk")
+    backend.switch(branch)  # branch output round-trips through switch
+    assert backend._url().endswith("/branches/fix%201")
+    backend.switch("trunk")
+    backend.switch("fix 1")
+    assert backend._url().endswith("/branches/fix%201")
+
+
+def test_svn_too_old_rejected(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+    monkeypatch.setattr(SubversionBackend, "_version", staticmethod(lambda: (1, 8)))
+    assert SubversionBackend.is_available() is False
+    with pytest.raises(VCSError, match=r"svn 1\.8 is too old.*1\.9\+"):
+        SubversionBackend(tmp_path).status()
+
+
+@svn_required
+def test_svn_layout_uses_rightmost_marker(tmp_path: Path) -> None:
+    repos = tmp_path / "nested"
+    subprocess.run(["svnadmin", "create", str(repos)], check=True, capture_output=True)
+    url = repos.as_uri()
+    _svn(
+        tmp_path, "mkdir", "--parents", "-m", "layout",
+        f"{url}/branches/projects/foo/trunk", f"{url}/branches/projects/foo/branches",
+    )  # fmt: skip
+    wc = tmp_path / "nestedwc"
+    _svn(tmp_path, "checkout", f"{url}/branches/projects/foo/trunk", str(wc))
+    backend = SubversionBackend(wc)
+    assert backend.current_branch() == "trunk"
+    backend.create_branch("fix")
+    assert backend._url() == f"{url}/branches/projects/foo/branches/fix"
+
+
+@svn_required
+def test_svn_branch_named_trunk_is_a_branch(svn_wc: Path) -> None:
+    backend = SubversionBackend(svn_wc)
+    backend.create_branch("trunk")
+    assert backend.current_branch() == "branches/trunk"
+    backend.create_branch("fix")  # base stays .../proj, not .../proj/branches
+    assert backend._url().endswith("/proj/branches/fix")
+
+
+@svn_required
+def test_svn_switch_rejects_nested_bare_name(svn_wc: Path) -> None:
+    with pytest.raises(VCSError, match="one path segment"):
+        SubversionBackend(svn_wc).switch("team/fix")
+
+
+@svn_required
+def test_svn_create_branch_outside_layout_rejected(tmp_path: Path) -> None:
+    repos = tmp_path / "flat"
+    subprocess.run(["svnadmin", "create", str(repos)], check=True, capture_output=True)
+    wc = tmp_path / "flatwc"
+    _svn(tmp_path, "checkout", repos.as_uri(), str(wc))
+    backend = SubversionBackend(wc)
+    assert backend.current_branch() == ""  # repository root, no line-of-work segment
+    with pytest.raises(VCSError, match="trunk/branches/tags"):
+        backend.create_branch("feature")
+
+
+@svn_required
+def test_svn_log_filters(svn_wc: Path) -> None:
+    backend = SubversionBackend(svn_wc)
+    assert backend.log().splitlines() == ["r3 add other.txt", "r2 initial commit", "r1 layout"]
+    assert backend.log(max_count=1).splitlines() == ["r3 add other.txt"]
+    assert backend.log(grep="initial").splitlines() == ["r2 initial commit"]
+    assert backend.log(author="bob").splitlines() == ["r3 add other.txt"]
+    assert backend.log(grep="other", author="alice") == ""
+    assert backend.log(paths=["file.txt"]).splitlines() == ["r2 initial commit"]
+    assert backend.log(paths=["file.txt", "other.txt"]).splitlines() == [
+        "r3 add other.txt",
+        "r2 initial commit",
+    ]
+    # max_count applies after filtering, not to the raw history window
+    assert backend.log(max_count=1, author="alice").splitlines() == ["r2 initial commit"]
+    assert backend.log(since="2000-01-01").splitlines()[0] == "r3 add other.txt"
+
+
+@svn_required
+@pytest.mark.parametrize("target", ["pkg", "."])
+def test_svn_stage_directory_schedules_nested_deletions(svn_wc: Path, target: str) -> None:
+    (svn_wc / "pkg").mkdir()
+    (svn_wc / "pkg" / "a.txt").write_text("a\n")
+    (svn_wc / "pkg" / "b.txt").write_text("b\n")
+    _svn(svn_wc, "add", "pkg")
+    _svn(svn_wc, "commit", "-m", "add pkg")
+    _svn(svn_wc, "update")
+    (svn_wc / "pkg" / "a.txt").unlink()
+    (svn_wc / "pkg" / "new.txt").write_text("n\n")
+    backend = SubversionBackend(svn_wc)
+    backend.stage([target])
+    assert sorted(backend.status().splitlines()) == ["A       pkg/new.txt", "D       pkg/a.txt"]
+    backend.commit("rework pkg")
+    assert backend.is_clean()
+
+
+@svn_required
+def test_svn_stage_keeps_dangling_versioned_symlink(svn_wc: Path) -> None:
+    (svn_wc / "link").symlink_to("nowhere")  # dangling from the start
+    _svn(svn_wc, "add", "link")
+    _svn(svn_wc, "commit", "-m", "add dangling link")
+    _svn(svn_wc, "update")
+    backend = SubversionBackend(svn_wc)
+    backend.stage(["link"])
+    assert backend.status() == ""  # not scheduled for deletion
+    assert (svn_wc / "link").is_symlink()
+
+
+@svn_required
+def test_svn_diff_base_takes_a_revision(svn_wc: Path) -> None:
+    backend = SubversionBackend(svn_wc)
+    (svn_wc / "file.txt").write_text("changed\n")
+    assert "+changed" in backend.diff(base="HEAD")
+    for name in ("trunk", "main", "branches/fix"):
+        with pytest.raises(VCSError, match="takes a revision"):
+            backend.diff(base=name)
+
+
+@svn_required
+def test_svn_diff_and_cat(svn_wc: Path) -> None:
+    backend = SubversionBackend(svn_wc)
+    (svn_wc / "file.txt").write_text("changed\n")
+    working = backend.diff()
+    assert "-hello" in working
+    assert "+changed" in working
+    assert "other.txt" not in working
+    assert "+other" in backend.diff(base="2")  # r3 added other.txt after the base
+    assert "file.txt" in backend.diff(paths=["file.txt"])
+    with pytest.raises(VCSError, match="does not support staging area"):
+        backend.diff(cached=True)
+    assert backend.cat("2", "file.txt") == "hello\n"
+
+
+@svn_required
+def test_svn_cat_follows_branch_history(svn_wc: Path) -> None:
+    backend = SubversionBackend(svn_wc)
+    backend.create_branch("feature")  # r4: copy of trunk@3
+    assert backend.cat("2", "file.txt") == "hello\n"  # pre-branch revision, via copy history
+    (svn_wc / "file.txt").unlink()
+    backend.stage(["file.txt"])
+    backend.commit("drop file.txt")  # r5: gone from the working copy
+    _svn(svn_wc, "update")
+    assert backend.cat("4", "file.txt") == "hello\n"  # deleted path still resolves at an older rev
+    assert backend.cat("2", "file.txt") == "hello\n"  # deleted on the branch, pre-branch revision
+
+
+@svn_required
+def test_svn_reset_worktree(svn_wc: Path) -> None:
+    backend = SubversionBackend(svn_wc)
+    _svn(svn_wc, "propset", "svn:ignore", "ignored.txt", ".")
+    _svn(svn_wc, "commit", "-m", "ignore ignored.txt")
+    _svn(svn_wc, "update")
+    (svn_wc / "file.txt").write_text("changed\n")
+    (svn_wc / "untracked.txt").write_text("u\n")
+    (svn_wc / "ignored.txt").write_text("i\n")
+    backend.reset_worktree()
+    assert backend.is_clean()
+    assert (svn_wc / "file.txt").read_text() == "hello\n"
+    assert not (svn_wc / "untracked.txt").exists()
+    assert (svn_wc / "ignored.txt").exists()  # ignored files are preserved, as with git clean -fd
 
 
-def test_unimplemented_raise_with_issue(tmp_path: Path) -> None:
-    svn = SubversionBackend(tmp_path)
-    with pytest.raises(VCSError, match=r"apache/magpie#602"):
-        svn.commit("x")
-    assert svn.distributed is False  # centralized model flagged
+@svn_required
+def test_svn_fetch_leaves_wc_and_push_refuses(svn_wc: Path) -> None:
+    backend = SubversionBackend(svn_wc)
+    (svn_wc / "file.txt").write_text("changed\n")
+    backend.fetch()
+    assert (svn_wc / "file.txt").read_text() == "changed\n"
+    with pytest.raises(VCSError, match="already published"):
+        backend.push("origin", "trunk")
 
 
 # -- hg backend operations -------------------------------------------------
@@ -299,10 +567,15 @@ def test_cli_unknown_backend_errors(git_repo: Path, capsys: pytest.CaptureFixtur
     assert "unknown backend" in capsys.readouterr().err
 
 
-def test_cli_unimplemented_backend_errors(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
-    (tmp_path / ".svn").mkdir()
-    assert main(["-C", str(tmp_path), "status"]) == 2
-    assert "apache/magpie#602" in capsys.readouterr().err
+@svn_required
+def test_cli_svn_detect_and_status(svn_wc: Path, capsys: pytest.CaptureFixture[str]) -> None:
+    assert main(["-C", str(svn_wc), "detect"]) == 0
+    assert capsys.readouterr().out.strip() == "svn"
+    assert main(["-C", str(svn_wc), "clean"]) == 0
+    (svn_wc / "file.txt").write_text("changed\n")
+    assert main(["-C", str(svn_wc), "clean"]) == 1
+    assert main(["-C", str(svn_wc), "push", "origin", "trunk"]) == 2
+    assert "already published" in capsys.readouterr().err
 
 
 @git_required