diff --git a/.agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md b/.agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md index 77d95d3541..e42b89c635 100644 --- a/.agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md +++ b/.agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md @@ -129,6 +129,16 @@ P1 已完成(2026-10-22): - 会话:`litheVcs` 只提供给插件启动的会话;`lsp.vcsRequest` 按操作名生成 `lithe/vcs/*` 请求,未协商的能力在写出前就拒绝;Provider 只能使用契约列出的错误码,其余一律变成 `vcs.provider.failed`。`lithe/vcs/authenticate` 在宿主实现交互式认证前按未知请求回绝。 - Node SDK:`backend.vcs(id, handlers, traits)` 按声明的处理函数协商能力,VCS 处理函数里的 `context.run` 会自动发出 `lithe/vcs/execution` 执行报告(只带命令行、退出码、耗时和截断后的 stderr,不带 stdout)。`sdk/node/examples/sample-vcs` 是一个把修订存在 JSON 文件里的玩具 VCS,Rust 端到端测试经真实 Core 会话跑通它的协商和全部 11 种操作。 +P2 已完成(2026-10-23): + +- 插件在 `Plugins/mac/Official/SvnSupport/`,后端是 `provider/*.mjs`,不依赖 npm 包(XML 解析自带),打包时把 `sdk/node/src` 复制进 `provider/vendor/`。清单声明 `vcsProviders` 能力,但宿主还没声明这项能力,所以 P3 之前不会进插件目录和市场。每个命令到 svn 的对应关系写在契约的“Subversion provider notes”里。 +- `head` 用根目录自己的工作修订(`svn info` 的结果)。提交后根目录停在旧修订,要 `update` 才前进,这是 SVN 的混合修订语义,不是错误,宿主不要拿它判断“提交没成功”。根历史从 `HEAD` 开始,刚提交的修订仍能列出。 +- 回滚:移动的**文件**用 `revert --remove-added` 删掉副本、恢复源文件;移动的**目录**不带 `--remove-added`,副本留在磁盘上变成未版本化文件。实测 `--remove-added` 会连同目录里用户的未版本化文件一起删掉,宁可多留文件,也不删用户数据。 +- 提交:只提交选中的路径(`--depth empty`),自动带上移动的另一半和新增的父目录;选中的未版本化路径先 `svn add`,选中的缺失路径先 `svn delete`,界面上不需要先“添加到 VCS”再提交。 +- svn 每个写命令结束前会等约 1 秒,保证之后的修改时间戳不同。生产环境保留这个等待;测试通过 `lsp.startServer` 的 `environment` 传入 `SVN_I_LOVE_CORRUPTED_WORKING_COPIES_SO_DISABLE_SLEEP_FOR_TIMESTAMPS=yes` 关掉,否则端到端测试超过 20 秒。 +- 第一版 `status` 每次查整个根,契约 v1 也没有按路径查询的参数。增量查询等 P3 有了 `.svn/wc.db` 变化事件、量出大工作副本的实际耗时后再加,要加就按能力版本加参数。 +- 测试:`test/record-fixtures.sh` 在临时 `file://` 仓库里造出每一种状态(移动、复制、替换、切换、锁、外部定义、树冲突、内容冲突、非 ASCII 和带 `@` 的文件名),录下 `status/info/log/blame --xml` 和需要区分的错误输出(svn 1.15.0),`node --test` 用它们测解析;升级 svn 后重录并审查 diff。`rust/lithe-core/tests/plugin_sdk/svn.rs` 用真实 `svn` 和 Core 会话跑状态、提交、过期提交、更新、冲突与解决、回滚、移动、删除、历史分页、Annotate 和二进制文件。它由 `LITHE_SVN` 开启,`verify-rust-core-crates.sh` 在找到 `svn` 和 `svnadmin` 时设置;没装 svn 的机器跳过这个测试并打印原因。 + ### 10. Git 什么时候、怎样接入 Git 接入不在本提案的范围内,这里只记下评审时已经确认的方向,供以后的提案使用: @@ -182,7 +192,7 @@ Git 接入不在本提案的范围内,这里只记下评审时已经确认的 - **统一层只被一种 VCS 验证过。** 第一期只有 SVN 用统一层,模型里可能藏着 SVN 的偏见。缓解办法:模型只收第 2 节表里 Git 和 SVN 都成立的概念,Git 专属的暂存维度已经作为可选能力进入 fixtures;Git 接入时如果发现缺口,按能力版本加字段。 - **Git 仍在快速变化。** Issue 评论里提到 Git 还没稳定。第一期不碰 Git,`git.*` 不冻结。 -- **状态读取是热路径。** SVN 在大工作副本上 `svn status` 较慢,由 Provider 按变化路径做增量查询,宿主合并刷新事件。 +- **状态读取是热路径。** SVN 在大工作副本上 `svn status` 较慢。第一版每次查整个根,宿主先合并刷新事件、限制刷新频率;实测太慢时再按能力版本加按路径的增量查询。 - **混合根和嵌套根。** Git 仓库里嵌套 SVN 工作副本时,文件必须只归属一个根,否则会重复显示或重复提交。识别规则和用户映射都要有 fixture 覆盖。 - **插件进程常驻的成本。** 只在识别到对应根时启动,关闭工作区即回收,规则与构建 Provider 相同。 - **回退方式。** 每个阶段都只新增路径,停用 SVN 插件即回到现状。 @@ -193,6 +203,7 @@ Git 接入不在本提案的范围内,这里只记下评审时已经确认的 - `rust/lithe-core/src/plugins/` - `rust/lithe-core/src/lsp/interface/` - `sdk/node/` +- `Plugins/mac/Official/SvnSupport/` - `rust/lithe-core/src/git/` - `rust/lithe-git-host/` - `macos/Sources/LitheGitModule/` diff --git a/Plugins/mac/Official/SvnSupport/.gitignore b/Plugins/mac/Official/SvnSupport/.gitignore new file mode 100644 index 0000000000..5854480eb0 --- /dev/null +++ b/Plugins/mac/Official/SvnSupport/.gitignore @@ -0,0 +1,2 @@ +# Copied from sdk/node/src when the package is assembled. +provider/vendor/ diff --git a/Plugins/mac/Official/SvnSupport/assemble-package.sh b/Plugins/mac/Official/SvnSupport/assemble-package.sh new file mode 100755 index 0000000000..06c0a01755 --- /dev/null +++ b/Plugins/mac/Official/SvnSupport/assemble-package.sh @@ -0,0 +1,38 @@ +#!/bin/zsh + +set -euo pipefail + +# Assembles the Subversion Support plugin package: the provider's modules and +# the Node SDK they import, vendored from sdk/node/src so the package runs +# offline without node_modules. The package holds no native code and is the +# same for every architecture; it is read-only at runtime. See +# .agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md. + +PLUGIN_DIR="${0:A:h}" +ROOT_DIR="${PLUGIN_DIR:h:h:h:h}" +OUTPUT_DIR="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --output) OUTPUT_DIR="$2"; shift 2 ;; + --arch) shift 2 ;; + *) print -u2 -- "Usage: $0 --output package-directory [--arch arm64|x86_64|universal]"; exit 2 ;; + esac +done +[[ -n "$OUTPUT_DIR" ]] || { print -u2 -- "--output is required"; exit 2; } + +provider="$OUTPUT_DIR/provider" +rm -rf "$provider" +mkdir -p "$provider/vendor/lithe-plugin-sdk" +cp "$PLUGIN_DIR"/provider/*.mjs "$provider/" +# The SDK's runtime modules only; its type declarations are for authors. +cp "$ROOT_DIR"/sdk/node/src/*.mjs "$provider/vendor/lithe-plugin-sdk/" + +# plugin.json names the entry exactly, so a rename fails here instead of when +# the host first opens a working copy. +entry=$(node -e 'const m = require(process.argv[1]); process.stdout.write(m.vcsProviders[0].launch.entry)' \ + "$PLUGIN_DIR/plugin.json") +[[ -f "$OUTPUT_DIR/$entry" ]] || { + print -u2 -- "Subversion Support declares $entry, which the assembled package does not contain." + exit 1 +} diff --git a/Plugins/mac/Official/SvnSupport/plugin.json b/Plugins/mac/Official/SvnSupport/plugin.json new file mode 100644 index 0000000000..f3f2ff5766 --- /dev/null +++ b/Plugins/mac/Official/SvnSupport/plugin.json @@ -0,0 +1,38 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.plugin.svn-support", + "displayName": "Subversion Support", + "version": "0.1.0", + "pluginAPIVersion": 3, + "requires": {"serverProcess": 1, "vcsProviders": 1}, + "vendor": { + "id": "dev.lithe", + "displayName": "Lithe", + "signatureRequirement": "publisherPackage" + }, + "permissions": ["languageServer.launch", "process.vcs"], + "activationEvents": ["onVcsRoot:svn"], + "languages": [], + "vcsProviders": [ + { + "id": "svn", + "displayName": "Subversion", + "detection": { + "markers": [{"name": ".svn", "type": "directory"}] + }, + "metadataPaths": [".svn/wc.db"], + "tool": { + "commands": ["svn"], + "validationArguments": ["--version", "--quiet"] + }, + "launch": { + "kind": "runtime", + "tool": "node", + "minimumVersion": "20.3", + "entry": "provider/svn-provider.mjs" + }, + "arguments": ["--stdio"], + "mayUseNetwork": true + } + ] +} diff --git a/Plugins/mac/Official/SvnSupport/provider/svn-output.mjs b/Plugins/mac/Official/SvnSupport/provider/svn-output.mjs new file mode 100644 index 0000000000..df34bfe084 --- /dev/null +++ b/Plugins/mac/Official/SvnSupport/provider/svn-output.mjs @@ -0,0 +1,329 @@ +// What the Subversion provider knows about the `svn` client, without running +// it: the environment it needs, how a path is passed, how its errors map to +// the contract's codes, and how its `--xml` output becomes contract replies. +// Everything here is pure so it is tested against recorded output +// (test/fixtures/) on machines without svn. + +import { parseXml } from './xml.mjs'; + +/** + * The environment every svn command runs with. svn converts paths from the + * locale's character set, and an app started from the Dock has no UTF-8 + * locale, so non-ASCII paths would fail; messages are English so the + * commit output can be read. Everything else is the user's environment, + * including where svn keeps its configuration and credential cache. + * + * @param {NodeJS.ProcessEnv} [environment] + * @param {NodeJS.Platform} [platform] + */ +export function svnEnvironment(environment = process.env, platform = process.platform) { + const result = { ...environment }; + delete result.LC_ALL; + delete result.LANGUAGE; + result.LC_MESSAGES = 'C'; + result.LC_CTYPE = platform === 'darwin' ? 'UTF-8' : 'C.UTF-8'; + return result; +} + +/** + * A working-copy path as an svn target. svn reads the text after the last + * `@` as a peg revision, so a trailing `@` keeps `a@b.txt` a file name. A + * move or copy destination is not a target and is passed as it is. + */ +export function target(path) { + return `${path}@`; +} + +/** Error and warning codes (`E155004`, `W150002`) svn printed, in order. */ +export function svnCodes(stderr) { + return [...stderr.matchAll(/^svn: (?:warning: )?([EW]\d{6}):/gm)].map((match) => match[1]); +} + +/** + * svn errors with a contract code, keyed by `VCS_ERRORS` names. The first + * listed code svn printed decides, so the outermost cause wins over the + * wrapper errors around it. + */ +const CONTRACT_ERRORS = new Map([ + // Working copy locked; previous operation not finished. + ['E155004', 'workingCopyLocked'], + ['E155037', 'workingCopyLocked'], + // Format too old for this client; client too old for this format. + ['E155036', 'workingCopyUnsupported'], + ['E155021', 'workingCopyUnsupported'], + // Out of date in the working copy, the repository, or the RA layer; + // a conflict found while the server merged the commit. + ['E155011', 'outOfDate'], + ['E160028', 'outOfDate'], + ['E160024', 'outOfDate'], + ['E170004', 'outOfDate'], + // A conflicted path is part of the operation. + ['E155015', 'conflict'], + // Authorization failed; no credentials left to try; certificate not trusted. + ['E170001', 'authenticationFailed'], + ['E215004', 'authenticationFailed'], + ['E230001', 'authenticationFailed'], +]); + +/** + * The contract error of a failed svn command: a `VCS_ERRORS` name, or + * `null` for a failure the host shows as a generic provider error, and the + * message to show. + * + * @param {string} stderr + * @returns {{ kind: string | null, message: string }} + */ +export function classifyFailure(stderr) { + const codes = svnCodes(stderr); + const kind = codes.map((code) => CONTRACT_ERRORS.get(code)).find(Boolean) ?? null; + const message = stderr.trim() || 'svn failed without an error message.'; + return { kind, message: truncate(message, 4000) }; +} + +function truncate(text, bytes) { + const encoded = Buffer.from(text, 'utf8'); + return encoded.length <= bytes ? text : `${encoded.subarray(0, bytes - 3).toString('utf8').replace(/�$/, '')}...`; +} + +/** Whether svn said the path has no base to read: unversioned, added, or replaced. */ +export function lacksBase(stderr) { + const codes = new Set(svnCodes(stderr)); + return ( + codes.has('W200005') || + codes.has('W155010') || + codes.has('E155010') || + codes.has('E195002') || + /has no pristine version/.test(stderr) + ); +} + +/** Whether svn said the path has no committed history yet (log, blame). */ +export function lacksHistory(stderr) { + const codes = new Set(svnCodes(stderr)); + return codes.has('E195002') || codes.has('W155010') || codes.has('E155010'); +} + +/** + * Whether `svn add` only skipped targets that were already versioned, which + * still makes it exit with an error after adding the others. + */ +export function onlyAlreadyVersioned(stderr) { + const codes = svnCodes(stderr); + return codes.includes('W150002') && codes.every((code) => code === 'W150002' || code === 'E200009'); +} + +/** svn prints `W155007` and exits 0 when the root is not a working copy. */ +export function notWorkingCopy(stderr) { + return svnCodes(stderr).includes('W155007'); +} + +/** A revision ID as the contract presents it. */ +export function revisionOf(id) { + return id === null || id === undefined ? null : { id: String(id), display: `r${id}`, kind: 'number' }; +} + +/** Whether `value` is a revision number svn accepts. */ +export function isRevisionNumber(value) { + return typeof value === 'string' && /^(0|[1-9]\d{0,15})$/.test(value); +} + +/** + * A path svn printed, as a root-relative contract path. svn prints local + * style, so Windows separators become `/`; the root itself is `null`. + */ +export function relativePath(path, platform = process.platform) { + const portable = platform === 'win32' ? path.replaceAll('\\', '/') : path; + return portable === '.' || portable === '' ? null : portable.replace(/^\.\//, ''); +} + +/** + * `svn info --xml` of a root: its working revision, URL, and the + * repository path history entries are relative to. + */ +export function parseInfo(xml) { + const entry = parseXml(xml).child('entry'); + if (!entry) throw new Error('svn info printed no entry.'); + const url = entry.textOf('url'); + const repositoryRoot = entry.child('repository')?.textOf('root') ?? null; + const relativeUrl = entry.textOf('relative-url'); + let repositoryPath = null; + if (relativeUrl?.startsWith('^/')) repositoryPath = decodeURIComponent(relativeUrl.slice(1)); + else if (url && repositoryRoot && url.startsWith(repositoryRoot)) { + repositoryPath = decodeURIComponent(url.slice(repositoryRoot.length)) || '/'; + } + const revision = entry.attributes.revision; + return { + revision: isRevisionNumber(revision) ? revision : null, + url, + repositoryPath, + }; +} + +/** + * Every entry of `svn status --xml`, by root-relative path, with what the + * operations need beyond the reply: the svn item and both ends of a move. + * + * @returns {Map} + */ +export function parseStatus(xml, platform = process.platform) { + const entries = new Map(); + for (const targetElement of parseXml(xml).all('target')) { + for (const entry of targetElement.all('entry')) { + const path = relativePath(entry.attributes.path ?? '', platform); + const status = entry.child('wc-status'); + // The root itself has no contract path; its property changes are not + // reported in version 1. + if (path === null || !status) continue; + const attributes = status.attributes; + entries.set(path, { + item: attributes.item ?? 'none', + props: attributes.props ?? 'none', + movedFrom: attributes['moved-from'] ? relativePath(attributes['moved-from'], platform) : null, + movedTo: attributes['moved-to'] ? relativePath(attributes['moved-to'], platform) : null, + copied: attributes.copied === 'true', + switched: attributes.switched === 'true', + treeConflicted: attributes['tree-conflicted'] === 'true', + locked: status.child('lock') !== null, + }); + } + } + return entries; +} + +/** Whether an entry is in a text, property, or tree conflict. */ +export function isConflicted(entry) { + return entry.item === 'conflicted' || entry.props === 'conflicted' || entry.treeConflicted; +} + +/** + * The contract change of one status entry, or `null` for an entry that is + * not a change: unmodified, an externals definition (its working copy is + * a root of its own), or incomplete after an interrupted update. + */ +export function changeOf(path, entry) { + const flags = []; + if (entry.props === 'modified') flags.push('propertiesModified'); + if (entry.locked) flags.push('locked'); + if (entry.treeConflicted) flags.push('treeConflict'); + const change = (status, oldPath = null) => ({ path, oldPath, status, flags }); + if (isConflicted(entry)) return change('conflicted'); + switch (entry.item) { + case 'modified': + case 'merged': + return change('modified'); + case 'added': + if (entry.movedFrom) return change('renamed', entry.movedFrom); + return change(entry.copied ? 'copied' : 'added'); + case 'deleted': + // The source of a move is reported by its destination, as a rename. + return entry.movedTo ? null : change('deleted'); + case 'replaced': + return change('replaced'); + case 'missing': + return change('missing'); + case 'obstructed': + return change('typeChanged'); + case 'unversioned': + return change('unversioned'); + case 'ignored': + return change('ignored'); + case 'normal': + case 'none': + if (entry.switched) return change('switched'); + if (entry.props === 'modified') return change('modified'); + return null; + default: + return null; + } +} + +/** The changes of a status snapshot, in svn's order; Core sorts them. */ +export function statusChanges(entries) { + return [...entries].map(([path, entry]) => changeOf(path, entry)).filter(Boolean); +} + +/** Paths a status snapshot reports as conflicted, for an update reply. */ +export function conflictedPaths(entries) { + return [...entries].filter(([, entry]) => isConflicted(entry)).map(([path]) => path); +} + +/** A repository path (`/trunk/src/a.txt`) relative to the root's repository path. */ +function underRoot(repositoryPath, rootPath) { + if (rootPath === null) return null; + if (rootPath === '/') return repositoryPath.length > 1 ? repositoryPath.slice(1) : null; + return repositoryPath.startsWith(`${rootPath}/`) ? repositoryPath.slice(rootPath.length + 1) : null; +} + +const LOG_ACTIONS = { A: 'added', D: 'deleted', M: 'modified', R: 'replaced' }; + +/** + * The changes of one revision inside the root. A path copied from a path + * the same revision deleted is a rename; paths outside the root are left + * out, since the contract only has root-relative paths. + */ +function logChanges(pathElements, rootPath) { + const items = pathElements.map((element) => ({ + path: element.text, + action: element.attributes.action, + from: element.attributes['copyfrom-path'] ?? null, + })); + const deleted = new Set(items.filter((item) => item.action === 'D').map((item) => item.path)); + const renamedFrom = new Set( + items.filter((item) => item.action === 'A' && item.from && deleted.has(item.from)).map((item) => item.from), + ); + const changes = []; + for (const item of items) { + const path = underRoot(item.path, rootPath); + if (path === null || (item.action === 'D' && renamedFrom.has(item.path))) continue; + const oldPath = item.from ? underRoot(item.from, rootPath) : null; + let status = LOG_ACTIONS[item.action] ?? 'modified'; + if (item.action === 'A' && item.from) status = renamedFrom.has(item.from) ? 'renamed' : 'copied'; + changes.push({ path, oldPath: status === 'renamed' || status === 'copied' ? oldPath : null, status, flags: [] }); + } + return changes; +} + +/** + * `svn log --xml [--verbose]` as history entries, newest first. Without + * `--verbose` an entry lists no changes. + * + * @param {string} xml + * @param {string | null} rootPath Repository path of the root, from `svn info`. + */ +export function parseLog(xml, rootPath) { + return parseXml(xml) + .all('logentry') + .map((entry) => ({ + revision: revisionOf(entry.attributes.revision), + author: entry.textOf('author'), + date: entry.textOf('date'), + message: entry.textOf('msg') ?? '', + changes: logChanges(entry.child('paths')?.all('path') ?? [], rootPath), + changesTruncated: false, + })); +} + +/** + * `svn blame --xml` of one file: per line the revision it comes from, or + * `null` for a line changed locally, and the details of each revision. + */ +export function parseBlame(xml) { + const blameTarget = parseXml(xml).child('target'); + if (!blameTarget) throw new Error('svn blame printed no target.'); + const revisions = new Map(); + const lines = blameTarget.all('entry').map((entry) => { + const commit = entry.child('commit'); + const id = commit?.attributes.revision; + if (!isRevisionNumber(id)) return null; + if (!revisions.has(id)) { + revisions.set(id, { revision: revisionOf(id), author: commit.textOf('author'), date: commit.textOf('date'), message: null }); + } + return id; + }); + return { lines, revisions }; +} + +/** The revision `svn commit` reports, or `null` when nothing was committed. */ +export function committedRevision(stdout) { + return /^Committed revision (\d+)\.$/m.exec(stdout)?.[1] ?? null; +} diff --git a/Plugins/mac/Official/SvnSupport/provider/svn-provider.mjs b/Plugins/mac/Official/SvnSupport/provider/svn-provider.mjs new file mode 100644 index 0000000000..6fb98b963b --- /dev/null +++ b/Plugins/mac/Official/SvnSupport/provider/svn-provider.mjs @@ -0,0 +1,428 @@ +// The official Subversion provider: answers Lithe's VCS provider contract +// (shared/contracts/vcs-provider-v1.md) by running the user's `svn` client +// in the working copy root and reading its `--xml` output. Parsing lives in +// svn-output.mjs; this file decides which commands an operation runs. +// +// Every command runs with `--non-interactive`, so svn answers from its own +// credential cache or fails; the provider never sees a password. Commands +// that change the working copy or the repository are reported to the host's +// console; reads are not. + +import { mkdtemp, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'; +import { realpathSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { createBackend, PluginError, VCS_ERRORS } from './vendor/lithe-plugin-sdk/index.mjs'; +import { + classifyFailure, + committedRevision, + conflictedPaths, + isRevisionNumber, + lacksBase, + lacksHistory, + notWorkingCopy, + onlyAlreadyVersioned, + parseBlame, + parseInfo, + parseLog, + parseStatus, + revisionOf, + statusChanges, + svnEnvironment, + target, +} from './svn-output.mjs'; + +/** Largest base content the contract carries. */ +const BASE_CONTENT_LIMIT = 32 * 1024 * 1024; +/** Captured `--xml` output; status of a large tree and long blames fit. */ +const XML_OUTPUT_LIMIT = 256 * 1024 * 1024; +/** Changes one history entry may list. */ +const MAX_ENTRY_CHANGES = 10_000; + +const TOOL_MISSING = 'Subversion (svn) was not found or could not run. Install Subversion 1.8 or newer.'; + +/** + * Runs `svn` in the root. A missing or unrunnable client is the contract's + * `toolUnavailable`; any exit status resolves for the caller to judge. + */ +async function svn(params, context, args, { report = false, maxOutputBytes = 16 * 1024 * 1024 } = {}) { + if (!params.tool) throw new PluginError(VCS_ERRORS.toolUnavailable, TOOL_MISSING); + try { + return await context.run(params.tool.executable, ['--non-interactive', ...args], { + cwd: params.root.path, + env: svnEnvironment(), + maxOutputBytes, + report, + }); + } catch (error) { + if (['ENOENT', 'EACCES', 'ENOEXEC'].includes(error?.code)) { + throw new PluginError(VCS_ERRORS.toolUnavailable, TOOL_MISSING); + } + throw error; + } +} + +/** + * The error of a failed command. svn names local paths absolutely; inside + * the root they are shown relative to it, as the host shows them. + */ +function failure(params, stderr) { + let text = stderr; + for (const root of rootSpellings(params.root.path)) text = text.replaceAll(`${root}/`, ''); + const { kind, message } = classifyFailure(text); + return new PluginError(kind ? VCS_ERRORS[kind] : 'svn.command.failed', message); +} + +/** The root as given and as svn resolves it (`/tmp` is `/private/tmp` on macOS). */ +function rootSpellings(root) { + try { + const real = realpathSync(root); + return real === root ? [root] : [root, real]; + } catch { + return [root]; + } +} + +/** The result of a command that must succeed. */ +function succeeded(params, result) { + if (result.exitCode !== 0 || notWorkingCopy(result.stderr)) throw failure(params, result.stderr); + return result; +} + +/** The output of a command that must succeed and whose output is parsed whole. */ +function complete(params, result) { + succeeded(params, result); + if (result.truncated) throw new PluginError('svn.output.tooLarge', 'svn printed more output than Lithe reads.'); + return result.stdout; +} + +async function rootInfo(params, context) { + return parseInfo(complete(params, await svn(params, context, ['info', '--xml'], { maxOutputBytes: 1 << 20 }))); +} + +/** Every status entry of the root, externals left to their own roots. */ +async function statusEntries(params, context, { includeIgnored = false } = {}) { + const args = ['status', '--xml', '--ignore-externals']; + if (includeIgnored) args.push('--no-ignore'); + return parseStatus(complete(params, await svn(params, context, args, { maxOutputBytes: XML_OUTPUT_LIMIT }))); +} + +/** + * Runs a command that takes many paths through a `--targets` file, which has + * no command-line length limit; each path is a peg-escaped target. + */ +async function withTargets(paths, run) { + const directory = await mkdtemp(join(tmpdir(), 'lithe-svn-')); + try { + const file = join(directory, 'targets'); + await writeFile(file, `${paths.map(target).join('\n')}\n`); + return await run(file, directory); + } finally { + await rm(directory, { recursive: true, force: true }); + } +} + +async function status(params, context) { + const info = await rootInfo(params, context); + const entries = await statusEntries(params, context, { includeIgnored: params.includeIgnored === true }); + return { + head: { revision: revisionOf(info.revision), branch: null, location: info.url }, + changes: statusChanges(entries), + truncated: false, + }; +} + +async function baseContent(params, context) { + const result = await svn(params, context, ['cat', '-r', 'BASE', '--', target(params.path)], { + maxOutputBytes: BASE_CONTENT_LIMIT + 1, + }); + if (result.exitCode !== 0) { + if (lacksBase(result.stderr)) return { content: null, binary: false }; + throw failure(params, result.stderr); + } + if (result.stdout.includes('\u0000')) return { content: null, binary: true }; + if (result.truncated) { + throw new PluginError('svn.baseContent.tooLarge', 'The base version is larger than 32 MiB and is not shown.'); + } + return { content: result.stdout, binary: false }; +} + +/** + * One page of `svn log`, newest first. The root's history starts at HEAD so + * a revision just committed is listed even though the root directory itself + * stays at its older revision until an update; a file's history starts at + * its own base, where its name is known to exist. + */ +async function logPage(params, context, path) { + const limit = params.limit ?? 100; + const cursor = params.cursor ?? null; + if (cursor !== null && !isRevisionNumber(cursor)) { + throw new PluginError('svn.history.invalidCursor', 'The history position is not a revision number.'); + } + const info = await rootInfo(params, context); + const start = cursor ?? (path === null ? 'HEAD' : 'BASE'); + const command = (verbose) => [ + 'log', + '--xml', + ...(verbose ? ['--verbose'] : []), + '--limit', + String(limit + 1), + '-r', + `${start}:1`, + ...(path === null ? [] : ['--', target(path)]), + ]; + let result = await svn(params, context, command(true), { maxOutputBytes: XML_OUTPUT_LIMIT }); + if (result.exitCode !== 0 && path !== null && lacksHistory(result.stderr)) return { entries: [], nextCursor: null }; + // A page of revisions that touched huge trees is listed without changes. + let verbose = true; + if (result.exitCode === 0 && result.truncated) { + verbose = false; + result = await svn(params, context, command(false), { maxOutputBytes: XML_OUTPUT_LIMIT }); + } + const entries = parseLog(complete(params, result), info.repositoryPath).map((entry) => + !verbose + ? { ...entry, changesTruncated: true } + : entry.changes.length > MAX_ENTRY_CHANGES + ? { ...entry, changes: entry.changes.slice(0, MAX_ENTRY_CHANGES), changesTruncated: true } + : entry, + ); + const next = entries.length > limit ? entries[limit].revision.id : null; + return { entries: entries.slice(0, limit), nextCursor: next }; +} + +/** Number of lines in `text`, as an editor counts them. */ +function lineCount(text) { + if (text === '') return 0; + return text.split('\n').length - (text.endsWith('\n') ? 1 : 0); +} + +async function annotate(params, context) { + const result = await svn(params, context, ['blame', '--xml', '--', target(params.path)], { + maxOutputBytes: XML_OUTPUT_LIMIT, + }); + if (result.exitCode !== 0 && lacksHistory(result.stderr)) { + // Added and not committed yet: every line is local. + const text = await readFile(join(params.root.path, params.path), 'utf8'); + return { revisions: [], lines: new Array(lineCount(text)).fill(null) }; + } + const { lines, revisions } = parseBlame(complete(params, result)); + await addMessages(params, context, revisions); + return { revisions: [...revisions.values()], lines }; +} + +/** + * Fills in the messages of blamed revisions from one `svn log` over their + * range. Messages are a convenience: when the log fails, for example + * offline, the annotation is shown without them. + */ +async function addMessages(params, context, revisions) { + if (revisions.size === 0) return; + const ids = [...revisions.keys()].map(Number); + const range = `${Math.max(...ids)}:${Math.min(...ids)}`; + const result = await svn(params, context, ['log', '--xml', '-r', range, '--', target(params.path)], { + maxOutputBytes: XML_OUTPUT_LIMIT, + }); + if (result.exitCode !== 0 || result.truncated) return; + try { + for (const entry of parseLog(result.stdout, null)) { + const blamed = revisions.get(entry.revision.id); + if (blamed) blamed.message = entry.message; + } + } catch { + // Unreadable log output leaves the messages out. + } +} + +/** Whether `path` lies inside one of `directories`. */ +function insideAny(path, directories) { + return directories.some((directory) => path.startsWith(`${directory}/`)); +} + +/** Every ancestor directory of `path`, nearest first. */ +function ancestors(path) { + const result = []; + for (let index = path.lastIndexOf('/'); index > 0; index = path.lastIndexOf('/', index - 1)) { + result.push(path.slice(0, index)); + } + return result; +} + +/** + * Check-in commits exactly the selected changes (`--depth empty`), with what + * svn needs alongside them: both halves of a move, added parent + * directories, and a new directory's contents when the selection added it. + * Selected unversioned paths are added and missing ones deleted first, as + * IDEA does. + */ +async function checkin(params, context) { + let entries = await statusEntries(params, context); + const selected = new Set(params.paths); + const unversioned = params.paths.filter((path) => entries.get(path)?.item === 'unversioned'); + const missing = params.paths.filter((path) => entries.get(path)?.item === 'missing'); + if (unversioned.length > 0) await addPaths(params, context, unversioned); + if (missing.length > 0) await deletePaths(params, context, missing); + if (unversioned.length > 0 || missing.length > 0) { + entries = await statusEntries(params, context); + for (const [path, entry] of entries) { + if (entry.item === 'added' && insideAny(path, unversioned)) selected.add(path); + } + } + for (const path of [...selected]) { + const entry = entries.get(path); + if (entry?.movedFrom) selected.add(entry.movedFrom); + if (entry?.movedTo) selected.add(entry.movedTo); + } + for (const path of [...selected]) { + for (const parent of ancestors(path)) { + const item = entries.get(parent)?.item; + if (item === 'added' || item === 'replaced') selected.add(parent); + } + } + return withTargets([...selected].sort(), async (targets, directory) => { + const messageFile = join(directory, 'message'); + await writeFile(messageFile, params.message); + const args = ['commit', '--depth', 'empty', '--encoding', 'UTF-8', '--file', messageFile, '--targets', targets]; + // Not `--quiet`: it also hides the committed revision, printed last. + const result = await svn(params, context, args, { report: true, maxOutputBytes: 64 * 1024 * 1024 }); + return { revision: revisionOf(committedRevision(succeeded(params, result).stdout)) }; + }); +} + +/** + * Rollback discards local changes of the selected paths. A scheduled add, + * delete, or replace is reverted with everything below it, which svn + * requires; a modified directory reverts only its own properties. Undoing + * a moved file deletes the copy and restores the source; a moved directory + * is left on disk unversioned, since svn would delete unversioned files the + * user put into it. + */ +async function rollback(params, context) { + const entries = await statusEntries(params, context); + const groups = { movedFiles: new Set(), deep: new Set(), shallow: new Set() }; + for (const path of params.paths) { + const entry = entries.get(path); + if (!entry || entry.item === 'unversioned' || entry.item === 'ignored') continue; + const partner = entry.movedFrom ?? entry.movedTo; + if (partner) { + const copy = entry.movedFrom ? path : entry.movedTo; + const directory = await isDirectory(join(params.root.path, copy)); + const group = directory ? groups.deep : groups.movedFiles; + group.add(path).add(partner); + } else if (['added', 'deleted', 'replaced', 'missing'].includes(entry.item)) { + groups.deep.add(path); + } else { + groups.shallow.add(path); + } + } + const revert = async (paths, extra) => { + if (paths.size === 0) return; + await withTargets([...paths].sort(), async (targets) => { + succeeded(params, await svn(params, context, ['revert', ...extra, '--targets', targets], { report: true })); + }); + }; + await revert(groups.movedFiles, ['--depth', 'infinity', '--remove-added']); + await revert(groups.deep, ['--depth', 'infinity']); + await revert(groups.shallow, ['--depth', 'empty']); + return {}; +} + +async function isDirectory(path) { + try { + return (await stat(path)).isDirectory(); + } catch { + return false; + } +} + +async function update(params, context) { + // Quiet: a large update prints a line per file; status reports the outcome. + const args = ['update', '--quiet', '--accept', 'postpone']; + if (params.revision !== undefined && params.revision !== null) { + if (!isRevisionNumber(params.revision)) { + throw new PluginError('svn.update.invalidRevision', 'The revision to update to is not a revision number.'); + } + args.push('-r', params.revision); + } + succeeded(params, await svn(params, context, args, { report: true })); + const info = await rootInfo(params, context); + const entries = await statusEntries(params, context); + return { revision: revisionOf(info.revision), conflicts: conflictedPaths(entries) }; +} + +async function addPaths(params, context, paths) { + await withTargets(paths, async (targets) => { + const result = await svn(params, context, ['add', '--parents', '--targets', targets], { report: true }); + // svn adds the other targets and then fails over one already versioned. + if (result.exitCode !== 0 && onlyAlreadyVersioned(result.stderr)) return; + succeeded(params, result); + }); +} + +async function deletePaths(params, context, paths) { + await withTargets(paths, async (targets) => { + succeeded(params, await svn(params, context, ['delete', '--force', '--targets', targets], { report: true })); + }); +} + +/** + * Adds, deletes, or moves paths through svn so it keeps tracking them. An + * unversioned source is moved on disk, since svn has nothing to record. + */ +async function fileOperation(params, context) { + if (params.kind === 'add') { + await addPaths(params, context, params.paths); + } else if (params.kind === 'delete') { + await deletePaths(params, context, params.paths); + } else { + // The destination is a plain path: svn reads a trailing `@` on it literally. + const args = ['move', '--parents', '--', target(params.from), params.to]; + const result = await svn(params, context, args, { report: true }); + const source = join(params.root.path, params.from); + if (result.exitCode !== 0 && lacksBase(result.stderr) && (await exists(source))) { + const destination = join(params.root.path, params.to); + await mkdir(dirname(destination), { recursive: true }); + await rename(source, destination); + } else { + succeeded(params, result); + } + } + return {}; +} + +async function exists(path) { + try { + await stat(path); + return true; + } catch { + return false; + } +} + +async function resolve(params, context) { + await withTargets(params.paths, async (targets) => { + const args = ['resolve', '--accept', 'working', '--depth', 'empty', '--targets', targets]; + succeeded(params, await svn(params, context, args, { report: true })); + }); + return {}; +} + +async function cleanup(params, context) { + succeeded(params, await svn(params, context, ['cleanup'], { report: true })); + return {}; +} + +createBackend({ name: 'svn-support', version: '0.1.0' }) + .vcs('svn', { + status, + baseContent, + history: (params, context) => logPage(params, context, null), + fileHistory: (params, context) => logPage(params, context, params.path), + annotate, + checkin, + rollback, + update, + fileOperation, + resolve, + cleanup, + }) + .listen(); diff --git a/Plugins/mac/Official/SvnSupport/provider/xml.mjs b/Plugins/mac/Official/SvnSupport/provider/xml.mjs new file mode 100644 index 0000000000..581bf98919 --- /dev/null +++ b/Plugins/mac/Official/SvnSupport/provider/xml.mjs @@ -0,0 +1,125 @@ +// A small XML reader for the documents `svn --xml` writes: elements, +// attributes, text, character references, and the XML declaration. The +// plugin package runs offline without node_modules, so it carries its own +// reader instead of a dependency. It does not validate against a DTD and +// rejects documents it cannot read completely, such as the partial output +// svn leaves when a command fails midway. + +const NAMED_ENTITIES = { lt: '<', gt: '>', amp: '&', quot: '"', apos: "'" }; + +/** One element: its name, attributes, child elements, and direct text. */ +export class XmlElement { + constructor(name, attributes) { + this.name = name; + this.attributes = attributes; + this.children = []; + this.text = ''; + } + + /** The first child element named `name`, or `null`. */ + child(name) { + return this.children.find((element) => element.name === name) ?? null; + } + + /** Every child element named `name`, in document order. */ + all(name) { + return this.children.filter((element) => element.name === name); + } + + /** Text of the first child element named `name`, or `null` without one. */ + textOf(name) { + return this.child(name)?.text ?? null; + } +} + +function decode(text) { + return text.replace(/&(#x[0-9a-fA-F]+|#[0-9]+|[a-zA-Z]+);/g, (reference, body) => { + if (body.startsWith('#x')) return String.fromCodePoint(Number.parseInt(body.slice(2), 16)); + if (body.startsWith('#')) return String.fromCodePoint(Number.parseInt(body.slice(1), 10)); + if (body in NAMED_ENTITIES) return NAMED_ENTITIES[body]; + throw new Error(`Unknown XML entity ${reference}.`); + }); +} + +/** Index of the `>` that ends the tag starting at `start`, skipping quoted values. */ +function tagEnd(source, start) { + let quote = null; + for (let index = start; index < source.length; index += 1) { + const character = source[index]; + if (quote) { + if (character === quote) quote = null; + } else if (character === '"' || character === "'") { + quote = character; + } else if (character === '>') { + return index; + } + } + throw new Error('An XML tag is not closed.'); +} + +function parseTag(body) { + const match = /^([^\s/>]+)/.exec(body); + if (!match) throw new Error('An XML tag has no name.'); + const attributes = {}; + const rest = body.slice(match[1].length); + for (const attribute of rest.matchAll(/([^\s=]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) { + attributes[attribute[1]] = decode(attribute[2] ?? attribute[3]); + } + return new XmlElement(match[1], attributes); +} + +/** + * Parses a complete document and returns its root element. + * + * @param {string} source + * @returns {XmlElement} + */ +export function parseXml(source) { + const stack = []; + let root = null; + let index = 0; + const appendText = (text) => { + if (stack.length > 0) stack.at(-1).text += decode(text); + else if (text.trim() !== '') throw new Error('Text outside the XML root element.'); + }; + while (index < source.length) { + const open = source.indexOf('<', index); + if (open === -1) { + appendText(source.slice(index)); + break; + } + if (open > index) appendText(source.slice(index, open)); + if (source.startsWith('', open); + if (end === -1) throw new Error('An XML declaration is not closed.'); + index = end + 2; + } else if (source.startsWith('', open); + if (end === -1) throw new Error('An XML comment is not closed.'); + index = end + 3; + } else if (source.startsWith('', open); + if (end === -1 || stack.length === 0) throw new Error('A CDATA section is not closed.'); + stack.at(-1).text += source.slice(open + 9, end); + index = end + 3; + } else if (source[open + 1] === '/') { + const end = tagEnd(source, open); + const name = source.slice(open + 2, end).trim(); + const element = stack.pop(); + if (element?.name !== name) throw new Error(`Unexpected closing tag .`); + index = end + 1; + } else { + const end = tagEnd(source, open); + const selfClosing = source[end - 1] === '/'; + const element = parseTag(source.slice(open + 1, selfClosing ? end - 1 : end)); + if (stack.length > 0) stack.at(-1).children.push(element); + else if (root === null) root = element; + else throw new Error('An XML document has more than one root element.'); + if (!selfClosing) stack.push(element); + index = end + 1; + } + } + if (root === null) throw new Error('The XML document is empty.'); + if (stack.length > 0) throw new Error(`The XML element <${stack.at(-1).name}> is not closed.`); + return root; +} diff --git a/Plugins/mac/Official/SvnSupport/test/fixtures/blame-local.xml b/Plugins/mac/Official/SvnSupport/test/fixtures/blame-local.xml new file mode 100644 index 0000000000..e48c607290 --- /dev/null +++ b/Plugins/mac/Official/SvnSupport/test/fixtures/blame-local.xml @@ -0,0 +1,41 @@ + + + + + +alice +2026-10-22T17:38:40.160782Z + + + + +bob +2026-10-22T17:38:41.096089Z + + + + +alice +2026-10-22T17:38:40.160782Z + + + + +bob +2026-10-22T17:38:41.096089Z + + + + + + diff --git a/Plugins/mac/Official/SvnSupport/test/fixtures/blame.xml b/Plugins/mac/Official/SvnSupport/test/fixtures/blame.xml new file mode 100644 index 0000000000..743857e01e --- /dev/null +++ b/Plugins/mac/Official/SvnSupport/test/fixtures/blame.xml @@ -0,0 +1,22 @@ + + + + + +alice +2026-10-22T17:38:40.160782Z + + + + +alice +2026-10-22T17:38:47.153220Z + + + + diff --git a/Plugins/mac/Official/SvnSupport/test/fixtures/commit.txt b/Plugins/mac/Official/SvnSupport/test/fixtures/commit.txt new file mode 100644 index 0000000000..9d244e90cf --- /dev/null +++ b/Plugins/mac/Official/SvnSupport/test/fixtures/commit.txt @@ -0,0 +1,4 @@ +Adding added.txt +Transmitting file data .done +Committing transaction... +Committed revision 14. diff --git a/Plugins/mac/Official/SvnSupport/test/fixtures/errors/add-versioned.txt b/Plugins/mac/Official/SvnSupport/test/fixtures/errors/add-versioned.txt new file mode 100644 index 0000000000..7100b4e21a --- /dev/null +++ b/Plugins/mac/Official/SvnSupport/test/fixtures/errors/add-versioned.txt @@ -0,0 +1,3 @@ +svn: warning: W150002: '/tmp/lab/wc/keep.txt' is already under version control +svn: E200009: Could not add all targets because some targets are already versioned +svn: E200009: Illegal target for the requested operation diff --git a/Plugins/mac/Official/SvnSupport/test/fixtures/errors/blame-added.txt b/Plugins/mac/Official/SvnSupport/test/fixtures/errors/blame-added.txt new file mode 100644 index 0000000000..e036e28895 --- /dev/null +++ b/Plugins/mac/Official/SvnSupport/test/fixtures/errors/blame-added.txt @@ -0,0 +1 @@ +svn: E195002: Path '/tmp/lab/wc/added.txt' has no committed revision diff --git a/Plugins/mac/Official/SvnSupport/test/fixtures/errors/cat-added.txt b/Plugins/mac/Official/SvnSupport/test/fixtures/errors/cat-added.txt new file mode 100644 index 0000000000..2c64cf8ccf --- /dev/null +++ b/Plugins/mac/Official/SvnSupport/test/fixtures/errors/cat-added.txt @@ -0,0 +1 @@ +svn: E200009: '/tmp/lab/wc/added.txt' has no pristine version until it is committed diff --git a/Plugins/mac/Official/SvnSupport/test/fixtures/errors/cat-unversioned.txt b/Plugins/mac/Official/SvnSupport/test/fixtures/errors/cat-unversioned.txt new file mode 100644 index 0000000000..adf8880b3e --- /dev/null +++ b/Plugins/mac/Official/SvnSupport/test/fixtures/errors/cat-unversioned.txt @@ -0,0 +1,3 @@ +svn: warning: W200005: '/tmp/lab/wc/unversioned.txt' is not under version control +svn: E200009: Could not cat all targets because some targets are not versioned +svn: E200009: Illegal target for the requested operation diff --git a/Plugins/mac/Official/SvnSupport/test/fixtures/errors/commit-out-of-date.txt b/Plugins/mac/Official/SvnSupport/test/fixtures/errors/commit-out-of-date.txt new file mode 100644 index 0000000000..9f0273433e --- /dev/null +++ b/Plugins/mac/Official/SvnSupport/test/fixtures/errors/commit-out-of-date.txt @@ -0,0 +1,3 @@ +svn: E155011: Commit failed (details follow): +svn: E155011: File '/tmp/lab/wc/keep.txt' is out of date +svn: E160028: File '/trunk/keep.txt' is out of date diff --git a/Plugins/mac/Official/SvnSupport/test/fixtures/errors/log-added.txt b/Plugins/mac/Official/SvnSupport/test/fixtures/errors/log-added.txt new file mode 100644 index 0000000000..e036e28895 --- /dev/null +++ b/Plugins/mac/Official/SvnSupport/test/fixtures/errors/log-added.txt @@ -0,0 +1 @@ +svn: E195002: Path '/tmp/lab/wc/added.txt' has no committed revision diff --git a/Plugins/mac/Official/SvnSupport/test/fixtures/errors/move-unversioned.txt b/Plugins/mac/Official/SvnSupport/test/fixtures/errors/move-unversioned.txt new file mode 100644 index 0000000000..7d295f9948 --- /dev/null +++ b/Plugins/mac/Official/SvnSupport/test/fixtures/errors/move-unversioned.txt @@ -0,0 +1 @@ +svn: E155010: Path '/tmp/lab/wc/unversioned.txt' does not exist diff --git a/Plugins/mac/Official/SvnSupport/test/fixtures/errors/status-outside.txt b/Plugins/mac/Official/SvnSupport/test/fixtures/errors/status-outside.txt new file mode 100644 index 0000000000..17951a9594 --- /dev/null +++ b/Plugins/mac/Official/SvnSupport/test/fixtures/errors/status-outside.txt @@ -0,0 +1 @@ +svn: warning: W155007: '/tmp/lab' is not a working copy diff --git a/Plugins/mac/Official/SvnSupport/test/fixtures/errors/update-locked.txt b/Plugins/mac/Official/SvnSupport/test/fixtures/errors/update-locked.txt new file mode 100644 index 0000000000..a147c69995 --- /dev/null +++ b/Plugins/mac/Official/SvnSupport/test/fixtures/errors/update-locked.txt @@ -0,0 +1,3 @@ +svn: E155004: Run 'svn cleanup' to remove locks (type 'svn help cleanup' for details) +svn: E155004: Working copy '/tmp/lab/wc' locked. +svn: E155004: '/tmp/lab/wc' is already locked. diff --git a/Plugins/mac/Official/SvnSupport/test/fixtures/info.xml b/Plugins/mac/Official/SvnSupport/test/fixtures/info.xml new file mode 100644 index 0000000000..d93c219ef8 --- /dev/null +++ b/Plugins/mac/Official/SvnSupport/test/fixtures/info.xml @@ -0,0 +1,27 @@ + + + +file:///tmp/lab/repo/trunk +^/trunk + +file:///tmp/lab/repo +aae2bf71-d0cc-4bdb-af1f-4ea096c6b0ac + + +/tmp/lab/wc +1.8 +31 +yes +normal +infinity + + +bob +2026-10-22T17:38:53.117139Z + + + diff --git a/Plugins/mac/Official/SvnSupport/test/fixtures/log-file.xml b/Plugins/mac/Official/SvnSupport/test/fixtures/log-file.xml new file mode 100644 index 0000000000..32a20af7d9 --- /dev/null +++ b/Plugins/mac/Official/SvnSupport/test/fixtures/log-file.xml @@ -0,0 +1,140 @@ + + + +alice +2026-10-22T17:38:44.121528Z + +/trunk/after.txt +/trunk/before.txt +/trunk/copy-of-template.txt +/trunk/keep.txt + +Rename, copy, and set a property + + +alice +2026-10-22T17:38:40.160782Z + +/trunk +/trunk/a@b.txt +/trunk/before.txt +/trunk/docs +/trunk/docs/readme.txt +/trunk/gone.txt +/trunk/image.bin +/trunk/keep.txt +/trunk/lib +/trunk/lib/lib.c +/trunk/locked.txt +/trunk/lost.txt +/trunk/replaced.txt +/trunk/src +/trunk/src/main.c +/trunk/src/util.c +/trunk/template.txt +/trunk/with space.txt +/trunk/ünïcödé.txt + +Add sources + +Second paragraph with <markup> & "quotes". + + diff --git a/Plugins/mac/Official/SvnSupport/test/fixtures/log-root.xml b/Plugins/mac/Official/SvnSupport/test/fixtures/log-root.xml new file mode 100644 index 0000000000..8e7adf9dae --- /dev/null +++ b/Plugins/mac/Official/SvnSupport/test/fixtures/log-root.xml @@ -0,0 +1,259 @@ + + + +bob +2026-10-22T17:38:53.117139Z + +/trunk/docs/readme.txt + +Delete readme elsewhere + + +bob +2026-10-22T17:38:52.100708Z + +/trunk/src/util.c + +Change util from elsewhere + + +alice +2026-10-22T17:38:49.116300Z + +/trunk + +Add an external + + +alice +2026-10-22T17:38:47.153220Z + +/trunk/keep.txt + + + + +alice +2026-10-22T17:38:46.136482Z + +/trunk/replaced.txt + +Replace replaced.txt + + +alice +2026-10-22T17:38:45.119503Z + +/trunk/gone.txt + +Delete gone.txt + + +alice +2026-10-22T17:38:44.121528Z + +/trunk/after.txt +/trunk/before.txt +/trunk/copy-of-template.txt +/trunk/keep.txt + +Rename, copy, and set a property + + +bob +2026-10-22T17:38:41.096089Z + +/trunk/src/main.c + +Change main + + +alice +2026-10-22T17:38:40.160782Z + +/trunk +/trunk/a@b.txt +/trunk/before.txt +/trunk/docs +/trunk/docs/readme.txt +/trunk/gone.txt +/trunk/image.bin +/trunk/keep.txt +/trunk/lib +/trunk/lib/lib.c +/trunk/locked.txt +/trunk/lost.txt +/trunk/replaced.txt +/trunk/src +/trunk/src/main.c +/trunk/src/util.c +/trunk/template.txt +/trunk/with space.txt +/trunk/ünïcödé.txt + +Add sources + +Second paragraph with <markup> & "quotes". + + +alice +2026-10-22T17:38:39.321932Z + +/branches +/trunk +/vendor +/vendor/lib + +Create layout + + diff --git a/Plugins/mac/Official/SvnSupport/test/fixtures/status-no-ignore.xml b/Plugins/mac/Official/SvnSupport/test/fixtures/status-no-ignore.xml new file mode 100644 index 0000000000..5f8f4b604e --- /dev/null +++ b/Plugins/mac/Official/SvnSupport/test/fixtures/status-no-ignore.xml @@ -0,0 +1,242 @@ + + + + + + +alice +2026-10-22T17:38:40.160782Z + + + + + + + + + + +alice +2026-10-22T17:38:44.121528Z + + + + + + + + + + +alice +2026-10-22T17:38:40.160782Z + + + + + + +alice +2026-10-22T17:38:44.121528Z + + + + + + +alice +2026-10-22T17:38:40.160782Z + + + + + + +alice +2026-10-22T17:38:47.090276Z + + + + + + +alice +2026-10-22T17:38:40.160782Z + + +opaquelocktoken:9e065183-076c-4da9-9fd8-14d101b1c5fe +alice +2026-10-22T17:38:57.137090Z + + + + + + +alice +2026-10-22T17:38:40.160782Z + + + + + + +alice +2026-10-22T17:38:44.121528Z + + + + + + + + + + +bob +2026-10-22T17:38:41.096089Z + + + + + + +bob +2026-10-22T17:38:52.100708Z + + + + + + + + + + + + + + + + + + + + + + + + + + +alice +2026-10-22T17:38:40.160782Z + + + + + + +alice +2026-10-22T17:38:40.160782Z + + + + + diff --git a/Plugins/mac/Official/SvnSupport/test/fixtures/status.xml b/Plugins/mac/Official/SvnSupport/test/fixtures/status.xml new file mode 100644 index 0000000000..08e2d32232 --- /dev/null +++ b/Plugins/mac/Official/SvnSupport/test/fixtures/status.xml @@ -0,0 +1,235 @@ + + + + + + +alice +2026-10-22T17:38:40.160782Z + + + + + + + + + + +alice +2026-10-22T17:38:44.121528Z + + + + + + +alice +2026-10-22T17:38:40.160782Z + + + + + + +alice +2026-10-22T17:38:44.121528Z + + + + + + +alice +2026-10-22T17:38:40.160782Z + + + + + + +alice +2026-10-22T17:38:47.090276Z + + + + + + +alice +2026-10-22T17:38:40.160782Z + + +opaquelocktoken:9e065183-076c-4da9-9fd8-14d101b1c5fe +alice +2026-10-22T17:38:57.137090Z + + + + + + +alice +2026-10-22T17:38:40.160782Z + + + + + + +alice +2026-10-22T17:38:44.121528Z + + + + + + + + + + +bob +2026-10-22T17:38:41.096089Z + + + + + + +bob +2026-10-22T17:38:52.100708Z + + + + + + + + + + + + + + + + + + + + + + + + + + +alice +2026-10-22T17:38:40.160782Z + + + + + + +alice +2026-10-22T17:38:40.160782Z + + + + + diff --git a/Plugins/mac/Official/SvnSupport/test/record-fixtures.sh b/Plugins/mac/Official/SvnSupport/test/record-fixtures.sh new file mode 100755 index 0000000000..1770cb5dbd --- /dev/null +++ b/Plugins/mac/Official/SvnSupport/test/record-fixtures.sh @@ -0,0 +1,141 @@ +#!/bin/zsh + +set -euo pipefail + +# Records the `svn --xml` output the parse tests read (test/fixtures/). It +# builds a throwaway file:// repository whose working copy holds every status +# the provider maps, then saves status, info, log, and blame output with the +# machine-specific directory replaced by /tmp/lab. Re-run it after a +# Subversion upgrade and review the fixture diff: +# +# Plugins/mac/Official/SvnSupport/test/record-fixtures.sh + +FIXTURES="${0:A:h}/fixtures" +LAB=$(mktemp -d "${TMPDIR:-/tmp}/lithe-svn-fixtures.XXXXXX") +trap 'rm -rf "$LAB"' EXIT +LAB=${LAB:A} +mkdir -p "$FIXTURES" + +# The same environment the provider uses: UTF-8 paths, English messages. +unset LC_ALL LANGUAGE +export LC_CTYPE=UTF-8 LC_MESSAGES=C +svn() { command svn --non-interactive --config-dir "$LAB/config" "$@"; } + +REPO="file://$LAB/repo" +svnadmin create "$LAB/repo" +svn mkdir -q --parents -m "Create layout" --username alice "$REPO/trunk" "$REPO/branches" "$REPO/vendor/lib" +svn checkout -q "$REPO/trunk" "$LAB/wc" +cd "$LAB/wc" + +commit() { svn commit -q --username alice -m "$1"; } + +# The files every later revision changes. +mkdir -p src docs lib +print 'library' > lib/lib.c +print -l one two three > src/main.c +print -l alpha beta > src/util.c +print 'notes' > docs/readme.txt +print 'keep' > keep.txt +print 'to delete' > gone.txt +print 'to lose' > lost.txt +print 'to replace' > replaced.txt +print 'to move' > before.txt +print 'to copy' > template.txt +print 'locked' > locked.txt +print 'ünïcödé' > 'ünïcödé.txt' +print 'at sign' > 'a@b.txt' +print 'space' > 'with space.txt' +print -n 'bin\0ary' > image.bin +svn add -q src docs lib keep.txt gone.txt lost.txt replaced.txt before.txt template.txt locked.txt \ + 'ünïcödé.txt' 'a@b.txt@' 'with space.txt' image.bin +svn propset -q svn:ignore '*.log' . +commit 'Add sources + +Second paragraph with & "quotes".' +# A second author on part of main.c, for blame. +print -l one TWO three four > src/main.c +svn commit -q --username bob -m 'Change main' src/main.c +# A rename, a copy, and a property-only change in one revision. +svn move -q before.txt after.txt +svn copy -q template.txt copy-of-template.txt +svn propset -q custom:flag yes keep.txt +commit 'Rename, copy, and set a property' +# A delete and a replace; a change outside the root, which history of the +# root leaves out; a revision without a message. +svn delete -q gone.txt +commit 'Delete gone.txt' +svn delete -q replaced.txt && print 'new content' > replaced.txt && svn add -q replaced.txt +commit 'Replace replaced.txt' +svn mkdir -q -m 'Outside the root' --username alice "$REPO/branches/feature" +print 'more' >> keep.txt +svn commit -q --username alice -m '' keep.txt +# The external the working copy carries as its own root. +mkdir "$LAB/vendor" && print 'vendor' > "$LAB/vendor/vendor.txt" +svn import -q -m 'Vendor code' --username alice "$LAB/vendor" "$REPO/vendor/lib/docs" +# A property of the root commits only when the root is at HEAD. +svn update -q +svn propset -q svn:externals "^/vendor/lib/docs vendor-docs" . +commit 'Add an external' +svn update -q + +# A conflicting change from a second working copy. +svn checkout -q "$REPO/trunk" "$LAB/other" +print -l alpha 'beta from other' > "$LAB/other/src/util.c" +svn commit -q --username bob -m 'Change util from elsewhere' "$LAB/other" +svn delete -q "$LAB/other/docs/readme.txt" +svn commit -q --username bob -m 'Delete readme elsewhere' "$LAB/other" + +# Local changes for status. +print 'local edit' >> src/main.c +print -l alpha 'beta locally' > src/util.c +print 'edited' >> docs/readme.txt +svn update -q --accept postpone || true +svn propset -q custom:flag no 'with space.txt' +print 'new' > added.txt && svn add -q added.txt +svn copy -q template.txt copied.txt +svn move -q after.txt moved.txt +svn delete -q copy-of-template.txt +rm lost.txt +svn delete -q replaced.txt && print 'again' > replaced.txt && svn add -q replaced.txt +print 'scratch' > unversioned.txt +print 'log' > build.log +svn lock -q --username alice locked.txt +svn switch -q --ignore-ancestry "^/branches/feature" lib +print 'ünïcödé edit' >> 'ünïcödé.txt' +print 'at edit' >> 'a@b.txt' + +record() { + local name=$1; shift + "$@" | sed -e "s|$LAB|/tmp/lab|g" > "$FIXTURES/$name" +} +record status.xml svn status --xml --ignore-externals +record status-no-ignore.xml svn status --xml --ignore-externals --no-ignore +record info.xml svn info --xml +record log-root.xml svn log --xml --verbose -r HEAD:1 +record log-file.xml svn log --xml --verbose -r BASE:1 -- 'moved.txt@' +record blame.xml svn blame --xml -- keep.txt@ +record blame-local.xml svn blame --xml -- src/main.c@ +# What svn prints on stderr for the failures the provider tells apart. +record_error() { + local name=$1; shift + { "$@" 2>&1 >/dev/null || true } | sed -e "s|$LAB|/tmp/lab|g" > "$FIXTURES/errors/$name" +} +mkdir -p "$FIXTURES/errors" +record_error cat-added.txt svn cat -r BASE -- added.txt@ +record_error cat-unversioned.txt svn cat -r BASE -- unversioned.txt@ +record_error log-added.txt svn log --xml -- added.txt@ +record_error blame-added.txt svn blame --xml -- added.txt@ +record_error add-versioned.txt svn add --parents -- keep.txt@ unversioned.txt@ +svn revert -q unversioned.txt +record_error move-unversioned.txt svn move -- unversioned.txt@ elsewhere.txt +record_error status-outside.txt svn status --xml "$LAB" +sqlite3 .svn/wc.db "insert into wc_lock (wc_id, local_dir_relpath, locked_levels) values (1, '', -1)" +record_error update-locked.txt svn update +svn cleanup +print 'theirs' > "$LAB/other/keep.txt" +svn commit -q --username bob -m 'Change keep elsewhere' "$LAB/other/keep.txt" +print 'mine' > keep.txt +record_error commit-out-of-date.txt svn commit -m 'Out of date' -- keep.txt@ +svn revert -q keep.txt +record commit.txt svn commit --username alice -m 'Commit for output' -- added.txt@ +print "Recorded $(svn --version --quiet) fixtures in $FIXTURES" diff --git a/Plugins/mac/Official/SvnSupport/test/svn-output.test.mjs b/Plugins/mac/Official/SvnSupport/test/svn-output.test.mjs new file mode 100644 index 0000000000..b5f3608baf --- /dev/null +++ b/Plugins/mac/Official/SvnSupport/test/svn-output.test.mjs @@ -0,0 +1,261 @@ +// Parses output recorded from svn 1.15 by record-fixtures.sh; edge cases svn +// does not produce in that scenario are written inline. + +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { test } from 'node:test'; +import { + changeOf, + classifyFailure, + committedRevision, + conflictedPaths, + isRevisionNumber, + lacksBase, + lacksHistory, + notWorkingCopy, + onlyAlreadyVersioned, + parseBlame, + parseInfo, + parseLog, + parseStatus, + relativePath, + statusChanges, + svnCodes, + svnEnvironment, + target, +} from '../provider/svn-output.mjs'; + +const fixture = (name) => readFileSync(new URL(`fixtures/${name}`, import.meta.url), 'utf8'); +const revision = (id) => ({ id, display: `r${id}`, kind: 'number' }); +const change = (path, status, { oldPath = null, flags = [] } = {}) => ({ path, oldPath, status, flags }); + +test('svn runs with a UTF-8 character set and English messages, keeping the rest', () => { + const environment = { PATH: '/usr/bin', HOME: '/home/u', LC_ALL: 'de_DE', LANGUAGE: 'de', LANG: 'de_DE.UTF-8' }; + assert.deepEqual(svnEnvironment(environment, 'darwin'), { + PATH: '/usr/bin', + HOME: '/home/u', + LANG: 'de_DE.UTF-8', + LC_CTYPE: 'UTF-8', + LC_MESSAGES: 'C', + }); + assert.equal(svnEnvironment(environment, 'linux').LC_CTYPE, 'C.UTF-8'); + assert.equal(environment.LC_ALL, 'de_DE', 'the given environment is not changed'); +}); + +test('a target ends with @ so svn never reads part of the name as a peg revision', () => { + assert.equal(target('a@b.txt'), 'a@b.txt@'); + assert.equal(target('src/main.c'), 'src/main.c@'); +}); + +test('failures map to contract codes from the outermost svn code', () => { + const kinds = { + 'update-locked.txt': 'workingCopyLocked', + 'commit-out-of-date.txt': 'outOfDate', + 'move-unversioned.txt': null, + 'cat-unversioned.txt': null, + }; + for (const [name, kind] of Object.entries(kinds)) { + assert.equal(classifyFailure(fixture(`errors/${name}`)).kind, kind, name); + } + for (const [stderr, kind] of [ + ["svn: E155036: The working copy at '/w' is too old (format 10) to work with client version '1.15.0'", 'workingCopyUnsupported'], + ['svn: E170001: Authorization failed', 'authenticationFailed'], + ['svn: E230001: Server SSL certificate verification failed: issuer is not trusted', 'authenticationFailed'], + ['svn: E215004: No more credentials or we tried too many times.\nsvn: E170001: Authentication failed', 'authenticationFailed'], + ["svn: E155015: Aborting commit: '/w/a.txt' remains in conflict", 'conflict'], + ['svn: E170013: Unable to connect to a repository', null], + ]) { + assert.equal(classifyFailure(stderr).kind, kind, stderr); + } + assert.deepEqual(svnCodes(fixture('errors/cat-unversioned.txt')), ['W200005', 'E200009', 'E200009']); + assert.match(classifyFailure('').message, /without an error message/); + const long = `svn: E170013: ${'é'.repeat(3000)}`; + assert.ok(Buffer.byteLength(classifyFailure(long).message) <= 4000); +}); + +test('a path without a base or history is told apart from a failure', () => { + assert.ok(lacksBase(fixture('errors/cat-added.txt'))); + assert.ok(lacksBase(fixture('errors/cat-unversioned.txt'))); + assert.ok(lacksBase(fixture('errors/move-unversioned.txt'))); + assert.ok(!lacksBase(fixture('errors/update-locked.txt'))); + assert.ok(lacksHistory(fixture('errors/log-added.txt'))); + assert.ok(lacksHistory(fixture('errors/blame-added.txt'))); + assert.ok(!lacksHistory(fixture('errors/commit-out-of-date.txt'))); + assert.ok(onlyAlreadyVersioned(fixture('errors/add-versioned.txt'))); + assert.ok(!onlyAlreadyVersioned(fixture('errors/cat-unversioned.txt'))); + assert.ok(notWorkingCopy(fixture('errors/status-outside.txt'))); + assert.ok(!notWorkingCopy('')); +}); + +test('info names the working revision, URL, and repository path of the root', () => { + assert.deepEqual(parseInfo(fixture('info.xml')), { + revision: '12', + url: 'file:///tmp/lab/repo/trunk', + repositoryPath: '/trunk', + }); + // svn before 1.8 printed no relative URL; the repository path is derived. + assert.deepEqual( + parseInfo(`https://h/r/branches/my%20fix + https://h/r`), + { revision: '3', url: 'https://h/r/branches/my%20fix', repositoryPath: '/branches/my fix' }, + ); + assert.equal( + parseInfo('https://h/r^/').repositoryPath, + '/', + ); + assert.throws(() => parseInfo(''), /no entry/); +}); + +const RECORDED_CHANGES = [ + change('a@b.txt', 'modified'), + change('added.txt', 'added'), + change('copied.txt', 'copied'), + change('copy-of-template.txt', 'deleted'), + change('docs/readme.txt', 'conflicted', { flags: ['treeConflict'] }), + change('lib', 'switched'), + change('lost.txt', 'missing'), + change('moved.txt', 'renamed', { oldPath: 'after.txt' }), + change('replaced.txt', 'replaced'), + change('src/main.c', 'modified'), + change('src/util.c', 'conflicted'), + change('src/util.c.mine', 'unversioned'), + change('src/util.c.r10', 'unversioned'), + change('src/util.c.r12', 'unversioned'), + change('unversioned.txt', 'unversioned'), + change('with space.txt', 'modified', { flags: ['propertiesModified'] }), + change('ünïcödé.txt', 'modified'), +]; + +test('status maps every recorded working copy state to a contract change', () => { + const entries = parseStatus(fixture('status.xml'), 'darwin'); + // The move source is reported by its destination; an unchanged locked file + // and the external's own working copy are not changes of this root. + assert.deepEqual(statusChanges(entries), RECORDED_CHANGES); + assert.equal(entries.get('after.txt').movedTo, 'moved.txt'); + assert.equal(entries.get('locked.txt').locked, true); + assert.equal(entries.get('vendor-docs').item, 'external'); + assert.deepEqual(conflictedPaths(entries), ['docs/readme.txt', 'src/util.c']); +}); + +test('status with ignored files adds them as ignored', () => { + const changes = statusChanges(parseStatus(fixture('status-no-ignore.xml'), 'darwin')); + assert.deepEqual( + changes.filter((item) => item.status === 'ignored'), + [change('build.log', 'ignored')], + ); + assert.equal(changes.length, RECORDED_CHANGES.length + 1); +}); + +test('status leaves out the root itself and converts Windows separators', () => { + const xml = ` + + a + + + + + + `; + assert.deepEqual(statusChanges(parseStatus(xml, 'win32')), [ + change('src/a.txt', 'modified', { flags: ['propertiesModified', 'locked'] }), + change('src/b.txt', 'renamed', { oldPath: 'old/b.txt' }), + change('obstructed', 'typeChanged'), + change('merged.txt', 'modified'), + change('props.txt', 'conflicted'), + ]); + assert.equal(relativePath('src\\a.txt', 'darwin'), 'src\\a.txt', 'a backslash is a name character on macOS'); + assert.equal(relativePath('.', 'darwin'), null); +}); + +test('an unknown svn item is not reported as a change', () => { + assert.equal(changeOf('x', { item: 'future', props: 'none', locked: false, treeConflicted: false }), null); +}); + +test('root history maps repository paths to the root and recognizes a move', () => { + const entries = parseLog(fixture('log-root.xml'), '/trunk'); + const byId = new Map(entries.map((entry) => [entry.revision.id, entry])); + assert.deepEqual( + entries.map((entry) => entry.revision.id), + ['12', '11', '10', '8', '6', '5', '4', '3', '2', '1'], + ); + assert.deepEqual(byId.get('4'), { + revision: revision('4'), + author: 'alice', + date: byId.get('4').date, + message: 'Rename, copy, and set a property', + changes: [ + change('after.txt', 'renamed', { oldPath: 'before.txt' }), + change('copy-of-template.txt', 'copied', { oldPath: 'template.txt' }), + change('keep.txt', 'modified'), + ], + changesTruncated: false, + }); + assert.match(byId.get('4').date, /^\d{4}-\d\d-\d\dT/); + assert.deepEqual(byId.get('6').changes, [change('replaced.txt', 'replaced')]); + assert.deepEqual(byId.get('5').changes, [change('gone.txt', 'deleted')]); + assert.equal(byId.get('8').message, '', 'an empty message stays empty'); + assert.deepEqual(byId.get('10').changes, [], 'a property change of the root has no root-relative path'); + assert.equal(byId.get('2').message, 'Add sources\n\nSecond paragraph with & "quotes".'); + assert.ok(byId.get('2').changes.some((item) => item.path === 'a@b.txt' && item.status === 'added')); +}); + +test('file history follows the file through its rename', () => { + const entries = parseLog(fixture('log-file.xml'), '/trunk'); + assert.deepEqual( + entries.map((entry) => entry.revision.id), + ['4', '2'], + ); +}); + +test('history leaves out paths outside the root and keeps outside copy sources unnamed', () => { + const xml = `a + + /trunk/a.txt + /branches/x/b.txt + /trunk-other/c.txt + /trunk/new + /trunk/old + + `; + const [entry, bare] = parseLog(xml, '/trunk'); + assert.deepEqual(entry.changes, [change('a.txt', 'copied'), change('new', 'renamed', { oldPath: 'old' })]); + assert.deepEqual(bare, { + revision: revision('8'), + author: null, + date: null, + message: '', + changes: [], + changesTruncated: false, + }); + assert.deepEqual( + parseLog(xml, '/')[0].changes.map((item) => item.path), + ['trunk/a.txt', 'branches/x/b.txt', 'trunk-other/c.txt', 'trunk/new'], + ); + assert.deepEqual(parseLog(xml, null)[0].changes, [], 'without a known root path no change is placed'); +}); + +test('blame names a revision per line, null for local changes, and each revision once', () => { + const { lines, revisions } = parseBlame(fixture('blame-local.xml')); + assert.deepEqual(lines, ['2', '3', '2', '3', null]); + assert.deepEqual([...revisions.keys()], ['2', '3']); + assert.deepEqual(revisions.get('3'), { + revision: revision('3'), + author: 'bob', + date: revisions.get('3').date, + message: null, + }); + assert.deepEqual(parseBlame(fixture('blame.xml')).lines, ['2', '8']); + assert.throws(() => parseBlame(''), /no target/); +}); + +test('commit output names the new revision; nothing committed names none', () => { + assert.equal(committedRevision(fixture('commit.txt')), '14'); + assert.equal(committedRevision(''), null); +}); + +test('revision numbers are plain non-negative integers', () => { + for (const value of ['0', '1', '1234567']) assert.ok(isRevisionNumber(value), value); + for (const value of ['', '01', '-1', 'HEAD', '1:2', '1.5', 5, null, '12345678901234567']) { + assert.ok(!isRevisionNumber(value), String(value)); + } +}); diff --git a/Plugins/mac/Official/SvnSupport/test/xml.test.mjs b/Plugins/mac/Official/SvnSupport/test/xml.test.mjs new file mode 100644 index 0000000000..ac589361dd --- /dev/null +++ b/Plugins/mac/Official/SvnSupport/test/xml.test.mjs @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { parseXml } from '../provider/xml.mjs'; + +test('reads elements, attributes in any quoting, text, and references', () => { + const root = parseXml(` + + + +Fix <tag> "quoted" 'single' + + & raw]]> + +`); + assert.equal(root.name, 'log'); + const entry = root.child('logentry'); + assert.deepEqual(entry.attributes, { revision: '7', kind: 'file', note: 'a > b & 中文' }); + assert.equal(entry.textOf('msg'), `Fix "quoted" 'single'`); + assert.equal(entry.textOf('empty'), ''); + assert.equal(entry.textOf('data'), ' & raw'); + assert.equal(entry.textOf('missing'), null); + assert.equal(entry.child('missing'), null); + assert.deepEqual( + root.all('logentry').map((element) => element.name), + ['logentry'], + ); +}); + +test('a > inside a quoted attribute value does not end the tag', () => { + const root = parseXml(''); + assert.equal(root.attributes.path, 'a>b.txt'); + assert.equal(root.all('entry').length, 1); +}); + +test('keeps message whitespace and line breaks as written', () => { + const root = parseXml(' first line\n\n indented\n'); + assert.equal(root.text, ' first line\n\n indented\n'); +}); + +test('refuses documents it cannot read completely', () => { + for (const [source, message] of [ + ['', /empty/], + ['', /empty/], + ['', /not closed/], + [' String { + private func pluginNotice(_ entry: OfficialPluginCatalog.Entry, language: String) -> String { let name = entry.displayName - let language = entry.languageDisplayName guard let snapshot = pluginSnapshot(entry) else { return usesChinese ? "检测到 \(language) 项目,请先在插件管理中下载并安装 \(name)。" diff --git a/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift b/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift index 2188331f2c..bf45da1773 100644 --- a/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift +++ b/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift @@ -559,6 +559,8 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { public let languageServers: [DeclarativeLanguageServer] public let executionProviders: [DeclarativeExecutionProvider] public let buildSystems: [DeclarativeBuildSystem] + /// VCS providers the plugin adds (`vcsProviders` capability). + public let vcsProviders: [DeclarativeVcsProvider] public let settings: [DeclarativePluginSetting] /// Buttons the plugin adds to settings pages (settings capability 2). public let settingActions: [DeclarativePluginSettingAction] @@ -604,6 +606,7 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { ) } buildSystems = decoded.buildSystems.map { $0.declaration() } + vcsProviders = decoded.vcsProviders.map { DeclarativeVcsProvider(id: $0.id, displayName: $0.displayName) } ui = decoded.ui settings = try decoded.settings.map { try $0.declaration() } settingActions = decoded.settingActions.map { $0.declaration() } @@ -654,6 +657,17 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { } )) } + if !vcsProviders.isEmpty { + // Root detection, launch, and replies belong to Rust Core; the + // module only carries the plugin's enablement. + modules.append(PluginModuleDeclaration( + manifest: LanguageExtensionModuleManifests.vcsProviders( + pluginID: decoded.id, + displayName: decoded.displayName, + enabledByDefault: enabledByDefault + ) + )) + } if let ui = decoded.ui { modules.append(PluginModuleDeclaration( manifest: ModuleManifest( @@ -920,6 +934,13 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { struct JSONPresence: Decodable { init(from decoder: Decoder) throws {} } + /// The parts of a VCS provider the host shows. Root detection, + /// commands, and reply checks stay with Rust Core. + struct VcsProvider: Decodable { + let id: String + let displayName: String + } + /// The parts of a build system the tool window lists. Detection, /// commands, and decoders stay with Rust Core. struct BuildSystem: Decodable { @@ -1037,6 +1058,7 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { let languageServers: [LanguageServer] let executionProviders: [ExecutionProvider] let buildSystems: [BuildSystem] + let vcsProviders: [VcsProvider] let ui: DeclarativeUIContributions? let settings: [Setting] let settingActions: [SettingAction] @@ -1055,13 +1077,14 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { languageServers = try values.decodeIfPresent([LanguageServer].self, forKey: .languageServers) ?? [] executionProviders = try values.decodeIfPresent([ExecutionProvider].self, forKey: .executionProviders) ?? [] buildSystems = try values.decodeIfPresent([BuildSystem].self, forKey: .buildSystems) ?? [] + vcsProviders = try values.decodeIfPresent([VcsProvider].self, forKey: .vcsProviders) ?? [] settings = try values.decodeIfPresent([Setting].self, forKey: .settings) ?? [] settingActions = try values.decodeIfPresent([SettingAction].self, forKey: .settingActions) ?? [] } private enum CodingKeys: String, CodingKey { case id, displayName, version, pluginAPIVersion, vendor, permissions, activationEvents - case languages, languageServers, executionProviders, buildSystems, ui, settings, settingActions + case languages, languageServers, executionProviders, buildSystems, vcsProviders, ui, settings, settingActions } } } diff --git a/macos/Sources/LitheCoreContracts/Vcs/VcsProviderContracts.swift b/macos/Sources/LitheCoreContracts/Vcs/VcsProviderContracts.swift new file mode 100644 index 0000000000..d36d1986a2 --- /dev/null +++ b/macos/Sources/LitheCoreContracts/Vcs/VcsProviderContracts.swift @@ -0,0 +1,17 @@ +import Foundation + +// Note: 统一 VCS 模型见 .agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md + +/// A VCS provider a declarative plugin adds (`vcsProviders` capability). +/// Rust Core owns its root detection, launch plan, requests, and reply +/// validation; the host only needs to know which VCS it is and its name. +public struct DeclarativeVcsProvider: Equatable, Sendable { + /// The VCS ID, such as `svn`, that roots and replies carry. + public let id: String + public let displayName: String + + public init(id: String, displayName: String) { + self.id = id + self.displayName = displayName + } +} diff --git a/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift b/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift index 3e41a999ab..d7cb626e02 100644 --- a/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift +++ b/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift @@ -229,15 +229,21 @@ public enum OfficialPluginCatalog { } public struct Entry: Equatable, Sendable { + /// The language an official language plugin adds. The host uses it to + /// suggest the plugin for a project before it is installed. + public struct Language: Equatable, Sendable { + public let id: String + public let displayName: String + public let fileExtensions: [String] + public let projectFileNames: [String] + } + public let id: PluginID public let displayName: String public let signatureRequirement: PluginSignatureRequirement public let distribution: Distribution - public let languageID: String - public let languageDisplayName: String - /// Used to suggest the plugin for a project before it is installed. - public let fileExtensions: [String] - public let projectFileNames: [String] + /// Nil for a plugin that adds no language, such as a VCS provider. + public let language: Language? /// The community plugin that replaces this retired official plugin. public var communityReplacementID: PluginID? { @@ -249,6 +255,7 @@ public enum OfficialPluginCatalog { public static let phpPluginID = PluginID("dev.lithe.plugin.php-support") public static let goPluginID = PluginID("dev.lithe.plugin.go-support") public static let javaPluginID = PluginID("dev.lithe.plugin.java-support") + public static let svnPluginID = PluginID("dev.lithe.plugin.svn-support") public static let entries: [Entry] = [ Entry( @@ -256,30 +263,43 @@ public enum OfficialPluginCatalog { displayName: "Java Support", signatureRequirement: .sameTeamAsHost, distribution: .bundled, - languageID: "java", - languageDisplayName: "Java", - fileExtensions: ["java"], - projectFileNames: ["pom.xml", "build.gradle", "build.gradle.kts"] + language: Entry.Language( + id: "java", + displayName: "Java", + fileExtensions: ["java"], + projectFileNames: ["pom.xml", "build.gradle", "build.gradle.kts"] + ) ), Entry( id: goPluginID, displayName: "Go Support", signatureRequirement: .publisherPackage, distribution: .migratedToCommunity(PluginID("io.github.lithe-idea.go-support")), - languageID: "go", - languageDisplayName: "Go", - fileExtensions: ["go"], - projectFileNames: ["go.mod", "go.work"] + language: Entry.Language( + id: "go", + displayName: "Go", + fileExtensions: ["go"], + projectFileNames: ["go.mod", "go.work"] + ) ), Entry( id: phpPluginID, displayName: "PHP Support", signatureRequirement: .publisherPackage, distribution: .migratedToCommunity(PluginID("io.github.lithe-idea.php-support")), - languageID: "php", - languageDisplayName: "PHP", - fileExtensions: ["php", "phtml"], - projectFileNames: ["composer.json"] + language: Entry.Language( + id: "php", + displayName: "PHP", + fileExtensions: ["php", "phtml"], + projectFileNames: ["composer.json"] + ) + ), + Entry( + id: svnPluginID, + displayName: "Subversion Support", + signatureRequirement: .publisherPackage, + distribution: .download, + language: nil ) ] @@ -347,4 +367,22 @@ public enum LanguageExtensionModuleManifests { dependencies: [.module(.workspace)] ) } + + /// One module for all VCS providers of a plugin. Like build systems, it + /// owns no process: the host starts a provider only for a root of its VCS + /// while the module is enabled. + public static func vcsProviders( + pluginID: String, + displayName: String, + enabledByDefault: Bool = false + ) -> ModuleManifest { + ModuleManifest( + id: .vcsProvidersExtension(pluginID), + displayName: "\(displayName) Version Control", + scope: .workspace, + defaultState: enabledByDefault ? .enabled : .disabled, + activationPolicy: .onDemand, + dependencies: [.module(.workspace)] + ) + } } diff --git a/macos/Sources/LitheModuleAPI/Lifecycle/ModuleTypes.swift b/macos/Sources/LitheModuleAPI/Lifecycle/ModuleTypes.swift index 63ec8f2b24..17e0f23364 100644 --- a/macos/Sources/LitheModuleAPI/Lifecycle/ModuleTypes.swift +++ b/macos/Sources/LitheModuleAPI/Lifecycle/ModuleTypes.swift @@ -304,6 +304,11 @@ public extension ModuleID { static func buildSystemsExtension(_ pluginID: String) -> ModuleID { ModuleID("\(pluginID).build-systems") } + + /// The VCS providers one declarative plugin declares, enabled together. + static func vcsProvidersExtension(_ pluginID: String) -> ModuleID { + ModuleID("\(pluginID).vcs-providers") + } } public extension ModuleCapabilityID { diff --git a/macos/Tests/LitheApplicationKernelTests/ModuleRuntimeTests.swift b/macos/Tests/LitheApplicationKernelTests/ModuleRuntimeTests.swift index 955c89bad8..5c47907729 100644 --- a/macos/Tests/LitheApplicationKernelTests/ModuleRuntimeTests.swift +++ b/macos/Tests/LitheApplicationKernelTests/ModuleRuntimeTests.swift @@ -391,7 +391,7 @@ struct ModuleRuntimeTests { @Test func aiAssistanceIsBuiltInRatherThanAnOfficialDownload() { #expect(BuiltInPluginCatalog.manifest(forModule: .aiAssistance) != nil) - #expect(OfficialPluginCatalog.entries.allSatisfy { $0.languageID != "ai" }) + #expect(OfficialPluginCatalog.entries.allSatisfy { $0.language?.id != "ai" }) } @Test diff --git a/macos/Tests/LitheTests/PluginManagerTests.swift b/macos/Tests/LitheTests/PluginManagerTests.swift index fe55a47fc0..b571fe105a 100644 --- a/macos/Tests/LitheTests/PluginManagerTests.swift +++ b/macos/Tests/LitheTests/PluginManagerTests.swift @@ -44,10 +44,25 @@ struct PluginManagerTests { let officialLanguagePlugins = try ["GoSupport", "JavaSupport", "PhpSupport"].map { try DeclarativePluginTestSupport.officialDescriptor($0).pluginManifest } - #expect(Set(officialLanguagePlugins.map(\.id)) == Set(OfficialPluginCatalog.entries.map(\.id))) + #expect(Set(officialLanguagePlugins.map(\.id)) + == Set(OfficialPluginCatalog.entries.filter { $0.language != nil }.map(\.id))) // Go and PHP moved to Lithe Store: installed copies keep their pinned // publisher signature, but the host no longer suggests them. - #expect(OfficialPluginCatalog.publishedEntries.map(\.id) == [OfficialPluginCatalog.javaPluginID]) + #expect(OfficialPluginCatalog.publishedEntries.map(\.id) + == [OfficialPluginCatalog.javaPluginID, OfficialPluginCatalog.svnPluginID]) + // Subversion Support adds a VCS provider, not a language: one module + // carries its enablement, and its package must carry the signature + // the host pins for it. This host does not declare `vcsProviders` + // yet, so the manifest is read as the release verifier reads it. + let svn = try DeclarativePluginDescriptor( + normalizedManifest: DeclarativePluginTestSupport.officialManifestData("SvnSupport") + ) + let svnEntry = try #require(OfficialPluginCatalog.entry(for: svn.id)) + #expect(svn.id == OfficialPluginCatalog.svnPluginID) + #expect(svn.vcsProviders == [DeclarativeVcsProvider(id: "svn", displayName: "Subversion")]) + #expect(svn.pluginManifest.modules.map(\.manifest.id) == [.vcsProvidersExtension(svn.id.rawValue)]) + #expect(svn.pluginManifest.vendor.signatureRequirement == svnEntry.signatureRequirement) + #expect(svnEntry.signatureRequirement == .publisherPackage && svnEntry.language == nil) #expect(OfficialPluginCatalog.entry(for: OfficialPluginCatalog.goPluginID)?.communityReplacementID == PluginID("io.github.lithe-idea.go-support")) #expect(OfficialPluginCatalog.entry(for: OfficialPluginCatalog.phpPluginID)?.communityReplacementID @@ -55,13 +70,14 @@ struct PluginManagerTests { #expect([OfficialPluginCatalog.goPluginID, OfficialPluginCatalog.phpPluginID].allSatisfy { OfficialPluginCatalog.entry(for: $0)?.signatureRequirement == .publisherPackage }) - #expect(officialLanguagePlugins.flatMap(\.modules).allSatisfy { + #expect((officialLanguagePlugins + [svn.pluginManifest]).flatMap(\.modules).allSatisfy { $0.manifest.defaultState == .disabled }) _ = try ValidatedPluginCatalog( manifests: BuiltInPluginCatalog.manifests + BundledLanguagePluginCatalog.manifests - + officialLanguagePlugins, + + officialLanguagePlugins + + [svn.pluginManifest], hostVersion: BuiltInPluginCatalog.hostVersion ) } diff --git a/rust/lithe-core/tests/plugin_sdk/mod.rs b/rust/lithe-core/tests/plugin_sdk/mod.rs index f363143f43..85ca3b055b 100644 --- a/rust/lithe-core/tests/plugin_sdk/mod.rs +++ b/rust/lithe-core/tests/plugin_sdk/mod.rs @@ -13,6 +13,7 @@ // Each test binary includes this module and uses only the helpers its SDK needs. #![allow(dead_code)] +pub mod svn; pub mod vcs; use lithe_core::execute_json; @@ -115,6 +116,26 @@ impl Session { executable: &str, arguments: &[Value], workspace: &Path, + ) -> Self { + Self::start_provider_with_environment( + provider_id, + plugin_id, + executable, + arguments, + workspace, + json!({}), + ) + } + + /// Starts a plugin-owned session with `environment` added to the + /// backend's inherited environment. + fn start_provider_with_environment( + provider_id: &str, + plugin_id: &str, + executable: &str, + arguments: &[Value], + workspace: &Path, + environment: Value, ) -> Self { let started = core( "lsp.startServer", @@ -123,6 +144,7 @@ impl Session { "pluginId": plugin_id, "executablePath": executable, "arguments": arguments, + "environment": environment, // Directory URI with a trailing slash, as the macOS host sends it. "rootUri": format!("file://{}/", workspace.display()), "workingDirectory": workspace, @@ -272,6 +294,15 @@ pub fn node_from_environment(test: &str) -> Option { Some(node.into_string().expect("UTF-8 Node.js path")) } +/// The `svn` client from `LITHE_SVN`, or `None` with a skip notice. +pub fn svn_from_environment(test: &str) -> Option { + let Some(svn) = std::env::var_os("LITHE_SVN") else { + eprintln!("skipped: set LITHE_SVN to an svn 1.8 or newer client to run {test}"); + return None; + }; + Some(svn.into_string().expect("UTF-8 svn path")) +} + pub fn repository_path(relative: &str) -> PathBuf { repository_root().join(relative) } diff --git a/rust/lithe-core/tests/plugin_sdk/svn.rs b/rust/lithe-core/tests/plugin_sdk/svn.rs new file mode 100644 index 0000000000..64bbb7ff0b --- /dev/null +++ b/rust/lithe-core/tests/plugin_sdk/svn.rs @@ -0,0 +1,434 @@ +//! End-to-end scenario of the official Subversion plugin +//! (`Plugins/mac/Official/SvnSupport`) against a real `svn` client and a +//! `file://` repository. The package is assembled by its own +//! `assemble-package.sh`, so the test runs exactly what is shipped: root +//! detection from the manifest, the launch plan, the negotiation, and every +//! operation through `lsp.vcsRequest`, including the failures a provider +//! must tell apart (out of date, conflict) and the moves svn must record. +//! A second working copy plays a colleague committing at the same time. + +use super::vcs::{change, revision_ids, Provider}; +use super::{core, listing, repository_path, Fixture, Session}; +use serde_json::{json, Value}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +const VCS: &str = "svn"; + +/// svn waits up to a second after changing working files so a later edit +/// gets a newer timestamp. Its own test suite turns that off with this +/// variable; the scenario does too, so its ~40 writes do not take a minute. +/// Every edit here changes the file's size, which svn compares as well. +const NO_TIMESTAMP_SLEEP: (&str, &str) = ( + "SVN_I_LOVE_CORRUPTED_WORKING_COPIES_SO_DISABLE_SLEEP_FOR_TIMESTAMPS", + "yes", +); + +/// Runs the scenario's own `svn` commands with the provider's environment and +/// a private configuration directory. +struct Svn<'a> { + executable: &'a str, + config: PathBuf, +} + +impl Svn<'_> { + fn run(&self, directory: &Path, arguments: &[&str]) -> String { + let output = Command::new(self.executable) + .args(["--non-interactive", "--config-dir"]) + .arg(&self.config) + .args(arguments) + .current_dir(directory) + .env_remove("LC_ALL") + .env_remove("LANGUAGE") + .env(NO_TIMESTAMP_SLEEP.0, NO_TIMESTAMP_SLEEP.1) + .env("LC_MESSAGES", "C") + .env( + "LC_CTYPE", + if cfg!(target_os = "macos") { + "UTF-8" + } else { + "C.UTF-8" + }, + ) + .output() + .unwrap_or_else(|error| panic!("run svn {arguments:?}: {error}")); + assert!( + output.status.success(), + "svn {arguments:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).expect("UTF-8 svn output") + } +} + +/// Assembles the plugin package as `build-official-plugins.sh` does. +fn assemble_package(destination: &Path) { + let source = repository_path("Plugins/mac/Official/SvnSupport"); + fs::create_dir_all(destination).expect("package directory"); + fs::copy(source.join("plugin.json"), destination.join("plugin.json")).expect("manifest"); + let status = Command::new(source.join("assemble-package.sh")) + .arg("--output") + .arg(destination) + .args(["--arch", "universal"]) + .status() + .expect("run assemble-package.sh"); + assert!(status.success(), "assemble-package.sh failed: {status}"); +} + +/// The execution report of the first `svn` subcommand named `subcommand`. +fn execution_of(provider: &mut Provider<'_>, subcommand: &str) -> Value { + let event = provider + .session + .await_event(&format!("the {subcommand} report"), |event| { + event["type"] == "log" + && event["message"] == "lithe/vcs/execution" + && serde_json::from_str::(event["detail"].as_str().unwrap_or("{}")) + .is_ok_and(|report| report["command"][2] == subcommand) + }); + serde_json::from_str(event["detail"].as_str().expect("report detail")).expect("report JSON") +} + +fn read(root: &Path, path: &str) -> String { + fs::read_to_string(root.join(path)).unwrap_or_else(|error| panic!("read {path}: {error}")) +} + +fn write(root: &Path, path: &str, contents: &str) { + let target = root.join(path); + fs::create_dir_all(target.parent().expect("parent")).expect("parent directory"); + fs::write(target, contents).unwrap_or_else(|error| panic!("write {path}: {error}")); +} + +/// Runs the scenario with `node` as the provider's runtime and `svn` as the +/// client the host resolved. +pub fn run_svn_scenario(node: &str, svn_executable: &str) { + let fixture = Fixture::new("svn"); + let package = fixture.root.join("package"); + assemble_package(&package); + let package_before = listing(&package); + let manifest: Value = + serde_json::from_slice(&fs::read(package.join("plugin.json")).expect("manifest")) + .expect("manifest JSON"); + let plugin_id = manifest["id"].as_str().expect("plugin ID").to_string(); + let validated = core( + "plugins.declarative.validate", + json!({ "manifest": manifest }), + ); + assert_eq!(validated["status"], "ok", "{validated}"); + + // Revision 1 creates trunk, revision 2 the files the scenario changes. + let svn = Svn { + executable: svn_executable, + config: fixture.root.join("svn-config"), + }; + let repository = fixture.root.join("repository"); + let status = Command::new(Path::new(svn_executable).with_file_name("svnadmin")) + .arg("create") + .arg(&repository) + .status() + .expect("run svnadmin"); + assert!(status.success(), "svnadmin create failed"); + let trunk = format!("file://{}/trunk", repository.display()); + svn.run( + &fixture.root, + &["mkdir", "-m", "Create trunk", "--username", "alice", &trunk], + ); + let workspace = fixture.workspace(); + let other = fixture.root.join("other"); + svn.run(&fixture.root, &["checkout", &trunk, "workspace"]); + write(&workspace, "a.txt", "one\ntwo\n"); + write(&workspace, "docs/b.txt", "bee\n"); + write(&workspace, "keep.txt", "keep\n"); + svn.run(&workspace, &["add", "a.txt", "docs", "keep.txt"]); + svn.run( + &workspace, + &["commit", "-m", "Initial", "--username", "alice"], + ); + // A commit leaves the root directory at its old revision; a fresh + // checkout, as users start from, has the whole tree at one revision. + svn.run(&workspace, &["update"]); + svn.run(&fixture.root, &["checkout", &trunk, "other"]); + + // Core finds the working copy root from the manifest's `.svn` marker. + let detection = core( + "vcs.detectRoots", + json!({ "workspaceRoot": workspace, "plugins": [{ "manifest": manifest }] }), + ); + assert_eq!(detection["status"], "ok", "{detection}"); + let root = detection["detection"]["roots"] + .as_array() + .expect("roots") + .iter() + .find(|root| root["vcs"] == VCS) + .unwrap_or_else(|| panic!("no Subversion root: {detection}")) + .clone(); + assert_eq!(root["path"], json!(workspace)); + + let plan = core( + "plugins.declarative.languageServerLaunchPlan", + json!({ + "manifest": manifest, + "vcsProviderID": VCS, + "request": { + "pluginDirectory": package, + "stateDirectory": fixture.root.join("state"), + "workspaceStateDirectory": fixture.root.join("workspace-state"), + "architecture": if cfg!(target_arch = "aarch64") { "arm64" } else { "x86_64" }, + } + }), + ); + assert_eq!(plan["status"], "ok", "{plan}"); + let plan = &plan["plan"]; + assert_eq!( + plan["vcsProvider"]["tool"]["commands"], + json!(["svn"]), + "{plan}" + ); + let arguments = plan["arguments"].as_array().expect("arguments").clone(); + let mut session = Session::start_provider_with_environment( + &format!("vcsProvider:{plugin_id}:{VCS}"), + &plugin_id, + node, + &arguments, + &workspace, + json!({ NO_TIMESTAMP_SLEEP.0: NO_TIMESTAMP_SLEEP.1 }), + ); + let negotiated = session.await_event("the VCS negotiation", |event| { + event["type"] == "vcsProvider" + }); + assert_eq!(negotiated["result"]["vcs"], VCS, "{negotiated}"); + let capabilities = negotiated["result"]["capabilities"].clone(); + // Subversion commits straight to the server and has no staging area. + assert_eq!( + capabilities, + json!({ + "annotate": 1, "baseContent": 1, "checkin": 1, "cleanup": 1, "conflicts": 1, + "fileHistory": 1, "fileOperations": 1, "history": 1, "rollback": 1, "status": 1, + "update": 1 + }) + ); + let mut provider = Provider { + session, + vcs: VCS, + capabilities, + root: &workspace, + tool: svn_executable, + sequence: 0, + }; + + // A client the host resolved but that cannot run is its own error. + let missing_tool = fixture.root.join("missing/svn"); + provider.tool = missing_tool.to_str().expect("UTF-8 path"); + let unavailable = provider.send("status", &json!({})); + assert_eq!( + unavailable["error"]["code"], "vcs.tool.unavailable", + "{unavailable}" + ); + provider.tool = svn_executable; + + // Local edits, a file removed without svn, and a new file. + write(&workspace, "a.txt", "one\nTWO\nthree\n"); + fs::remove_file(workspace.join("docs/b.txt")).expect("remove b.txt"); + write(&workspace, "c.txt", "sea\n"); + assert_eq!( + provider.changes(), + [ + change("a.txt", "modified"), + change("c.txt", "unversioned"), + change("docs/b.txt", "missing"), + ] + ); + let head = provider.request("status", json!({}))["head"].clone(); + assert_eq!( + head, + json!({ "revision": { "id": "2", "display": "r2", "kind": "number" }, "branch": null, "location": trunk }) + ); + provider.request( + "fileOperation", + json!({ "kind": "add", "paths": ["c.txt"] }), + ); + assert_eq!(provider.changes()[1], change("c.txt", "added")); + assert_eq!( + provider.request("baseContent", json!({ "path": "a.txt" })), + json!({ "content": "one\ntwo\n", "binary": false }) + ); + assert_eq!( + provider.request("baseContent", json!({ "path": "c.txt" })), + json!({ "content": null, "binary": false }) + ); + + // A colleague commits keep.txt first; committing ours is out of date, + // which the host shows as its own error. + write(&other, "keep.txt", "theirs\n"); + svn.run(&other, &["commit", "-m", "Theirs", "--username", "bob"]); + write(&workspace, "keep.txt", "mine, edited\n"); + let rejected = provider.send( + "checkin", + &json!({ "paths": ["keep.txt"], "message": "Mine" }), + ); + assert_eq!(rejected["error"]["code"], "vcs.outOfDate", "{rejected}"); + provider.request("rollback", json!({ "paths": ["keep.txt"] })); + assert_eq!(read(&workspace, "keep.txt"), "keep\n"); + + // Mutating commands are reported for the console; reads are not. + provider.request("cleanup", json!({})); + let report = execution_of(&mut provider, "cleanup"); + assert_eq!( + report["command"], + json!([svn_executable, "--non-interactive", "cleanup"]) + ); + assert_eq!(report["exitCode"], 0, "{report}"); + + // The missing file is deleted through svn as part of the check-in. + let checked_in = provider.request( + "checkin", + json!({ "paths": ["a.txt", "c.txt", "docs/b.txt"], "message": "Second\n\nWith a body." }), + ); + assert_eq!( + checked_in["revision"], + json!({ "id": "4", "display": "r4", "kind": "number" }) + ); + assert!(provider.changes().is_empty()); + assert_eq!( + provider.request("update", json!({})), + json!({ "revision": { "id": "4", "display": "r4", "kind": "number" }, "conflicts": [] }) + ); + assert_eq!(read(&workspace, "keep.txt"), "theirs\n"); + + // History pages run newest first and resume from the cursor. + let first = provider.request("history", json!({ "limit": 1 })); + assert_eq!(revision_ids(&first["entries"]), ["4"]); + assert_eq!(first["nextCursor"], "3"); + let latest = &first["entries"][0]; + assert_eq!(latest["message"], "Second\n\nWith a body."); + assert_eq!( + latest["changes"], + json!([ + { "path": "a.txt", "oldPath": null, "status": "modified", "flags": [] }, + { "path": "c.txt", "oldPath": null, "status": "added", "flags": [] }, + { "path": "docs/b.txt", "oldPath": null, "status": "deleted", "flags": [] } + ]) + ); + let rest = provider.request("history", json!({ "limit": 2, "cursor": "3" })); + assert_eq!(revision_ids(&rest["entries"]), ["3", "2"]); + assert_eq!(rest["entries"][1]["author"], "alice"); + assert_eq!(rest["nextCursor"], "1"); + let file_history = provider.request("fileHistory", json!({ "path": "a.txt" })); + assert_eq!(revision_ids(&file_history["entries"]), ["4", "2"]); + assert_eq!(file_history["nextCursor"], Value::Null); + + // Annotation follows the working file: a local line has no revision. + write(&workspace, "a.txt", "one\nTWO\nthree\nfour\n"); + let annotation = provider.request("annotate", json!({ "path": "a.txt" })); + assert_eq!(annotation["lines"], json!(["2", "4", "4", null])); + assert_eq!(revision_ids(&annotation["revisions"]), ["2", "4"]); + assert_eq!(annotation["revisions"][0]["message"], "Initial"); + provider.request("rollback", json!({ "paths": ["a.txt"] })); + + // A move is a rename from its source; rolling it back restores the source. + provider.request( + "fileOperation", + json!({ "kind": "move", "from": "c.txt", "to": "docs/c.txt" }), + ); + assert_eq!( + provider.changes(), + [( + "docs/c.txt".to_string(), + "renamed".to_string(), + json!("c.txt") + )] + ); + provider.request("rollback", json!({ "paths": ["docs/c.txt"] })); + assert!(!workspace.join("docs/c.txt").exists()); + assert_eq!(read(&workspace, "c.txt"), "sea\n"); + assert!(provider.changes().is_empty()); + + // Checking in the destination alone commits both halves of the move. + provider.request( + "fileOperation", + json!({ "kind": "move", "from": "c.txt", "to": "docs/c.txt" }), + ); + let moved = provider.request( + "checkin", + json!({ "paths": ["docs/c.txt"], "message": "Move c" }), + ); + assert_eq!(moved["revision"]["id"], "5"); + assert!(provider.changes().is_empty()); + let move_entry = provider.request("history", json!({ "limit": 1 })); + assert_eq!( + move_entry["entries"][0]["changes"], + json!([{ "path": "docs/c.txt", "oldPath": "c.txt", "status": "renamed", "flags": [] }]) + ); + + // An update that conflicts leaves the file conflicted until resolved. + svn.run(&other, &["update"]); + write(&other, "docs/c.txt", "sea from bob\n"); + svn.run(&other, &["commit", "-m", "Bob's c", "--username", "bob"]); + write(&workspace, "docs/c.txt", "sea from us\n"); + let updated = provider.request("update", json!({})); + assert_eq!(updated["revision"]["id"], "6"); + assert_eq!(updated["conflicts"], json!(["docs/c.txt"])); + let conflicted = provider.send( + "checkin", + &json!({ "paths": ["docs/c.txt"], "message": "Too early" }), + ); + assert_eq!(conflicted["error"]["code"], "vcs.conflict", "{conflicted}"); + fs::write(workspace.join("docs/c.txt"), "sea from both\n").expect("merge by hand"); + provider.request("resolve", json!({ "paths": ["docs/c.txt"] })); + assert_eq!(provider.changes(), [change("docs/c.txt", "modified")]); + assert_eq!(read(&workspace, "docs/c.txt"), "sea from both\n"); + provider.request("rollback", json!({ "paths": ["docs/c.txt"] })); + assert_eq!(read(&workspace, "docs/c.txt"), "sea from bob\n"); + + // A tracked delete removes the file; rollback brings it back. + provider.request( + "fileOperation", + json!({ "kind": "delete", "paths": ["keep.txt"] }), + ); + assert!(!workspace.join("keep.txt").exists()); + assert_eq!(provider.changes(), [change("keep.txt", "deleted")]); + provider.request("rollback", json!({ "paths": ["keep.txt"] })); + assert_eq!(read(&workspace, "keep.txt"), "theirs\n"); + + // New paths are added on check-in: a whole directory, a name svn would + // read as a peg revision, and a binary file whose base is not sent. + write(&workspace, "new/x.txt", "x\n"); + write(&workspace, "new/sub/y.txt", "y\n"); + write(&workspace, "a@b.txt", "at\n"); + fs::write(workspace.join("image.bin"), b"bin\0ary").expect("binary file"); + assert_eq!( + provider.changes(), + [ + change("a@b.txt", "unversioned"), + change("image.bin", "unversioned"), + change("new", "unversioned"), + ] + ); + let added = provider.request( + "checkin", + json!({ "paths": ["new", "a@b.txt", "image.bin"], "message": "Add new files" }), + ); + assert_eq!(added["revision"]["id"], "7"); + assert!(provider.changes().is_empty()); + assert_eq!( + revision_ids( + &provider.request("fileHistory", json!({ "path": "new/sub/y.txt" }))["entries"] + ), + ["7"] + ); + assert_eq!( + provider.request("baseContent", json!({ "path": "a@b.txt" })), + json!({ "content": "at\n", "binary": false }) + ); + assert_eq!( + provider.request("baseContent", json!({ "path": "image.bin" })), + json!({ "content": null, "binary": true }) + ); + + provider.session.stop(); + drop(provider); + assert!( + listing(&package) == package_before, + "the plugin package changed at runtime" + ); +} diff --git a/rust/lithe-core/tests/plugin_sdk/vcs.rs b/rust/lithe-core/tests/plugin_sdk/vcs.rs index 6f5cd74de4..2e080489c7 100644 --- a/rust/lithe-core/tests/plugin_sdk/vcs.rs +++ b/rust/lithe-core/tests/plugin_sdk/vcs.rs @@ -28,17 +28,18 @@ fn initial_revision() -> Value { } /// One provider session with what it negotiated and the host's resolved tool. -struct Provider<'a> { - session: Session, - capabilities: Value, - root: &'a Path, - tool: &'a str, - sequence: usize, +pub(super) struct Provider<'a> { + pub(super) session: Session, + pub(super) vcs: &'static str, + pub(super) capabilities: Value, + pub(super) root: &'a Path, + pub(super) tool: &'a str, + pub(super) sequence: usize, } impl Provider<'_> { /// Sends one operation and returns its `requestCompleted` event. - fn send(&mut self, operation: &str, arguments: &Value) -> Value { + pub(super) fn send(&mut self, operation: &str, arguments: &Value) -> Value { self.sequence += 1; let operation_id = format!("{operation}-{}", self.sequence); core( @@ -46,7 +47,7 @@ impl Provider<'_> { json!({ "sessionId": self.session.id, "operationId": operation_id, - "vcs": VCS, + "vcs": self.vcs, "operation": operation, "root": self.root, "tool": self.tool, @@ -57,7 +58,7 @@ impl Provider<'_> { } /// Sends one operation and returns the reply as `vcs.decodeReply` normalizes it. - fn request(&mut self, operation: &str, arguments: Value) -> Value { + pub(super) fn request(&mut self, operation: &str, arguments: Value) -> Value { let completed = self.send(operation, &arguments); assert!( completed.get("error").is_none(), @@ -77,7 +78,7 @@ impl Provider<'_> { } /// `(path, status, oldPath)` of each change, in Core's order. - fn changes(&mut self) -> Vec<(String, String, Value)> { + pub(super) fn changes(&mut self) -> Vec<(String, String, Value)> { self.request("status", json!({}))["changes"] .as_array() .expect("decoded changes") @@ -93,11 +94,11 @@ impl Provider<'_> { } } -fn change(path: &str, status: &str) -> (String, String, Value) { +pub(super) fn change(path: &str, status: &str) -> (String, String, Value) { (path.to_string(), status.to_string(), Value::Null) } -fn revision_ids(entries: &Value) -> Vec { +pub(super) fn revision_ids(entries: &Value) -> Vec { entries .as_array() .expect("entries") @@ -192,6 +193,7 @@ pub fn run_vcs_scenario(node: &str) { ); let mut provider = Provider { session, + vcs: VCS, capabilities, root: &workspace, tool: node, diff --git a/rust/lithe-core/tests/plugin_sdk_node.rs b/rust/lithe-core/tests/plugin_sdk_node.rs index 8cd689b422..e5ff4b954c 100644 --- a/rust/lithe-core/tests/plugin_sdk_node.rs +++ b/rust/lithe-core/tests/plugin_sdk_node.rs @@ -1,8 +1,10 @@ //! End-to-end check of the official Node.js plugin SDK (`sdk/node`) against //! Rust Core; the scenario is shared with the Java SDK in `plugin_sdk/mod.rs`. -//! The VCS scenario is in `plugin_sdk/vcs.rs`. Both run only when -//! `LITHE_PLUGIN_SDK_NODE` names a Node.js 20.3 or newer executable; -//! `scripts/verify-rust-core-crates.sh` sets it. +//! The VCS scenario is in `plugin_sdk/vcs.rs`, and the official Subversion +//! plugin, a Node SDK backend, in `plugin_sdk/svn.rs`. They run only when +//! `LITHE_PLUGIN_SDK_NODE` names a Node.js 20.3 or newer executable, and the +//! Subversion scenario also needs `LITHE_SVN`; +//! `scripts/verify-rust-core-crates.sh` sets both when the tools exist. mod plugin_sdk; @@ -27,3 +29,16 @@ fn the_node_sdk_sample_vcs_provider_answers_every_operation() { }; plugin_sdk::vcs::run_vcs_scenario(&node); } + +#[test] +fn the_official_subversion_plugin_works_on_a_real_working_copy() { + let Some(node) = plugin_sdk::node_from_environment("the Subversion plugin end-to-end check") + else { + return; + }; + let Some(svn) = plugin_sdk::svn_from_environment("the Subversion plugin end-to-end check") + else { + return; + }; + plugin_sdk::svn::run_svn_scenario(&node, &svn); +} diff --git a/scripts/verify-rust-core-crates.sh b/scripts/verify-rust-core-crates.sh index 3a195347f7..19d332ec45 100755 --- a/scripts/verify-rust-core-crates.sh +++ b/scripts/verify-rust-core-crates.sh @@ -33,6 +33,10 @@ node --test scripts/test-ide-mcp.mjs node --test --test-reporter=spec --test-reporter-destination=stdout \ --test-reporter=junit --test-reporter-destination=.artifacts/test-stability/plugin-sdk-node.xml \ sdk/node/test/*.test.mjs +# The Subversion plugin parses recorded `svn --xml` output; no svn needed. +node --test --test-reporter=spec --test-reporter-destination=stdout \ + --test-reporter=junit --test-reporter-destination=.artifacts/test-stability/svn-support-node.xml \ + Plugins/mac/Official/SvnSupport/test/*.test.mjs # The Java plugin SDK compiles for Java 8 with any newer JDK. When # LITHE_PLUGIN_SDK_JAVA_RUNTIME names an older `java` (CI sets a Java 8 one), # its tests and the sample backend run on that runtime instead of the build JDK. @@ -42,6 +46,13 @@ done java_runtime="${LITHE_PLUGIN_SDK_JAVA_RUNTIME:-$(command -v java)}" mvn -B -ntp -f sdk/java/pom.xml verify -Djvm="$java_runtime" cp sdk/java/plugin-sdk/target/surefire-reports/TEST-*.xml .artifacts/test-stability/ +# The Subversion plugin's end-to-end test needs a real svn client and +# svnadmin, which not every machine has; it reports itself skipped without. +if command -v svn >/dev/null && command -v svnadmin >/dev/null; then + export LITHE_SVN="$(command -v svn)" +else + print "verify-rust-core-crates: svn is not installed; the Subversion plugin end-to-end test is skipped." +fi # Opts the plugin SDK end-to-end tests in; they drive each sample backend # through Core's real plugin session. LITHE_PLUGIN_SDK_NODE="$(command -v node)" \ diff --git a/shared/contracts/vcs-provider-v1.md b/shared/contracts/vcs-provider-v1.md index 999c334b56..6ad79e122a 100644 --- a/shared/contracts/vcs-provider-v1.md +++ b/shared/contracts/vcs-provider-v1.md @@ -354,8 +354,35 @@ must not contain credentials. ## Subversion provider notes -The official Subversion plugin calls the user's `svn` client and parses its -`--xml` output (`status`, `info`, `log`, `blame`). Every invocation uses -`--non-interactive`, so only Subversion's own credential cache answers. +The official Subversion plugin (`Plugins/mac/Official/SvnSupport`) calls the +user's `svn` client, 1.8 or newer, and parses its `--xml` output (`status`, +`info`, `log`, `blame`). Every invocation uses `--non-interactive`, so only +Subversion's own credential cache answers, and runs with a UTF-8 `LC_CTYPE` +and `LC_MESSAGES=C` so non-ASCII paths work and errors can be classified. Working copies older than format 1.7 (one `.svn` per directory) are reported -as `vcs.workingCopy.unsupported`. +as `vcs.workingCopy.unsupported`. How each operation maps to svn: + +| Operation | svn | Notes | +| --- | --- | --- | +| `status` | `info`, `status --ignore-externals` | `head.revision` is the root directory's own working revision, as `svn info` shows it: after a check-in it stays at the older revision until `update` (mixed revisions). Property changes of the root itself are not reported. An external is a root of its own. A move source is not listed; its destination is `renamed`. An unmodified locked file is not a change. | +| `baseContent` | `cat -r BASE` | A NUL byte makes the content binary. Added, replaced, and unversioned files have no base. | +| `history`, `fileHistory` | `log --verbose` | The root's history starts at `HEAD`, so a revision just committed is listed; a file's starts at its base. A page whose changed paths exceed the output limit is returned with `changesTruncated`. | +| `annotate` | `blame`, then `log` for messages | Locally changed lines are `null`. Messages are left out when the log fails. | +| `checkin` | `commit --depth empty` | Commits exactly the selected paths plus both halves of a move and added parent directories. Selected unversioned paths are added first, with everything below a new directory; selected missing paths are deleted. | +| `rollback` | `revert` | A scheduled add, delete, or replace is reverted with everything below it. Undoing a moved file deletes the copy and restores the source; a moved directory's copy stays on disk unversioned, because svn would delete unversioned files inside it. | +| `update` | `update --accept postpone` | Conflicts are read from status afterwards. | +| `fileOperation` | `add --parents`, `delete --force`, `move --parents` | Moving an unversioned path renames it on disk. | +| `resolve` | `resolve --accept working` | The user's working file is taken as the resolution. | +| `cleanup` | `cleanup` | | + +Paths are passed with a trailing `@` so a name containing `@` is never read as +a peg revision; a move destination is not a target and is passed as it is. +svn errors map to contract codes by their first known code: `E155004` and +`E155037` are `vcs.workingCopy.locked`; `E155036` and `E155021` +`vcs.workingCopy.unsupported`; `E155011`, `E160028`, `E160024`, and `E170004` +`vcs.outOfDate`; `E155015` `vcs.conflict`; `E170001`, `E215004`, and +`E230001` `vcs.authentication.failed`. Any other failure reaches the host as +`vcs.provider.failed` with svn's message, with paths inside the root shown +relative to it. A command that changes working files waits up to a second +afterwards so later edits get newer timestamps; hosts should not expect a +write operation to finish faster. diff --git a/shared/platform-feature-matrix/features/vcs-subversion-and-plugin-providers.json b/shared/platform-feature-matrix/features/vcs-subversion-and-plugin-providers.json index 03858b1e9b..78050297ce 100644 --- a/shared/platform-feature-matrix/features/vcs-subversion-and-plugin-providers.json +++ b/shared/platform-feature-matrix/features/vcs-subversion-and-plugin-providers.json @@ -15,6 +15,12 @@ "sdk/node/test/vcs.test.mjs", "sdk/node/examples/sample-vcs", "rust/lithe-core/tests/plugin_sdk/vcs.rs", + "Plugins/mac/Official/SvnSupport/provider/svn-provider.mjs", + "Plugins/mac/Official/SvnSupport/provider/svn-output.mjs", + "Plugins/mac/Official/SvnSupport/test/svn-output.test.mjs", + "Plugins/mac/Official/SvnSupport/test/fixtures", + "rust/lithe-core/tests/plugin_sdk/svn.rs", + "macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift", ".agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md" ], "implementationStatus": "missing", @@ -37,6 +43,6 @@ "verificationStatus": "not-applicable" }, "owner": "Git", - "verification": "Core 共享层自动化:cargo test -p lithe-core vcs、cargo test -p lithe-core --lib lsp、npm test --prefix sdk/node、LITHE_PLUGIN_SDK_NODE=$(command -v node) cargo test -p lithe-core --test plugin_sdk_node 与 scripts/verify-shared-contracts.sh,覆盖 vcsProviders 清单校验与 git 保留、onVcsRoot 激活、svn 根识别(嵌套、歧义目录与映射、git worktree 文件、工作区位于工作副本内部、剪枝目录、深度上限)、vcs.ownerOf 最深根归属、litheVcs 只向插件会话提供、协商缺必需能力时拒绝、lsp.vcsRequest 按操作生成请求与能力检查、Provider 错误码白名单、vcs.decodeReply 的回复校验用例,以及用 Node SDK 写的示例 Provider 经真实 Core 会话完成协商、全部 11 种操作和执行报告。宿主验收:安装 Subversion 官方插件后,在本地 svnadmin create 建立的 file:// 仓库检出的工作副本上验证根识别、文件状态颜色、gutter、提交、更新、回滚、add/move/delete 跟踪、文件历史、Annotate 和冲突显示;同时打开一个包含 Git 仓库与 SVN 工作副本的工作区,确认文件只归属最深的根,Git 行为不变;运行前后 app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。", - "notes": "Issue #827。Rust Core 已实现 vcs.detectRoots、vcs.ownerOf、vcs.decodeReply、lsp.vcsRequest 与 litheVcs 协商,Node SDK 已支持 litheVcs(P1);两端宿主尚未声明 vcsProviders,也没有调用这些命令,所以仍记为未实现。分阶段计划见提议 Note。" + "verification": "Core 共享层自动化:cargo test -p lithe-core vcs、cargo test -p lithe-core --lib lsp、npm test --prefix sdk/node、LITHE_PLUGIN_SDK_NODE=$(command -v node) cargo test -p lithe-core --test plugin_sdk_node 与 scripts/verify-shared-contracts.sh,覆盖 vcsProviders 清单校验与 git 保留、onVcsRoot 激活、svn 根识别(嵌套、歧义目录与映射、git worktree 文件、工作区位于工作副本内部、剪枝目录、深度上限)、vcs.ownerOf 最深根归属、litheVcs 只向插件会话提供、协商缺必需能力时拒绝、lsp.vcsRequest 按操作生成请求与能力检查、Provider 错误码白名单、vcs.decodeReply 的回复校验用例,以及用 Node SDK 写的示例 Provider 经真实 Core 会话完成协商、全部 11 种操作和执行报告。Subversion 官方插件:node --test Plugins/mac/Official/SvnSupport/test/*.test.mjs 用 svn 1.15 录制的 status/info/log/blame --xml 与错误输出样例覆盖解析和错误码映射;LITHE_PLUGIN_SDK_NODE=$(command -v node) LITHE_SVN=$(command -v svn) cargo test -p lithe-core --test plugin_sdk_node 在 svnadmin create 建立的 file:// 仓库上经真实 Core 会话跑通状态、提交、过期提交、更新与冲突、resolve、回滚、移动、删除、历史分页、Annotate 和二进制文件;scripts/verify-official-plugins.sh 校验插件包与宿主固定的签名要求。宿主验收:安装 Subversion 官方插件后,在本地 svnadmin create 建立的 file:// 仓库检出的工作副本上验证根识别、文件状态颜色、gutter、提交、更新、回滚、add/move/delete 跟踪、文件历史、Annotate 和冲突显示;同时打开一个包含 Git 仓库与 SVN 工作副本的工作区,确认文件只归属最深的根,Git 行为不变;运行前后 app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。", + "notes": "Issue #827。Rust Core 已实现 vcs.detectRoots、vcs.ownerOf、vcs.decodeReply、lsp.vcsRequest 与 litheVcs 协商,Node SDK 已支持 litheVcs(P1);Subversion 官方插件 Plugins/mac/Official/SvnSupport 已实现全部 11 种操作,macOS 官方插件目录已固定它的发布者签名要求(P2)。两端宿主尚未声明 vcsProviders,也没有调用这些命令,插件还不能安装,所以仍记为未实现。分阶段计划见提议 Note。" }