diff --git a/.agents/notes/implemented/architecture/2026-10-17-plugin-capability-versions.md b/.agents/notes/implemented/architecture/2026-10-17-plugin-capability-versions.md index 874a938e90..22e9e85898 100644 --- a/.agents/notes/implemented/architecture/2026-10-17-plugin-capability-versions.md +++ b/.agents/notes/implemented/architecture/2026-10-17-plugin-capability-versions.md @@ -32,6 +32,7 @@ | `ui` | `ui`;版本 `2` 增加工具窗口里的网页内容 | | `buildSystems` | 声明式构建识别、模型和命令计划 | | `buildProviders` | 任一 `buildSystems[].provider`,包括自有进程和共享语言服务器两种写法 | +| `vcsProviders` | `vcsProviders[]`:插件进程实现的版本控制系统(工作副本标记、元数据路径、命令行工具) | 规则: @@ -46,6 +47,8 @@ 为什么 `serverProcess` 单独作为一个能力:启动字段由语言服务器和构建系统的自有 Provider 共用。以后新增一种启动方式时,只升级 `serverProcess`,`languageServers` 和 `buildSystems` 的其它字段不受牵连。共享写法的 Provider 复用插件里的语言服务器,所以不单独计入 `serverProcess`。两种 Provider 都必须声明 `buildProviders: 1`:能启动语言服务器或执行构建命令,并不代表能完成构建协议协商、请求和生命周期管理。纯模板构建插件不声明 `buildProviders`,否则会因多列能力被拒绝。 +`vcsProviders` 同样依赖 `serverProcess`:每个 VCS Provider 都是插件自己的进程,启动字段按 `serverProcess` 校验,所以清单要同时写 `vcsProviders: 1` 和 `serverProcess: 1`。只写前者,Core 会报少列了 `serverProcess`。工作副本标记和元数据路径留在 `vcsProviders` 里,因为宿主在进程启动前就要靠它们找到工作副本根,见[统一 VCS 模型提案](../../proposed/architecture/2026-10-22-unified-vcs-provider-model.md)。 + ### 2. 宿主自己声明能运行什么 Core 能校验一个能力,不代表宿主能运行它。宿主在 `plugins.declarative.validate`、`plugins.marketplace.validateIndex` 和 `plugins.marketplace.installReview` 里发送 `hostCapabilities: {"<能力>": [版本…]}`,由 Core 判断: @@ -56,7 +59,7 @@ Core 能校验一个能力,不代表宿主能运行它。宿主在 `plugins.de 能力表放在宿主代码里,写在能力实现的旁边,Core 不再按平台去猜。原来按 `darwin-` 前缀和级别写死的判断,就是 Core 替宿主猜、又容易和宿主实现漂移的例子。 -macOS 宿主接受级别 1 到 3(`supportedDeclarativeAPIVersions = [1, 2, 3]`),能力表是 `PluginManifest.hostCapabilities`,在校验、索引和安装审查时都发给 Core;其中的 `buildSystems: [1]` 随 macOS 的 Build 工具窗口一起加入,但 macOS 尚未实现 Provider,所以不声明 `buildProviders`;市场过滤、清单校验和安装审查都会拒绝需要它的插件,纯模板构建插件仍可用。Core 自己支持 `buildProviders: 1`,不传宿主表时仍可校验并生成 Provider 计划。Windows 还没有声明式插件宿主,不发送能力表,所以 Windows 不列出级别 3 的插件。宿主接入某个能力时,要做两件事:让宿主接受级别 3,并在宿主自己的能力表里加上这个能力的版本,而且只在宿主真正能运行它时才加。 +macOS 宿主接受级别 1 到 3(`supportedDeclarativeAPIVersions = [1, 2, 3]`),能力表是 `PluginManifest.hostCapabilities`,在校验、索引和安装审查时都发给 Core;其中的 `buildSystems: [1]` 随 macOS 的 Build 工具窗口一起加入,但 macOS 尚未实现 Provider,所以不声明 `buildProviders`;市场过滤、清单校验和安装审查都会拒绝需要它的插件,纯模板构建插件仍可用。Core 自己支持 `buildProviders: 1`,不传宿主表时仍可校验并生成 Provider 计划。`vcsProviders: 1` 也是这样:Core 已经能校验清单、识别工作副本根并转发请求,但 macOS 和 Windows 都还没有接入 VCS Provider 会话,所以两端的能力表都不包含它,需要它的插件在两端都不可安装。Windows 还没有声明式插件宿主,不发送能力表,所以 Windows 不列出级别 3 的插件。宿主接入某个能力时,要做两件事:让宿主接受级别 3,并在宿主自己的能力表里加上这个能力的版本,而且只在宿主真正能运行它时才加。 ### 3. 市场索引带上能力版本 @@ -69,7 +72,7 @@ macOS 宿主接受级别 1 到 3(`supportedDeclarativeAPIVersions = [1, 2, 3]` 清单回答的问题只有三个:启动什么进程、需要哪些权限、对哪些文件和工程生效。进程启动以后能回答哪些请求、支持哪些可选数据,在 LSP `initialize` 里协商: -- 宿主发送 `capabilities.experimental.litheBuild: {"versions": [1]}`。 +- 宿主发送 `capabilities.experimental.litheBuild: {"versions": [1]}`。VCS Provider 用同样的方式协商 `litheVcs`,回复里还带上它实现了哪些 VCS 能力(例如 `history`、`annotate`),宿主据此决定显示哪些操作,见 `shared/contracts/vcs-provider-v1.md`。 - Provider 从中选一个版本,回复 `{"version": 1}`。没有回复版本、或回复了宿主没提供的版本时,宿主不把它当作 Provider 使用。 - 双方都忽略协商对象、请求参数和结果里自己不认识的字段,所以同一个版本内可以继续增加可选数据。另一方不能忽略的改动,才需要新的协议版本。 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 f21db215d2..bf907e0b8a 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 @@ -6,9 +6,11 @@ Lithe 现在只认识 Git:Rust Core、macOS 和 Windows 的变更列表、提交面板、项目树文件颜色、编辑器行标记(gutter)都直接使用 Git 类型。[Issue #827](https://github.com/1lck/Lithe-IDEA/issues/827) 要求支持 SVN,以后还可能有 Mercurial、Perforce 等其他 VCS(Version Control System,版本控制系统)。 -提案照搬[构建系统插件契约](../../implemented/architecture/2026-10-17-build-system-plugin-contract.md)已经验证过的做法:Rust Core 定义一份与具体 VCS 无关的统一模型和 `vcs.*` 命令,每种 VCS 是一个 Provider(回答“这个目录现在是什么状态、怎样提交、怎样回滚”的实现)。Git 是内置 Provider,只在 Core 里加一层转换,现有 `git.*` 代码和契约不动;SVN 作为官方插件,以插件进程的形式接入,后续其他 VCS 走同一条路。 +提案照搬[构建系统插件契约](../../implemented/architecture/2026-10-17-build-system-plugin-contract.md)已经验证过的做法:Rust Core 定义一份与具体 VCS 无关的统一模型和 `vcs.*` 命令,每种 VCS 是一个 Provider(回答“这个目录现在是什么状态、怎样提交、怎样回滚”的实现)。SVN 作为第一个官方插件 Provider,后端用官方 Node SDK 编写,以插件进程的形式接入,后续其他 VCS 走同一条路。 -对日常开发的影响:在统一层落地之前,新的 Git 功能照常写在 Git 模块里;但凡是“任何 VCS 都会有”的界面(文件状态颜色、gutter、提交、回滚、文件历史、Annotate),不要再新增对 Git 类型的直接依赖,等统一层就绪后改读 `vcs.*`。 +2026-10-22 评审决定:**Git 暂不接入统一层**。现有 Git 代码约 8 万行(Rust、Swift、TypeScript 合计,不含测试),两端还有重复的确定性逻辑,直接“包一层”会把这些债一起带进统一层。所以第一期统一层只服务插件 Provider,Git 保持现有 `git.*` 和 Git 模块不变;Git 的精简、迁移和改成内置官方插件另起提案(第 10 节)。 + +对日常开发的影响:新的 Git 功能照常写在 Git 模块里;但凡是“任何 VCS 都会有”的界面(文件状态颜色、gutter、提交、回滚、文件历史、Annotate),不要再新增对 Git 类型的直接依赖,以免 Git 迁移时多一处要拆。 ## 问题 @@ -34,11 +36,11 @@ Lithe 现在只认识 Git:Rust Core、macOS 和 Windows 的变更列表、提 | 层 | 位置(计划) | 负责 | 不负责 | | --- | --- | --- | --- | -| Rust Core 的 `vcs` 模块 | `rust/lithe-core/src/vcs/`(新) | 统一模型、`vcs.*` 命令、Provider 注册表(Registry,按 VCS ID 登记和查找实现的目录)、根目录识别、插件回复的解码和校验 | 启动进程、弹窗、存储 | -| 宿主 | macOS 新的 VCS 工作区模块;Windows 的 VCS 状态 store | 根映射与用户覆盖、文件监听触发刷新、Changelist、Shelve、凭据交互、Provider 进程生命周期、项目控制台 | 解析任何 VCS 的输出 | -| Provider | Git:Core 内置;其他 VCS:插件进程 | 回答状态、基线内容、历史、Annotate、提交、回滚、更新等事实 | 界面和跨根聚合 | +| Rust Core 的 `vcs` 模块 | `rust/lithe-core/src/vcs/`(新) | 统一模型、`vcs.*` 命令、根目录识别、请求计划、插件回复的解码和校验 | 启动进程、弹窗、存储 | +| 宿主 | macOS 新的 VCS 工作区模块;Windows 的 VCS 状态 store | 根映射与用户覆盖、文件监听触发刷新、凭据交互、Provider 进程生命周期、项目控制台 | 解析任何 VCS 的输出 | +| Provider | 插件进程(第一期只有 SVN);Git 暂不接入 | 回答状态、基线内容、历史、Annotate、提交、回滚、更新等事实 | 界面和跨根聚合 | -内置 Git Provider 在 Core 内部直接调用现有 `git::*` 函数,再把结果转换成统一形状。`git.*` 命令保持原样,现有 Git 界面和老调用方不受影响。插件 Provider 的回复先由 Core 校验(路径必须在根内、排序稳定、数量和大小有上限),再转换成和 Git 完全相同的形状。所以宿主拿到的 `vcs.status` 结果不区分来自哪个 Provider。 +插件 Provider 的回复先由 Core 校验(路径必须在根内、排序稳定、数量和大小有上限),再转换成统一形状。所以宿主拿到的结果不区分来自哪个 Provider,以后 Git 接入时也只需要产出同一形状。 ### 2. 统一模型只收所有 VCS 都成立的概念 @@ -58,7 +60,7 @@ Lithe 现在只认识 Git:Rust Core、macOS 和 Windows 的变更列表、提 ### 3. 界面看能力,不看 VCS 的名字 -每个 Provider 声明自己支持哪些能力,界面只按能力出现入口。必备能力是状态、基线内容、提交和回滚;其余都可选:历史、文件历史、Annotate、更新、推送、暂存区(staging)、分支、冲突处理、文件操作跟踪、锁、属性、清理等。 +每个 Provider 声明自己支持哪些能力,界面只按能力出现入口。必备能力是状态、基线内容、提交和回滚;协议版本 1 的可选能力是历史、文件历史、Annotate、更新、推送、暂存区(staging)、文件操作跟踪、冲突处理和清理。分支、锁、属性、忽略规则等到有第二个 Provider 需要时,再按能力版本加入。 集中式与分布式 VCS 的差别也表达成能力: @@ -90,31 +92,50 @@ Git Log 图、Rebase、Worktree、Stash、Push 和 Fetch 对话框、历史改 - 进程启动后,宿主在 `initialize` 里通过 `capabilities.experimental.litheVcs` 协商版本,Provider 在回复里给出自己的能力集。能力集描述的是运行中的进程,所以不写进清单,这遵守[声明式插件按能力分版本](../../implemented/architecture/2026-10-17-plugin-capability-versions.md)第 4 节的规则。 - 请求方法统一以 `lithe/vcs/` 开头。宿主不按 Provider ID 写分支;进程按“插件 ID + VCS ID + 工作区”隔离,生命周期规则与构建 Provider 相同。 -字段和方法的草案见 [VCS Provider 契约草案](../../../../shared/contracts/vcs-provider-v1.md),示例数据见 [`shared/fixtures/vcs/model-v1.json`](../../../../shared/fixtures/vcs/model-v1.json)。草案在 P1 落地前都可以修改,任何 Core 命令、清单校验和宿主能力表都还不接受这些名字。 +字段和方法见 [VCS Provider 契约](../../../../shared/contracts/vcs-provider-v1.md),示例数据见 [`shared/fixtures/vcs/model-v1.json`](../../../../shared/fixtures/vcs/model-v1.json)。Core 已按契约实现(见第 9 节的进度),但还没有宿主声明 `vcsProviders`,所以插件市场不会列出这类插件;宿主接入前契约仍可以修改,改动要同时更新 fixtures 和 Core 测试。 ### 7. SVN 作为官方插件 SVN 插件调用用户系统里的 `svn` 命令行,解析它稳定的 `--xml` 输出。IDEA 的 svn4idea 也是这样做的(`plugins/svn4idea/src/org/jetbrains/idea/svn/status/CmdStatusClient.kt` 使用 `svn status --xml`)。这和 Lithe 使用系统 Git 的方式一致,不需要打包 JVM 或跨平台编译 libsvn。 -凭据规则沿用[Git 执行与项目控制台](../../implemented/architecture/2026-09-12-git-execution-and-project-console.md):SVN 自己的认证缓存优先回答;需要交互时,Provider 通过协议向宿主发出认证挑战,宿主复用现有认证界面。命令始终带 `--non-interactive`,密码通过 `--password-from-stdin` 传入,不出现在命令参数、控制台和日志里,Lithe 也不保存密码。 +后端进程用官方 Node SDK([lithe-plugin-docs](https://github.com/Lithe-IDEA/lithe-plugin-docs) 的 `sdk/node`)编写,以 `runtime` 方式启动(`tool: node`)。SDK 已经负责线协议、协商、取消、子进程清理和错误映射,Cargo 社区插件也走这条路;同一份后端在 macOS 和 Windows 上都能运行,插件包里没有原生二进制,不涉及按架构编译和 Gatekeeper。代价是用户除了 `svn` 还需要 Node 20.3 以上,安装审查和缺少运行时的提示要说清楚。SDK 新增 `litheVcs` 支持,和 `litheBuild` 并列;2026-10-23 起 SDK 不在主仓库,这部分改动在 lithe-plugin-docs 提交(见[插件契约、SDK 与开发者资料的发布](../../implemented/architecture/2026-10-23-plugin-contract-publishing.md))。 + +凭据规则沿用[Git 执行与项目控制台](../../implemented/architecture/2026-09-12-git-execution-and-project-console.md):命令始终带 `--non-interactive`,SVN 自己的认证缓存优先回答。第一版只依赖这份缓存,认证失败时返回 `vcs.authentication.failed`,宿主提示原因;交互式认证(宿主弹窗收集用户名和密码,经 `--password-from-stdin` 传给 `svn`)在宿主阶段实现。密码不出现在命令参数、控制台和日志里,Lithe 也不保存密码。 ### 8. 宿主接手与 VCS 无关的状态 -- **根映射**:一个工作区可以同时有 Git 和 SVN 根。文件归属最深的那个根;同一目录同时出现两种标记时,要求用户在设置里选择,不能猜。IDEA 用 `VcsDirectoryMapping` 保存这种映射。 -- **Changelist 和 Shelve**:从 `LitheGitModule` 上移到宿主的 VCS 层,存储键从“Git 仓库路径”改为“VCS 根 ID”,迁移时继续读取旧格式。IDEA 的 `ChangeListManagerImpl` 同样在 `platform/vcs-impl`,不属于任何一种 VCS。 +- **根映射**:一个工作区可以同时有 Git 和 SVN 根。文件归属最深的那个根;同一目录同时出现两种标记时,要求用户在设置里选择,不能猜。IDEA 用 `VcsDirectoryMapping` 保存这种映射。Git 暂不接入统一层,但 Core 识别根时仍认得 `.git` 标记,这样嵌套和歧义的判断对 Git 也成立,Git 根继续交给 Git 模块。 +- **Changelist 和 Shelve**:长期应从 `LitheGitModule` 上移到宿主的 VCS 层(IDEA 的 `ChangeListManagerImpl` 在 `platform/vcs-impl`,不属于任何一种 VCS)。这一步和 Git 迁移一起做;第一期 SVN 根的变更不分组,也不支持 Shelve。 - **监听刷新**:[单仓库 Git 状态监听提案](../bug-fix/2026-09-13-single-repository-git-status-observation.md)里要求的监听范围,扩展成“每个 Provider 声明的元数据目录”,例如 SVN 的 `.svn/wc.db`。 -- **项目控制台**:现有 Git 执行事件多带一个 VCS ID 字段,插件 Provider 用通知上报实际执行的命令和输出。展示、折叠和脱敏规则不变。 +- **项目控制台**:现有 Git 执行事件多带一个 VCS ID 字段。插件 Provider 用 `lithe/vcs/execution` 通知上报实际执行的命令和输出,Core 把它转成会话日志事件交给宿主。展示、折叠和脱敏规则不变。 ### 9. 分阶段落地 +2026-10-22 起按“先底层、再 SVN、Git 最后”的顺序: + | 阶段 | 内容 | 完成标志 | | --- | --- | --- | | P0 | 本提案、契约草案和 fixtures | 评审通过 | -| P1 | Core `vcs` 模块:类型、注册表、根识别;内置 Git Provider 的读路径(状态、基线内容、历史、文件历史、Annotate) | 同一批测试仓库上 `vcs.*` 与 `git.*` 的结果等价 | -| P2 | macOS 只读的通用界面改读 `vcs.*`:项目树颜色、gutter、状态栏 | Git 项目的行为和外观没有变化 | -| P3 | 插件 `vcsProviders` 能力、`litheVcs` 协商、宿主 Provider 会话(复用构建 Provider 的会话代码);Changelist/Shelve 上移;通用提交面板 | 测试用的假 Provider 插件跑通完整流程 | -| P4 | SVN 官方插件 MVP | 见验收标准 | -| P5 | Windows 按同一契约和 fixtures 接入 | Windows 通过同一组 fixtures | +| P1 | Core:插件清单 `vcsProviders` 能力、根识别、请求计划与插件回复解码、`litheVcs` 协商和 `lsp.vcsRequest`;Node SDK 支持 `litheVcs` | fixtures 和用 SDK 写的假 Provider 经真实 Core 会话跑通 | +| P2 | SVN 官方插件(Node SDK 后端) | 录制的 `svn --xml` 样例解析测试;本地 `file://` 仓库上的端到端测试 | +| P3 | macOS 宿主:Provider 会话(复用构建 Provider 的会话代码)、项目树颜色和 gutter 合并展示插件根、通用“版本控制”工具窗口(变更、提交、回滚、更新)、文件操作跟踪、交互式认证 | 见验收标准 | +| P4 | Windows 按同一契约和 fixtures 接入 | Windows 通过同一组 fixtures | +| 之后 | Git 精简并接入统一层,见第 10 节 | 另起提案 | + +P1 已完成(2026-10-22): + +- 插件清单:API 级别 3 的 `vcsProviders`、`process.vcs` 权限和 `onVcsRoot:` 激活事件;`git` 是保留 ID。安装审查列出 Provider 进程,SVN 这类工具路径另放在 `vcsTool` 字段,不覆盖运行时的 `tool`。 +- 根识别 `vcs.detectRoots`:只读目录项,不读文件、不启动进程;工作区自己没有标记时向上查祖先目录,所以“工作区开在 SVN 工作副本的子目录里”也能识别。新增 `vcs.ownerOf`,让各宿主用同一条“最深根”规则给文件找归属,不必各写一份。 +- 会话:`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,端到端场景经真实 Core 会话跑通它的协商和全部 11 种操作。SDK、示例和端到端场景随 SDK 迁到 lithe-plugin-docs,在那里用 `scripts/sdk-e2e.mjs --ref <本分支>` 运行;主仓库只保留契约、fixtures 和 Core 测试。 + +### 10. Git 什么时候、怎样接入 + +Git 接入不在本提案的范围内,这里只记下评审时已经确认的方向,供以后的提案使用: + +- **先精简再接入。** 提交图投影在 Core 里已有一份(`git/graph.rs` 的 `project_graph`),但生产代码没有调用,macOS 和 Windows 又各写了一份布局;执行日志、Changelist、状态模型等确定性逻辑也是两端各一份。接入时按切片迁移,每一片两端同时切换,并在同一个 PR 里删掉旧路径。 +- **Git 改成随应用内置的官方插件**,和 SVN 走同一个注册入口;通用界面不再直接依赖 `LitheGitModule`。是否搬到独立进程是传输方式问题,可以单独决定。 +- **平台层功能先做在统一层**:三方合并工具、行级历史、Changelist、Shelf 在 IDEA 里都属于平台而不属于 git4idea,补齐时直接给所有 VCS 用。 ## 考虑过的备选方案 @@ -126,9 +147,17 @@ SVN 插件调用用户系统里的 `svn` 命令行,解析它稳定的 `--xml` 界面只需要面对一套 API,看起来最“统一”。但 Rebase、Worktree、暂存区这些概念在 SVN 里不存在,要么让通用层变成 Git 的翻版,要么为了照顾最弱的 VCS 削掉 Git 的能力。IDEA 也没有这样做,因此只统一跨 VCS 成立的概念。 +### 第一期就把 Git 包成内置 Provider + +最初的方案是在 Core 里给现有 `git::*` 加一层转换,让 Git 和插件同时产出统一形状。评审时发现这样会把两端重复的逻辑和 Git 专属的结果类型原样带进统一层,以后再精简反而更难,因此改为第一期不接 Git,先用 SVN 把底层跑通(第 10 节)。 + ### Git 也改成插件进程 -这样内置和插件完全对称。但 Git 是 Lithe 的核心体验,状态读取在热路径上,认证、取消和执行事件链路也刚稳定;搬到进程外对用户没有收益,迁移风险却很大。内置 Provider 和插件 Provider 共用 Core 里同一套转换和校验,结果形状已经对称,因此不搬。 +这样内置和插件完全对称。但 Git 是 Lithe 的核心体验,状态读取在热路径上,认证、取消和执行事件链路也刚稳定;第一期不动 Git。以后 Git 改成内置官方插件时,进程内还是进程外只是传输方式,可以单独决定。 + +### SVN 后端用 Rust 写成原生可执行文件 + +不需要用户安装 Node,启动也更快。但插件包要为每个平台和架构各编译一份、单独签名,macOS 上下载来的原生可执行文件还要处理 Gatekeeper 隔离;而 Node SDK 已经实现协议、取消和子进程清理,社区构建插件也在用。SVN 后端的工作主要是调用 `svn` 和解析 XML,不是性能热点,因此选 Node SDK。 ### SVN 内置到宿主 @@ -144,23 +173,25 @@ SVN 插件调用用户系统里的 `svn` 命令行,解析它稳定的 `--xml` ## 验收标准 -- P1:在同一批 Git 测试仓库上,`vcs.status`、基线内容、历史、文件历史和 Annotate 与对应 `git.*` 结果等价;大仓库上 `vcs.status` 比 `git.status` 只多出转换开销,不额外启动进程。 -- P2:macOS 项目树颜色、gutter 和状态栏改读统一模型后,Git 项目的现有测试和截图检查全部通过,行为没有变化。 -- P3:一个只用于测试的假 VCS Provider 插件完成安装审查、协商、状态、提交、回滚和进程清理;停用或卸载插件后进程退出,它的根从界面消失;未协商成功的进程收不到 `lithe/vcs/*` 请求。 -- P4:SVN 官方插件在本地 `svnadmin create` 建立的 `file://` 仓库上完成状态、提交、更新、回滚、add/move/delete 跟踪、Annotate、文件历史和冲突显示;密码不出现在进程参数、控制台和日志中。 +- P1:清单校验接受和拒绝的情况都有 fixture;根识别覆盖嵌套、歧义和 Git worktree 文件标记;插件回复的校验用例全部通过;用 Node SDK 写的假 Provider 经真实 Core 会话完成协商和每种请求,未协商成功的进程收不到 `lithe/vcs/*` 请求。 +- P2:SVN 插件对录制的 `svn status/info/log/blame --xml` 样例解析正确;在本地 `svnadmin create` 建立的 `file://` 仓库上完成状态、提交、更新、回滚、add/move/delete、Annotate、文件历史和冲突显示。 +- P3:macOS 上安装 SVN 插件后完成安装审查、根识别、文件颜色、gutter、提交、回滚、更新和进程清理;停用或卸载插件后进程退出,它的根从界面消失;Git 项目的行为没有变化;密码不出现在进程参数、控制台和日志中。 - 每个阶段都运行对应的 Core、契约、Note 和平台验证脚本;Windows 的结论只来自 Windows 上的实际运行。 ## 风险 -- **Git 仍在快速变化。** Issue 评论里提到 Git 还没稳定。缓解办法:统一层只包装稳定的概念,`git.*` 不冻结,Git 专属功能继续在 Git 模块里演进。 -- **状态读取是热路径。** 内置 Git Provider 在 Core 内直接转换,不额外起进程。SVN 在大工作副本上 `svn status` 较慢,由 Provider 按变化路径做增量查询,宿主合并刷新事件。 -- **Changelist 和 Shelve 的存储迁移。** 改变存储键可能丢失用户已有分组。迁移时保留读取旧格式,并用旧数据做回归测试。 +- **统一层只被一种 VCS 验证过。** 第一期只有 SVN 用统一层,模型里可能藏着 SVN 的偏见。缓解办法:模型只收第 2 节表里 Git 和 SVN 都成立的概念,Git 专属的暂存维度已经作为可选能力进入 fixtures;Git 接入时如果发现缺口,按能力版本加字段。 +- **Git 仍在快速变化。** Issue 评论里提到 Git 还没稳定。第一期不碰 Git,`git.*` 不冻结。 +- **状态读取是热路径。** SVN 在大工作副本上 `svn status` 较慢,由 Provider 按变化路径做增量查询,宿主合并刷新事件。 - **混合根和嵌套根。** Git 仓库里嵌套 SVN 工作副本时,文件必须只归属一个根,否则会重复显示或重复提交。识别规则和用户映射都要有 fixture 覆盖。 - **插件进程常驻的成本。** 只在识别到对应根时启动,关闭工作区即回收,规则与构建 Provider 相同。 -- **回退方式。** 每个阶段都只新增路径。P2 切换后保留旧读取路径一个版本,出问题可以切回。 +- **回退方式。** 每个阶段都只新增路径,停用 SVN 插件即回到现状。 ## 适用范围 +- `rust/lithe-core/src/vcs/` +- `rust/lithe-core/src/plugins/` +- `rust/lithe-core/src/lsp/interface/` - `rust/lithe-core/src/git/` - `rust/lithe-git-host/` - `macos/Sources/LitheGitModule/` diff --git a/rust/lithe-core/src/lib.rs b/rust/lithe-core/src/lib.rs index a29c869700..fa8b0118ae 100644 --- a/rust/lithe-core/src/lib.rs +++ b/rust/lithe-core/src/lib.rs @@ -20,6 +20,7 @@ pub mod process; mod project; mod protocol; mod runtime; +mod vcs; pub use protocol::{ CoreCommand, CoreError, CoreEvent, CoreRequest, CoreResponse, ErrorCode, ResponseData, diff --git a/rust/lithe-core/src/lsp/interface/client.rs b/rust/lithe-core/src/lsp/interface/client.rs index 348be598d6..eff141681c 100644 --- a/rust/lithe-core/src/lsp/interface/client.rs +++ b/rust/lithe-core/src/lsp/interface/client.rs @@ -593,6 +593,30 @@ pub(super) fn client_apply_server_message_for_plugin( .server_capabilities .retain(|feature| feature != plugin_function::FUNCTIONS_FEATURE); } + // Only a plugin's own process may act as a VCS provider; a + // host-owned server that happens to answer is ignored. + state.vcs_provider = plugin_owned + .then(|| { + super::vcs_provider::negotiate( + result.get("capabilities").unwrap_or(&Value::Null), + ) + }) + .flatten(); + match &state.vcs_provider { + Some(negotiation) => events.push(LspClientEvent { + kind: super::vcs_provider::NEGOTIATED_EVENT.to_string(), + request_id: None, + method: Some("initialize".to_string()), + uri: None, + version: None, + diagnostics: None, + result: Some(json!(negotiation)), + error: None, + }), + None => state + .server_capabilities + .retain(|feature| feature != super::vcs_provider::VCS_PROVIDER_FEATURE), + } state.text_document_sync = text_document_sync_kind(result.get("capabilities").unwrap_or(&Value::Null)); if let Some(options) = result @@ -619,6 +643,7 @@ pub(super) fn client_apply_server_message_for_plugin( state.diagnostics.clear(); state.diagnostic_versions.clear(); state.plugin_functions.clear(); + state.vcs_provider = None; responses.push(json_rpc_message_without_params(None, "exit")?); } let result = if pending.as_deref() == Some("textDocument/semanticTokens/full") { @@ -724,6 +749,8 @@ fn experimental_capabilities(plugin_owned: bool) -> Value { if plugin_owned { let (functions_key, functions_offer) = plugin_function::client_experimental_capabilities(); experimental[functions_key] = functions_offer; + let (vcs_key, vcs_offer) = super::vcs_provider::client_experimental_capabilities(); + experimental[vcs_key] = vcs_offer; } experimental } @@ -1671,6 +1698,9 @@ fn feature_names_from_capabilities(capabilities: &Value) -> Vec { if plugin_function::negotiate(capabilities).is_some() { insert_unique(&mut values, plugin_function::FUNCTIONS_FEATURE); } + if super::vcs_provider::negotiate(capabilities).is_some() { + insert_unique(&mut values, super::vcs_provider::VCS_PROVIDER_FEATURE); + } values } diff --git a/rust/lithe-core/src/lsp/interface/engine.rs b/rust/lithe-core/src/lsp/interface/engine.rs index 32eb50caa4..4f891153ad 100644 --- a/rust/lithe-core/src/lsp/interface/engine.rs +++ b/rust/lithe-core/src/lsp/interface/engine.rs @@ -59,6 +59,10 @@ const DEFAULT_SHUTDOWN_TIMEOUT_MS: u64 = 2_000; /// large build, so it may outlive the ordinary request timeout, but never /// stays pending without a deadline. const MAX_BUILD_PROVIDER_TIMEOUT_MS: u64 = 60 * 60_000; +/// Upper bound of one VCS-provider request. An update or check-in of a large +/// working copy over a slow link can take long, but never stays pending +/// without a deadline. +const MAX_VCS_PROVIDER_TIMEOUT_MS: u64 = 60 * 60_000; const MONITOR_INTERVAL_MS: u64 = 10; /// JSON-RPC method carrying Java Debug Server build commands. const JAVA_BUILD_METHOD: &str = "workspace/executeCommand"; @@ -390,6 +394,37 @@ pub struct BuildProviderRequest { pub timeout_milliseconds: Option, } +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +/// One VCS-provider request. Core plans the method and params from the +/// operation and its arguments, so the host never writes a raw method. The +/// result arrives as a `requestCompleted` event whose `result.value` is the +/// provider's reply, which the host passes to `vcs.decodeReply`; a failure +/// carries a `vcs.*` code. +pub struct VcsProviderRequest { + pub session_id: String, + #[serde(default)] + pub operation_id: Option, + /// The VCS the host launched the session for; a session that negotiated + /// another VCS is refused. + pub vcs: String, + /// Operation name, such as `status` or `checkin`. + pub operation: String, + /// Native absolute path of the working-copy root. + pub root: String, + /// Absolute path of the VCS client the host resolved, when the provider + /// declares one. + #[serde(default)] + pub tool: Option, + /// Operation arguments; see `crate::vcs` for each operation's shape. + #[serde(default)] + pub arguments: Value, + /// Deadline of this request, in milliseconds, capped at + /// `MAX_VCS_PROVIDER_TIMEOUT_MS`; the session's request timeout when absent. + #[serde(default)] + pub timeout_milliseconds: Option, +} + #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] /// One plugin-function invocation. The result arrives as a `requestCompleted` @@ -1222,6 +1257,18 @@ pub fn build_provider_request( Ok(OperationResponse { operation_id }) } +/// Sends one VCS-provider request to a session that negotiated the protocol. +pub fn vcs_provider_request(request: VcsProviderRequest) -> Result { + let operation_id = request + .operation_id + .clone() + .unwrap_or_else(|| engine().next_operation_id()); + engine() + .session(&request.session_id)? + .vcs_request(request, operation_id.clone())?; + Ok(OperationResponse { operation_id }) +} + /// Invokes one plugin function on a session of the function's plugin. pub fn plugin_function_request( request: PluginFunctionRequest, @@ -2021,6 +2068,84 @@ impl RuntimeSession { self.send_request_or_fail(&outbound_order, messages, &request_id) } + /// Writes a VCS-provider request planned by `crate::vcs::plan`, which + /// checks the operation's arguments and negotiated capability. + fn vcs_request( + &self, + request: VcsProviderRequest, + operation_id: String, + ) -> Result<(), CoreError> { + let operation = crate::vcs::Operation::parse(&request.operation).ok_or_else(|| { + CoreError::new(ErrorCode::InvalidRequest, "Unknown VCS operation") + .with_details(request.operation.clone()) + })?; + let outbound_order = self.lock_outbound_order()?; + let (messages, request_id) = { + let mut state = self.lock_state()?; + if state.lifecycle != LspLifecycleState::Ready || !state.client.initialized { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Language server is not ready.", + )); + } + if state.request_by_operation.contains_key(&operation_id) + || state.java_builds.contains(&operation_id) + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "The language-server operation ID is already pending.", + )); + } + let negotiation = state + .client + .vcs_provider + .as_ref() + .filter(|negotiation| negotiation.vcs == request.vcs) + .ok_or_else(|| { + CoreError::new( + ErrorCode::NotSupported, + format!("The process is not a negotiated {} provider.", request.vcs), + ) + .with_details("vcs.provider.unavailable") + })?; + let planned = crate::vcs::plan( + operation, + &request.root, + request.tool.as_deref(), + request.arguments, + &negotiation.capabilities, + )?; + let timeout = request + .timeout_milliseconds + .map(|milliseconds| { + Duration::from_millis(milliseconds.clamp(1, MAX_VCS_PROVIDER_TIMEOUT_MS)) + }) + .unwrap_or(state.request_timeout); + let response = + allocate_raw_request(state.client.clone(), planned.method, planned.params)?; + let request_id = (response.state.next_request_id - 1).to_string(); + let now = Instant::now(); + state.client = response.state; + state.pending.insert( + request_id.clone(), + PendingRequest { + kind: PendingKind::Feature, + operation_id: Some(operation_id.clone()), + method: planned.method.to_string(), + document_uri: None, + document_version: None, + created_at: now, + deadline: now + timeout, + }, + ); + state + .request_by_operation + .insert(operation_id, request_id.clone()); + (response.messages, request_id) + }; + self.send_request_or_fail(&outbound_order, messages, &request_id) + } + /// Writes a `lithe/function/invoke` request. The plan comes from /// `plugin_function::plan_invocation`, so plugin identity, negotiation, /// and registration are checked before anything reaches the process. @@ -2904,6 +3029,40 @@ impl RuntimeSession { ); } } + Some(PendingKind::Feature) + if pending_before + .as_ref() + .is_some_and(|pending| crate::vcs::is_vcs_method(&pending.method)) => + { + if let Some(pending) = pending_before.as_ref() { + if let Some(operation_id) = &pending.operation_id { + let (result, error) = match super::vcs_provider::reply_outcome(&value) { + Ok(result) => (Some(result), None), + Err(failure) => ( + None, + Some(runtime_error( + self, + failure.code, + "request", + Some(&pending.method), + None, + &failure.message, + None, + None, + )), + ), + }; + push_request_event( + self, + &mut state, + operation_id, + &pending.method, + result, + error, + ); + } + } + } Some(PendingKind::Feature) => { if let Some(pending) = pending_before.as_ref() { if let Some(operation_id) = &pending.operation_id { @@ -3526,6 +3685,8 @@ impl RuntimeSession { push_functions_event(self, &mut state, result); } else if event.kind == "functionProgress" { push_function_progress_locked(self, &mut state, event.result); + } else if event.kind == super::vcs_provider::NEGOTIATED_EVENT { + push_vcs_provider_event(self, &mut state, event.result.unwrap_or_default()); } else if event.kind == "diagnostics" { if let Some(uri) = event.uri.as_deref() { push_diagnostics_event( @@ -5276,6 +5437,37 @@ fn push_function_progress_locked( ); } +/// Publishes the VCS and capabilities a provider negotiated, so the host can +/// check the VCS against the manifest entry and decide which actions to show. +fn push_vcs_provider_event(session: &RuntimeSession, state: &mut SessionState, result: Value) { + let sequence = take_sequence(state); + enqueue_runtime_event( + session, + state, + LspRuntimeEvent { + kind: "vcsProvider".to_string(), + sequence, + provider_id: session.provider_id.clone(), + session_id: session.id.clone(), + state: None, + operation_id: None, + method: None, + uri: None, + version: None, + diagnostics: None, + result: Some(result), + error: None, + capabilities: None, + server_info: None, + level: None, + message: None, + detail: None, + maven_profile_project: None, + maven_profile_task: None, + }, + ); +} + /// Publishes the session's registered functions after a registry change. fn push_functions_event(session: &RuntimeSession, state: &mut SessionState, result: Value) { let sequence = take_sequence(state); @@ -5860,6 +6052,7 @@ fn clear_runtime_state(session: &RuntimeSession, state: &mut SessionState) { state.request_by_operation.clear(); state.function_by_operation.clear(); clear_function_registry(session, state); + state.client.vcs_provider = None; state.linked_document_paths = LinkedDocumentPaths::default(); state.java_navigation_marker_batches.clear(); state.java_navigation_marker_cache.clear(); @@ -10826,6 +11019,260 @@ public class Main { assert!(harness.server.await_notification("$/cancelRequest")); } + // VCS providers against a scripted backend. Only a plugin-owned session + // is offered `litheVcs`, and only one whose process answered with a VCS + // ID and every required capability receives requests, each planned by + // Core from an operation name. + + const VCS_PLUGIN: &str = "dev.lithe.fixture.vcs-provider"; + + fn vcs_capabilities() -> Value { + json!({ "status": 1, "baseContent": 1, "checkin": 1, "rollback": 1, "update": 1 }) + } + + fn vcs_backend(answer: Value) -> Harness { + let mut harness = Harness::start(|request| { + request.plugin_id = Some(VCS_PLUGIN.to_string()); + }); + harness + .server + .complete_initialize(json!({ "experimental": { "litheVcs": answer } })); + harness.await_state(LspLifecycleState::Ready); + harness + } + + fn negotiated_vcs_provider() -> Harness { + vcs_backend(json!({ "version": 1, "vcs": "svn", "capabilities": vcs_capabilities() })) + } + + /// A native absolute path on every platform the tests run on. + fn working_copy() -> String { + std::env::temp_dir() + .join("lithe-vcs-working-copy") + .to_string_lossy() + .into_owned() + } + + fn vcs_request( + harness: &Harness, + vcs: &str, + operation: &str, + arguments: Value, + ) -> Result { + let operation_id = harness.engine.next_operation_id(); + harness + .session() + .vcs_request( + VcsProviderRequest { + session_id: harness.session_id.clone(), + operation_id: Some(operation_id.clone()), + vcs: vcs.to_string(), + operation: operation.to_string(), + root: working_copy(), + tool: None, + arguments, + timeout_milliseconds: None, + }, + operation_id.clone(), + ) + .map(|()| operation_id) + } + + /// Feature names the session recorded from the provider's answer. + fn vcs_features(harness: &Harness) -> Vec { + let session = harness.session(); + let state = session.lock_state().expect("state"); + state.client.server_capabilities.clone() + } + + fn no_vcs_request_written(harness: &Harness) -> bool { + harness.server.messages().iter().all(|message| { + !message["method"] + .as_str() + .is_some_and(|method| method.starts_with("lithe/vcs/")) + }) + } + + #[test] + fn host_sessions_are_not_offered_the_vcs_protocol() { + let harness = Harness::ready(); + let initialize = harness + .server + .messages() + .into_iter() + .find(|message| message["method"] == "initialize") + .expect("initialize should have been written"); + assert!(initialize["params"]["capabilities"]["experimental"] + .get("litheVcs") + .is_none()); + let error = vcs_request(&harness, "svn", "status", json!({})) + .expect_err("a host session is never a provider"); + assert!(matches!(error.code, ErrorCode::NotSupported)); + assert_eq!(error.details.as_deref(), Some("vcs.provider.unavailable")); + assert!(no_vcs_request_written(&harness)); + } + + /// The negotiated VCS and capabilities reach the host as one event, so + /// it can check the VCS against the manifest and pick the actions to show. + #[test] + fn a_negotiated_provider_is_announced_with_its_capabilities() { + let mut harness = negotiated_vcs_provider(); + let event = harness + .await_event(|event| event.kind == "vcsProvider") + .clone(); + assert_eq!( + event.result, + Some(json!({ "vcs": "svn", "capabilities": vcs_capabilities() })) + ); + assert!(vcs_features(&harness) + .iter() + .any(|feature| feature == "litheVcs")); + } + + /// Version 1 has no host side for credentials or console routing yet: an + /// authentication request is refused so the provider fails with + /// `vcs.authentication.failed`, and an execution report is passed on as a + /// session log entry. + #[test] + fn provider_authentication_is_refused_and_execution_reports_pass_through() { + let mut harness = negotiated_vcs_provider(); + harness.server.send(json!({ + "jsonrpc": "2.0", + "id": "auth-1", + "method": "lithe/vcs/authenticate", + "params": { "realm": " Example" } + })); + assert_eq!(await_reply(&harness, "auth-1")["error"]["code"], -32601); + harness.server.send(json!({ + "jsonrpc": "2.0", + "method": "lithe/vcs/execution", + "params": { "kind": "started", "arguments": ["status", "--xml"] } + })); + let event = harness + .await_event(|event| { + event.kind == "log" && event.message.as_deref() == Some("lithe/vcs/execution") + }) + .clone(); + assert!(event + .detail + .as_deref() + .is_some_and(|detail| detail.contains("--xml"))); + } + + /// An answer without a required capability makes the process a plain + /// plugin session: no announcement, no feature, and no request. + #[test] + fn an_incomplete_vcs_answer_is_not_a_provider() { + let mut harness = vcs_backend(json!({ + "version": 1, "vcs": "svn", "capabilities": { "status": 1, "baseContent": 1 } + })); + harness.poll(); + assert!(harness + .events + .iter() + .all(|event| event.kind != "vcsProvider")); + assert!(vcs_features(&harness) + .iter() + .all(|feature| feature != "litheVcs")); + let error = vcs_request(&harness, "svn", "status", json!({})).expect_err("refused"); + assert_eq!(error.details.as_deref(), Some("vcs.provider.unavailable")); + assert!(no_vcs_request_written(&harness)); + } + + #[test] + fn a_vcs_request_is_planned_from_its_operation_and_returns_the_reply() { + let mut harness = negotiated_vcs_provider(); + let operation = vcs_request( + &harness, + "svn", + "checkin", + json!({ "paths": ["src/b.txt", "src/a.txt"], "message": "Fix" }), + ) + .expect("accepted"); + let request_id = harness + .server + .await_request("lithe/vcs/checkin") + .expect("the request should reach the provider"); + assert_eq!( + harness.written_request(&request_id)["params"], + json!({ + "paths": ["src/a.txt", "src/b.txt"], + "message": "Fix", + "root": { "path": working_copy() }, + "tool": null + }) + ); + let reply = json!({ "revision": { "id": "12", "display": "r12", "kind": "number" } }); + harness + .server + .send(json!({ "jsonrpc": "2.0", "id": request_id, "result": reply })); + let completed = harness + .await_event(|event| { + event.kind == "requestCompleted" + && event.operation_id.as_deref() == Some(operation.as_str()) + }) + .clone(); + assert!(completed.error.is_none(), "{:?}", completed.error); + assert_eq!(completed.result, Some(json!({ "value": reply }))); + assert!(harness.snapshot().pending_operation_ids.is_empty()); + } + + /// The host names an operation, never a method, so the session cannot + /// be used as a tunnel; another VCS, an unknown operation, a capability + /// the provider lacks, or a path leaving the root never reach the wire. + #[test] + fn vcs_requests_are_refused_before_they_are_written() { + let harness = negotiated_vcs_provider(); + let error = vcs_request(&harness, "hg", "status", json!({})).expect_err("wrong VCS"); + assert_eq!(error.details.as_deref(), Some("vcs.provider.unavailable")); + let error = + vcs_request(&harness, "svn", "shutdown", json!({})).expect_err("no such operation"); + assert!(matches!(error.code, ErrorCode::InvalidRequest)); + let error = vcs_request(&harness, "svn", "annotate", json!({ "path": "a.txt" })) + .expect_err("not negotiated"); + assert!(matches!(error.code, ErrorCode::NotSupported)); + assert_eq!(error.details.as_deref(), Some("vcs.capability.unsupported")); + let error = vcs_request(&harness, "svn", "rollback", json!({ "paths": ["../x"] })) + .expect_err("outside the root"); + assert!(matches!(error.code, ErrorCode::InvalidRequest)); + assert!(no_vcs_request_written(&harness)); + } + + /// A provider may name one of the contract's failure codes; any other + /// code, including one of Lithe's own, becomes the generic failure. + #[test] + fn vcs_failures_keep_only_contract_codes() { + let mut harness = negotiated_vcs_provider(); + for (index, (data, expected)) in [ + (json!({ "code": "vcs.outOfDate" }), "vcs.outOfDate"), + ( + json!({ "code": "plugin.vcs.invalidOutput" }), + "vcs.provider.failed", + ), + ] + .into_iter() + .enumerate() + { + let operation = vcs_request(&harness, "svn", "update", json!({})).expect("sent"); + let request_id = harness + .server + .await_request_at("lithe/vcs/update", index) + .expect("the request should reach the provider"); + harness.server.send(json!({ + "jsonrpc": "2.0", + "id": request_id, + "error": { "code": -32000, "message": "Working copy is out of date.", "data": data } + })); + let error = harness + .await_event(|event| event.operation_id.as_deref() == Some(operation.as_str())) + .error + .clone() + .expect("an error reply fails the request"); + assert_eq!(error.code, expected); + assert_eq!(error.message, "Working copy is out of date."); + } + } + // Plugin functions against a scripted backend. The scripted server plays // the plugin process, so every outcome (result, failure, cancellation, // deadline, late reply, revocation, and compatibility with a backend that @@ -10992,7 +11439,11 @@ public class Main { .expect("initialize should have been written"); assert_eq!( initialize["params"]["capabilities"]["experimental"], - json!({ "litheBuild": { "versions": [1] }, "litheFunctions": { "versions": [1] } }) + json!({ + "litheBuild": { "versions": [1] }, + "litheFunctions": { "versions": [1] }, + "litheVcs": { "versions": [1] } + }) ); // Another plugin's reference never reaches this process. @@ -11234,12 +11685,13 @@ public class Main { revoked.error.as_ref().map(|error| error.code.as_str()), Some("plugin.function.revoked") ); - assert!(harness + // The revocation is published before the cancellation is written, so + // the write is awaited rather than expected to be there already. + let cancel = harness .server - .messages() - .iter() - .any(|message| message["method"] == "$/cancelRequest" - && message["params"]["id"] == request_id)); + .await_notification_at("$/cancelRequest", 0) + .expect("the revoked call should be cancelled on the wire"); + assert_eq!(cancel["params"]["id"], request_id); let error = invoke(&harness, FUNCTION_PLUGIN, "snapshot.clear", None) .expect_err("a withdrawn function is unknown"); assert_eq!(error.details.as_deref(), Some("plugin.function.unknown")); diff --git a/rust/lithe-core/src/lsp/interface/mod.rs b/rust/lithe-core/src/lsp/interface/mod.rs index 7fe3b90fab..7214d5a168 100644 --- a/rust/lithe-core/src/lsp/interface/mod.rs +++ b/rust/lithe-core/src/lsp/interface/mod.rs @@ -9,6 +9,7 @@ mod process; mod scripted; mod transport; mod types; +mod vcs_provider; pub(crate) use client::*; pub(crate) use engine::*; diff --git a/rust/lithe-core/src/lsp/interface/types.rs b/rust/lithe-core/src/lsp/interface/types.rs index 95f5083d6f..5f1cd4e3dd 100644 --- a/rust/lithe-core/src/lsp/interface/types.rs +++ b/rust/lithe-core/src/lsp/interface/types.rs @@ -139,6 +139,10 @@ pub struct LspClientState { /// negotiated; empty otherwise. #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] pub plugin_functions: BTreeSet, + /// The VCS and capabilities a plugin process answered under + /// `litheVcs`; `None` when the protocol was not negotiated. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub vcs_provider: Option, } impl Default for LspClientState { @@ -156,6 +160,7 @@ impl Default for LspClientState { diagnostics: BTreeMap::new(), diagnostic_versions: BTreeMap::new(), plugin_functions: BTreeSet::new(), + vcs_provider: None, } } } diff --git a/rust/lithe-core/src/lsp/interface/vcs_provider.rs b/rust/lithe-core/src/lsp/interface/vcs_provider.rs new file mode 100644 index 0000000000..23a6f6266c --- /dev/null +++ b/rust/lithe-core/src/lsp/interface/vcs_provider.rs @@ -0,0 +1,183 @@ +//! The VCS-provider protocol a plugin's LSP base-protocol process speaks. +//! +//! The manifest says how to start the process and which VCS it provides; +//! the protocol version, the VCS ID, and the provider's capabilities are +//! negotiated in `initialize` under `capabilities.experimental.litheVcs`. +//! Only plugin-owned sessions are offered the protocol, and requests are +//! written only to a session whose answer named a version this client +//! offered, a valid VCS ID, and every required capability. The rules live in +//! `shared/contracts/vcs-provider-v1.md`. + +use crate::vcs::{negotiated_capabilities, Capabilities}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +/// Key under `capabilities.experimental` on both sides of the exchange. +const EXPERIMENTAL_KEY: &str = "litheVcs"; + +/// VCS-provider protocol versions this client speaks, in preference order. +const SUPPORTED_VERSIONS: [u64; 1] = [1]; + +/// Feature name recorded in the session's server capabilities once a +/// provider answered a supported version. +pub(crate) const VCS_PROVIDER_FEATURE: &str = "litheVcs"; + +/// Client event that carries the negotiated provider to the engine. +pub(crate) const NEGOTIATED_EVENT: &str = "vcsProviderNegotiated"; + +/// Longest provider error message passed to the host, in UTF-8 bytes. +const MAX_MESSAGE_BYTES: usize = 4096; + +/// Contract codes a provider may put in `error.data.code`. Any other code, +/// or none, becomes [`codes::FAILED`]. +const PROVIDER_ERROR_CODES: &[&str] = &[ + "vcs.authentication.failed", + "vcs.conflict", + "vcs.outOfDate", + "vcs.tool.unavailable", + "vcs.workingCopy.locked", + "vcs.workingCopy.unsupported", +]; + +/// Error codes Core assigns to a failed VCS request. +pub(crate) mod codes { + /// The provider failed without one of the contract's specific codes. + pub const FAILED: &str = "vcs.provider.failed"; + /// The reply is not a JSON-RPC result. + pub const INVALID_OUTPUT: &str = "plugin.vcs.invalidOutput"; +} + +/// What a provider answered in `initialize`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VcsNegotiation { + /// The VCS the process provides; the host checks it against the + /// manifest entry it launched. + pub vcs: String, + /// Capabilities of protocol version 1 the provider supports. + pub capabilities: Capabilities, +} + +/// The client half of the negotiation, placed under `capabilities.experimental`. +pub(crate) fn client_experimental_capabilities() -> (&'static str, Value) { + (EXPERIMENTAL_KEY, json!({ "versions": SUPPORTED_VERSIONS })) +} + +/// The provider's answer, or `None` when it chose no offered version, named +/// no valid VCS ID, or lacks a required capability. Unknown fields and +/// capabilities are ignored, as the contract requires. +pub(crate) fn negotiate(capabilities: &Value) -> Option { + let answer = capabilities + .get("experimental") + .and_then(|experimental| experimental.get(EXPERIMENTAL_KEY))?; + let version = answer.get("version").and_then(Value::as_u64)?; + if !SUPPORTED_VERSIONS.contains(&version) { + return None; + } + let vcs = answer + .get("vcs") + .and_then(Value::as_str) + .filter(|vcs| crate::vcs::is_vcs_id(vcs))?; + Some(VcsNegotiation { + vcs: vcs.to_string(), + capabilities: negotiated_capabilities(answer.get("capabilities")?)?, + }) +} + +/// A failed VCS request: a contract code and the provider's message. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct VcsFailure { + pub(crate) code: &'static str, + pub(crate) message: String, +} + +fn bounded_message(value: &str) -> String { + let mut end = value.len().min(MAX_MESSAGE_BYTES); + while !value.is_char_boundary(end) { + end -= 1; + } + value[..end].to_string() +} + +/// Normalizes the provider's JSON-RPC response. A success is wrapped as +/// `{ "value": result }` like build-provider replies, so the host passes +/// `value` to `vcs.decodeReply` unchanged. +pub(crate) fn reply_outcome(response: &Value) -> Result { + if let Some(error) = response.get("error") { + let message = error + .get("message") + .and_then(Value::as_str) + .map(bounded_message) + .filter(|message| !message.trim().is_empty()) + .unwrap_or_else(|| "The VCS provider failed.".to_string()); + let code = error + .get("data") + .and_then(|data| data.get("code")) + .and_then(Value::as_str) + .and_then(|code| PROVIDER_ERROR_CODES.iter().find(|known| **known == code)) + .copied() + .unwrap_or(codes::FAILED); + return Err(VcsFailure { code, message }); + } + match response.get("result") { + Some(result) => Ok(json!({ "value": result })), + None => Err(VcsFailure { + code: codes::INVALID_OUTPUT, + message: "The VCS provider returned no result.".to_string(), + }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn answer(value: Value) -> Value { + json!({ "experimental": { "litheVcs": value } }) + } + + const REQUIRED: &str = r#"{ "status": 1, "baseContent": 1, "checkin": 1, "rollback": 1 }"#; + + #[test] + fn only_a_complete_answer_to_an_offered_version_counts() { + let capabilities: Value = serde_json::from_str(REQUIRED).expect("JSON"); + let negotiation = negotiate(&answer(json!({ + "version": 1, "vcs": "svn", "capabilities": capabilities, "future": true + }))) + .expect("negotiated"); + assert_eq!(negotiation.vcs, "svn"); + assert_eq!(negotiation.capabilities.len(), 4); + // A version the host never offered, a malformed VCS ID, a missing + // capability map, or a missing required capability must keep VCS + // requests off the session. + for rejected in [ + json!({ "version": 2, "vcs": "svn", "capabilities": capabilities }), + json!({ "version": 1, "vcs": "SVN", "capabilities": capabilities }), + json!({ "version": 1, "vcs": "svn" }), + json!({ "version": 1, "vcs": "svn", "capabilities": { "status": 1 } }), + ] { + assert_eq!(negotiate(&answer(rejected.clone())), None, "{rejected}"); + } + assert_eq!(negotiate(&json!({})), None); + } + + #[test] + fn provider_errors_keep_only_contract_codes() { + let failure = reply_outcome(&json!({ + "error": { "code": -32000, "message": "Out of date", "data": { "code": "vcs.outOfDate" } } + })) + .expect_err("failed"); + assert_eq!(failure.code, "vcs.outOfDate"); + // A plugin cannot make its own code look like one of Lithe's. + let failure = reply_outcome(&json!({ + "error": { "code": -32000, "message": "", "data": { "code": "plugin.vcs.invalidOutput" } } + })) + .expect_err("failed"); + assert_eq!(failure.code, codes::FAILED); + assert_eq!(failure.message, "The VCS provider failed."); + assert_eq!( + reply_outcome(&json!({ "result": null })).expect("ok"), + json!({ "value": null }) + ); + } +} diff --git a/rust/lithe-core/src/plugins/capabilities.rs b/rust/lithe-core/src/plugins/capabilities.rs index 69eaaf0d6b..17bce296ec 100644 --- a/rust/lithe-core/src/plugins/capabilities.rs +++ b/rust/lithe-core/src/plugins/capabilities.rs @@ -41,6 +41,9 @@ pub(crate) const UI: &str = "ui"; pub(crate) const BUILD_SYSTEMS: &str = "buildSystems"; /// `buildSystems[].provider`, whether it owns or shares a language server process. pub(crate) const BUILD_PROVIDERS: &str = "buildProviders"; +/// `vcsProviders[]`: version control systems implemented by a plugin +/// process; see `crate::vcs`. +pub(crate) const VCS_PROVIDERS: &str = "vcsProviders"; /// Shared executable candidate order and strict explicit selection. pub(crate) const EXECUTABLE_RESOLUTION: &str = "executableResolution"; @@ -57,6 +60,7 @@ pub const SUPPORTED_CAPABILITIES: &[(&str, &[u32])] = &[ (SERVER_PROCESS, &[1]), (SETTINGS, &[1, 2]), (UI, &[1, 2]), + (VCS_PROVIDERS, &[1]), ]; /// Longest capability ID, so a hostile index cannot grow host state. diff --git a/rust/lithe-core/src/plugins/declarative.rs b/rust/lithe-core/src/plugins/declarative.rs index f302d6ac9a..322eb3f866 100644 --- a/rust/lithe-core/src/plugins/declarative.rs +++ b/rust/lithe-core/src/plugins/declarative.rs @@ -44,6 +44,9 @@ const PERMISSION_UI_WEB: &str = "ui.web"; /// Running a declared build system's executable: model, task, test, and /// dependency commands. API level 3. const PERMISSION_BUILD: &str = "process.build"; +/// Letting a VCS provider change the working copy and contact its server. +/// API level 3. +const PERMISSION_VCS: &str = "process.vcs"; const KNOWN_PERMISSIONS: &[&str] = &[ PERMISSION_BUILD, PERMISSION_LANGUAGE_SERVER, @@ -51,6 +54,7 @@ const KNOWN_PERMISSIONS: &[&str] = &[ PERMISSION_TEST, PERMISSION_UI, PERMISSION_UI_WEB, + PERMISSION_VCS, ]; /// Runtime tools the host knows how to discover and pass to a language /// server. The host exports each as `LITHE__PATH` and puts its @@ -156,6 +160,10 @@ pub struct DeclarativePluginManifest { /// shape and its validation live in `crate::build`. #[serde(default, skip_serializing_if = "Vec::is_empty")] build_systems: Vec, + /// Version control systems the plugin implements. API level 3; the + /// shape and its validation live in `crate::vcs`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + vcs_providers: Vec, } /// UI supplied by plugins. The host owns every window shell: docking, title, @@ -416,6 +424,7 @@ pub(crate) struct ServerProcess { pub(crate) enum ServerRole { LanguageServer, BuildProvider, + VcsProvider, } impl ServerRole { @@ -423,6 +432,7 @@ impl ServerRole { match self { Self::LanguageServer => "language server", Self::BuildProvider => "build provider", + Self::VcsProvider => "VCS provider", } } } @@ -758,7 +768,11 @@ impl DeclarativePluginManifest { if self.plugin_api_version >= CAPABILITIES_API_VERSION { capabilities::check_used(&self.requires, &self.used_capabilities())?; } - if self.languages.is_empty() && self.ui.is_none() && self.build_systems.is_empty() { + if self.languages.is_empty() + && self.ui.is_none() + && self.build_systems.is_empty() + && self.vcs_providers.is_empty() + { return Err(invalid("languages", "The plugin declares no languages.")); } @@ -884,6 +898,7 @@ impl DeclarativePluginManifest { && self.execution_providers.is_empty() && self.ui.is_none() && self.build_systems.is_empty() + && self.vcs_providers.is_empty() { return Err(invalid( "languages", @@ -901,13 +916,20 @@ impl DeclarativePluginManifest { language_servers: &server_languages, }, )?; + crate::vcs::validate_declarations( + &mut self.vcs_providers, + &crate::vcs::DeclarationContext { + plugin_api_version: self.plugin_api_version, + declared_settings: &declared_settings, + }, + )?; self.validate_permissions()?; self.validate_activation_events()?; Ok(self) } /// Capabilities whose sections the manifest declares. Every language - /// server and every own build provider is a `serverProcess`. + /// server, own build provider, and VCS provider is a `serverProcess`. fn used_capabilities(&self) -> BTreeSet<&'static str> { let sections = [ ( @@ -935,6 +957,7 @@ impl DeclarativePluginManifest { ( capabilities::SERVER_PROCESS, !self.language_servers.is_empty() + || !self.vcs_providers.is_empty() || self .build_systems .iter() @@ -956,6 +979,7 @@ impl DeclarativePluginManifest { .iter() .any(|system| system.provider.is_some()), ), + (capabilities::VCS_PROVIDERS, !self.vcs_providers.is_empty()), ]; sections .into_iter() @@ -1078,6 +1102,7 @@ impl DeclarativePluginManifest { // A provider process of its own is a server process like a language // server, so it needs the same consent. if !self.language_servers.is_empty() + || !self.vcs_providers.is_empty() || self .build_systems .iter() @@ -1096,6 +1121,9 @@ impl DeclarativePluginManifest { if !self.build_systems.is_empty() { required.insert(PERMISSION_BUILD); } + if !self.vcs_providers.is_empty() { + required.insert(PERMISSION_VCS); + } for permission in required { if !self .permissions @@ -1128,6 +1156,9 @@ impl DeclarativePluginManifest { for build_system in &self.build_systems { expected.insert(crate::build::activation_event(build_system)); } + for provider in &self.vcs_providers { + expected.insert(crate::vcs::activation_event(provider)); + } if let Some(ui) = &self.ui { for window in &ui.tool_windows { expected.insert(format!("onView:{}", window.id)); @@ -1190,6 +1221,11 @@ impl DeclarativePluginManifest { &self.build_systems } + /// Validated VCS providers, sorted by VCS ID. + pub(crate) fn vcs_providers(&self) -> &[crate::vcs::VcsProviderDeclaration] { + &self.vcs_providers + } + /// Checks the declared settings, sorts them by ID, and returns the IDs /// launch templates may refer to. fn validate_settings(&mut self) -> Validation { @@ -1339,6 +1375,9 @@ impl DeclarativePluginManifest { for build_system in &self.build_systems { programs.extend(crate::build::programs(build_system)); } + for provider in &self.vcs_providers { + programs.extend(crate::vcs::programs(provider)); + } // Order by kind, then language or tool, then the full serialized // value as a total tie-breaker; duplicates (two servers needing the // same runtime) collapse to one entry. @@ -1764,6 +1803,7 @@ impl ServerProcess { let suffix = match role { ServerRole::LanguageServer => "LanguageServer", ServerRole::BuildProvider => "BuildProvider", + ServerRole::VcsProvider => "VcsProvider", }; let mut program = match &self.launch { LanguageServerLaunch::Packaged { path } => json!({ @@ -2634,6 +2674,29 @@ fn build_provider_launch_plan( Ok(plan) } +/// The launch plan of a VCS provider. `vcsProvider` repeats what the host +/// needs to resolve the client tool and to check the provider's answer in +/// `initialize`, which must name the same VCS. +fn vcs_provider_launch_plan( + manifest: &DeclarativePluginManifest, + vcs_id: &str, + request: &LanguageServerLaunchPlanRequest, +) -> Validation { + let provider = manifest + .vcs_providers + .iter() + .find(|provider| provider.id == vcs_id) + .ok_or_else(|| { + PluginRejection::new( + "plugin.launch.unavailable", + format!("The plugin declares no VCS provider {vcs_id}."), + ) + })?; + let mut plan = server_launch_plan(manifest, &provider.process, None, request)?; + plan["vcsProvider"] = provider.descriptor(&manifest.id); + Ok(plan) +} + fn server_launch_plan( manifest: &DeclarativePluginManifest, process: &ServerProcess, @@ -3168,8 +3231,8 @@ struct PlanPayload { request: Request, } -/// A launch plan names the process by the language whose server it is, or -/// by the build system whose provider it is. +/// A launch plan names the process by the language whose server it is, by +/// the build system whose provider it is, or by the VCS it provides. #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct LaunchPlanPayload { @@ -3178,12 +3241,15 @@ struct LaunchPlanPayload { language_id: Option, #[serde(default, rename = "buildSystemID")] build_system_id: Option, + #[serde(default, rename = "vcsProviderID")] + vcs_provider_id: Option, request: LanguageServerLaunchPlanRequest, } enum LaunchSelection<'a> { Language(&'a str), BuildSystem(&'a str), + VcsProvider(&'a str), } pub(crate) fn decode_payload Deserialize<'de>>( @@ -3266,15 +3332,20 @@ pub fn test_plan_command(payload: Value) -> Result { pub fn language_server_launch_plan_command(payload: Value) -> Result { const COMMAND: &str = "plugins.declarative.languageServerLaunchPlan"; let payload: LaunchPlanPayload = decode_payload(payload, COMMAND)?; - let selected = match (&payload.language_id, &payload.build_system_id) { - (Some(language_id), None) => LaunchSelection::Language(language_id), - (None, Some(build_system_id)) => LaunchSelection::BuildSystem(build_system_id), + let selected = match ( + &payload.language_id, + &payload.build_system_id, + &payload.vcs_provider_id, + ) { + (Some(language_id), None, None) => LaunchSelection::Language(language_id), + (None, Some(build_system_id), None) => LaunchSelection::BuildSystem(build_system_id), + (None, None, Some(vcs_id)) => LaunchSelection::VcsProvider(vcs_id), _ => { return Err(CoreError::new( ErrorCode::InvalidRequest, format!("Invalid {COMMAND} payload"), ) - .with_details("Name exactly one of languageID and buildSystemID.")) + .with_details("Name exactly one of languageID, buildSystemID, and vcsProviderID.")) } }; Ok(outcome(validate_manifest_value(payload.manifest).and_then( @@ -3286,6 +3357,9 @@ pub fn language_server_launch_plan_command(payload: Value) -> Result { build_provider_launch_plan(&manifest, build_system_id, &payload.request) } + LaunchSelection::VcsProvider(vcs_id) => { + vcs_provider_launch_plan(&manifest, vcs_id, &payload.request) + } } .map(|plan| ("plan", plan)) }, diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index 5a76d00f19..5af138fd59 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -94,6 +94,12 @@ pub enum CoreCommand { BuildSyncFingerprint, /// Maps build options to the Java language server's project import (`build.languageImport`). BuildLanguageImport, + /// Detects VCS roots and registered providers of a workspace (`vcs.detectRoots`). + VcsDetectRoots, + /// Assigns workspace paths to detected VCS roots (`vcs.ownerOf`). + VcsOwnerOf, + /// Validates and normalizes a VCS provider's reply (`vcs.decodeReply`). + VcsDecodeReply, /// Validates and filters a marketplace index (`plugins.marketplace.validateIndex`). PluginsMarketplaceValidateIndex, /// Resolves a user-entered marketplace source URL (`plugins.marketplace.resolveSource`). @@ -258,6 +264,8 @@ pub enum CoreCommand { LspRequest, /// Queues one build-provider request (`lsp.buildRequest`). LspBuildRequest, + /// Queues one VCS-provider request (`lsp.vcsRequest`). + LspVcsRequest, /// Invokes one plugin function (`lsp.functionRequest`). LspFunctionRequest, /// Queues shared Java gutter marker resolution (`java.navigationMarkers`). @@ -454,6 +462,9 @@ impl CoreCommand { "build.decodeDependencies" => Some(Self::BuildDecodeDependencies), "build.syncFingerprint" => Some(Self::BuildSyncFingerprint), "build.languageImport" => Some(Self::BuildLanguageImport), + "vcs.detectRoots" => Some(Self::VcsDetectRoots), + "vcs.ownerOf" => Some(Self::VcsOwnerOf), + "vcs.decodeReply" => Some(Self::VcsDecodeReply), "plugins.marketplace.validateIndex" => Some(Self::PluginsMarketplaceValidateIndex), "plugins.marketplace.resolveSource" => Some(Self::PluginsMarketplaceResolveSource), "plugins.marketplace.installReview" => Some(Self::PluginsMarketplaceInstallReview), @@ -534,6 +545,7 @@ impl CoreCommand { "lsp.closeDocument" => Some(Self::LspCloseDocument), "lsp.request" => Some(Self::LspRequest), "lsp.buildRequest" => Some(Self::LspBuildRequest), + "lsp.vcsRequest" => Some(Self::LspVcsRequest), "lsp.functionRequest" => Some(Self::LspFunctionRequest), "java.resolveNavigation" => Some(Self::JavaResolveNavigation), "lsp.cancelOperation" => Some(Self::LspCancelOperation), @@ -631,6 +643,7 @@ mod tests { "lsp.closeDocument", "lsp.request", "lsp.buildRequest", + "lsp.vcsRequest", "lsp.functionRequest", "lsp.cancelOperation", "lsp.pollEvents", diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index 24c0fb1c3a..44b86dec0e 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -255,6 +255,15 @@ fn execute(request: &str) -> CoreResponse { parsed.payload, crate::build::build_language_import_command, ), + CoreCommand::VcsDetectRoots => { + plugin_response(id, parsed.payload, crate::vcs::vcs_detect_roots_command) + } + CoreCommand::VcsOwnerOf => { + plugin_response(id, parsed.payload, crate::vcs::vcs_owner_of_command) + } + CoreCommand::VcsDecodeReply => { + plugin_response(id, parsed.payload, crate::vcs::vcs_decode_reply_command) + } CoreCommand::PluginsMarketplaceValidateIndex => plugin_response( id, parsed.payload, @@ -1584,6 +1593,21 @@ fn execute(request: &str) -> CoreResponse { Err(error) => CoreResponse::failure(id, error), } } + CoreCommand::LspVcsRequest => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid VCS-provider request") + .with_details(error.to_string()) + }) + .and_then(crate::lsp::vcs_provider_request) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("VCS-provider response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } CoreCommand::LspFunctionRequest => { match serde_json::from_value::(parsed.payload) .map_err(|error| { diff --git a/rust/lithe-core/src/tests/mod.rs b/rust/lithe-core/src/tests/mod.rs index eecd05e983..5592927215 100644 --- a/rust/lithe-core/src/tests/mod.rs +++ b/rust/lithe-core/src/tests/mod.rs @@ -24,3 +24,4 @@ mod protocol; mod run_configuration; mod spring; pub(crate) mod support; +mod vcs; diff --git a/rust/lithe-core/src/tests/vcs.rs b/rust/lithe-core/src/tests/vcs.rs new file mode 100644 index 0000000000..6fad30d8d2 --- /dev/null +++ b/rust/lithe-core/src/tests/vcs.rs @@ -0,0 +1,401 @@ +//! VCS providers declared by API level 3 plugins: manifest validation, +//! install review, launch plans, root detection over real directories, and +//! reply decoding, checked against `shared/fixtures/vcs/model-v1.json` and +//! the Subversion provider manifest fixture. + +use super::support::temporary_root; +use crate::plugins::declarative::{language_server_launch_plan_command, validate_command}; +use crate::plugins::marketplace::install_review_command; +use crate::protocol::ErrorCode; +use crate::vcs::{ + negotiated_capabilities, vcs_decode_reply_command, vcs_detect_roots_command, + vcs_owner_of_command, +}; +use serde_json::{json, Value}; +use std::fs; +use std::path::{Path, PathBuf}; + +const MODEL: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/fixtures/vcs/model-v1.json" +)); +const SVN_PROVIDER: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/contracts/plugins/v1/fixtures/vcs-provider.plugin.json" +)); + +fn model() -> Value { + serde_json::from_str(MODEL).expect("the VCS model fixture is JSON") +} + +fn svn_provider() -> Value { + serde_json::from_str(SVN_PROVIDER).expect("the provider fixture is JSON") +} + +fn validate(manifest: Value) -> Value { + validate_command(json!({ "manifest": manifest })).expect("well-formed payload") +} + +/// Removes its directory when dropped, so a failed assertion cleans up too. +struct Sandbox(PathBuf); + +impl Sandbox { + fn new(label: &str) -> Self { + let root = temporary_root(label); + fs::create_dir_all(&root).unwrap(); + // Detection reports resolved paths, and the temporary directory is + // itself behind a link on macOS. + Self(root.canonicalize().unwrap()) + } + + fn directory(&self, relative: &str) -> PathBuf { + let path = self.0.join(relative); + fs::create_dir_all(&path).unwrap(); + path + } +} + +impl Drop for Sandbox { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +#[test] +fn the_provider_fixture_validates_and_is_reviewed_with_its_tool() { + let outcome = validate(svn_provider()); + assert_eq!(outcome["status"], "ok", "{outcome}"); + + let review = install_review_command(json!({ "manifest": svn_provider() })).unwrap(); + assert_eq!(review["status"], "ok", "{review}"); + let programs = review["review"]["programs"].as_array().unwrap(); + let provider = programs + .iter() + .find(|program| program["kind"] == "runtimeVcsProvider") + .unwrap_or_else(|| panic!("no provider program: {programs:?}")); + assert_eq!(provider["vcs"], "svn"); + assert_eq!(provider["tool"], "node", "{provider}"); + assert_eq!(provider["entry"], "provider/main.mjs"); + assert_eq!( + provider["vcsTool"], + json!({ "commands": ["svn"], "validationArguments": ["--version", "--quiet"] }) + ); + assert_eq!(provider["mayUseNetwork"], true); + assert!( + programs.contains(&json!({ "kind": "runtime", "tool": "node", "minimumVersion": "20.3" })), + "{programs:?}" + ); +} + +/// A provider changes the working copy and starts its own process, so the +/// manifest must ask for both, name its activation event, and say which +/// contract versions it needs; Git stays reserved for the built-in support. +#[test] +fn provider_manifests_state_permissions_activation_and_requirements() { + let rejected = |mutate: fn(&mut Value)| { + let mut manifest = svn_provider(); + mutate(&mut manifest); + let outcome = validate(manifest); + assert_eq!(outcome["status"], "rejected", "{outcome}"); + outcome["error"].clone() + }; + let missing_permission = rejected(|manifest| { + manifest["permissions"] = json!(["languageServer.launch"]); + }); + assert_eq!( + missing_permission["field"], "permissions", + "{missing_permission}" + ); + let missing_launch = rejected(|manifest| manifest["permissions"] = json!(["process.vcs"])); + assert_eq!(missing_launch["field"], "permissions", "{missing_launch}"); + let missing_activation = rejected(|manifest| manifest["activationEvents"] = json!([])); + assert_eq!( + missing_activation["field"], "activationEvents", + "{missing_activation}" + ); + let missing_requirement = rejected(|manifest| { + manifest["requires"] = json!({ "serverProcess": 1 }); + }); + assert!( + missing_requirement.to_string().contains("vcsProviders"), + "{missing_requirement}" + ); + let reserved = rejected(|manifest| { + manifest["vcsProviders"][0]["id"] = json!("git"); + manifest["activationEvents"] = json!(["onVcsRoot:git"]); + }); + assert_eq!(reserved["code"], "plugin.vcs.reserved", "{reserved}"); + let level_two = rejected(|manifest| manifest["pluginAPIVersion"] = json!(2)); + assert_ne!(level_two["code"], "plugin.vcs.reserved", "{level_two}"); + let escaping = rejected(|manifest| { + manifest["vcsProviders"][0]["metadataPaths"] = json!(["../wc.db"]); + }); + assert_eq!( + escaping["field"], "vcsProviders[0].metadataPaths[0]", + "{escaping}" + ); + let path_tool = rejected(|manifest| { + manifest["vcsProviders"][0]["tool"]["commands"] = json!(["/usr/bin/svn"]); + }); + assert_eq!( + path_tool["field"], "vcsProviders[0].tool.commands", + "{path_tool}" + ); +} + +#[test] +fn a_launch_plan_names_the_vcs_provider_it_starts() { + let request = json!({ + "pluginDirectory": "/plugins/svn", + "stateDirectory": "/state/svn", + "workspaceStateDirectory": "/state/svn/workspace", + "architecture": "arm64" + }); + let plan = language_server_launch_plan_command(json!({ + "manifest": svn_provider(), + "vcsProviderID": "svn", + "request": request, + })) + .unwrap(); + assert_eq!(plan["status"], "ok", "{plan}"); + let plan = &plan["plan"]; + assert_eq!(plan["vcsProvider"]["vcs"], "svn", "{plan}"); + assert_eq!( + plan["vcsProvider"]["origin"], + json!({ "kind": "plugin", "pluginID": "dev.lithe.fixture.vcs-provider" }) + ); + assert_eq!(plan["vcsProvider"]["metadataPaths"], json!([".svn/wc.db"])); + + let unknown = language_server_launch_plan_command(json!({ + "manifest": svn_provider(), + "vcsProviderID": "hg", + "request": request, + })) + .unwrap(); + assert_eq!(unknown["status"], "rejected", "{unknown}"); + let both = language_server_launch_plan_command(json!({ + "manifest": svn_provider(), + "languageID": "java", + "vcsProviderID": "svn", + "request": request, + })) + .unwrap_err(); + assert!(matches!(both.code, ErrorCode::InvalidRequest), "{both:?}"); +} + +fn under(path: &Value, root: &Path) -> bool { + path.as_str() + .is_some_and(|path| Path::new(path).starts_with(root)) +} + +/// Builds each case's markers in a fresh directory and checks the roots, +/// ambiguous directories, unused mappings, and file ownership. Roots found +/// above the case's directory belong to the machine running the test and +/// are left out. +#[test] +fn root_detection_matches_the_contract_cases() { + let model = model(); + for case in model["rootDetectionCases"].as_array().unwrap() { + let name = case["name"].as_str().unwrap(); + let sandbox = Sandbox::new("vcs-roots"); + for marker in case["markers"].as_array().unwrap() { + let directory = sandbox.directory(marker["directory"].as_str().unwrap()); + let path = directory.join(marker["name"].as_str().unwrap()); + match marker["type"].as_str().unwrap() { + "directory" => fs::create_dir_all(path).unwrap(), + _ => fs::write(path, "gitdir: elsewhere\n").unwrap(), + } + } + let workspace = sandbox.directory(case["workspace"].as_str().unwrap_or("")); + let outcome = vcs_detect_roots_command(json!({ + "workspaceRoot": workspace.to_string_lossy(), + "plugins": [{ "manifest": svn_provider() }], + "mappings": case.get("mappings").cloned().unwrap_or(json!([])), + })) + .unwrap(); + assert_eq!(outcome["status"], "ok", "{name}: {outcome}"); + let detection = &outcome["detection"]; + assert_eq!(detection["truncated"], false, "{name}"); + + let roots: Vec<&Value> = detection["roots"] + .as_array() + .unwrap() + .iter() + .filter(|root| under(&root["path"], &sandbox.0)) + .collect(); + let found: Vec = roots + .iter() + .map(|root| json!({ "id": root["id"], "vcs": root["vcs"], "mapped": root["mapped"] })) + .collect(); + let expected: Vec = case["expectedRoots"] + .as_array() + .unwrap() + .iter() + .map(|root| { + json!({ + "id": root["id"], + "vcs": root["vcs"], + "mapped": root.get("mapped").cloned().unwrap_or(json!(false)), + }) + }) + .collect(); + assert_eq!(found, expected, "{name}: {detection}"); + assert_eq!(detection["ambiguous"], case["expectedAmbiguous"], "{name}"); + assert_eq!( + detection["unusedMappings"], + case.get("expectedUnusedMappings") + .cloned() + .unwrap_or(json!([])), + "{name}" + ); + + let ownership = case["expectedOwnership"].as_array().unwrap(); + let owners = vcs_owner_of_command(json!({ + "roots": roots.iter().map(|root| root["id"].clone()).collect::>(), + "ambiguous": detection["ambiguous"] + .as_array() + .unwrap() + .iter() + .map(|directory| directory["id"].clone()) + .collect::>(), + "paths": ownership.iter().map(|entry| entry["path"].clone()).collect::>(), + })) + .unwrap(); + assert_eq!(owners["status"], "ok", "{name}: {owners}"); + let expected: Vec = ownership + .iter() + .map(|entry| json!({ "path": entry["path"], "root": entry["root"] })) + .collect(); + assert_eq!(owners["owners"], json!(expected), "{name}"); + } +} + +/// The built-in Git entry and the plugin's descriptor are what a host shows; +/// a second plugin claiming the same VCS loses to the official namespace +/// whatever order the host lists plugins in, and a malformed manifest is +/// reported by its position without hiding the others. +#[test] +fn detection_lists_providers_and_reports_conflicts_and_rejections() { + let model = model(); + let sandbox = Sandbox::new("vcs-providers"); + // `com.` sorts before `dev.`, so only the namespace rule lets the + // official fixture win. + let mut community = svn_provider(); + community["id"] = json!("com.example.svn"); + let mut broken = svn_provider(); + broken["vcsProviders"][0]["id"] = json!("SVN"); + for plugins in [ + vec![community.clone(), broken.clone(), svn_provider()], + vec![svn_provider(), broken.clone(), community.clone()], + ] { + let outcome = vcs_detect_roots_command(json!({ + "workspaceRoot": sandbox.0.to_string_lossy(), + "plugins": plugins + .into_iter() + .map(|manifest| json!({ "manifest": manifest })) + .collect::>(), + })) + .unwrap(); + let detection = &outcome["detection"]; + let providers = detection["providers"].as_array().unwrap(); + assert_eq!(providers[0], model["providers"][0], "{detection}"); + assert_eq!(providers.len(), 2, "{detection}"); + assert_eq!(providers[1], { + let mut descriptor = model["providers"][1].clone(); + descriptor.as_object_mut().unwrap().remove("capabilities"); + descriptor["metadataPaths"] = json!([".svn/wc.db"]); + descriptor["tool"] = + json!({ "commands": ["svn"], "validationArguments": ["--version", "--quiet"] }); + descriptor["mayUseNetwork"] = json!(true); + descriptor + }); + assert_eq!( + detection["conflicts"], + json!([{ + "pluginID": "com.example.svn", + "vcs": "svn", + "by": "dev.lithe.fixture.vcs-provider" + }]) + ); + let rejected = detection["rejected"].as_array().unwrap(); + assert_eq!(rejected.len(), 1, "{detection}"); + assert_eq!(rejected[0]["index"], 1); + } + + let error = vcs_detect_roots_command(json!({ "workspaceRoot": "relative/path" })) + .expect_err("a relative workspace is a malformed call"); + assert!(matches!(error.code, ErrorCode::InvalidRequest), "{error:?}"); +} + +#[test] +fn ownership_queries_reject_malformed_ids_and_paths() { + for payload in [ + json!({ "roots": ["../a"], "paths": ["x"] }), + json!({ "roots": [""], "ambiguous": ["/abs"], "paths": ["x"] }), + json!({ "roots": [""], "paths": ["../x"] }), + json!({ "roots": [""], "paths": ["a\\b"] }), + ] { + let error = vcs_owner_of_command(payload.clone()).expect_err("malformed"); + assert!(matches!(error.code, ErrorCode::InvalidRequest), "{payload}"); + } +} + +fn decode(operation: &str, capabilities: &Value, request: &Value, reply: &Value) -> Value { + vcs_decode_reply_command(json!({ + "operation": operation, + "capabilities": capabilities, + "request": request, + "reply": reply, + })) + .expect("well-formed payload") +} + +/// A normalized snapshot is a fixed point: decoding it again changes +/// nothing, so hosts can rely on the fixture as the decoded shape. +#[test] +fn status_snapshots_are_the_decoded_shape() { + let model = model(); + for case in model["statusSnapshots"].as_array().unwrap() { + let name = case["name"].as_str().unwrap(); + let capabilities = &case["capabilities"]; + assert!( + negotiated_capabilities(capabilities).is_some(), + "{name}: the capabilities must be a valid negotiation" + ); + let outcome = decode("status", capabilities, &json!({}), &case["snapshot"]); + assert_eq!(outcome["status"], "ok", "{name}: {outcome}"); + assert_eq!(outcome["result"], case["snapshot"], "{name}"); + } + // The Subversion provider's capabilities survive negotiation unchanged. + let svn = &model["providers"][1]["capabilities"]; + assert_eq!(json!(negotiated_capabilities(svn).expect("complete")), *svn); +} + +#[test] +fn plugin_replies_match_the_validation_cases() { + let model = model(); + let capabilities = &model["providers"][1]["capabilities"]; + for case in model["pluginReplyValidationCases"].as_array().unwrap() { + let name = case["name"].as_str().unwrap(); + let operation = case["operation"].as_str().unwrap_or("status"); + let request = case.get("request").cloned().unwrap_or(json!({})); + let outcome = decode(operation, capabilities, &request, &case["reply"]); + if let Some(code) = case.get("expectedError") { + assert_eq!(outcome["status"], "rejected", "{name}: {outcome}"); + assert_eq!(outcome["error"]["code"], *code, "{name}"); + } else if let Some(changes) = case.get("expectedChanges") { + assert_eq!(outcome["status"], "ok", "{name}: {outcome}"); + assert_eq!(outcome["result"]["changes"], *changes, "{name}"); + } else { + assert_eq!(outcome["status"], "ok", "{name}: {outcome}"); + assert_eq!(outcome["result"], case["expectedResult"], "{name}"); + } + } + let error = vcs_decode_reply_command(json!({ + "operation": "push", + "capabilities": capabilities, + "reply": {}, + })) + .expect_err("push has no reply shape in version 1"); + assert!(matches!(error.code, ErrorCode::InvalidRequest), "{error:?}"); +} diff --git a/rust/lithe-core/src/vcs/commands.rs b/rust/lithe-core/src/vcs/commands.rs new file mode 100644 index 0000000000..9f989092dd --- /dev/null +++ b/rust/lithe-core/src/vcs/commands.rs @@ -0,0 +1,263 @@ +//! The `vcs.*` commands: JSON in, outcome envelope out. +//! +//! Like `build.detect`, `vcs.detectRoots` takes the manifests of the enabled +//! plugins and validates each again, so a host never trusts its own cache of +//! a manifest. Malformed payloads fail the call; rejected manifests and +//! replies come back as `{status: "rejected", error}`. + +use super::manifest::{built_in_git_markers, VcsProviderDeclaration, BUILT_IN_GIT}; +use super::model::{is_root_relative, Capabilities}; +use super::requests::{self, Operation}; +use super::roots::{self, Mapping, MarkerSet}; +use crate::plugins::declarative::{ + decode_payload, is_official_namespace, outcome, validate_manifest_value, +}; +use crate::protocol::{CoreError, ErrorCode}; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::collections::BTreeMap; +use std::path::PathBuf; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct DetectRootsRequest { + /// Absolute workspace root. + workspace_root: String, + /// Manifests of the enabled plugins, in any order. + #[serde(default)] + plugins: Vec, + /// The user's choices for ambiguous directories. + #[serde(default)] + mappings: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct DetectPlugin { + manifest: Value, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct MappingInput { + id: String, + vcs: String, +} + +/// The declaration that answers for one VCS ID. +struct Owner<'a> { + plugin_id: &'a str, + declaration: &'a VcsProviderDeclaration, +} + +/// Official plugins win a VCS ID, then the lower plugin ID, so the result +/// does not depend on the order a host lists plugins in. +fn precedes(candidate: &Owner<'_>, current: &Owner<'_>) -> bool { + ( + !is_official_namespace(candidate.plugin_id), + candidate.plugin_id, + ) < (!is_official_namespace(current.plugin_id), current.plugin_id) +} + +/// `vcs.detectRoots`: registered providers, the roots of every VCS with a +/// provider (and of the built-in Git), ambiguous directories, and which +/// mappings applied. +pub(crate) fn vcs_detect_roots_command(payload: Value) -> Result { + let request: DetectRootsRequest = decode_payload(payload, "vcs.detectRoots")?; + let workspace = PathBuf::from(&request.workspace_root); + if !workspace.is_absolute() || request.workspace_root.contains('\0') { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "The workspace root must be an absolute path.", + )); + } + // Ancestors are read from the resolved location: a workspace opened + // through a link sits in the link target's working copy. + let workspace = workspace.canonicalize().unwrap_or(workspace); + + let mut rejected = Vec::new(); + let mut manifests = Vec::new(); + for (index, plugin) in request.plugins.into_iter().enumerate() { + match validate_manifest_value(plugin.manifest) { + Ok(manifest) => manifests.push(manifest), + Err(error) => rejected.push(json!({ "index": index, "error": error })), + } + } + let mut owners: BTreeMap<&str, Owner<'_>> = BTreeMap::new(); + let mut conflicts = Vec::new(); + for manifest in &manifests { + for declaration in manifest.vcs_providers() { + let candidate = Owner { + plugin_id: manifest.id(), + declaration, + }; + let (winner, loser) = match owners.remove(declaration.id.as_str()) { + None => { + owners.insert(&declaration.id, candidate); + continue; + } + Some(current) if precedes(&candidate, ¤t) => (candidate, current), + Some(current) => (current, candidate), + }; + conflicts.push(json!({ + "pluginID": loser.plugin_id, + "vcs": declaration.id, + "by": winner.plugin_id, + })); + owners.insert(&declaration.id, winner); + } + } + conflicts.sort_by_key(Value::to_string); + + let git_markers = built_in_git_markers(); + let mut providers = vec![json!({ + "vcs": BUILT_IN_GIT, + "displayName": "Git", + "origin": { "kind": "builtIn" }, + "markers": git_markers, + })]; + let mut sets = vec![MarkerSet { + vcs: BUILT_IN_GIT, + markers: &git_markers, + }]; + for owner in owners.values() { + providers.push(owner.declaration.descriptor(owner.plugin_id)); + sets.push(MarkerSet { + vcs: &owner.declaration.id, + markers: &owner.declaration.detection.markers, + }); + } + let mappings: Vec = request + .mappings + .into_iter() + .map(|mapping| Mapping { + id: mapping.id, + vcs: mapping.vcs, + }) + .collect(); + let detection = roots::detect(&workspace, &sets, &mappings); + let roots: Vec = detection + .roots + .iter() + .map(|root| { + json!({ + "id": root.id, + "vcs": root.vcs, + "path": root.path.to_string_lossy(), + "mapped": root.mapped, + }) + }) + .collect(); + let ambiguous: Vec = detection + .ambiguous + .iter() + .map(|directory| json!({ "id": directory.id, "vcs": directory.vcs })) + .collect(); + let unused: Vec = detection + .unused_mappings + .iter() + .map(|mapping| json!({ "id": mapping.id, "vcs": mapping.vcs })) + .collect(); + Ok(outcome(Ok(( + "detection", + json!({ + "providers": providers, + "roots": roots, + "ambiguous": ambiguous, + "unusedMappings": unused, + "truncated": detection.truncated, + "rejected": rejected, + "conflicts": conflicts, + }), + )))) +} + +/// Most paths one `vcs.ownerOf` call may ask about. +const MAX_OWNER_PATHS: usize = 100_000; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct OwnerOfRequest { + /// Root IDs of one `vcs.detectRoots` result. + roots: Vec, + /// Its unmapped ambiguous directories. + #[serde(default)] + ambiguous: Vec, + /// Workspace-relative paths with `/` separators; `""` is the workspace. + paths: Vec, +} + +/// `vcs.ownerOf`: the root that owns each path under the rule of +/// `vcs.detectRoots`, so every host assigns files the same way. +pub(crate) fn vcs_owner_of_command(payload: Value) -> Result { + let request: OwnerOfRequest = decode_payload(payload, "vcs.ownerOf")?; + if request.paths.len() > MAX_OWNER_PATHS { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + format!("One call asks about at most {MAX_OWNER_PATHS} paths."), + )); + } + if let Some(id) = request + .roots + .iter() + .chain(&request.ambiguous) + .find(|id| !roots::is_directory_id(id)) + { + return Err( + CoreError::new(ErrorCode::InvalidRequest, "Malformed root ID").with_details(id.clone()), + ); + } + if let Some(path) = request + .paths + .iter() + .find(|path| !path.is_empty() && !is_root_relative(path)) + { + return Err( + CoreError::new(ErrorCode::InvalidRequest, "Malformed workspace path") + .with_details(path.clone()), + ); + } + let owners: Vec = request + .paths + .iter() + .map(|path| { + json!({ + "path": path, + "root": roots::owner(&request.roots, &request.ambiguous, path), + }) + }) + .collect(); + Ok(outcome(Ok(("owners", json!(owners))))) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct DecodeReplyRequest { + operation: String, + /// Capabilities the session negotiated, as Core reported them. + capabilities: Capabilities, + /// The arguments the host sent with the request. + #[serde(default)] + request: Value, + /// The provider's result, unwrapped from the session's `value`. + reply: Value, +} + +/// `vcs.decodeReply`: a provider's reply as the normalized result, or +/// `plugin.vcs.invalidOutput`. +pub(crate) fn vcs_decode_reply_command(payload: Value) -> Result { + let request: DecodeReplyRequest = decode_payload(payload, "vcs.decodeReply")?; + let operation = Operation::parse(&request.operation).ok_or_else(|| { + CoreError::new(ErrorCode::InvalidRequest, "Unknown VCS operation") + .with_details(request.operation.clone()) + })?; + Ok(outcome( + requests::decode( + operation, + &request.request, + &request.reply, + &request.capabilities, + ) + .map(|result| ("result", result)), + )) +} diff --git a/rust/lithe-core/src/vcs/manifest.rs b/rust/lithe-core/src/vcs/manifest.rs new file mode 100644 index 0000000000..9b0d024254 --- /dev/null +++ b/rust/lithe-core/src/vcs/manifest.rs @@ -0,0 +1,286 @@ +//! `vcsProviders[]` of an API level 3 plugin manifest: what a host must know +//! about a VCS before starting any process for it. +//! +//! A declaration names the VCS, the static markers that identify its roots, +//! the metadata the host watches to refresh status, the client tool the +//! provider drives, and the provider process with the launch fields every +//! plugin server process shares. Capabilities are not declared here: they +//! belong to the running provider and are negotiated in `initialize`. + +use super::model::is_vcs_id; +use crate::plugins::declarative::{ + invalid, is_bare_command, is_safe_relative_path, sorted_unique, PluginRejection, ServerProcess, + ServerRole, Validation, +}; +use crate::plugins::settings::DeclaredSettings; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::BTreeSet; + +/// The built-in VCS ID. Git is not part of the unified layer yet, but Core +/// recognizes its roots so a nested working copy is assigned correctly, and +/// no plugin may claim the ID. +pub(crate) const BUILT_IN_GIT: &str = "git"; + +/// Most providers one plugin may declare. +const MAX_PROVIDERS: usize = 8; +/// Most markers one provider may declare. +const MAX_MARKERS: usize = 8; +/// Most metadata paths one provider may ask the host to watch. +const MAX_METADATA_PATHS: usize = 16; +/// Most tool command names one provider may list. +const MAX_TOOL_COMMANDS: usize = 8; +/// Longest display name, in characters. +const MAX_DISPLAY_NAME: usize = 64; + +/// One VCS a plugin implements. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct VcsProviderDeclaration { + /// Stable lowercase VCS ID (`svn`, `hg`); the provider must answer the + /// same ID in `initialize`. + pub(crate) id: String, + pub(crate) display_name: String, + pub(crate) detection: VcsDetection, + /// Root-relative files or directories whose change means the status may + /// be stale, such as `.svn/wc.db`. Sorted and unique once validated. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) metadata_paths: Vec, + /// The VCS client the provider runs, which the host resolves and passes + /// with every request. Absent for a provider that needs no client. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) tool: Option, + #[serde(flatten)] + pub(crate) process: ServerProcess, + /// Whether operations may contact a server; shown in the install review. + pub(crate) may_use_network: bool, +} + +/// Static markers of a root. Detection never starts a process. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct VcsDetection { + pub(crate) markers: Vec, +} + +/// An entry directly inside a root that marks it, such as the `.svn` +/// directory. The kind matters: a Git worktree has a `.git` file. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct VcsMarker { + pub(crate) name: String, + #[serde(rename = "type")] + pub(crate) kind: MarkerKind, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum MarkerKind { + Directory, + File, +} + +/// A client executable the host discovers like a `systemCommand` launch. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct VcsTool { + /// Bare command names, tried in order. + pub(crate) commands: Vec, + /// Arguments of a probe that must exit successfully before the host + /// trusts a candidate; empty means no probe. + #[serde(default)] + pub(crate) validation_arguments: Vec, +} + +/// The markers Core uses for the built-in Git support: a `.git` directory, +/// or the `.git` file of a linked worktree or submodule. +pub(crate) fn built_in_git_markers() -> Vec { + vec![ + VcsMarker { + name: ".git".to_string(), + kind: MarkerKind::Directory, + }, + VcsMarker { + name: ".git".to_string(), + kind: MarkerKind::File, + }, + ] +} + +fn is_marker_name(value: &str) -> bool { + !value.is_empty() + && value != "." + && value != ".." + && value.len() <= 255 + && !value.contains(['/', '\\', '\0']) +} + +/// What the declaration context needs from the manifest. +pub(crate) struct DeclarationContext<'a> { + pub(crate) plugin_api_version: u32, + pub(crate) declared_settings: &'a DeclaredSettings, +} + +/// Validates every declaration and sorts them by ID. +pub(crate) fn validate_declarations( + providers: &mut [VcsProviderDeclaration], + context: &DeclarationContext<'_>, +) -> Validation<()> { + if providers.is_empty() { + return Ok(()); + } + if context.plugin_api_version < 3 { + return Err(invalid( + "vcsProviders", + "VCS providers require plugin API level 3.", + )); + } + if providers.len() > MAX_PROVIDERS { + return Err(invalid( + "vcsProviders", + format!("A plugin declares at most {MAX_PROVIDERS} VCS providers."), + )); + } + let mut ids = BTreeSet::new(); + for (index, provider) in providers.iter_mut().enumerate() { + let field = format!("vcsProviders[{index}]"); + provider.validate(&field, context)?; + if !ids.insert(provider.id.clone()) { + return Err(invalid( + format!("{field}.id"), + "A VCS is declared more than once.", + )); + } + } + providers.sort_by(|left, right| left.id.cmp(&right.id)); + Ok(()) +} + +impl VcsProviderDeclaration { + fn validate(&mut self, field: &str, context: &DeclarationContext<'_>) -> Validation<()> { + if !is_vcs_id(&self.id) { + return Err(invalid( + format!("{field}.id"), + "A VCS ID is a lowercase letter followed by up to 31 lowercase letters, digits, or hyphens.", + )); + } + if self.id == BUILT_IN_GIT { + return Err(PluginRejection::at( + "plugin.vcs.reserved", + format!("{field}.id"), + "Git is built into Lithe and cannot be provided by a plugin yet.", + )); + } + let display_name = self.display_name.trim(); + if display_name.is_empty() + || display_name.chars().count() > MAX_DISPLAY_NAME + || display_name.chars().any(char::is_control) + { + return Err(invalid( + format!("{field}.displayName"), + "The display name is one line of 1-64 characters.", + )); + } + + let markers = &mut self.detection.markers; + if markers.is_empty() || markers.len() > MAX_MARKERS { + return Err(invalid( + format!("{field}.detection.markers"), + format!("Detection needs 1-{MAX_MARKERS} markers."), + )); + } + if let Some(index) = markers + .iter() + .position(|marker| !is_marker_name(&marker.name)) + { + return Err(invalid( + format!("{field}.detection.markers[{index}].name"), + "A marker is a name directly inside the root.", + )); + } + markers.sort(); + markers.dedup(); + + if self.metadata_paths.len() > MAX_METADATA_PATHS { + return Err(invalid( + format!("{field}.metadataPaths"), + format!("At most {MAX_METADATA_PATHS} metadata paths are watched."), + )); + } + if let Some(index) = self.metadata_paths.iter().position(|path| { + !is_safe_relative_path(path) || path.split('/').any(|s| s.is_empty() || s == ".") + }) { + return Err(invalid( + format!("{field}.metadataPaths[{index}]"), + "A metadata path must stay inside the root.", + )); + } + self.metadata_paths = sorted_unique(std::mem::take(&mut self.metadata_paths)); + + if let Some(tool) = &self.tool { + if tool.commands.is_empty() + || tool.commands.len() > MAX_TOOL_COMMANDS + || tool + .commands + .iter() + .any(|command| !is_bare_command(command)) + { + return Err(invalid( + format!("{field}.tool.commands"), + format!("A tool names 1-{MAX_TOOL_COMMANDS} bare executable names."), + )); + } + if tool + .validation_arguments + .iter() + .any(|argument| argument.contains('\0')) + { + return Err(invalid( + format!("{field}.tool.validationArguments"), + "Tool arguments must not contain NUL.", + )); + } + } + + self.process.validate( + field, + context.plugin_api_version, + context.declared_settings, + ServerRole::VcsProvider, + ) + } + + /// Display data and requirements a host keeps for a provider; the + /// process launch is planned separately. + pub(crate) fn descriptor(&self, plugin_id: &str) -> Value { + json!({ + "vcs": self.id, + "displayName": self.display_name, + "origin": { "kind": "plugin", "pluginID": plugin_id }, + "markers": self.detection.markers, + "metadataPaths": self.metadata_paths, + "tool": self.tool, + "mayUseNetwork": self.may_use_network, + }) + } +} + +/// Activation event the host raises before starting a VCS provider, once a +/// root of its VCS was detected. +pub(crate) fn activation_event(provider: &VcsProviderDeclaration) -> String { + format!("onVcsRoot:{}", provider.id) +} + +/// Install-review entries: the provider process with the runtimes it needs. +/// The tool is part of the provider entry, so pointing it at another client +/// is a new program the user must approve. +pub(crate) fn programs(provider: &VcsProviderDeclaration) -> Vec { + let mut program = provider.process.program(ServerRole::VcsProvider); + program["vcs"] = json!(provider.id); + // `tool` already names a runtime launch's runtime. + program["vcsTool"] = json!(provider.tool); + program["mayUseNetwork"] = json!(provider.may_use_network); + let mut programs = vec![program]; + programs.extend(provider.process.runtime_programs()); + programs +} diff --git a/rust/lithe-core/src/vcs/mod.rs b/rust/lithe-core/src/vcs/mod.rs new file mode 100644 index 0000000000..18dc26112c --- /dev/null +++ b/rust/lithe-core/src/vcs/mod.rs @@ -0,0 +1,29 @@ +//! The unified VCS layer: one model of roots, file status, history, and +//! check-in that every version control system maps onto, and the +//! deterministic half of talking to a plugin VCS provider. +//! +//! A provider is a plugin process speaking the LSP base protocol, like a +//! build provider. Core validates its `vcsProviders` declaration, detects +//! roots from static markers, plans each request from an operation name and +//! checked arguments, and validates every reply before a host renders it. +//! Hosts resolve the client tool, start and stop processes, watch metadata, +//! and own every piece of UI and state such as changelists. +//! +//! Git is not part of this layer yet: Core recognizes `.git` markers so a +//! nested working copy is assigned to the right root, and the existing +//! `git.*` commands keep serving Git roots unchanged. +//! +//! Note: the design is recorded in .agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md + +mod commands; +mod manifest; +mod model; +mod requests; +mod roots; + +pub(crate) use commands::*; +pub(crate) use manifest::{ + activation_event, programs, validate_declarations, DeclarationContext, VcsProviderDeclaration, +}; +pub(crate) use model::{is_vcs_id, negotiated_capabilities, Capabilities}; +pub(crate) use requests::{is_vcs_method, plan, Operation}; diff --git a/rust/lithe-core/src/vcs/model.rs b/rust/lithe-core/src/vcs/model.rs new file mode 100644 index 0000000000..9fc128da66 --- /dev/null +++ b/rust/lithe-core/src/vcs/model.rs @@ -0,0 +1,726 @@ +//! The VCS-neutral model: capability IDs, file statuses, revisions, and the +//! validation that turns a plugin provider's reply into a result both hosts +//! can render without trusting the provider. +//! +//! Replies are untrusted input. A value outside the contract fails the reply +//! with `plugin.vcs.invalidOutput`; optional data Core does not know (a flag, +//! a field) is dropped, so a newer provider can add it within version 1. +//! Lists the host renders as sets are sorted here, and every list is capped, +//! so both hosts show the same result for the same reply. + +use crate::plugins::declarative::{PluginRejection, Validation}; +use serde_json::{json, Map, Value}; +use std::collections::{BTreeMap, BTreeSet}; + +/// Error code of every reply that fails validation or exceeds a limit. +pub(crate) const INVALID_OUTPUT: &str = "plugin.vcs.invalidOutput"; + +/// Capability IDs of protocol version 1. `true` marks the capabilities +/// every provider must support; a provider without one of them is not used. +pub(crate) const CAPABILITIES: &[(&str, bool)] = &[ + ("annotate", false), + ("baseContent", true), + ("checkin", true), + ("cleanup", false), + ("conflicts", false), + ("fileHistory", false), + ("fileOperations", false), + ("history", false), + ("push", false), + ("rollback", true), + ("staging", false), + ("status", true), + ("update", false), +]; + +/// Version of each capability this Core build understands. +const CAPABILITY_VERSION: u64 = 1; + +/// A snapshot keeps at most this many changes and is then marked truncated. +pub(crate) const MAX_CHANGES: usize = 100_000; +/// Largest base-revision content, in UTF-8 bytes, a reply may carry. +pub(crate) const MAX_BASE_CONTENT_BYTES: usize = 32 * 1024 * 1024; +/// Most entries one history page may return; the request limit is lower. +pub(crate) const MAX_HISTORY_PAGE: usize = 500; +/// Most changed paths one history entry may list. +const MAX_ENTRY_CHANGES: usize = 10_000; +/// Most lines an annotation may cover. +pub(crate) const MAX_ANNOTATED_LINES: usize = 500_000; +/// Most distinct revisions one annotation may reference. +const MAX_ANNOTATION_REVISIONS: usize = 100_000; +/// Longest revision ID, opaque cursor, branch, or author, in characters. +const MAX_SHORT_TEXT: usize = 256; +/// Longest revision display text, in characters. +const MAX_REVISION_DISPLAY: usize = 64; +/// Longest commit message, in UTF-8 bytes. +pub(crate) const MAX_MESSAGE_BYTES: usize = 1024 * 1024; +/// Longest remote location after sanitizing, in characters. +const MAX_LOCATION: usize = 2048; +/// Longest root-relative path, in UTF-8 bytes. +const MAX_PATH_BYTES: usize = 4096; + +/// File status values of version 1. A reply with any other value fails. +const STATUSES: &[&str] = &[ + "added", + "conflicted", + "copied", + "deleted", + "ignored", + "missing", + "modified", + "renamed", + "replaced", + "switched", + "typeChanged", + "unversioned", +]; + +/// Statuses whose change names the path it came from. +const STATUSES_WITH_ORIGIN: &[&str] = &["copied", "renamed"]; + +/// Refinements of a status. Unknown flags are dropped, not rejected. +const FLAGS: &[&str] = &[ + "external", + "locked", + "lockedByOther", + "propertiesModified", + "submodule", + "treeConflict", +]; + +/// Staged state of a change; only providers with `staging` report it. +const STAGING: &[&str] = &["partial", "staged", "unstaged"]; + +/// How a host presents a revision ID; never used to order revisions. +const REVISION_KINDS: &[&str] = &["hash", "number", "opaque"]; + +/// Capabilities a provider negotiated, by ID, with their versions. +pub(crate) type Capabilities = BTreeMap; + +fn invalid_output(message: impl Into) -> PluginRejection { + PluginRejection::new(INVALID_OUTPUT, message) +} + +/// Whether `id` is a capability of protocol version 1. +pub(crate) fn is_capability(id: &str) -> bool { + CAPABILITIES.iter().any(|(known, _)| *known == id) +} + +/// The capabilities of a provider's `initialize` answer that this build +/// understands, or `None` when a required one is missing or the value is +/// not an object. Unknown IDs and versions are dropped: a newer provider may +/// offer more, and the host then hides only what Core cannot validate. +pub(crate) fn negotiated_capabilities(value: &Value) -> Option { + let offered = value.as_object()?; + let capabilities: Capabilities = offered + .iter() + .filter(|(id, version)| is_capability(id) && version.as_u64() == Some(CAPABILITY_VERSION)) + .map(|(id, _)| (id.clone(), CAPABILITY_VERSION)) + .collect(); + CAPABILITIES + .iter() + .filter(|(_, required)| *required) + .all(|(id, _)| capabilities.contains_key(*id)) + .then_some(capabilities) +} + +/// Whether a stable VCS ID is well formed: a lowercase letter, then up to +/// 31 lowercase letters, digits, or hyphens (`git`, `svn`, `hg`). +pub(crate) fn is_vcs_id(value: &str) -> bool { + let mut characters = value.chars(); + characters + .next() + .is_some_and(|first| first.is_ascii_lowercase()) + && value.len() <= 32 + && characters.all(|character| { + character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-' + }) +} + +/// Whether `value` is a path inside a root: relative, `/`-separated, and +/// without empty, `.`, or `..` segments that could leave or alias the root. +pub(crate) fn is_root_relative(value: &str) -> bool { + !value.is_empty() + && value.len() <= MAX_PATH_BYTES + && !value.starts_with('/') + && !value.contains('\\') + && !value.chars().any(char::is_control) + && value + .split('/') + .all(|segment| !segment.is_empty() && segment != "." && segment != "..") + // A drive prefix would make the path absolute on Windows. + && !value.split('/').next().is_some_and(|first| first.ends_with(':')) +} + +/// Removes user info, query, and fragment from a remote location, so a URL +/// that carries credentials never leaves Core. Locations without a scheme +/// (a local repository path) are kept apart from the query and fragment. +pub(crate) fn sanitize_location(value: &str) -> Option { + let value = value.split(['?', '#']).next().unwrap_or_default(); + let sanitized = match value.split_once("://") { + Some((scheme, rest)) => { + let (authority, path) = rest + .find('/') + .map_or((rest, ""), |index| rest.split_at(index)); + let host = authority + .rsplit_once('@') + .map_or(authority, |(_, host)| host); + format!("{scheme}://{host}{path}") + } + None => value.to_string(), + }; + (!sanitized.is_empty() + && sanitized.chars().count() <= MAX_LOCATION + && !sanitized.chars().any(char::is_control)) + .then_some(sanitized) +} + +fn object<'a>(value: &'a Value, what: &str) -> Validation<&'a Map> { + value + .as_object() + .ok_or_else(|| invalid_output(format!("The {what} must be an object."))) +} + +fn short_text(value: &str, limit: usize) -> bool { + !value.is_empty() && value.chars().count() <= limit && !value.chars().any(char::is_control) +} + +/// An optional string field: absent and `null` are the same, anything but a +/// short single-line string fails. +fn optional_text( + members: &Map, + key: &str, + limit: usize, + what: &str, +) -> Validation> { + match members.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(text)) if short_text(text, limit) => Ok(Some(text.clone())), + Some(_) => Err(invalid_output(format!( + "The {what} {key} must be a single-line string of at most {limit} characters." + ))), + } +} + +fn root_path(value: Option<&Value>, what: &str) -> Validation { + match value { + Some(Value::String(path)) if is_root_relative(path) => Ok(path.clone()), + _ => Err(invalid_output(format!( + "The {what} must be a path inside the root." + ))), + } +} + +/// A revision as `{id, display, kind}`. `display` falls back to the ID. +pub(crate) fn revision(value: &Value) -> Validation { + let members = object(value, "revision")?; + let id = match members.get("id") { + Some(Value::String(id)) if short_text(id, MAX_SHORT_TEXT) => id.clone(), + _ => { + return Err(invalid_output( + "A revision ID must be a single-line string of at most 256 characters.", + )) + } + }; + let display = optional_text(members, "display", MAX_REVISION_DISPLAY, "revision")? + .unwrap_or_else(|| id.chars().take(MAX_REVISION_DISPLAY).collect()); + let kind = match members.get("kind") { + None | Some(Value::Null) => "opaque", + Some(Value::String(kind)) if REVISION_KINDS.contains(&kind.as_str()) => kind.as_str(), + Some(_) => { + return Err(invalid_output( + "A revision kind must be hash, number, or opaque.", + )) + } + }; + Ok(json!({ "id": id, "display": display, "kind": kind })) +} + +fn optional_revision(value: Option<&Value>) -> Validation { + match value { + None | Some(Value::Null) => Ok(Value::Null), + Some(value) => revision(value), + } +} + +/// One change of a status snapshot or a history entry. +fn change(value: &Value, staging: bool) -> Validation { + let members = object(value, "change")?; + let path = root_path(members.get("path"), "change path")?; + let status = match members.get("status") { + Some(Value::String(status)) if STATUSES.contains(&status.as_str()) => status.clone(), + Some(Value::String(status)) => { + return Err(invalid_output(format!( + "The file status {status} is not part of the contract." + ))) + } + _ => return Err(invalid_output("A change needs a status.")), + }; + let old_path = match members.get("oldPath") { + None | Some(Value::Null) => Value::Null, + // Only a copy or rename names where the file came from; anything + // else would make the host show a move that never happened. + Some(value) if STATUSES_WITH_ORIGIN.contains(&status.as_str()) => { + json!(root_path(Some(value), "original path")?) + } + Some(_) => { + return Err(invalid_output(format!( + "A {status} change cannot name an original path." + ))) + } + }; + let flags: BTreeSet<&str> = match members.get("flags") { + None | Some(Value::Null) => BTreeSet::new(), + Some(Value::Array(flags)) => flags + .iter() + .filter_map(Value::as_str) + .filter(|flag| FLAGS.contains(flag)) + .collect(), + Some(_) => return Err(invalid_output("Change flags must be an array.")), + }; + let mut normalized = json!({ + "path": path, + "oldPath": old_path, + "status": status, + "flags": flags, + }); + match (members.get("staging"), staging) { + (None | Some(Value::Null), false) => {} + (Some(Value::String(state)), true) if STAGING.contains(&state.as_str()) => { + normalized["staging"] = json!(state); + } + (_, true) => return Err(invalid_output( + "A provider with staging must report staged, unstaged, or partial for every change.", + )), + (Some(_), false) => { + return Err(invalid_output( + "A change carries staging although the provider did not negotiate staging.", + )) + } + } + Ok(normalized) +} + +/// Validated changes sorted by path; duplicate paths fail the reply. +fn changes(value: Option<&Value>, staging: bool, limit: usize) -> Validation<(Vec, bool)> { + let items = match value { + Some(Value::Array(items)) => items, + None | Some(Value::Null) => return Ok((Vec::new(), false)), + Some(_) => return Err(invalid_output("Changes must be an array.")), + }; + let mut by_path = BTreeMap::new(); + for item in items { + let change = change(item, staging)?; + let path = change["path"].as_str().unwrap_or_default().to_string(); + if by_path.insert(path.clone(), change).is_some() { + return Err(invalid_output(format!( + "The path {path} is reported more than once." + ))); + } + } + let truncated = by_path.len() > limit; + Ok((by_path.into_values().take(limit).collect(), truncated)) +} + +fn boolean(members: &Map, key: &str) -> Validation { + match members.get(key) { + None | Some(Value::Null) => Ok(false), + Some(Value::Bool(value)) => Ok(*value), + Some(_) => Err(invalid_output(format!("{key} must be a boolean."))), + } +} + +/// `lithe/vcs/status`: `{head, changes, truncated}` with changes sorted by +/// path and capped at [`MAX_CHANGES`]. +pub(crate) fn decode_status(reply: &Value, capabilities: &Capabilities) -> Validation { + let members = object(reply, "status reply")?; + let head = match members.get("head") { + None | Some(Value::Null) => json!({ "revision": null, "branch": null, "location": null }), + Some(head) => { + let head = object(head, "head")?; + let location = optional_text(head, "location", MAX_LOCATION, "head")? + .map(|location| { + sanitize_location(&location) + .ok_or_else(|| invalid_output("The head location is not a location.")) + }) + .transpose()?; + json!({ + "revision": optional_revision(head.get("revision"))?, + "branch": optional_text(head, "branch", MAX_SHORT_TEXT, "head")?, + "location": location, + }) + } + }; + let (changes, capped) = changes( + members.get("changes"), + capabilities.contains_key("staging"), + MAX_CHANGES, + )?; + Ok(json!({ + "head": head, + "changes": changes, + // A provider that stopped early says so; Core adds its own cap. + "truncated": capped || boolean(members, "truncated")?, + })) +} + +/// `lithe/vcs/baseContent`: the file's text at its base revision, `null` +/// content when it has none (an added file), and `binary` when the host +/// must not diff it as text. +pub(crate) fn decode_base_content(reply: &Value) -> Validation { + let members = object(reply, "base content reply")?; + let binary = boolean(members, "binary")?; + let content = match members.get("content") { + None | Some(Value::Null) => Value::Null, + Some(Value::String(_)) if binary => { + return Err(invalid_output( + "Binary base content is reported without text.", + )) + } + Some(Value::String(text)) if text.len() <= MAX_BASE_CONTENT_BYTES => json!(text), + Some(Value::String(_)) => return Err(invalid_output("The base content exceeds 32 MiB.")), + Some(_) => return Err(invalid_output("content must be a string or null.")), + }; + Ok(json!({ "content": content, "binary": binary })) +} + +fn message(members: &Map) -> Validation { + match members.get("message") { + None | Some(Value::Null) => Ok(String::new()), + Some(Value::String(text)) if text.len() <= MAX_MESSAGE_BYTES && !text.contains('\0') => { + Ok(text.clone()) + } + Some(_) => Err(invalid_output( + "A message must be a string of at most 1 MiB without NUL.", + )), + } +} + +/// Author, date, and message of one revision. Dates are passed through as +/// the provider wrote them (RFC 3339); hosts format but never compare them. +fn revision_details(members: &Map) -> Validation { + Ok(json!({ + "revision": revision(members.get("revision").unwrap_or(&Value::Null))?, + "author": optional_text(members, "author", MAX_SHORT_TEXT, "revision")?, + "date": optional_text(members, "date", MAX_SHORT_TEXT, "revision")?, + "message": message(members)?, + })) +} + +/// `lithe/vcs/history` and `lithe/vcs/fileHistory`: one page of entries in +/// the provider's order (newest first), and the cursor of the next page. +pub(crate) fn decode_history(reply: &Value, limit: usize) -> Validation { + let members = object(reply, "history reply")?; + let items = match members.get("entries") { + Some(Value::Array(items)) => items, + _ => return Err(invalid_output("A history reply needs an entries array.")), + }; + // The provider received the limit; more entries means it ignored it. + if items.len() > limit.min(MAX_HISTORY_PAGE) { + return Err(invalid_output(format!( + "The history page has more than the {limit} requested entries." + ))); + } + let mut entries = Vec::with_capacity(items.len()); + for item in items { + let entry = object(item, "history entry")?; + let mut normalized = revision_details(entry)?; + let (changes, truncated) = changes(entry.get("changes"), false, MAX_ENTRY_CHANGES)?; + normalized["changes"] = json!(changes); + normalized["changesTruncated"] = json!(truncated || boolean(entry, "changesTruncated")?); + entries.push(normalized); + } + let next_cursor = optional_text(members, "nextCursor", MAX_SHORT_TEXT, "history")?; + Ok(json!({ "entries": entries, "nextCursor": next_cursor })) +} + +/// `lithe/vcs/annotate`: the revisions a file's lines come from, and per +/// line the ID of one of them, or `null` for a line not yet checked in. +pub(crate) fn decode_annotation(reply: &Value) -> Validation { + let members = object(reply, "annotation reply")?; + let (Some(Value::Array(revisions)), Some(Value::Array(lines))) = + (members.get("revisions"), members.get("lines")) + else { + return Err(invalid_output( + "An annotation needs revisions and lines arrays.", + )); + }; + if revisions.len() > MAX_ANNOTATION_REVISIONS || lines.len() > MAX_ANNOTATED_LINES { + return Err(invalid_output("The annotation exceeds its size limit.")); + } + let mut by_id = BTreeMap::new(); + for item in revisions { + let details = revision_details(object(item, "annotation revision")?)?; + let id = details["revision"]["id"] + .as_str() + .unwrap_or_default() + .to_string(); + if by_id.insert(id.clone(), details).is_some() { + return Err(invalid_output(format!( + "The revision {id} is listed more than once." + ))); + } + } + let mut used = BTreeSet::new(); + let mut normalized_lines = Vec::with_capacity(lines.len()); + for line in lines { + match line { + Value::Null => normalized_lines.push(Value::Null), + Value::String(id) if by_id.contains_key(id) => { + used.insert(id.clone()); + normalized_lines.push(json!(id)); + } + _ => { + return Err(invalid_output( + "Every annotated line names a listed revision or null.", + )) + } + } + } + // Revisions no line uses would show up in a legend with nothing to point + // at; sorting by ID keeps the list stable without ordering revisions. + let revisions: Vec = by_id + .into_iter() + .filter(|(id, _)| used.contains(id)) + .map(|(_, details)| details) + .collect(); + Ok(json!({ "revisions": revisions, "lines": normalized_lines })) +} + +/// `lithe/vcs/checkin`: the new revision, or `null` when the provider +/// cannot name it. +pub(crate) fn decode_checkin(reply: &Value) -> Validation { + let members = object(reply, "check-in reply")?; + Ok(json!({ "revision": optional_revision(members.get("revision"))? })) +} + +/// `lithe/vcs/update`: the revision the working copy is at now, and the +/// paths the update left conflicted, sorted. +pub(crate) fn decode_update(reply: &Value) -> Validation { + let members = object(reply, "update reply")?; + let conflicts: BTreeSet = match members.get("conflicts") { + None | Some(Value::Null) => BTreeSet::new(), + Some(Value::Array(paths)) => paths + .iter() + .map(|path| root_path(Some(path), "conflicted path")) + .collect::>()?, + Some(_) => return Err(invalid_output("conflicts must be an array of paths.")), + }; + if conflicts.len() > MAX_CHANGES { + return Err(invalid_output("The update reports too many conflicts.")); + } + Ok(json!({ + "revision": optional_revision(members.get("revision"))?, + "conflicts": conflicts, + })) +} + +/// Replies that carry no data. Anything in them is ignored, so a provider +/// may report extra details without breaking an older host. +pub(crate) fn decode_empty(reply: &Value) -> Validation { + match reply { + Value::Null | Value::Object(_) => Ok(json!({})), + _ => Err(invalid_output("The reply must be an object or null.")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn capabilities(staging: bool) -> Capabilities { + let mut capabilities: Capabilities = CAPABILITIES + .iter() + .filter(|(_, required)| *required) + .map(|(id, _)| (id.to_string(), 1)) + .collect(); + if staging { + capabilities.insert("staging".to_string(), 1); + } + capabilities + } + + #[test] + fn negotiation_keeps_known_version_one_capabilities_and_requires_the_core_set() { + let negotiated = negotiated_capabilities(&json!({ + "status": 1, "baseContent": 1, "checkin": 1, "rollback": 1, + "annotate": 2, "futureThing": 1, "update": 1 + })) + .expect("required set present"); + assert_eq!( + negotiated.keys().map(String::as_str).collect::>(), + ["baseContent", "checkin", "rollback", "status", "update"] + ); + // A provider without rollback cannot back the generic commit UI. + assert!( + negotiated_capabilities(&json!({ "status": 1, "baseContent": 1, "checkin": 1 })) + .is_none() + ); + assert!(negotiated_capabilities(&json!(["status"])).is_none()); + } + + #[test] + fn root_relative_paths_cannot_leave_or_alias_the_root() { + for valid in ["a.txt", "src/Main.java", ".svnignore", "a b/c"] { + assert!(is_root_relative(valid), "{valid}"); + } + for invalid in [ + "", + "/etc/passwd", + "../x", + "a/../b", + "a//b", + "./a", + "a/", + "a\\b", + "C:/x", + "a\u{0}b", + ] { + assert!(!is_root_relative(invalid), "{invalid:?}"); + } + } + + #[test] + fn locations_lose_credentials_query_and_fragment() { + assert_eq!( + sanitize_location("https://user:secret@svn.example.invalid/repo/trunk?p=1#f") + .as_deref(), + Some("https://svn.example.invalid/repo/trunk") + ); + assert_eq!( + sanitize_location("svn+ssh://me@host.invalid").as_deref(), + Some("svn+ssh://host.invalid") + ); + assert_eq!( + sanitize_location("file:///var/svn/repo").as_deref(), + Some("file:///var/svn/repo") + ); + assert_eq!(sanitize_location("?only=query"), None); + } + + #[test] + fn status_reply_is_normalized_and_capped() { + let reply = json!({ + "head": { + "revision": { "id": "12", "kind": "number" }, + "location": "https://u:p@svn.example.invalid/r", + "branch": null + }, + "changes": [ + { "path": "b", "status": "modified", "flags": ["propertiesModified", "locked", "x"] }, + { "path": "a", "oldPath": "old", "status": "renamed" } + ] + }); + let snapshot = decode_status(&reply, &capabilities(false)).expect("valid"); + assert_eq!( + snapshot, + json!({ + "head": { + "revision": { "id": "12", "display": "12", "kind": "number" }, + "branch": null, + "location": "https://svn.example.invalid/r" + }, + "changes": [ + { "path": "a", "oldPath": "old", "status": "renamed", "flags": [] }, + { "path": "b", "oldPath": null, "status": "modified", "flags": ["locked", "propertiesModified"] } + ], + "truncated": false + }) + ); + } + + #[test] + fn status_reply_rejects_contract_violations() { + for reply in [ + json!({ "changes": [{ "path": "a", "status": "modified" }, { "path": "a", "status": "added" }] }), + json!({ "changes": [{ "path": "a", "oldPath": "b", "status": "modified" }] }), + json!({ "changes": [{ "path": "a", "status": "modified", "flags": "locked" }] }), + json!({ "changes": "a" }), + json!({ "head": { "revision": { "id": "" } } }), + json!({ "head": { "revision": { "id": "1", "kind": "date" } } }), + ] { + let error = decode_status(&reply, &capabilities(false)).expect_err("rejected"); + assert_eq!(error.code, INVALID_OUTPUT, "{reply}"); + } + // With staging negotiated every change must say how it is staged. + let error = decode_status( + &json!({ "changes": [{ "path": "a", "status": "modified" }] }), + &capabilities(true), + ) + .expect_err("missing staging"); + assert_eq!(error.code, INVALID_OUTPUT); + } + + #[test] + fn oversized_snapshots_are_marked_truncated() { + let changes: Vec = (0..=MAX_CHANGES) + .map(|index| json!({ "path": format!("f{index:06}"), "status": "unversioned" })) + .collect(); + let snapshot = + decode_status(&json!({ "changes": changes }), &capabilities(false)).expect("valid"); + assert_eq!( + snapshot["changes"].as_array().map(Vec::len), + Some(MAX_CHANGES) + ); + assert_eq!(snapshot["truncated"], json!(true)); + } + + #[test] + fn base_content_distinguishes_text_binary_and_absent() { + assert_eq!( + decode_base_content(&json!({ "content": "x\n" })).expect("text"), + json!({ "content": "x\n", "binary": false }) + ); + assert_eq!( + decode_base_content(&json!({ "content": null, "binary": true })).expect("binary"), + json!({ "content": null, "binary": true }) + ); + assert!(decode_base_content(&json!({ "content": "x", "binary": true })).is_err()); + } + + #[test] + fn history_page_keeps_provider_order_and_enforces_the_limit() { + let reply = json!({ + "entries": [ + { "revision": { "id": "9", "kind": "number" }, "author": "ann", "date": "2026-10-22T08:00:00Z", "message": "second", + "changes": [{ "path": "b", "status": "modified" }, { "path": "a", "status": "added" }] }, + { "revision": { "id": "3", "kind": "number" }, "message": "first" } + ], + "nextCursor": "3" + }); + let page = decode_history(&reply, 2).expect("valid"); + assert_eq!(page["entries"][0]["revision"]["id"], "9"); + assert_eq!(page["entries"][0]["changes"][0]["path"], "a"); + assert_eq!(page["entries"][1]["author"], Value::Null); + assert_eq!(page["nextCursor"], "3"); + assert!(decode_history(&reply, 1).is_err()); + } + + #[test] + fn annotation_lines_must_reference_listed_revisions() { + let reply = json!({ + "revisions": [ + { "revision": { "id": "2" }, "author": "bo" }, + { "revision": { "id": "1" }, "author": "al" }, + { "revision": { "id": "7" } } + ], + "lines": ["1", "2", null, "1"] + }); + let annotation = decode_annotation(&reply).expect("valid"); + assert_eq!(annotation["lines"], json!(["1", "2", null, "1"])); + // Unused revision 7 is dropped; the rest are ordered by ID. + assert_eq!(annotation["revisions"][0]["revision"]["id"], "1"); + assert_eq!(annotation["revisions"].as_array().map(Vec::len), Some(2)); + assert!(decode_annotation(&json!({ "revisions": [], "lines": ["1"] })).is_err()); + } + + #[test] + fn update_reports_sorted_conflicts_inside_the_root() { + assert_eq!( + decode_update(&json!({ "revision": { "id": "5" }, "conflicts": ["b", "a", "b"] })) + .expect("valid"), + json!({ "revision": { "id": "5", "display": "5", "kind": "opaque" }, "conflicts": ["a", "b"] }) + ); + assert!(decode_update(&json!({ "conflicts": ["/abs"] })).is_err()); + } +} diff --git a/rust/lithe-core/src/vcs/requests.rs b/rust/lithe-core/src/vcs/requests.rs new file mode 100644 index 0000000000..4923fe42c4 --- /dev/null +++ b/rust/lithe-core/src/vcs/requests.rs @@ -0,0 +1,503 @@ +//! Operations of the VCS provider protocol: which method and capability each +//! one uses, how a host's arguments become request params, and how the +//! provider's reply is decoded. +//! +//! The host names an operation, never a method, so a session that negotiated +//! the protocol cannot be used as a generic JSON-RPC tunnel. Arguments come +//! from the host and are checked as strictly as replies: paths stay inside the +//! root, lists are bounded, and unknown fields fail, because a provider runs +//! the VCS client with them. + +use super::model::{self, Capabilities, MAX_HISTORY_PAGE, MAX_MESSAGE_BYTES}; +use crate::plugins::declarative::Validation; +use crate::protocol::{CoreError, ErrorCode}; +use serde::de::DeserializeOwned; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::collections::BTreeSet; +use std::path::Path; + +/// Most paths one mutation may name. +const MAX_PATHS: usize = 10_000; +/// History page size when the host does not ask for one. +const DEFAULT_HISTORY_LIMIT: usize = 100; + +/// One operation of protocol version 1. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Operation { + Status, + BaseContent, + History, + FileHistory, + Annotate, + Checkin, + Rollback, + Update, + FileOperation, + Resolve, + Cleanup, +} + +/// Every operation, for method checks and contract tests. +pub(crate) const OPERATIONS: [Operation; 11] = [ + Operation::Status, + Operation::BaseContent, + Operation::History, + Operation::FileHistory, + Operation::Annotate, + Operation::Checkin, + Operation::Rollback, + Operation::Update, + Operation::FileOperation, + Operation::Resolve, + Operation::Cleanup, +]; + +impl Operation { + /// Parses the operation name a host sends, which is also the last + /// segment of its method. + pub(crate) fn parse(name: &str) -> Option { + OPERATIONS + .into_iter() + .find(|operation| operation.name() == name) + } + + pub(crate) fn name(self) -> &'static str { + match self { + Self::Status => "status", + Self::BaseContent => "baseContent", + Self::History => "history", + Self::FileHistory => "fileHistory", + Self::Annotate => "annotate", + Self::Checkin => "checkin", + Self::Rollback => "rollback", + Self::Update => "update", + Self::FileOperation => "fileOperation", + Self::Resolve => "resolve", + Self::Cleanup => "cleanup", + } + } + + /// JSON-RPC method the host writes to the provider. + pub(crate) fn method(self) -> &'static str { + match self { + Self::Status => "lithe/vcs/status", + Self::BaseContent => "lithe/vcs/baseContent", + Self::History => "lithe/vcs/history", + Self::FileHistory => "lithe/vcs/fileHistory", + Self::Annotate => "lithe/vcs/annotate", + Self::Checkin => "lithe/vcs/checkin", + Self::Rollback => "lithe/vcs/rollback", + Self::Update => "lithe/vcs/update", + Self::FileOperation => "lithe/vcs/fileOperation", + Self::Resolve => "lithe/vcs/resolve", + Self::Cleanup => "lithe/vcs/cleanup", + } + } + + /// Capability the provider must have negotiated for this operation. + pub(crate) fn capability(self) -> &'static str { + match self { + Self::FileOperation => "fileOperations", + Self::Resolve => "conflicts", + other => other.name(), + } + } +} + +/// Whether `method` belongs to the protocol; its replies are wrapped like +/// command results so no LSP normalizer reinterprets them. +pub(crate) fn is_vcs_method(method: &str) -> bool { + OPERATIONS + .into_iter() + .any(|operation| operation.method() == method) +} + +fn invalid_request(message: impl Into) -> CoreError { + CoreError::new(ErrorCode::InvalidRequest, message) +} + +fn arguments(operation: Operation, value: Value) -> Result { + // An operation without arguments may omit them entirely. + let value = if value.is_null() { json!({}) } else { value }; + serde_json::from_value(value).map_err(|error| { + invalid_request(format!("Invalid {} arguments", operation.name())) + .with_details(error.to_string()) + }) +} + +fn root_path(path: &str) -> Result { + if model::is_root_relative(path) { + Ok(path.to_string()) + } else { + Err(invalid_request("A VCS path must be relative to its root.").with_details(path)) + } +} + +/// Root-relative paths, sorted and unique; at least one, at most +/// [`MAX_PATHS`]. +fn path_list(paths: &[String]) -> Result, CoreError> { + if paths.is_empty() || paths.len() > MAX_PATHS { + return Err(invalid_request(format!( + "An operation names 1-{MAX_PATHS} paths." + ))); + } + let unique: BTreeSet = paths + .iter() + .map(|path| root_path(path)) + .collect::>()?; + Ok(unique.into_iter().collect()) +} + +fn absolute_path(value: &str, what: &str) -> Result { + if Path::new(value).is_absolute() && !value.contains('\0') { + Ok(value.to_string()) + } else { + Err(invalid_request(format!( + "The {what} must be an absolute path." + ))) + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct StatusArguments { + #[serde(default)] + include_ignored: bool, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct PathArguments { + path: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct PathsArguments { + paths: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct HistoryArguments { + #[serde(default)] + path: Option, + /// Opaque cursor from the previous page; absent for the first page. + #[serde(default)] + cursor: Option, + #[serde(default)] + limit: Option, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct CheckinArguments { + paths: Vec, + message: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct UpdateArguments { + /// Revision ID to update to, as the provider reported it; absent for + /// the newest revision. + #[serde(default)] + revision: Option, +} + +/// A change made in the project tree that the VCS must record. `move` +/// carries both ends so the VCS keeps the file's history. +#[derive(Deserialize)] +#[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)] +enum FileOperationArguments { + Add { paths: Vec }, + Delete { paths: Vec }, + Move { from: String, to: String }, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct NoArguments {} + +/// The params of one request, without `root` and `tool`. +fn operation_params(operation: Operation, value: Value) -> Result { + Ok(match operation { + Operation::Status => { + let arguments: StatusArguments = arguments(operation, value)?; + json!({ "includeIgnored": arguments.include_ignored }) + } + Operation::BaseContent | Operation::Annotate => { + let arguments: PathArguments = arguments(operation, value)?; + json!({ "path": root_path(&arguments.path)? }) + } + Operation::History | Operation::FileHistory => { + let arguments: HistoryArguments = arguments(operation, value)?; + let limit = arguments.limit.unwrap_or(DEFAULT_HISTORY_LIMIT); + if limit == 0 || limit > MAX_HISTORY_PAGE { + return Err(invalid_request(format!( + "A history page has 1-{MAX_HISTORY_PAGE} entries." + ))); + } + if let Some(cursor) = &arguments.cursor { + if cursor.is_empty() || cursor.len() > 256 || cursor.chars().any(char::is_control) { + return Err(invalid_request("The history cursor is malformed.")); + } + } + let mut params = json!({ "cursor": arguments.cursor, "limit": limit }); + match (operation, arguments.path) { + (Operation::FileHistory, Some(path)) => params["path"] = json!(root_path(&path)?), + (Operation::History, None) => {} + _ => { + return Err(invalid_request( + "fileHistory needs a path; history of the whole root takes none.", + )) + } + } + params + } + Operation::Checkin => { + let arguments: CheckinArguments = arguments(operation, value)?; + if arguments.message.trim().is_empty() + || arguments.message.len() > MAX_MESSAGE_BYTES + || arguments.message.contains('\0') + { + return Err(invalid_request( + "A check-in message is non-empty text of at most 1 MiB.", + )); + } + json!({ "paths": path_list(&arguments.paths)?, "message": arguments.message }) + } + Operation::Rollback | Operation::Resolve => { + let arguments: PathsArguments = arguments(operation, value)?; + json!({ "paths": path_list(&arguments.paths)? }) + } + Operation::Update => { + let arguments: UpdateArguments = arguments(operation, value)?; + if let Some(revision) = &arguments.revision { + if revision.is_empty() + || revision.len() > 256 + || revision.chars().any(char::is_control) + { + return Err(invalid_request("The revision ID is malformed.")); + } + } + json!({ "revision": arguments.revision }) + } + Operation::FileOperation => match arguments(operation, value)? { + FileOperationArguments::Add { paths } => { + json!({ "kind": "add", "paths": path_list(&paths)? }) + } + FileOperationArguments::Delete { paths } => { + json!({ "kind": "delete", "paths": path_list(&paths)? }) + } + FileOperationArguments::Move { from, to } => { + let (from, to) = (root_path(&from)?, root_path(&to)?); + if from == to { + return Err(invalid_request("A move needs two different paths.")); + } + json!({ "kind": "move", "from": from, "to": to }) + } + }, + Operation::Cleanup => { + let _: NoArguments = arguments(operation, value)?; + json!({}) + } + }) +} + +/// A request ready to be written to a provider session. +#[derive(Debug)] +pub(crate) struct PlannedRequest { + pub(crate) method: &'static str, + pub(crate) params: Value, +} + +/// Plans one request. `root` is the native path of the working-copy root and +/// `tool` the client executable the host resolved for the provider; both are +/// sent with every request so the provider never searches for either. +pub(crate) fn plan( + operation: Operation, + root: &str, + tool: Option<&str>, + arguments: Value, + capabilities: &Capabilities, +) -> Result { + if !capabilities.contains_key(operation.capability()) { + return Err(CoreError::new( + ErrorCode::NotSupported, + format!( + "The VCS provider did not negotiate {}.", + operation.capability() + ), + ) + .with_details("vcs.capability.unsupported")); + } + let mut params = operation_params(operation, arguments)?; + params["root"] = json!({ "path": absolute_path(root, "VCS root")? }); + params["tool"] = match tool { + Some(tool) => json!({ "executable": absolute_path(tool, "VCS tool")? }), + None => Value::Null, + }; + Ok(PlannedRequest { + method: operation.method(), + params, + }) +} + +/// Decodes the reply to `operation`. `request` is the arguments the host +/// sent, which bound what a valid reply may contain. +pub(crate) fn decode( + operation: Operation, + request: &Value, + reply: &Value, + capabilities: &Capabilities, +) -> Validation { + match operation { + Operation::Status => model::decode_status(reply, capabilities), + Operation::BaseContent => model::decode_base_content(reply), + Operation::History | Operation::FileHistory => { + let limit = request + .get("limit") + .and_then(Value::as_u64) + .and_then(|limit| usize::try_from(limit).ok()) + .unwrap_or(DEFAULT_HISTORY_LIMIT) + .min(MAX_HISTORY_PAGE); + model::decode_history(reply, limit) + } + Operation::Annotate => model::decode_annotation(reply), + Operation::Checkin => model::decode_checkin(reply), + Operation::Update => model::decode_update(reply), + Operation::Rollback + | Operation::FileOperation + | Operation::Resolve + | Operation::Cleanup => model::decode_empty(reply), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn all_capabilities() -> Capabilities { + model::CAPABILITIES + .iter() + .map(|(id, _)| (id.to_string(), 1)) + .collect() + } + + fn root() -> String { + std::env::temp_dir() + .join("lithe-vcs-root") + .to_string_lossy() + .into_owned() + } + + #[test] + fn every_operation_round_trips_its_name_and_uses_a_contract_capability() { + for operation in OPERATIONS { + assert_eq!(Operation::parse(operation.name()), Some(operation)); + assert!(is_vcs_method(operation.method())); + assert!( + model::is_capability(operation.capability()), + "{operation:?}" + ); + } + assert!(!is_vcs_method("workspace/executeCommand")); + assert_eq!(Operation::parse("push"), None); + } + + #[test] + fn a_plan_carries_root_tool_and_checked_arguments() { + let tool = std::env::temp_dir() + .join("svn") + .to_string_lossy() + .into_owned(); + let planned = plan( + Operation::Checkin, + &root(), + Some(&tool), + json!({ "paths": ["b", "a", "b"], "message": "Fix" }), + &all_capabilities(), + ) + .expect("planned"); + assert_eq!(planned.method, "lithe/vcs/checkin"); + assert_eq!(planned.params["paths"], json!(["a", "b"])); + assert_eq!(planned.params["root"]["path"], json!(root())); + assert_eq!(planned.params["tool"]["executable"], json!(tool)); + } + + #[test] + fn a_plan_rejects_unsafe_or_unknown_arguments() { + let capabilities = all_capabilities(); + for (operation, arguments) in [ + (Operation::Rollback, json!({ "paths": ["../x"] })), + (Operation::Rollback, json!({ "paths": [] })), + ( + Operation::Checkin, + json!({ "paths": ["a"], "message": " " }), + ), + (Operation::Status, json!({ "recursive": false })), + (Operation::History, json!({ "path": "a" })), + (Operation::FileHistory, json!({})), + (Operation::History, json!({ "limit": 0 })), + ( + Operation::FileOperation, + json!({ "kind": "move", "from": "a", "to": "a" }), + ), + (Operation::Cleanup, json!({ "force": true })), + ] { + let error = plan(operation, &root(), None, arguments.clone(), &capabilities) + .expect_err("rejected"); + assert!( + matches!(error.code, ErrorCode::InvalidRequest), + "{operation:?} {arguments}" + ); + } + assert!(plan( + Operation::Status, + "relative", + None, + Value::Null, + &capabilities + ) + .is_err()); + } + + #[test] + fn an_operation_needs_its_negotiated_capability() { + let mut capabilities = all_capabilities(); + capabilities.remove("conflicts"); + let error = plan( + Operation::Resolve, + &root(), + None, + json!({ "paths": ["a"] }), + &capabilities, + ) + .expect_err("unsupported"); + assert!(matches!(error.code, ErrorCode::NotSupported)); + assert_eq!(error.details.as_deref(), Some("vcs.capability.unsupported")); + } + + #[test] + fn history_replies_are_bounded_by_the_requested_limit() { + let reply = json!({ "entries": [ + { "revision": { "id": "2" } }, { "revision": { "id": "1" } } + ] }); + assert!(decode( + Operation::History, + &json!({ "limit": 2 }), + &reply, + &all_capabilities() + ) + .is_ok()); + assert!(decode( + Operation::History, + &json!({ "limit": 1 }), + &reply, + &all_capabilities() + ) + .is_err()); + } +} diff --git a/rust/lithe-core/src/vcs/roots.rs b/rust/lithe-core/src/vcs/roots.rs new file mode 100644 index 0000000000..51a7e64d02 --- /dev/null +++ b/rust/lithe-core/src/vcs/roots.rs @@ -0,0 +1,376 @@ +//! Which VCS owns which directory of a workspace. +//! +//! Roots are found from static markers only, so opening a workspace never +//! starts a provider. One bounded walk serves every provider: it records the +//! markers directly inside each directory, never descends into a VCS's own +//! metadata directory or through a symbolic link, and stops at a depth and +//! directory budget. When the workspace root itself carries no marker, its +//! ancestors are checked too, because a Subversion working copy has a single +//! `.svn` at its top and a workspace may be opened below it. +//! +//! A file belongs to the deepest root that contains it. A directory with +//! markers of several providers is ambiguous and owns nothing until the +//! user maps it to one of them; Core never guesses. + +use super::manifest::{MarkerKind, VcsMarker}; +use super::model::is_root_relative; +use std::collections::{BTreeMap, BTreeSet, VecDeque}; +use std::fs::{self, FileType}; +use std::path::{Path, PathBuf}; + +/// Deepest directory, below the workspace root, whose markers are read. +const MAX_DEPTH: usize = 8; +/// Most directories one detection reads before reporting a partial result. +const MAX_DIRECTORIES: usize = 20_000; + +/// Directories that hold generated or downloaded files and never a working +/// copy worth tracking, and whose size would dominate the walk. Lithe's own +/// worktrees are pruned so they are not listed as nested repositories. +const PRUNED: &[&str] = &[ + ".build", + ".cargo", + ".dart_tool", + ".gradle", + ".idea", + ".lithe", + ".mypy_cache", + ".next", + ".nuxt", + ".pytest_cache", + ".stack-work", + ".svelte-kit", + ".swiftpm", + ".terraform", + ".tox", + ".venv", + ".vscode", + ".worktree", + ".worktrees", + "DerivedData", + "Pods", + "__pycache__", + "bower_components", + "node_modules", + "target", +]; + +/// The markers of one VCS, as detection sees them. +pub(crate) struct MarkerSet<'a> { + pub(crate) vcs: &'a str, + pub(crate) markers: &'a [VcsMarker], +} + +/// A detected root. `id` is workspace-relative with `/` separators: `""` is +/// the workspace root and `..` segments name an enclosing directory. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Root { + pub(crate) id: String, + pub(crate) vcs: String, + pub(crate) path: PathBuf, + /// Whether the user's mapping, not a single marker, chose the VCS. + pub(crate) mapped: bool, +} + +/// A directory with markers of several VCSs and no mapping. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Ambiguous { + pub(crate) id: String, + pub(crate) vcs: Vec, +} + +/// The user's choice for an ambiguous directory. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Mapping { + pub(crate) id: String, + pub(crate) vcs: String, +} + +#[derive(Debug, Default)] +pub(crate) struct Detection { + pub(crate) roots: Vec, + pub(crate) ambiguous: Vec, + /// Mappings that name no ambiguous directory, or a VCS whose markers + /// are not there. Hosts may offer to forget them. + pub(crate) unused_mappings: Vec, + /// Whether the directory budget cut the walk short, so a root may be + /// missing. Roots deeper than [`MAX_DEPTH`] are never looked for. + pub(crate) truncated: bool, +} + +/// Whether `id` names a directory enclosing the workspace: only `..` +/// segments. A directory inside the workspace may still be named `..repo`. +fn is_enclosing(id: &str) -> bool { + !id.is_empty() && id.split('/').all(|segment| segment == "..") +} + +/// Sort key that puts enclosing roots first, then inner roots by depth. +pub(crate) fn depth(id: &str) -> i64 { + if id.is_empty() { + return 0; + } + let segments = id.split('/').count() as i64; + if is_enclosing(id) { + -segments + } else { + segments + } +} + +/// Whether `id` has the shape of a root or ambiguous-directory ID: `""`, +/// only `..` segments, or a path inside the workspace. +pub(crate) fn is_directory_id(id: &str) -> bool { + id.is_empty() || is_enclosing(id) || is_root_relative(id) +} + +/// Whether the root or ambiguous directory `id` contains the +/// workspace-relative `path`. +fn contains(id: &str, path: &str) -> bool { + id.is_empty() + || is_enclosing(id) + || path == id + || path + .strip_prefix(id) + .is_some_and(|rest| rest.starts_with('/')) +} + +/// The root ID that owns a workspace-relative path, or `None` when the path +/// is outside every root or inside an unmapped ambiguous directory. +pub(crate) fn owner<'a>(roots: &'a [String], ambiguous: &[String], path: &str) -> Option<&'a str> { + let deepest_root = roots + .iter() + .filter(|root| contains(root, path)) + .max_by_key(|root| depth(root)); + let deepest_ambiguous = ambiguous + .iter() + .filter(|directory| contains(directory, path)) + .map(|directory| depth(directory)) + .max(); + match (deepest_root, deepest_ambiguous) { + (Some(root), Some(ambiguous)) if ambiguous > depth(root) => None, + (root, _) => root.map(String::as_str), + } +} + +/// Resolves a directory entry's kind, following a link only for the +/// marker check itself (a `.git` that links elsewhere still marks a root). +fn marker_kind(path: &Path, file_type: FileType) -> Option { + let file_type = if file_type.is_symlink() { + fs::metadata(path).ok()?.file_type() + } else { + file_type + }; + if file_type.is_dir() { + Some(MarkerKind::Directory) + } else if file_type.is_file() { + Some(MarkerKind::File) + } else { + None + } +} + +fn matches(sets: &[MarkerSet<'_>], name: &str, kind: MarkerKind) -> Vec { + sets.iter() + .filter(|set| { + set.markers + .iter() + .any(|marker| marker.name == name && marker.kind == kind) + }) + .map(|set| set.vcs.to_string()) + .collect() +} + +/// VCSs whose markers sit directly in `directory`, checked by name. +fn markers_at(directory: &Path, sets: &[MarkerSet<'_>]) -> BTreeSet { + let names: BTreeSet<&str> = sets + .iter() + .flat_map(|set| set.markers.iter().map(|marker| marker.name.as_str())) + .collect(); + let mut found = BTreeSet::new(); + for name in names { + let path = directory.join(name); + let Ok(metadata) = fs::symlink_metadata(&path) else { + continue; + }; + if let Some(kind) = marker_kind(&path, metadata.file_type()) { + found.extend(matches(sets, name, kind)); + } + } + found +} + +/// Walks the workspace and its ancestors. `workspace` must be absolute. +pub(crate) fn detect(workspace: &Path, sets: &[MarkerSet<'_>], mappings: &[Mapping]) -> Detection { + let metadata_directories: BTreeSet<&str> = sets + .iter() + .flat_map(|set| set.markers.iter()) + .filter(|marker| marker.kind == MarkerKind::Directory) + .map(|marker| marker.name.as_str()) + .collect(); + let mut found: BTreeMap)> = BTreeMap::new(); + let mut truncated = false; + let mut visited = 0usize; + let mut queue = VecDeque::from([(workspace.to_path_buf(), String::new(), 0usize)]); + // Breadth first, children sorted by name: when the budget runs out the + // shallow roots are kept, and two runs over one tree agree. + while let Some((directory, relative, level)) = queue.pop_front() { + if visited == MAX_DIRECTORIES { + truncated = true; + break; + } + visited += 1; + let Ok(entries) = fs::read_dir(&directory) else { + continue; + }; + let mut entries: Vec<_> = entries + .flatten() + .filter_map(|entry| { + let name = entry.file_name().to_str()?.to_string(); + Some((name, entry.path(), entry.file_type().ok()?)) + }) + .collect(); + entries.sort_by(|left, right| left.0.cmp(&right.0)); + let mut here = BTreeSet::new(); + for (name, path, file_type) in entries { + if let Some(kind) = marker_kind(&path, file_type) { + here.extend(matches(sets, &name, kind)); + } + // Source trees are routinely deeper than the limit, so stopping + // there is the rule rather than a partial result. + if !file_type.is_dir() + || level == MAX_DEPTH + || metadata_directories.contains(name.as_str()) + || PRUNED.contains(&name.as_str()) + { + continue; + } + let child = if relative.is_empty() { + name + } else { + format!("{relative}/{name}") + }; + queue.push_back((path, child, level + 1)); + } + if !here.is_empty() { + found.insert(relative, (directory, here)); + } + } + + if !found.contains_key("") { + let mut id = String::from(".."); + let mut ancestor = workspace.parent(); + while let Some(directory) = ancestor { + let here = markers_at(directory, sets); + if !here.is_empty() { + found.insert(id, (directory.to_path_buf(), here)); + break; + } + id.push_str("/.."); + ancestor = directory.parent(); + } + } + + let mut detection = Detection { + truncated, + ..Detection::default() + }; + let mut used = BTreeSet::new(); + for (id, (path, vcs)) in found { + if vcs.len() == 1 { + let vcs = vcs.into_iter().next().unwrap_or_default(); + detection.roots.push(Root { + id, + vcs, + path, + mapped: false, + }); + continue; + } + match mappings + .iter() + .find(|mapping| mapping.id == id && vcs.contains(&mapping.vcs)) + { + Some(mapping) => { + used.insert((mapping.id.clone(), mapping.vcs.clone())); + detection.roots.push(Root { + id, + vcs: mapping.vcs.clone(), + path, + mapped: true, + }); + } + None => detection.ambiguous.push(Ambiguous { + id, + vcs: vcs.into_iter().collect(), + }), + } + } + detection.unused_mappings = mappings + .iter() + .filter(|mapping| !used.contains(&(mapping.id.clone(), mapping.vcs.clone()))) + .cloned() + .collect(); + detection + .unused_mappings + .sort_by(|left, right| (&left.id, &left.vcs).cmp(&(&right.id, &right.vcs))); + detection.unused_mappings.dedup(); + detection + .roots + .sort_by(|left, right| (depth(&left.id), &left.id).cmp(&(depth(&right.id), &right.id))); + detection + .ambiguous + .sort_by(|left, right| (depth(&left.id), &left.id).cmp(&(depth(&right.id), &right.id))); + detection +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ids(values: &[&str]) -> Vec { + values.iter().map(ToString::to_string).collect() + } + + #[test] + fn the_deepest_root_owns_a_path_and_ambiguity_blocks_fallback() { + let roots = ids(&["..", "", "a/b"]); + let ambiguous = ids(&["m"]); + let owner_of = |path| owner(&roots, &ambiguous, path); + assert_eq!(owner_of("README.md"), Some("")); + assert_eq!(owner_of("a/b/c.txt"), Some("a/b")); + assert_eq!(owner_of("a/bc.txt"), Some("")); + // Files below an unmapped ambiguous directory belong to nobody, not + // to the enclosing Git root. + assert_eq!(owner_of("m/x.txt"), None); + assert_eq!(owner_of("mx.txt"), Some("")); + } + + #[test] + fn a_directory_named_like_dot_dot_is_inside_the_workspace() { + let roots = ids(&["", "..repo"]); + let owner_of = |path| owner(&roots, &[], path); + assert_eq!(owner_of("..repo/a.txt"), Some("..repo")); + assert_eq!(owner_of("README.md"), Some("")); + // Without a workspace root, `..repo` owns only its own files. + let inner = ids(&["..repo"]); + assert_eq!(owner(&inner, &[], "src/main.rs"), None); + assert_eq!(depth("..repo"), 1); + assert!(is_directory_id("..repo/x")); + } + + #[test] + fn enclosing_roots_sort_before_inner_ones() { + let mut ids = vec!["a/b", "", "../..", "a", ".."]; + ids.sort_by_key(|id| (depth(id), *id)); + assert_eq!(ids, ["../..", "..", "", "a", "a/b"]); + } + + #[test] + fn directory_ids_are_enclosing_or_inside_the_workspace() { + for id in ["", "..", "../..", "a", "a/b"] { + assert!(is_directory_id(id), "{id}"); + } + for id in ["/a", "../a", "a/..", "a//b", "./a", "a\\b"] { + assert!(!is_directory_id(id), "{id}"); + } + } +} diff --git a/shared/contracts/plugins/v1/README.md b/shared/contracts/plugins/v1/README.md index 981f4c0e98..5405eb9845 100644 --- a/shared/contracts/plugins/v1/README.md +++ b/shared/contracts/plugins/v1/README.md @@ -197,8 +197,8 @@ contains a machine path, and the host decides where state lives. | `${setting:}` | Current value of a declared [plugin setting](#plugin-settings-api-2). Not available in `validationArguments`, and never inside a package path. | `plugins.declarative.languageServerLaunchPlan` expands a server's launch. The -host selects the process with exactly one of `languageID` (a language server) -and `buildSystemID` (a build provider), passes `{pluginDirectory, +host selects the process with exactly one of `languageID` (a language server), +`buildSystemID` (a build provider), and `vcsProviderID` (a VCS provider), passes `{pluginDirectory, stateDirectory, workspaceStateDirectory, architecture, settings?}`, and receives: @@ -213,9 +213,11 @@ receives: | `requiredPackagePaths` | Sorted package-relative paths the launch points at. The host verifies each is inside the package and not a symbolic link before starting the server. | | `settings` | Effective value of every declared setting that produced this plan; absent when the plugin declares none. | | `buildProvider` | Present only for a `buildSystemID` selection: `{buildSystemID, executesProjectCode, mayUseNetwork, languageServer?}`. `languageServer` names the language whose server is the provider; the rest of the plan is then that server's plan, and the host reuses its running session instead of starting a second process. | +| `vcsProvider` | Present only for a `vcsProviderID` selection: the [provider descriptor](https://github.com/1lck/Lithe-IDEA/blob/preview/shared/contracts/vcs-provider-v1.md#provider-descriptor) the host keeps for the session. | -A language without a server, an undeclared build system, and a build system -without a provider are `plugin.launch.unavailable`. Core never creates or +A language without a server, an undeclared build system, a build system +without a provider, and an undeclared VCS provider are +`plugin.launch.unavailable`. Core never creates or reads any of these paths. The command also serves API 1 manifests, whose arguments are returned unchanged. @@ -368,7 +370,7 @@ files sorted by path, or an empty list when no project marker is present. | --- | --- | | `1` | Languages, `packaged` and `systemCommand` language servers, `node` and `go` runtime dependencies, run and test providers. | | `2` | `runtime` launches with a packaged or discovered runtime, host integrations, the `java` runtime, launch templates, `initializationOptions`, and plugin settings. | -| `3` | Capability versions (`requires`), and `buildSystems`: declarative build tools with detection, options, a project model, tasks, run entries, tests, diagnostics, dependencies, and sync. | +| `3` | Capability versions (`requires`), `buildSystems`: declarative build tools with detection, options, a project model, tasks, run entries, tests, diagnostics, dependencies, and sync, and [`vcsProviders`](#api-level-3-vcs-providers). | A level 2 feature in a level 1 manifest is refused with `plugin.manifest.invalid` (`plugin.runtime.unsupported` for the `java` tool), @@ -392,12 +394,13 @@ hosts. Level 3 versions each section on its own. A level 3 manifest lists in | --- | --- | --- | | `languages` | `languages[]` | `1` | | `languageServers` | `languageServers[]` apart from their launch fields | `1`, `2` (adds [`workspaceConfiguration`](#workspace-configuration-languageservers-2)) | -| `serverProcess` | The launch fields of language servers and of build providers with a process of their own: `launch`, `arguments`, `validationArguments`, `runtimeDependencies`, `initializationOptions` | `1` | +| `serverProcess` | The launch fields of language servers, of build providers with a process of their own, and of VCS providers: `launch`, `arguments`, `validationArguments`, `runtimeDependencies`, `initializationOptions` | `1` | | `executionProviders` | `executionProviders[]` | `1` | | `settings` | `settings[]` and `settingActions[]` | `1`, `2` (adds [placement, scope, effect, page buttons](#settings-v2-placement-scope-effect-and-page-buttons)) | | `ui` | `ui` | `1`, `2` (adds [web content](#web-content-ui-2)) | | `buildSystems` | Declarative build detection, models, and command plans | `1` | | `buildProviders` | `buildSystems[].provider`, including own-process and shared language-server providers | `1` | +| `vcsProviders` | `vcsProviders[]` | `1` | Version 1 of every capability means what its section means at level 2 (runtime launches, launch templates, `initializationOptions`, and so on), so @@ -418,7 +421,8 @@ moving a level 2 manifest to level 3 only adds `requires`. `buildSystems[].provider` requires `buildProviders: 1`, whether it starts its own process or shares a language server. A template-only build plugin must omit `buildProviders`. Launch support alone does not implement Provider - negotiation, requests, and lifecycle. + negotiation, requests, and lifecycle. Every VCS provider starts its own + process, so `vcsProviders` always comes with `serverProcess`. - The base fields (`schemaVersion`, `id`, `displayName`, `version`, `vendor`, `permissions`, `activationEvents`, and `requires` itself) change only with a new `pluginAPIVersion`. @@ -443,8 +447,10 @@ The macOS host runs levels 1 to 3 and sends its capability table, including `buildSystems: [1]`, with all three commands. It does not implement build providers and does not declare `buildProviders`. Provider plugins are therefore filtered from the market and refused by declarative validation and install -review; template-only build plugins remain available. Core supports -`buildProviders: 1` for validation and plans without a host table. Windows has +review; template-only build plugins remain available. It does not declare +`vcsProviders` either, so VCS provider plugins are filtered the same way until +the macOS host starts providers. Core supports `buildProviders: 1` and +`vcsProviders: 1` for validation and plans without a host table. Windows has no declarative plugin host and sends no `hostCapabilities`, so it offers no level 3 version. @@ -473,10 +479,11 @@ Rules for publishing across levels and capability versions: | Permission | Required when | | --- | --- | -| `languageServer.launch` | `languageServers` is not empty | +| `languageServer.launch` | `languageServers` or `vcsProviders` is not empty, or a build provider starts its own process | | `process.run` | any provider declares `run` | | `process.test` | any provider declares `test` | | `process.build` | `buildSystems` is not empty | +| `process.vcs` | `vcsProviders` is not empty | | `ui.contribute` | `ui` is declared | | `ui.web` | a tool window shows [web content](#web-content-ui-2) | @@ -486,6 +493,7 @@ Rules for publishing across levels and capability versions: | `onRun:` | Activate the run provider when the user runs a file of the language. | | `onTest:` | Activate the test provider when the user lists or runs tests. | | `onBuildSystem:` | Activate the build system before its first plan, model, or decode. | +| `onVcsRoot:` | Start the VCS provider once a root of its VCS is detected in the workspace. | Each provider needs its event and each event needs a provider. Enabling a plugin only permits activation; nothing starts until its event occurs. @@ -622,7 +630,8 @@ the plugin, sorted by `kind`, language, and tool: | `runtimeLanguageServer` | `languageID`, `tool`, `packagedRuntime`, `entry`, `runtimeArguments`, `arguments`, `validationArguments` | Package entry started by a runtime; `packagedRuntime` is `null` for a discovered runtime | | `buildSystem` | `buildSystemID`, `invocation`, `options`, `tasks`, `run`, `test`, `model`, `dependencies`, `hostIntegration`?, `provider`? | Every command line the build system can plan; `provider` is the shared form, which sends build requests to a language server | | `packagedBuildProvider`, `systemBuildProvider`, `runtimeBuildProvider` | the matching language-server fields with `buildSystemID`, `executesProjectCode`, `mayUseNetwork` instead of `languageID` | A build provider with a process of its own | -| `runtime` | `tool`, `minimumVersion`? | Runtime the host discovers and passes to a language server or build provider | +| `packagedVcsProvider`, `systemVcsProvider`, `runtimeVcsProvider` | the matching language-server fields with `vcs`, `vcsTool`, `mayUseNetwork` instead of `languageID` | A VCS provider process; `vcsTool` is the declared VCS client or `null` | +| `runtime` | `tool`, `minimumVersion`? | Runtime the host discovers and passes to a language server, build provider, or VCS provider | | `run` | `languageID`, `executable`, `arguments` | Run command and its argument template | | `test` | `languageID`, `executable`, `workspaceArguments`, `fileArguments`, `testCaseArguments` | Test command and its argument templates | @@ -965,6 +974,25 @@ digests the watched files. A selection the declaration does not allow is `plugin.build.invalidSelection`; an operation it does not declare is `plugin.build.unavailable`. +## API level 3: VCS providers + +`vcsProviders[]` declares version control systems the plugin implements, each +through its own LSP base-protocol process: the VCS ID, the static markers that +identify a root, metadata paths the host watches, the VCS client the provider +drives, and the shared [`serverProcess`](#capability-versions-level-3) launch +fields. The ID `git` is reserved for the built-in support +(`plugin.vcs.reserved`). Capabilities belong to the running process and are +negotiated in `initialize` under `experimental.litheVcs`, never declared in +the manifest. + +Detection never starts a process: `vcs.detectRoots` finds roots from the +markers, and the host starts a provider only after a root of its VCS is +found. The install review lists the provider as a `…VcsProvider` program with +its VCS client (`vcsTool`) and `mayUseNetwork`. Requests go through +`lsp.vcsRequest` by operation name and replies are normalized by +`vcs.decodeReply`. The fields, operations, reply shapes, and error codes are +defined in [the VCS provider contract](https://github.com/1lck/Lithe-IDEA/blob/preview/shared/contracts/vcs-provider-v1.md). + ## Plugin functions A plugin backend (a language server or build provider process the plugin @@ -1169,7 +1197,7 @@ valid. ## Outcomes and error codes -`plugins.declarative.*`, `plugins.marketplace.*`, and `build.*` commands return `{"status": "ok", …}` or +`plugins.declarative.*`, `plugins.marketplace.*`, `build.*`, and `vcs.*` commands return `{"status": "ok", …}` or `{"status": "rejected", "error": {"code", "message", "field"?}}`. A malformed command payload is a Core `invalid_request` failure instead. @@ -1188,11 +1216,13 @@ command payload is a Core `invalid_request` failure instead. | `plugin.test.unsupportedProject` | Core | No project marker in the workspace | | `plugin.execution.unavailable` | Core | The plugin has no run/test provider for the language | | `plugin.integration.unsupported` | Core | A host integration the host does not implement, or one requested by a plugin outside the official namespace | -| `plugin.launch.unavailable` | Core | The plugin has no language server for the language, or no provider process for the build system | +| `plugin.launch.unavailable` | Core | The plugin has no language server for the language, no provider process for the build system, or no such VCS provider | | `plugin.build.unavailable` | Core | The plugin declares no such build system, or the build system does not declare the requested operation | | `plugin.build.invalidSelection` | Core | A build request selects a scope, target, filter, or argument the declaration does not allow, or misses a required path | | `plugin.build.invalidOutput` | Core | Build-tool output or a report does not fit its decoder | | `plugin.launch.invalidRequest` | Core | A launch plan request has an unusable directory or an unknown architecture | +| `plugin.vcs.reserved` | Core | A VCS provider claims the ID of the built-in Git support | +| `plugin.vcs.invalidOutput` | Core | A VCS provider's reply does not fit the [VCS provider contract](https://github.com/1lck/Lithe-IDEA/blob/preview/shared/contracts/vcs-provider-v1.md) | | `plugin.index.invalid` | Core | Malformed index root or entry, or an invalid URL, checksum, platform, version, or date | | `plugin.index.schemaUnsupported` | Core | The index `schemaVersion` is not `1` | | `plugin.index.officialNamespace` | Core | A third-party source lists a `dev.lithe` ID | diff --git a/shared/contracts/plugins/v1/fixtures/vcs-provider.plugin.json b/shared/contracts/plugins/v1/fixtures/vcs-provider.plugin.json new file mode 100644 index 0000000000..b78094beac --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/vcs-provider.plugin.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.fixture.vcs-provider", + "displayName": "VCS Provider Fixture", + "version": "0.1.0", + "pluginAPIVersion": 3, + "requires": { "serverProcess": 1, "vcsProviders": 1 }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "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/main.mjs" + }, + "arguments": ["--stdio"], + "mayUseNetwork": true + } + ] +} diff --git a/shared/contracts/plugins/v1/plugin-manifest.schema.json b/shared/contracts/plugins/v1/plugin-manifest.schema.json index 3571dd1a21..c24d138ffb 100644 --- a/shared/contracts/plugins/v1/plugin-manifest.schema.json +++ b/shared/contracts/plugins/v1/plugin-manifest.schema.json @@ -27,9 +27,10 @@ "executionProviders": { "enum": [1] }, "languageServers": { "enum": [1] }, "languages": { "enum": [1] }, - "serverProcess": { "description": "The launch fields of language servers and of build providers that start their own process.", "enum": [1] }, + "serverProcess": { "description": "The launch fields of language servers, of build providers that start their own process, and of VCS providers.", "enum": [1] }, "settings": { "enum": [1] }, - "ui": { "description": "Version 2 adds web content in tool windows.", "enum": [1, 2] } + "ui": { "description": "Version 2 adds web content in tool windows.", "enum": [1, 2] }, + "vcsProviders": { "description": "Required exactly when vcsProviders is non-empty. The provider protocol itself is negotiated in initialize (shared/contracts/vcs-provider-v1.md).", "enum": [1] } } }, "vendor": { @@ -44,11 +45,11 @@ }, "permissions": { "type": "array", - "items": { "enum": ["languageServer.launch", "process.build", "process.run", "process.test", "ui.contribute", "ui.web"] } + "items": { "enum": ["languageServer.launch", "process.build", "process.run", "process.test", "process.vcs", "ui.contribute", "ui.web"] } }, "activationEvents": { "type": "array", - "items": { "type": "string", "pattern": "^(onLanguage|onRun|onTest|onView|onCommand|onBuildSystem):[a-z0-9.+_-]+$" } + "items": { "type": "string", "pattern": "^(onLanguage|onRun|onTest|onView|onCommand|onBuildSystem|onVcsRoot):[a-z0-9.+_-]+$" } }, "languages": { "type": "array", @@ -69,6 +70,12 @@ "maxItems": 16, "items": { "$ref": "#/$defs/buildSystem" } }, + "vcsProviders": { + "description": "pluginAPIVersion 3: version control systems the plugin provides, each through its own LSP base-protocol process. Requires languageServer.launch, process.vcs, and one onVcsRoot: activation event per provider.", + "type": "array", + "maxItems": 8, + "items": { "$ref": "#/$defs/vcsProvider" } + }, "ui": { "type": "object", "additionalProperties": false, @@ -362,7 +369,7 @@ } }, "serverProcess": { - "description": "The launch fields a language server and a pluginAPIVersion 3 build provider share; both are LSP base-protocol processes and pass the same validation.", + "description": "The launch fields a language server, a pluginAPIVersion 3 build provider, and a VCS provider share; all are LSP base-protocol processes and pass the same validation.", "type": "object", "required": ["launch"], "properties": { @@ -452,6 +459,66 @@ } ] }, + "vcsProvider": { + "description": "pluginAPIVersion 3: a VCS provider process (shared/contracts/vcs-provider-v1.md). Detection uses the markers only and never starts the process; capabilities are negotiated, not declared.", + "type": "object", + "$ref": "#/$defs/serverProcess", + "unevaluatedProperties": false, + "required": ["id", "displayName", "detection", "mayUseNetwork"], + "properties": { + "id": { + "description": "Stable VCS ID; git is reserved for the built-in support.", + "type": "string", + "pattern": "^[a-z][a-z0-9-]{0,31}$", + "not": { "const": "git" } + }, + "displayName": { "type": "string", "minLength": 1, "maxLength": 64 }, + "detection": { + "type": "object", + "additionalProperties": false, + "required": ["markers"], + "properties": { + "markers": { + "description": "Entries directly inside a root that mark it.", + "type": "array", + "minItems": 1, + "maxItems": 8, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["name", "type"], + "properties": { + "name": { "type": "string", "minLength": 1, "maxLength": 255, "pattern": "^[^/\\\\]+$", "not": { "enum": [".", ".."] } }, + "type": { "enum": ["directory", "file"] } + } + } + } + } + }, + "metadataPaths": { + "description": "Root-relative paths the host watches; a change means the status may be stale.", + "type": "array", + "maxItems": 16, + "items": { "type": "string", "minLength": 1 } + }, + "tool": { + "description": "The VCS client the provider drives. The host resolves it like a systemCommand launch and passes its absolute path with every request.", + "type": "object", + "additionalProperties": false, + "required": ["commands"], + "properties": { + "commands": { + "type": "array", + "minItems": 1, + "maxItems": 8, + "items": { "type": "string", "pattern": "^[^/\\\\\\s]+$" } + }, + "validationArguments": { "type": "array", "items": { "type": "string" } } + } + }, + "mayUseNetwork": { "type": "boolean" } + } + }, "minimumVersion": { "description": "pluginAPIVersion 2: one to three dot-separated numbers, for example 21 or 18.17.", "type": "string", diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index b5807098c8..edd03d5ea8 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -836,6 +836,7 @@ package manager owns the download and Lithe does not infer bytes from logs. | `lsp.request` | Submit a typed semantic request and return an opaque operation ID | | `lsp.buildRequest` | Forward one planned `lithe/build/*` request to a session that negotiated the build-provider protocol | | `lsp.functionRequest` | Invoke one registered plugin function on a session its plugin started | +| `lsp.vcsRequest` | Send one VCS operation, planned by Core, to a session that negotiated the VCS-provider protocol | | `java.navigationMarkers` | Resolve versioned Java gutter markers from bounded JDT LS semantic requests | | `java.resolveNavigation` | Resolve one Java gutter marker to normalized parent or implementation locations | | `lsp.cancelOperation` | Cancel one pending semantic operation | @@ -911,7 +912,7 @@ package manager owns the download and Lithe does not infer bytes from logs. | `plugins.declarative.runPlan` | Expand a declarative plugin's run template for one workspace file | | `plugins.declarative.discoverTests` | List a declarative plugin's workspace and file test items | | `plugins.declarative.testPlan` | Expand a declarative plugin's test template for a workspace, file, or test case | -| `plugins.declarative.languageServerLaunchPlan` | Expand a declarative plugin's language-server or build-provider launch with host directories and architecture | +| `plugins.declarative.languageServerLaunchPlan` | Expand a declarative plugin's language-server, build-provider, or VCS-provider launch with host directories and architecture | | `plugins.declarative.resolveSettings` | Merge a plugin's stored global and project setting layers into effective values with their source | | `plugins.declarative.workspaceConfiguration` | Expand a language server's `workspaceConfiguration` with the merged setting values | | `plugins.declarative.checkRuntimeVersion` | Decide whether a launch runtime's version output meets the declared minimum version | @@ -929,6 +930,9 @@ package manager owns the download and Lithe does not infer bytes from logs. | `build.decodeDependencies` | Decode dependency output or a provider result into one project's bounded tree | | `build.syncFingerprint` | Digest the files a build model depends on, to tell whether it is stale | | `build.languageImport` | Map a build system's options to the Java language server's Maven or Gradle project import | +| `vcs.detectRoots` | Find the VCS roots of a workspace from static markers, with registered providers, ambiguous directories, and applied mappings | +| `vcs.ownerOf` | Assign workspace paths to the detected root that owns each | +| `vcs.decodeReply` | Validate a VCS provider's reply and return the normalized result | | `plugins.marketplace.validateIndex` | Validate a marketplace index and report each plugin's availability for one platform | | `plugins.marketplace.resolveSource` | Resolve a user-entered marketplace source to the index URL to fetch | | `plugins.marketplace.installReview` | List a package's permissions and programs, and those not yet approved | @@ -2350,6 +2354,23 @@ event carries the provider's reply unchanged as `result.value`, which the host passes to `build.decodeModel` or `build.decodeDependencies` as `result`; cancellation and late results follow `lsp.cancelOperation`. +`lsp.vcsRequest` accepts `{ sessionId, operationId?, vcs, operation, root, +tool?, arguments?, timeoutMilliseconds? }` and returns `{ operationId }`. +`operation` is a [VCS provider contract](vcs-provider-v1.md#lspvcsrequest) +operation name, never a method; Core checks `arguments`, adds the absolute +`root` and `tool` paths, and writes the operation's `lithe/vcs/*` request. +Only plugin-owned sessions offer `capabilities.experimental.litheVcs: +{ versions: [1] }`; a session whose process did not answer with a VCS ID and +every required capability, or answered another VCS than `vcs`, refuses the +request with `not_supported` and details `vcs.provider.unavailable`, and an +operation whose capability was not negotiated is `not_supported` with details +`vcs.capability.unsupported`. A successful negotiation emits a `vcsProvider` +runtime event whose `result` is `{ vcs, capabilities }`. The +`requestCompleted` event carries the provider's reply as `result.value`, which +the host passes to `vcs.decodeReply`; a failed request carries a `vcs.*` +`error.code` (the provider's own code only when the contract lists it). +Timeout, cancellation, and late results follow `lsp.buildRequest`. + A plugin-owned session started with `workspaceConfiguration` answers each `workspace/configuration` item from it by `section` (an exact key wins over a dotted path; no section returns the whole object, a missing one `null`) and @@ -3136,6 +3157,26 @@ inside `workspaceRoot` (and the given `outputFile`); none of the commands writes anything or starts a process. `shared/contracts/plugins/v1/fixtures/build-plans.json` holds the golden plans and decoded outputs. +### VCS providers + +The `vcs.*` commands implement API level 3 `vcsProviders` of +[`plugins/v1`](plugins/v1/README.md) and the +[VCS provider contract](vcs-provider-v1.md). Results use the +`plugins.declarative.*` envelope; a malformed payload, a relative +`workspaceRoot`, or an unknown operation is an `invalid_request` failure. + +| Command | Request | Result | +| --- | --- | --- | +| `vcs.detectRoots` | `{ workspaceRoot, plugins: [{ manifest }], mappings?: [{ id, vcs }] }` | `detection`: `{ providers, roots: [{ id, vcs, path, mapped }], ambiguous: [{ id, vcs }], unusedMappings, truncated, rejected: [{ index, error }], conflicts: [{ pluginID, vcs, by }] }` | +| `vcs.ownerOf` | `{ roots: [id], ambiguous?: [id], paths: [path] }` | `owners`: `[{ path, root }]` in request order; `root` is `null` outside every root or inside an unmapped ambiguous directory | +| `vcs.decodeReply` | `{ operation, capabilities, request?, reply }` | `result`: the normalized reply, or `plugin.vcs.invalidOutput` | + +`vcs.detectRoots` reads directory entries below `workspaceRoot` (at most eight +levels and 20,000 directories) and the marker names in its ancestors; it never +reads file contents, writes anything, or starts a process. The other two +commands touch no file. `shared/fixtures/vcs/model-v1.json` holds the +detection cases, decoded snapshots, and reply validation cases. + ### Git patch discovery and rebase amendment preconditions `git.patchExport` accepts optional `metadataOnly` (default false). In metadata diff --git a/shared/contracts/vcs-provider-v1.md b/shared/contracts/vcs-provider-v1.md index 3d80008eb8..4cd2ec5fea 100644 --- a/shared/contracts/vcs-provider-v1.md +++ b/shared/contracts/vcs-provider-v1.md @@ -1,61 +1,251 @@ -# VCS provider contract v1 (draft) +# VCS provider contract v1 -> **Status: draft, not implemented.** No Core command, plugin manifest field, -> host capability, or provider method below is accepted by any Lithe build yet. -> Names and fields may change until phase P1 lands. The decision record is +> **Status: Core implemented (P1); no host uses it yet.** Core accepts the +> `vcsProviders` manifest capability and the commands below. The macOS and +> Windows hosts do not start providers yet, so no build shows a plugin VCS. +> The decision record is > [Unified VCS model and providers](../../.agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md); -> example data is in [`shared/fixtures/vcs/model-v1.json`](../fixtures/vcs/model-v1.json). +> example data is in [`shared/fixtures/vcs/model-v1.json`](../fixtures/vcs/model-v1.json) +> and [`plugins/v1/fixtures/vcs-provider.plugin.json`](plugins/v1/fixtures/vcs-provider.plugin.json). This contract defines one VCS-neutral model for the features every version control system has: roots, file status, base content, history, annotation, -check-in, rollback, and update. A **provider** implements it for one VCS. Git is -a built-in provider inside Core that adapts the existing `git.*` operations; -other systems (Subversion first) are plugin providers that run as LSP -base-protocol processes, the same process kind as -[build providers](plugins/v1/README.md#build-providers). +check-in, rollback, and update. A **provider** implements it for one VCS as a +plugin process speaking the LSP base protocol, the same process kind as +[build providers](plugins/v1/README.md#build-providers). Subversion is the +first provider. -`git.*` commands, fields, and fixtures are unchanged by this contract. Features -that only exist in one VCS (Git log graph, rebase, worktrees, stash, push -dialog; Subversion locks UI, properties editor, relocate) stay in that -provider's own module or plugin and are not modeled here. +Git is not part of this layer yet. Core recognizes `.git` markers so a nested +working copy is assigned to the right root, and the `git.*` commands keep +serving Git roots unchanged. Features that only exist in one VCS (Git log +graph, rebase, worktrees, stash; Subversion locks, properties, relocate) are +not modeled here. ## Shared rules -- UTF-8 JSON; deterministic ordering of every list. -- Paths inside a root are root-relative with `/` separators. A root's own - identity is a workspace-relative `id` (`..` allowed for enclosing roots); its - native `path` is a platform binding, never a portable identity. -- Line numbers are one-based; missing values are `null`. +- UTF-8 JSON; every list has a deterministic order. +- Paths inside a root are root-relative with `/` separators and no empty, + `.`, or `..` segment. A root's identity is a workspace-relative `id` (`""` + for the workspace, `..` segments for an enclosing directory); its native + `path` is a platform binding, never a portable identity. +- Missing values are `null`. - Hosts never parse or order revision IDs; only the provider compares them. -- Remote locations (Subversion URLs, Git remotes) are display data with user - info, query, and fragment removed before they leave Core. +- Remote locations are display data; Core removes user info, query, and + fragment before they leave it. - Secrets never appear in arguments, results, console events, or logs. -## Model +## Manifest (plugin API level 3) -### Provider descriptor +```json +{ + "pluginAPIVersion": 3, + "requires": { "serverProcess": 1, "vcsProviders": 1 }, + "permissions": ["languageServer.launch", "process.vcs"], + "activationEvents": ["onVcsRoot:svn"], + "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/main.mjs" }, + "arguments": ["--stdio"], + "mayUseNetwork": true + } + ] +} +``` + +| Field | Rule | +| --- | --- | +| `id` | `[a-z][a-z0-9-]{0,31}`; unique in the plugin. `git` is reserved (`plugin.vcs.reserved`). | +| `displayName` | One line of 1-64 characters. | +| `detection.markers` | 1-8 entries directly inside a root; `type` is `directory` or `file`. | +| `metadataPaths` | Up to 16 root-relative paths the host watches to refresh status. | +| `tool` | Optional. Bare command names the host resolves like a `systemCommand` launch, and an optional probe. | +| `launch`, `arguments`, `validationArguments`, `runtimeDependencies`, `initializationOptions` | The shared server-process fields. | +| `mayUseNetwork` | Shown in the install review. | + +A plugin declares at most 8 providers. A non-empty `vcsProviders` requires the +`languageServer.launch` and `process.vcs` permissions, the activation event +`onVcsRoot:` for every provider, and `requires.vcsProviders` and +`requires.serverProcess`. Capabilities are not declared in the manifest: they +belong to the running process and are negotiated. + +The install review lists one program per provider, of kind +`packagedVcsProvider`, `systemVcsProvider`, or `runtimeVcsProvider`, with +`vcs`, `vcsTool` (the declared `tool`), and `mayUseNetwork`, plus the +runtime it needs. Changing the tool or the launch is a new program the user +approves again. + +`plugins.declarative.languageServerLaunchPlan` with `vcsProviderID` plans +the process and adds `vcsProvider`, the descriptor below. + +## Provider descriptor + +```json +{ + "vcs": "svn", + "displayName": "Subversion", + "origin": { "kind": "plugin", "pluginID": "dev.lithe.plugin.svn-support" }, + "markers": [{ "name": ".svn", "type": "directory" }], + "metadataPaths": [".svn/wc.db"], + "tool": { "commands": ["svn"], "validationArguments": ["--version", "--quiet"] }, + "mayUseNetwork": true +} +``` + +The built-in Git entry is `{ vcs: "git", displayName: "Git", origin: { kind: +"builtIn" }, markers }` with the `.git` directory and file markers. + +## Core commands + +| Command | Purpose | +| --- | --- | +| `vcs.detectRoots` | Providers, roots, ambiguous directories, and applied mappings of a workspace | +| `vcs.ownerOf` | The root that owns each workspace path | +| `lsp.vcsRequest` | Sends one operation to a negotiated provider session | +| `vcs.decodeReply` | Validates a provider's reply and returns the normalized result | + +### `vcs.detectRoots` + +```json +{ + "workspaceRoot": "/abs/workspace", + "plugins": [{ "manifest": { } }], + "mappings": [{ "id": "mixed", "vcs": "svn" }] +} +``` + +Core validates every manifest again; a rejected one is listed as +`rejected: [{ index, error }]` and the rest still count. When two plugins claim +one VCS, an official plugin wins, then the lower plugin ID; the loser is listed +in `conflicts: [{ pluginID, vcs, by }]`. A relative `workspaceRoot` fails the +call. + +```json +{ + "status": "ok", + "detection": { + "providers": [ ], + "roots": [{ "id": "services/legacy", "vcs": "svn", "path": "/abs/workspace/services/legacy", "mapped": false }], + "ambiguous": [{ "id": "mixed", "vcs": ["git", "svn"] }], + "unusedMappings": [], + "truncated": false, + "rejected": [], + "conflicts": [] + } +} +``` + +Roots come from static markers; detection never starts a process. One +breadth-first walk reads the markers directly inside each directory down to +eight levels below the workspace, at most 20,000 directories. It never enters a +marker directory (`.git`, `.svn`), a symbolic link, or a dependency or build +directory such as `node_modules`, `target`, or `.worktrees`. When the workspace +itself has no marker, its ancestors are checked, because a Subversion working +copy has one `.svn` at its top; the enclosing root's `id` is `..`, `../..`, …. +`truncated` is `true` only when the directory budget ran out, so a root may be +missing. + +A directory with markers of several VCSs is ambiguous until a mapping names +one of them; Core never guesses. Mappings that name no ambiguous directory, or +a VCS whose markers are not there, are returned in `unusedMappings`. Roots and +ambiguous directories are ordered by depth (enclosing first), then `id`. + +### `vcs.ownerOf` + +```json +{ "roots": ["", "services/legacy"], "ambiguous": ["mixed"], "paths": ["README.md", "mixed/a.txt"] } +``` + +returns `{ status: "ok", owners: [{ path, root }] }` in request order. A path +belongs to the deepest root that contains it; inside an unmapped ambiguous +directory it belongs to no root (`null`), not to an enclosing one. At most +100,000 paths per call. + +### `lsp.vcsRequest` ```json { - "vcs": "git", - "displayName": "Git", - "origin": { "kind": "builtIn" }, - "capabilities": { "status": 1, "baseContent": 1, "checkin": 1, "rollback": 1, "push": 1, "staging": 1 } + "sessionID": "…", + "operationID": "…", + "vcs": "svn", + "operation": "checkin", + "root": "/abs/workspace/services/legacy", + "tool": "/opt/homebrew/bin/svn", + "arguments": { "paths": ["src/Main.java"], "message": "Fix" }, + "timeoutMilliseconds": 600000 } ``` -`vcs` is a stable lowercase ID (`git`, `svn`, `hg`, …). Plugin providers report -`origin: { "kind": "plugin", "pluginID": "" }`. Two installed providers may -not claim the same `vcs`; the second is reported as a conflict and not -registered. +The host names an operation, never a method, so a session cannot be used as a +generic JSON-RPC tunnel. Core refuses the request before writing it when the +session did not negotiate a provider for `vcs` +(`notSupported`, details `vcs.provider.unavailable`), when the provider did not +negotiate the operation's capability (`notSupported`, details +`vcs.capability.unsupported`), or when the arguments are malformed +(`invalidRequest`). `timeoutMilliseconds` is capped at one hour; the session's +request timeout applies when it is absent. The result arrives as a +`requestCompleted` event with `result: { value: }`; a failure carries +one of the error codes below. + +| Operation | Method | Capability | Arguments | +| --- | --- | --- | --- | +| `status` | `lithe/vcs/status` | `status` | `{ includeIgnored? }` | +| `baseContent` | `lithe/vcs/baseContent` | `baseContent` | `{ path }` | +| `history` | `lithe/vcs/history` | `history` | `{ cursor?, limit? }` | +| `fileHistory` | `lithe/vcs/fileHistory` | `fileHistory` | `{ path, cursor?, limit? }` | +| `annotate` | `lithe/vcs/annotate` | `annotate` | `{ path }` | +| `checkin` | `lithe/vcs/checkin` | `checkin` | `{ paths, message }` | +| `rollback` | `lithe/vcs/rollback` | `rollback` | `{ paths }` | +| `update` | `lithe/vcs/update` | `update` | `{ revision? }` | +| `fileOperation` | `lithe/vcs/fileOperation` | `fileOperations` | `{ kind: "add" \| "delete", paths }` or `{ kind: "move", from, to }` | +| `resolve` | `lithe/vcs/resolve` | `conflicts` | `{ paths }` | +| `cleanup` | `lithe/vcs/cleanup` | `cleanup` | `{}` | + +`paths` holds 1-10,000 root-relative paths; Core sorts and deduplicates them. +A history page has 1-500 entries (100 by default). A check-in message is +non-empty text of at most 1 MiB. Unknown argument fields fail. + +Core adds `root: { path }` and `tool: { executable } | null` to the params. +The provider receives, for example: + +```json +{ "paths": ["src/Main.java"], "message": "Fix", "root": { "path": "/abs/…/legacy" }, "tool": { "executable": "/opt/homebrew/bin/svn" } } +``` + +### `vcs.decodeReply` + +```json +{ "operation": "status", "capabilities": { "status": 1, "…": 1 }, "request": { }, "reply": { } } +``` + +`capabilities` is what the session reported in its `vcsProvider` event, +`request` the arguments the host sent, and `reply` the `value` of the +completed request. The result is `{ status: "ok", result }` or +`{ status: "rejected", error: { code: "plugin.vcs.invalidOutput", … } }`. -`capabilities` maps capability IDs to the negotiated version. A missing key -means unsupported; the host hides the entry point. UI behavior is chosen from -capabilities only, never from `vcs`. +## Negotiation + +The host offers `capabilities.experimental.litheVcs: { "versions": [1] }` in +`initialize`, to plugin-owned sessions only. The provider answers: + +```json +{ "litheVcs": { "version": 1, "vcs": "svn", "capabilities": { "status": 1, "baseContent": 1, "checkin": 1, "rollback": 1, "update": 1 } } } +``` + +A session is a provider only when the answer names an offered version, a valid +VCS ID, and every required capability. Unknown capabilities, and known ones at +another version, are ignored. Core then publishes a runtime event of kind +`vcsProvider` with `result: { vcs, capabilities }`; the host compares `vcs` +with the manifest entry it launched and stops a session that answered another +one. A session without a valid answer receives no `lithe/vcs/*` request. | Capability | Required | Meaning | | --- | --- | --- | -| `status` | yes | File status for a root | +| `status` | yes | File status of a root | | `baseContent` | yes | Content of a file at its base revision (Diff, gutter) | | `checkin` | yes | Record selected changes as a new revision | | `rollback` | yes | Discard local changes of selected files | @@ -65,26 +255,17 @@ capabilities only, never from `vcs`. | `update` | no | Bring remote revisions into the working copy | | `push` | no | Check-in is local; publishing is a separate step | | `staging` | no | Changes carry a staged/unstaged dimension | -| `fileOperations` | no | Add/move/delete must go through the VCS to be tracked | +| `fileOperations` | no | Add, move, and delete must go through the VCS to be tracked | | `conflicts` | no | Report and resolve conflicted files | -| `ignore` | no | Add paths to the VCS ignore list | -| `lock` | no | Lock/unlock files and report lock owners | | `cleanup` | no | Recover an interrupted working copy | -A provider without `push` commits directly to the server; the host labels the -action accordingly and does not offer "Commit and Push". +UI behavior is chosen from capabilities only, never from `vcs`. A provider +without `push` commits directly to the server; the host labels the action +accordingly and does not offer "Commit and Push". Version 1 has no operation +for `push`: a provider that negotiates it tells the host the VCS has a +publishing step, which a later version will model. -### Root - -```json -{ "id": "services/legacy", "vcs": "svn", "path": "", "location": "https://svn.example.invalid/repo/trunk" } -``` - -Core detects roots from static markers without starting a provider. A file -belongs to the deepest root that contains it. A directory that matches markers -of two providers is reported as `ambiguous` and stays unassigned until the user -maps it; Core never guesses. Roots are ordered by workspace containment, depth, -then `id`. +## Replies ### Revision @@ -92,17 +273,17 @@ then `id`. { "id": "1234", "display": "r1234", "kind": "number" } ``` -`kind` is `hash`, `number`, or `opaque` and only affects presentation (for -example monospace hashes). `id` is passed back to the provider unchanged. +`kind` is `hash`, `number`, or `opaque` (the default) and only affects +presentation. `display` defaults to `id`. `id` is passed back to the provider +unchanged. -### Status snapshot +### `status` ```json { - "root": "services/legacy", "head": { "revision": { "id": "1234", "display": "r1234", "kind": "number" }, "branch": null, "location": "https://svn.example.invalid/repo/trunk" }, "changes": [ - { "path": "src/Main.java", "oldPath": null, "status": "modified", "flags": ["propertiesModified"] } + { "path": "src/Main.java", "oldPath": null, "status": "modified", "flags": ["locked", "propertiesModified"] } ], "truncated": false } @@ -110,122 +291,71 @@ example monospace hashes). `id` is passed back to the provider unchanged. `status` is one of `modified`, `added`, `deleted`, `missing` (removed from disk without telling the VCS), `renamed`, `copied`, `replaced`, `typeChanged`, -`unversioned`, `ignored`, `conflicted`, `switched`. A plugin reply with any -other value fails with `plugin.vcs.invalidOutput`. +`unversioned`, `ignored`, `conflicted`, `switched`; any other value is invalid. +Only `renamed` and `copied` may name an `oldPath`. -`flags` refine a status: `propertiesModified`, `locked`, `lockedByOther`, -`treeConflict`, `external`, `submodule`. Core drops unknown flags so a newer -provider can add optional data within version 1. +`flags` refine a status: `external`, `locked`, `lockedByOther`, +`propertiesModified`, `submodule`, `treeConflict`. Core drops unknown flags so +a newer provider can add optional data within version 1, and sorts the rest. -Providers with `staging` add `staging: "staged" | "unstaged" | "partial"` to each -change. Changelist membership is host state and never part of a provider -reply. +A provider that negotiated `staging` gives every change +`staging: "staged" | "unstaged" | "partial"`; any other provider gives none. +Changelist membership is host state and never part of a reply. -`changes` is ordered by `path`; Core sorts plugin replies, so a provider need -not. Core caps a snapshot at 100,000 changes and sets `truncated: true`; a -truncated snapshot must not be presented as complete. +Core sorts `changes` by `path` and rejects duplicates. It caps a snapshot at +100,000 changes and sets `truncated: true`, as does a provider that stopped +early; a truncated snapshot must not be presented as complete. -Core rejects a plugin reply with `plugin.vcs.invalidOutput` when a path is -absolute, contains a `..` segment, or otherwise resolves outside the root, or -when a change carries `staging` although the provider did not negotiate the -`staging` capability. +### Other operations -## Core commands (planned) - -| Command | Purpose | -| --- | --- | -| `vcs.providers` | Registered providers, their origin and negotiated capabilities | -| `vcs.roots` | Detected roots, ambiguous directories, and applied user mappings | -| `vcs.status` | Status snapshot of one root | -| `vcs.baseContent` | Base-revision content of one file | -| `vcs.history` / `vcs.fileHistory` | Paged history with an opaque cursor | -| `vcs.annotate` | Per-line revision of one file | -| `vcs.checkin` / `vcs.rollback` / `vcs.update` | Mutations, with the existing execution-event lifecycle | -| `vcs.fileOperation` | Track an add, move, or delete made in the project tree | - -For the built-in Git provider Core executes the request itself. For a plugin -provider, Core follows the build-provider pattern: it returns -`provider: { method, params }`, the host sends it through the negotiated LSP -session, and Core decodes and validates the reply into the same result shape. -Exact plan/decode command names are fixed in P1. +| Operation | Reply | Rules | +| --- | --- | --- | +| `baseContent` | `{ content: string \| null, binary: bool }` | `null` content: the file has no base (added or unversioned); binary content is not sent. At most 32 MiB. | +| `history`, `fileHistory` | `{ entries: [{ revision, author, date, message, changes, changesTruncated }], nextCursor }` | Provider order (newest first) is kept; more entries than requested is invalid. Each entry lists at most 10,000 changes. | +| `annotate` | `{ revisions: [{ revision, author, date, message }], lines: [id \| null] }` | Each line names a listed revision ID or `null`; only used revisions are returned, sorted by ID. At most 500,000 lines. | +| `checkin` | `{ revision }` | The new revision, or `null` when the VCS does not report one. | +| `update` | `{ revision, conflicts: [path] }` | Conflicted paths, sorted and inside the root. | +| `rollback`, `fileOperation`, `resolve`, `cleanup` | `{}` | Unknown fields are ignored. | -## Plugin providers (planned, plugin API level 3) +A path that is absolute, has a `..` segment, or otherwise leaves the root, a +missing required field, or a size limit exceeded makes the reply invalid. -### Manifest +## Provider messages -```json -{ - "pluginAPIVersion": 3, - "requires": { "vcsProviders": 1, "serverProcess": 1 }, - "vcsProviders": [ - { - "id": "svn", - "displayName": "Subversion", - "detection": { "markers": [{ "name": ".svn", "type": "directory" }] }, - "metadataPaths": [".svn/wc.db"], - "tool": { "commands": ["svn"], "validationArguments": ["--version", "--quiet"] }, - "launch": { "kind": "packaged", "path": "bin/lithe-svn-provider" } - } - ] -} -``` - -The manifest only states what the host must know before starting a process: -the VCS ID, detection markers, metadata paths to watch, the client tool the -provider drives, and the shared `serverProcess` launch fields. The host -resolves `tool.commands` with the same discovery as `systemCommand` launches -(PATH, user override) and sends the absolute path as `tool: { executable }` in -every request, as it does for build providers, so the provider does not repeat -tool lookup. A missing tool is reported once and the root stays visible as -unavailable rather than clean. - -A non-empty `vcsProviders` requires `languageServer.launch` (the plugin starts -its own process, like an own-process build provider), a new `process.vcs` -permission (the provider changes the working copy and contacts remote -servers), and the activation event `onVcsRoot:`. Detection never starts a -process; the host starts the provider after a root of its VCS is found, which -the install review must state. - -### Negotiation - -The host sends `capabilities.experimental.litheVcs: { "versions": [1] }` in -`initialize`. The provider answers -`{ "version": 1, "vcs": "", "capabilities": { … } }`. A session without a -valid answer, or with a `vcs` different from the manifest entry, is not used as -a provider and receives no `lithe/vcs/*` request. Capabilities belong to the -running process and are not repeated in the manifest. - -### Methods - -| Direction | Method | Kind | +| Direction | Method | Version 1 behavior | | --- | --- | --- | -| host → provider | `lithe/vcs/status`, `lithe/vcs/baseContent`, `lithe/vcs/history`, `lithe/vcs/fileHistory`, `lithe/vcs/annotate`, `lithe/vcs/checkin`, `lithe/vcs/rollback`, `lithe/vcs/update`, `lithe/vcs/fileOperation` | request | -| provider → host | `lithe/vcs/execution` | notification: an actual command started, produced output, or finished (project console) | -| provider → host | `lithe/vcs/authenticate` | request: credential challenge answered through the host's existing authentication UI; answers are never persisted by Lithe | +| provider → host | `lithe/vcs/execution` | Notification of a command the provider ran: `{ command: [executable, ...arguments], exitCode: number \| null, durationMilliseconds, stderr }`, with `stderr` cut to 16 KiB and no stdout. Core passes it on as a session `log` event (`message` is the method, `detail` the params as JSON); routing it to the project console is host work (P3). Arguments are shown as sent, so a provider never puts a credential in them. | +| provider → host | `lithe/vcs/authenticate` | Not accepted yet: Core answers `-32601`. A provider runs its client non-interactively and fails with `vcs.authentication.failed`. | -Both sides ignore unknown fields in params and results. Requests carry -`operationID`; `$/cancelRequest` and the host deadline end them with -`requestCancelled` or `requestTimeout`. +Both sides ignore unknown fields in params and results. `$/cancelRequest` and +the request deadline end a request with `requestCancelled` or +`requestTimeout`. -## Error codes (draft) +## Error codes -| Code | Meaning | -| --- | --- | -| `vcs.root.notFound` | The path is not inside a detected or mapped root | -| `vcs.root.ambiguous` | The directory matches several providers and is unmapped | -| `vcs.provider.unavailable` | The provider is not registered, not negotiated, or has exited | -| `vcs.capability.unsupported` | The request needs a capability the provider did not negotiate | -| `vcs.authentication.failed` | Credentials were rejected after the allowed retries | -| `vcs.outOfDate` | The server rejected a check-in because the working copy is out of date | -| `vcs.conflict` | The operation stopped on conflicted files | -| `plugin.vcs.invalidOutput` | A plugin reply failed validation or exceeded a limit | - -Platform details go in `details`; messages must not contain credentials. +| Code | Raised by | Meaning | +| --- | --- | --- | +| `vcs.provider.unavailable` | Core (`details` of `notSupported`) | The session negotiated no provider, or one for another VCS | +| `vcs.capability.unsupported` | Core (`details` of `notSupported`) | The operation needs a capability the provider did not negotiate | +| `plugin.vcs.reserved` | Core (manifest) | A plugin declared the reserved `git` ID | +| `plugin.vcs.invalidOutput` | Core | A reply failed validation, exceeded a limit, or had no result | +| `vcs.provider.failed` | Core | The provider failed without one of the codes below | +| `vcs.authentication.failed` | provider | Credentials were missing or rejected | +| `vcs.conflict` | provider | The operation stopped on conflicted files | +| `vcs.outOfDate` | provider | The server rejected a check-in because the working copy is out of date | +| `vcs.tool.unavailable` | provider | The VCS client could not be run | +| `vcs.workingCopy.locked` | provider | The working copy is locked by an interrupted operation; `cleanup` may help | +| `vcs.workingCopy.unsupported` | provider | The working copy format is not supported | + +A provider reports its code in the JSON-RPC `error.data.code`. Core keeps only +the provider codes listed here; any other code, including Core's own, becomes +`vcs.provider.failed`. The provider's message (at most 4 KiB) is passed on and +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`; passwords use `--password-from-stdin`. Subversion's own -credential cache answers first. Working copies older than format 1.7 (one -`.svn` per directory) are not supported. +`--non-interactive`, so only Subversion's own credential cache answers. +Working copies older than format 1.7 (one `.svn` per directory) are reported +as `vcs.workingCopy.unsupported`. diff --git a/shared/fixtures/vcs/model-v1.json b/shared/fixtures/vcs/model-v1.json index d62109adac..afe2685038 100644 --- a/shared/fixtures/vcs/model-v1.json +++ b/shared/fixtures/vcs/model-v1.json @@ -6,26 +6,33 @@ { "vcs": "git", "displayName": "Git", - "origin": { "kind": "builtIn" }, - "capabilities": { - "annotate": 1, - "baseContent": 1, - "checkin": 1, - "conflicts": 1, - "fileHistory": 1, - "history": 1, - "ignore": 1, - "push": 1, - "rollback": 1, - "staging": 1, - "status": 1, - "update": 1 - } + "origin": { + "kind": "builtIn" + }, + "markers": [ + { + "name": ".git", + "type": "directory" + }, + { + "name": ".git", + "type": "file" + } + ] }, { "vcs": "svn", "displayName": "Subversion", - "origin": { "kind": "plugin", "pluginID": "dev.lithe.plugin.svn-support" }, + "origin": { + "kind": "plugin", + "pluginID": "dev.lithe.fixture.vcs-provider" + }, + "markers": [ + { + "name": ".svn", + "type": "directory" + } + ], "capabilities": { "annotate": 1, "baseContent": 1, @@ -35,8 +42,6 @@ "fileHistory": 1, "fileOperations": 1, "history": 1, - "ignore": 1, - "lock": 1, "rollback": 1, "status": 1, "update": 1 @@ -47,73 +52,386 @@ { "name": "nested Subversion working copy inside a Git repository", "markers": [ - { "directory": "", "name": ".git", "type": "directory" }, - { "directory": "services/legacy", "name": ".svn", "type": "directory" } + { + "directory": "", + "name": ".git", + "type": "directory" + }, + { + "directory": "services/legacy", + "name": ".svn", + "type": "directory" + } ], "expectedRoots": [ - { "id": "", "vcs": "git" }, - { "id": "services/legacy", "vcs": "svn" } + { + "id": "", + "vcs": "git" + }, + { + "id": "services/legacy", + "vcs": "svn" + } ], "expectedOwnership": [ - { "path": "README.md", "root": "" }, - { "path": "services/legacy/src/Main.java", "root": "services/legacy" } + { + "path": "README.md", + "root": "" + }, + { + "path": "services/legacy/src/Main.java", + "root": "services/legacy" + }, + { + "path": "services/legacy-tools/a.sh", + "root": "" + } ], "expectedAmbiguous": [] }, { - "name": "one directory with markers of two providers stays unmapped", + "name": "an unmapped directory with markers of two providers owns nothing and blocks the outer root", "markers": [ - { "directory": "mixed", "name": ".git", "type": "directory" }, - { "directory": "mixed", "name": ".svn", "type": "directory" } + { + "directory": "", + "name": ".git", + "type": "directory" + }, + { + "directory": "mixed", + "name": ".git", + "type": "directory" + }, + { + "directory": "mixed", + "name": ".svn", + "type": "directory" + } + ], + "expectedRoots": [ + { + "id": "", + "vcs": "git" + } + ], + "expectedOwnership": [ + { + "path": "mixed/a.txt", + "root": null + }, + { + "path": "mixed.txt", + "root": "" + } + ], + "expectedAmbiguous": [ + { + "id": "mixed", + "vcs": [ + "git", + "svn" + ] + } + ] + }, + { + "name": "a mapping chooses the provider of an ambiguous directory", + "markers": [ + { + "directory": "", + "name": ".git", + "type": "directory" + }, + { + "directory": "mixed", + "name": ".git", + "type": "directory" + }, + { + "directory": "mixed", + "name": ".svn", + "type": "directory" + } + ], + "mappings": [ + { + "id": "gone", + "vcs": "git" + }, + { + "id": "mixed", + "vcs": "hg" + }, + { + "id": "mixed", + "vcs": "svn" + } + ], + "expectedRoots": [ + { + "id": "", + "vcs": "git" + }, + { + "id": "mixed", + "vcs": "svn", + "mapped": true + } ], - "expectedRoots": [], - "expectedOwnership": [{ "path": "mixed/a.txt", "root": null }], - "expectedAmbiguous": [{ "id": "mixed", "vcs": ["git", "svn"] }] + "expectedOwnership": [ + { + "path": "mixed/a.txt", + "root": "mixed" + } + ], + "expectedAmbiguous": [], + "expectedUnusedMappings": [ + { + "id": "gone", + "vcs": "git" + }, + { + "id": "mixed", + "vcs": "hg" + } + ] }, { "name": "Git worktree file marker", - "markers": [{ "directory": "linked", "name": ".git", "type": "file" }], - "expectedRoots": [{ "id": "linked", "vcs": "git" }], - "expectedOwnership": [{ "path": "linked/src/lib.rs", "root": "linked" }], + "markers": [ + { + "directory": "linked", + "name": ".git", + "type": "file" + } + ], + "expectedRoots": [ + { + "id": "linked", + "vcs": "git" + } + ], + "expectedOwnership": [ + { + "path": "linked/src/lib.rs", + "root": "linked" + }, + { + "path": "other/a.txt", + "root": null + } + ], + "expectedAmbiguous": [] + }, + { + "name": "a workspace opened inside a Subversion working copy", + "workspace": "trunk/module", + "markers": [ + { + "directory": "", + "name": ".svn", + "type": "directory" + } + ], + "expectedRoots": [ + { + "id": "../..", + "vcs": "svn" + } + ], + "expectedOwnership": [ + { + "path": "src/Main.java", + "root": "../.." + } + ], + "expectedAmbiguous": [] + }, + { + "name": "metadata and dependency directories are not searched", + "markers": [ + { + "directory": "", + "name": ".svn", + "type": "directory" + }, + { + "directory": ".svn/pristine", + "name": ".git", + "type": "directory" + }, + { + "directory": "node_modules/left-pad", + "name": ".git", + "type": "directory" + }, + { + "directory": "target/checkout", + "name": ".svn", + "type": "directory" + } + ], + "expectedRoots": [ + { + "id": "", + "vcs": "svn" + } + ], + "expectedOwnership": [ + { + "path": "node_modules/left-pad/index.js", + "root": "" + } + ], + "expectedAmbiguous": [] + }, + { + "name": "roots deeper than eight directories are not looked for", + "markers": [ + { + "directory": "a/b/c/d/e/f/g/h", + "name": ".svn", + "type": "directory" + }, + { + "directory": "z/b/c/d/e/f/g/h/i", + "name": ".git", + "type": "directory" + } + ], + "expectedRoots": [ + { + "id": "a/b/c/d/e/f/g/h", + "vcs": "svn" + } + ], + "expectedOwnership": [ + { + "path": "z/b/c/d/e/f/g/h/i/a.txt", + "root": null + } + ], "expectedAmbiguous": [] } ], "statusSnapshots": [ { - "name": "git root with staging dimension", - "vcs": "git", + "name": "provider with a staging dimension", + "capabilities": { + "baseContent": 1, + "checkin": 1, + "rollback": 1, + "status": 1, + "staging": 1 + }, "snapshot": { - "root": "", "head": { - "revision": { "id": "4b825dc642cb6eb9a060e54bf8d69288fbee4904", "display": "4b825dc6", "kind": "hash" }, + "revision": { + "id": "4b825dc642cb6eb9a060e54bf8d69288fbee4904", + "display": "4b825dc6", + "kind": "hash" + }, "branch": "preview", - "location": "https://github.example.invalid/owner/repo.git" + "location": "https://vcs.example.invalid/owner/repo.git" }, "changes": [ - { "path": "docs/new.md", "oldPath": null, "status": "added", "flags": [], "staging": "staged" }, - { "path": "src/lib.rs", "oldPath": null, "status": "modified", "flags": [], "staging": "partial" }, - { "path": "src/renamed.rs", "oldPath": "src/old.rs", "status": "renamed", "flags": [], "staging": "staged" }, - { "path": "vendor/module", "oldPath": null, "status": "modified", "flags": ["submodule"], "staging": "unstaged" } + { + "path": "docs/new.md", + "oldPath": null, + "status": "added", + "flags": [], + "staging": "staged" + }, + { + "path": "src/lib.rs", + "oldPath": null, + "status": "modified", + "flags": [], + "staging": "partial" + }, + { + "path": "src/renamed.rs", + "oldPath": "src/old.rs", + "status": "renamed", + "flags": [], + "staging": "staged" + }, + { + "path": "vendor/module", + "oldPath": null, + "status": "modified", + "flags": [ + "submodule" + ], + "staging": "unstaged" + } ], "truncated": false } }, { "name": "svn root without staging, with Subversion-only flags", - "vcs": "svn", + "capabilities": { + "annotate": 1, + "baseContent": 1, + "checkin": 1, + "cleanup": 1, + "conflicts": 1, + "fileHistory": 1, + "fileOperations": 1, + "history": 1, + "rollback": 1, + "status": 1, + "update": 1 + }, "snapshot": { - "root": "services/legacy", "head": { - "revision": { "id": "1234", "display": "r1234", "kind": "number" }, + "revision": { + "id": "1234", + "display": "r1234", + "kind": "number" + }, "branch": null, "location": "https://svn.example.invalid/repo/trunk" }, "changes": [ - { "path": "build.xml", "oldPath": null, "status": "missing", "flags": [] }, - { "path": "lib/vendor", "oldPath": null, "status": "unversioned", "flags": ["external"] }, - { "path": "src/Main.java", "oldPath": null, "status": "modified", "flags": ["propertiesModified", "locked"] }, - { "path": "src/Moved.java", "oldPath": "src/Old.java", "status": "renamed", "flags": [] }, - { "path": "src/Shared.java", "oldPath": null, "status": "conflicted", "flags": ["treeConflict"] } + { + "path": "build.xml", + "oldPath": null, + "status": "missing", + "flags": [] + }, + { + "path": "lib/vendor", + "oldPath": null, + "status": "unversioned", + "flags": [ + "external" + ] + }, + { + "path": "src/Main.java", + "oldPath": null, + "status": "modified", + "flags": [ + "locked", + "propertiesModified" + ] + }, + { + "path": "src/Moved.java", + "oldPath": "src/Old.java", + "status": "renamed", + "flags": [] + }, + { + "path": "src/Shared.java", + "oldPath": null, + "status": "conflicted", + "flags": [ + "treeConflict" + ] + } ], "truncated": false } @@ -122,36 +440,224 @@ "pluginReplyValidationCases": [ { "name": "unknown status value is rejected", - "reply": { "changes": [{ "path": "a.txt", "oldPath": null, "status": "obstructed", "flags": [] }] }, + "reply": { + "changes": [ + { + "path": "a.txt", + "oldPath": null, + "status": "obstructed", + "flags": [] + } + ] + }, "expectedError": "plugin.vcs.invalidOutput" }, { "name": "path escaping the root is rejected", - "reply": { "changes": [{ "path": "../outside.txt", "oldPath": null, "status": "modified", "flags": [] }] }, + "reply": { + "changes": [ + { + "path": "../outside.txt", + "oldPath": null, + "status": "modified", + "flags": [] + } + ] + }, "expectedError": "plugin.vcs.invalidOutput" }, { "name": "unknown flag is dropped, change is kept", - "reply": { "changes": [{ "path": "a.txt", "oldPath": null, "status": "modified", "flags": ["futureFlag", "locked"] }] }, - "expectedChanges": [{ "path": "a.txt", "oldPath": null, "status": "modified", "flags": ["locked"] }] + "reply": { + "changes": [ + { + "path": "a.txt", + "oldPath": null, + "status": "modified", + "flags": [ + "futureFlag", + "locked" + ] + } + ] + }, + "expectedChanges": [ + { + "path": "a.txt", + "oldPath": null, + "status": "modified", + "flags": [ + "locked" + ] + } + ] }, { "name": "staging field from a provider without staging is rejected", - "reply": { "changes": [{ "path": "a.txt", "oldPath": null, "status": "modified", "flags": [], "staging": "staged" }] }, + "reply": { + "changes": [ + { + "path": "a.txt", + "oldPath": null, + "status": "modified", + "flags": [], + "staging": "staged" + } + ] + }, "expectedError": "plugin.vcs.invalidOutput" }, { "name": "unordered changes are sorted by path", "reply": { "changes": [ - { "path": "b.txt", "oldPath": null, "status": "added", "flags": [] }, - { "path": "a.txt", "oldPath": null, "status": "deleted", "flags": [] } + { + "path": "b.txt", + "oldPath": null, + "status": "added", + "flags": [] + }, + { + "path": "a.txt", + "oldPath": null, + "status": "deleted", + "flags": [] + } ] }, "expectedChanges": [ - { "path": "a.txt", "oldPath": null, "status": "deleted", "flags": [] }, - { "path": "b.txt", "oldPath": null, "status": "added", "flags": [] } + { + "path": "a.txt", + "oldPath": null, + "status": "deleted", + "flags": [] + }, + { + "path": "b.txt", + "oldPath": null, + "status": "added", + "flags": [] + } ] + }, + { + "name": "credentials, query, and fragment leave the head location", + "reply": { + "head": { + "revision": { + "id": "7" + }, + "location": "https://alice:secret@svn.example.invalid/repo/trunk?p=1#top" + }, + "changes": [] + }, + "expectedResult": { + "head": { + "revision": { + "id": "7", + "display": "7", + "kind": "opaque" + }, + "branch": null, + "location": "https://svn.example.invalid/repo/trunk" + }, + "changes": [], + "truncated": false + } + }, + { + "name": "an original path on a modification is rejected", + "reply": { + "changes": [ + { + "path": "a.txt", + "oldPath": "b.txt", + "status": "modified", + "flags": [] + } + ] + }, + "expectedError": "plugin.vcs.invalidOutput" + }, + { + "name": "a history page longer than the request is rejected", + "operation": "history", + "request": { + "limit": 1 + }, + "reply": { + "entries": [ + { + "revision": { + "id": "2" + }, + "author": "a", + "date": "2026-10-22T00:00:00Z", + "message": "m" + }, + { + "revision": { + "id": "1" + }, + "author": "a", + "date": "2026-10-21T00:00:00Z", + "message": "m" + } + ], + "nextCursor": null + }, + "expectedError": "plugin.vcs.invalidOutput" + }, + { + "name": "an annotated line must name a listed revision", + "operation": "annotate", + "request": { + "path": "a.txt" + }, + "reply": { + "revisions": [ + { + "revision": { + "id": "1" + }, + "author": "a", + "date": "2026-10-21T00:00:00Z", + "message": "m" + } + ], + "lines": [ + "1", + "2" + ] + }, + "expectedError": "plugin.vcs.invalidOutput" + }, + { + "name": "update conflicts are sorted and stay inside the root", + "operation": "update", + "request": {}, + "reply": { + "revision": { + "id": "9", + "display": "r9", + "kind": "number" + }, + "conflicts": [ + "b.txt", + "a.txt" + ] + }, + "expectedResult": { + "revision": { + "id": "9", + "display": "r9", + "kind": "number" + }, + "conflicts": [ + "a.txt", + "b.txt" + ] + } } ] } 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 c54f270a5c..93e0744031 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 @@ -7,6 +7,10 @@ "evidence": [ "shared/contracts/vcs-provider-v1.md", "shared/fixtures/vcs/model-v1.json", + "rust/lithe-core/src/vcs", + "rust/lithe-core/src/lsp/interface/vcs_provider.rs", + "rust/lithe-core/src/tests/vcs.rs", + "shared/contracts/plugins/v1/fixtures/vcs-provider.plugin.json", ".agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md" ], "implementationStatus": "missing", @@ -15,12 +19,16 @@ "windows": { "evidence": [ "shared/contracts/vcs-provider-v1.md", - "shared/fixtures/vcs/model-v1.json" + "shared/fixtures/vcs/model-v1.json", + "rust/lithe-core/src/vcs", + "rust/lithe-core/src/lsp/interface/vcs_provider.rs", + "rust/lithe-core/src/tests/vcs.rs", + "shared/contracts/plugins/v1/fixtures/vcs-provider.plugin.json" ], "implementationStatus": "missing", "verificationStatus": "not-applicable" }, "owner": "Git", - "verification": "安装 Subversion 官方插件后,在本地 svnadmin create 建立的 file:// 仓库检出的工作副本上验证根识别、文件状态颜色、gutter、提交、更新、回滚、add/move/delete 跟踪、文件历史、Annotate 和冲突显示;同时打开一个包含 Git 仓库与 SVN 工作副本的工作区,确认文件只归属最深的根,Git 行为不变。", - "notes": "Issue #827。当前只有提议 Note、契约草案和 fixtures,两端都没有实现入口;分阶段计划见提议 Note。" + "verification": "Core 共享层自动化:cargo test -p lithe-core vcs、cargo test -p lithe-core --lib lsp 与 scripts/verify-shared-contracts.sh,覆盖 vcsProviders 清单校验与 git 保留、onVcsRoot 激活、svn 根识别(嵌套、歧义目录与映射、git worktree 文件、工作区位于工作副本内部、剪枝目录、深度上限)、vcs.ownerOf 最深根归属、litheVcs 只向插件会话提供、协商缺必需能力时拒绝、lsp.vcsRequest 按操作生成请求与能力检查、Provider 错误码白名单、vcs.decodeReply 的回复校验用例;Node SDK 的 litheVcs 与示例 Provider 端到端测试在 lithe-plugin-docs 运行(scripts/sdk-e2e.mjs)。宿主验收:安装 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 支持随 SDK 迁到 lithe-plugin-docs(P1);两端宿主尚未声明 vcsProviders,也没有调用这些命令,所以仍记为未实现。分阶段计划见提议 Note。" }