From 4f6b090a08fa0efaa0333cd0721acb05e5942ca1 Mon Sep 17 00:00:00 2001 From: limityan Date: Sat, 1 Aug 2026 15:19:37 +0800 Subject: [PATCH] feat(cli): add workspace diff viewer Expose a bounded HEAD-to-worktree snapshot through Runtime Git ports and Shared IPC. Add the OpenCode-compatible /diff TUI with responsive navigation and fail-closed worktree and remote boundaries. --- .../agent-runtime-deployment-design.md | 9 +- docs/architecture/cli-product-line-design.md | 6 +- .../core-boundaries/rules/feature-rules.mjs | 4 +- .../rules/source/forbidden-rules.mjs | 2 +- scripts/core-boundaries/self-test.mjs | 10 + src/apps/cli/src/actions.rs | 35 +- src/apps/cli/src/agent/runtime_client.rs | 108 ++- src/apps/cli/src/modes/chat.rs | 8 + src/apps/cli/src/modes/chat/account.rs | 4 + src/apps/cli/src/modes/chat/commands.rs | 86 +++ src/apps/cli/src/modes/chat/input.rs | 11 + src/apps/cli/src/modes/chat/run.rs | 3 + src/apps/cli/src/modes/chat/tests.rs | 55 ++ src/apps/cli/src/shared_runtime.rs | 6 + src/apps/cli/src/ui/chat/popups.rs | 32 +- src/apps/cli/src/ui/chat/render.rs | 1 + src/apps/cli/src/ui/chat/state.rs | 4 + src/apps/cli/src/ui/command_palette.rs | 1 + src/apps/cli/src/ui/mod.rs | 1 + src/apps/cli/src/ui/startup.rs | 1 + src/apps/cli/src/ui/workspace_diff.rs | 644 ++++++++++++++++++ .../adapters/agent-runtime-ipc/AGENTS-CN.md | 2 +- .../adapters/agent-runtime-ipc/AGENTS.md | 2 +- .../agent-runtime-ipc/src/operation.rs | 11 +- .../agent-runtime-ipc/src/protocol.rs | 2 +- .../src/tests/protocol_contracts.rs | 52 +- .../src/product_runtime/runtime_services.rs | 37 +- src/crates/contracts/runtime-ports/src/lib.rs | 50 +- .../runtime-ports/tests/git_port_contracts.rs | 31 + .../execution/agent-runtime/src/runtime.rs | 18 +- src/crates/execution/agent-runtime/src/sdk.rs | 7 +- .../agent-runtime/tests/sdk_smoke.rs | 71 +- .../services/services-integrations/Cargo.toml | 9 +- .../services-integrations/src/git/mod.rs | 2 + .../src/git/runtime_port.rs | 330 +++++++++ .../tests/git_contracts.rs | 314 ++++++++- 36 files changed, 1937 insertions(+), 32 deletions(-) create mode 100644 src/apps/cli/src/ui/workspace_diff.rs create mode 100644 src/crates/contracts/runtime-ports/tests/git_port_contracts.rs create mode 100644 src/crates/services/services-integrations/src/git/runtime_port.rs diff --git a/docs/architecture/agent-runtime-deployment-design.md b/docs/architecture/agent-runtime-deployment-design.md index 98eba0aba7..1356823dfc 100644 --- a/docs/architecture/agent-runtime-deployment-design.md +++ b/docs/architecture/agent-runtime-deployment-design.md @@ -42,7 +42,7 @@ flowchart TB | Session 写入 | BitFun Runtime 的持久化 Session 由 `SessionManager` 管理;同一存储位置中的同一 Session 同时只允许一个本机进程写入,list/view 等只读操作不受影响 | | 当前 HTTP Server | 只提供 health/info/WebSocket 外壳,未装配 Agent Runtime,因此不取得 workspace ownership;`bootstrap.rs` 仅保持 agent-enabled composition 的一致边界,不由当前入口启动 | | Shared local IPC | 未发布的本机协议已有 discovery、实例锁、严格握手、Session 控制权、有界事件流和 cleanup;唯一 consumer 是第一方交互式 TUI adapter | -| Shared TUI | `bitfun --shared` / `bitfun chat --shared` 可列出、创建、恢复 Session,删除未被控制的空闲非当前 Session,通过 `/fork` 从完整历史或选中提示词之前创建分支,重命名当前 Session,读取 transcript,切换当前 Session 的 Agent mode/model,通过 `/reload [skills|instructions]` 刷新声明式上下文,通过 `/compact` 或 `/summarize` 压缩当前 Session 上下文,提交/取消 Turn,处理 Permission 和 UserInput;默认仍是 Embedded | +| Shared TUI | `bitfun --shared` / `bitfun chat --shared` 可列出、创建、恢复 Session,删除未被控制的空闲非当前 Session,通过 `/fork` 从完整历史或选中提示词之前创建分支,重命名当前 Session,读取 transcript,切换当前 Session 的 Agent mode/model,通过 `/reload [skills|instructions]` 刷新声明式上下文,通过 `/compact` 或 `/summarize` 压缩当前 Session 上下文,在 Turn 空闲时通过 `/diff` 读取 Runtime 绑定工作区的只读差异,提交/取消 Turn,处理 Permission 和 UserInput;默认仍是 Embedded | | Shared GUI/Headless/ACP/SDK Host/Remote | 未交付,也不会由 `--shared` 隐式启用;Replay、Observer、通用 Controller transfer 和 Session archive 同样不在当前协议中 | 因此当前交付的是一条窄的、显式启用的 Shared TUI deployment,不是通用本机 Server。具体 `EventQueue` 仍由 Core 产品装配;IPC 只把当前 TUI 必需的强类型操作和事件映射到同一个 Runtime owner,没有事件重放或公开协议承诺。 @@ -207,13 +207,13 @@ sequenceDiagram end ``` -当前私有协议(v10)只覆盖 TUI 已有用户旅程需要的窄操作: +当前私有协议(v11)只覆盖 TUI 已有用户旅程需要的窄操作: | 已支持 | 明确不支持 | |---|---| | Health、Session list/create、原子 restore(含 transcript 与 pending Permission)、删除未被控制的空闲 Session、当前 Session fork(含 transcript)、rename、Agent mode/model update、声明式上下文 reload | Session archive、跨 workspace attach、transcript 分页、模型目录/默认值和 Agent/Subagent 管理 | | Turn submit/cancel、当前 Session 手动 context compaction | replay、cursor、resume event stream | -| pending/respond Permission、submit UserInput answers | observer、通用 controller transfer、多 Session multiplex | +| pending/respond Permission、submit UserInput answers、只读 workspace diff | observer、通用 controller transfer、多 Session multiplex | | 连接断开清理、Session-filtered events | detach/observer/通用 controller transfer、SDK callbacks、GUI/Remote/Peer/ACP/Headless wire | 这些操作先满足以下本机 IPC 地基,而不把协议升级为公开 SDK: @@ -229,6 +229,7 @@ sequenceDiagram - 未认证连接也计入有界 connection budget,单个客户端不能无限制造 server task; - 未知 frame/operation 信封字段、未知 operation、错误身份和不兼容版本 fail closed;复用的 Runtime DTO 按其既有反序列化契约处理字段; - v10 增加两个只读、current-controller 限定的工作区引用 operation:按当前 Session 搜索文件/目录,以及按 user message ID 读取已持久化的结构化引用。两者复用 Agent Runtime 的 workspace-reference port,不赋予 IPC adapter 文件系统或 Session 持久化所有权,也不扩展为 Remote 或公开 SDK 协议。 +- v11 增加无请求体、无 Session lease 的只读 workspace diff operation。它只在当前连接没有活动 Turn 时查询 Runtime 启动时绑定的 canonical workspace,避免单连接请求排序阻塞流事件或 Turn 控制,并返回 Runtime Port DTO;Git 行为仍由 `services-integrations` provider 持有。文本 patch 总量限制为 3 MiB,为 JSON 转义和 envelope 预留既有 8 MiB response frame 的空间;该 operation 不隐式获得 stage/reset/commit、Remote 或公开 SDK 能力。 - 一个连接最多控制一个 Session、同时最多提交一个活动 Turn;一个 Session 同时只有一个 controller。create/restore/fork 在完整结果通过大小检查后才原子切换控制权,失败时保留原 Session。fork 只接受当前 controller 的空闲 Session;无选中 Turn 时复制到最新持久化 Turn,指定 `before_turn_id` 时只复制该 Turn 之前的历史。活动 Turn 期间不能切换或 fork Session,也不能修改其名称、Agent mode 或 model;删除只作用于非当前且未被任何连接控制的 Session。 - Submit 与手动 context compaction 都使用调用方已有的 `turn_id` 标识不确定结果;若操作超时,返回 `outcome_unknown`、关闭连接并按该 ID 取消。手动 compaction 要求当前 controller 且 Session 空闲,由 Core 通过与普通对话 Turn 共用的原子准入路径创建一个可审计 maintenance Turn,并在取得所有权后读取压缩上下文:planning 阶段允许取消,atomic commit 开始后忽略晚到取消并保持 Processing 直至终态持久化完成。maintenance Turn 保留在权威 transcript 中但不进入模型上下文,live/restored payload 使用同一 compression ID 和 `applied` 事实;commit 后的持久化故障发布明确失败终态而不是遗留 Processing。断连取消只有得到确认后才释放 Session 控制权;无法确认时继续隔离该 Session,直到 Runtime 进程退出。 - Session delete/rename 和 Agent mode/model update 复用既有 Runtime 端口和校验,Runtime 对最终结果保持权威并拒绝无效目标。它们都是有副作用操作;发送前编码或 frame 上限失败表示请求未执行,连接仍可使用。rename 写入失败时恢复旧 metadata:确认恢复后返回明确失败,无法确认时返回 `outcome_unknown`。Shared Client 在请求写入后响应超时或丢失连接时也返回 `outcome_unknown` 并断开连接。两种情况都不自动重试:rename 由用户恢复 Session 并核对当前值;delete 由用户重新打开 `/sessions` 核对目标是否仍存在。模式与模型目录仍是同版本第一方产品事实,不加入 IPC。 @@ -262,7 +263,7 @@ flowchart LR | Shared receive | 每个方向只有一个严格 transport decode 边界 | 未知信封字段和不兼容版本 fail closed;严格校验可以检查规范化 JSON,但不能把动态 JSON 传入 Runtime owner | | 多 TUI | 一个 Runtime、最多 64 个连接;每个 Client 的 command channel 容量为 64、event channel 容量为 256 | request gate 使每个 Client 同时只有一个请求进入 channel;事件落后时失效而非无限缓存 | -协议只承载当前交互所需的小型控制请求和既有事件。大 transcript 继续受 frame 上限约束;本阶段不为假设场景增加通用分页、二进制 side channel、压缩或批处理协议。 +协议只承载当前交互所需的小型控制请求、受 3 MiB 文本上限保护的 workspace diff 快照和既有事件。大 transcript 继续受 frame 上限约束;本阶段不为假设场景增加通用分页、二进制 side channel、压缩或批处理协议。 ## 5. Development and Physical Views · Level 1 diff --git a/docs/architecture/cli-product-line-design.md b/docs/architecture/cli-product-line-design.md index 88aafb1764..120c9381f8 100644 --- a/docs/architecture/cli-product-line-design.md +++ b/docs/architecture/cli-product-line-design.md @@ -89,7 +89,7 @@ BitFun CLI 应成为可独立安装和发布的 Agent 产品,而不是 Desktop 当前主线已经具备以下基础: -- 交互式 TUI、Markdown/代码/Diff/工具卡片、主题、模型/Agent/MCP/Skill/Subagent/Session 选择;权限请求默认询问, +- 交互式 TUI、Markdown/代码/Diff/工具卡片、主题、模型/Agent/MCP/Skill/Subagent/Session 选择;OpenCode 对齐的 `/diff` 通过 Runtime Git port 查看当前工作区 staged、unstaged 与 untracked 的 `HEAD` 相对差异;权限请求默认询问, 提供 `Allow once / Allow always / Reject`,其中 `Allow always` 只对当前运行上下文中的同名工具有效。 - `exec` 支持 stdin、会话恢复/分叉、Patch 输出和 `text/json/stream-json`。非交互执行默认拒绝权限请求, 显式 `--auto` 才在本次调用内自动批准;兼容参数 `--confirm` 隐藏并映射到安全默认值。`Ctrl+C` 会请求取消 @@ -258,13 +258,15 @@ Headless CLI 和公开 Agent SDK 都调用同一 Agent Runtime API,但交付 | 形态 | 默认部署 | 当前 Shared 范围 | |---|---|---| -| 交互式 TUI | Embedded | 显式 `--shared` 后支持 Session list/create/restore/delete/fork、transcript、当前 Session rename/Agent mode/model、声明式上下文 reload、当前 Session 手动 context compaction、Session undo/redo、Turn submit/cancel、Permission 和 UserInput | +| 交互式 TUI | Embedded | 显式 `--shared` 后支持 Session list/create/restore/delete/fork、transcript、当前 Session rename/Agent mode/model、声明式上下文 reload、当前 Session 手动 context compaction、Session undo/redo、只读 workspace diff、Turn submit/cancel、Permission 和 UserInput | | `bitfun exec` / CI | Embedded | 不接受 Shared;保持独立进程、stdout/stderr 和退出码语义 | | ACP / SDK Host / GUI / Remote / Peer | 各自既有部署 | 不消费 TUI IPC,也不因本开关改变生命周期 | Shared TUI 不提供 Session archive、模型目录/默认值、Agent/Subagent 管理、MCP/扩展、账号同步、用量、observer、replay 或通用 controller transfer;对应入口给出明确的 Embedded 恢复建议,不在 Client 进程初始化第二套 Core owner。 Shared 模式的斜杠命令、快捷键帮助和底部提示使用同一能力投影:OpenCode 对齐的 `/fork` 以 `Full session` 或历史用户提示词选择分支边界;选择提示词时 fork 只复制该 Turn 之前的历史,并把提示词放回 composer 而不自动发送。`/rename ` 修改当前 Session 名称;`/agent`、Tab 和 Shift+Tab 只切换当前 Session 的 Agent mode;`/models` 只切换当前 Session 的 model;`/reload [skills|instructions]` 刷新下一条消息使用的声明式上下文;OpenCode 对齐的 `/compact` 及其 `/summarize` alias 以一个可取消的 maintenance Turn 压缩当前 Session 上下文,不增加自创命令或快捷键。该 Turn 与普通对话共用 Session 原子准入,取得所有权后再读取待压缩上下文;权威 transcript 保留完整 tool payload,但重建模型上下文时排除该 maintenance Turn。Embedded 与 Shared 的 `/help` 都从 Action Registry 展示这些入口;在 slash menu 中选择 rename 只预填命令并等待用户输入名称。若外部来源使用相同命令名,用户明确选择的 BitFun 命令可完成这一次参数提交,即使偏好保存失败也不会重新弹出来源选择。它们不进入管理页面,也不修改未来 Session 的默认值。其他不支持动作不显示为可执行入口。Session 切换或 fork 失败保留原控制权;Shared fork 只有在新 Session 与 transcript 的响应可编码后才原子转移 controller。单个连接已有活动 Turn 时拒绝重复提交、fork、manual compaction 以及 Session rename/mode/model update,但允许 reload 只影响下一条消息;事件订阅失效后当前视图立即失效并要求重启 Shared TUI。 +`/diff` 只使用 OpenCode 已有命令名,不增加 alias 或全局快捷键,并且只在当前 Turn 空闲时进入。查看器以 `n`/`p` 切换文件、`]`/`[` 跳转 hunk,并保留方向键和 PageUp/PageDown 滚动;窄屏隐藏文件栏但保留当前 patch。TUI 只持有快照和导航状态,Git 仓库发现、差异计算及 staged/unstaged/untracked 事实由 `services-integrations` 中的 Runtime provider 负责。补丁与 OpenCode working-tree 视图一致,是 `HEAD` 到当前工作树的单一净补丁;staged/unstaged/untracked 仅作为文件状态展示,不在本阶段引入分区补丁或 source switch,相互抵消时仍保留文件和状态。冲突文件保留 `U` 状态并显示明确占位,不把 libgit2 的不完整两路投影伪装成三路冲突内容。Embedded 与 Shared 调用同一个 `AgentRuntime::workspace_diff`;TUI 仅在 Session execution workspace 与 Runtime 绑定的 project workspace 相同时调用,其他 worktree 与 Remote Session 明确返回不支持,避免显示错误的本地工作区事实。Shared 的无请求体只读 operation 不取得 Session lease,但要求连接没有活动 Turn,加载期间同一 Shared 客户端不再发起其他 Runtime 请求。单文件文本 diff 上限 1 MiB、总文本 diff 上限 3 MiB、文件数上限 256,超限和二进制文件显示明确占位。该入口不包含 refresh watcher、split diff、stage/reset/commit、变更归因、Remote 或 Relay 映射。 + OpenCode 对齐的 `/undo` 与 `/redo` 只提供这两个命令名,不增加 alias、快捷键或通用 checkpoint API。Core 以持久化的 staged boundary 同步裁剪可见 transcript、模型上下文和该边界后的受跟踪工作区改动;`/undo` 把被撤销的用户提示词放回 composer,连续调用可继续向前,`/redo` 逐级恢复直到清除 boundary,usage 与 fork 也只读取该 boundary 前的可见事实。活动 Turn 必须先由 scheduler 完成取消和 drain,失败时不写入 boundary;Shared 仍要求 current controller,并以 Runtime 返回的权威 transcript 更新 TUI。暂存期间提交新提示词或执行既有本地 snapshot accept/reject/rollback mutation,会先进入可恢复的 committing 阶段,永久删除隐藏后缀再建立新分支;同一阶段的 snapshot record 被拒绝,避免追加到隐藏历史。崩溃恢复会按持久化阶段重放 workspace/context 对齐或完成提交;跨文件系统与 Session 存储不宣称原子事务,部分失败返回 `outcome_unknown` 并要求恢复 Session 后检查。该能力当前只支持本地工作区,Remote 在任何回退写入前返回 `NotAvailable`。 部署差异由 CLI Runtime client 封装。Embedded 以 Rust 类型直接调用 `AgentRuntime`,不初始化 IPC 或执行 JSON 编解码;Shared 将同一业务请求映射为一个有界本机 frame,Client/Server 各自只编码一次,再交给同一 Runtime owner。多 TUI 复用一个 Runtime 进程,连接和队列保持有界,不按 TUI 数量复制 Session owner。详细的 4+1 视图、帧上限和并发边界见 diff --git a/scripts/core-boundaries/rules/feature-rules.mjs b/scripts/core-boundaries/rules/feature-rules.mjs index 11d5919555..0498c69128 100644 --- a/scripts/core-boundaries/rules/feature-rules.mjs +++ b/scripts/core-boundaries/rules/feature-rules.mjs @@ -63,7 +63,7 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'anyhow', ownerFeatures: ['browser-control', 'debug-log', 'mcp', 'remote-connect', 'remote-ssh', 'remote-ssh-concrete'] }, { depName: 'async-trait', - ownerFeatures: ['mcp', 'remote-connect', 'remote-ssh', 'remote-ssh-concrete', 'review-platform', 'script-tool-runtime', 'speech', 'workspace-search'], + ownerFeatures: ['git', 'mcp', 'remote-connect', 'remote-ssh', 'remote-ssh-concrete', 'review-platform', 'script-tool-runtime', 'speech', 'workspace-search'], }, { depName: 'base64', @@ -72,7 +72,7 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'bitfun-agent-runtime', ownerFeatures: ['deep-research', 'hook-import'] }, { depName: 'bitfun-core-types', ownerFeatures: ['speech'] }, { depName: 'bitfun-product-domains', ownerFeatures: ['canvas-runtime', 'function-agents', 'hook-import', 'miniapp-runtime', 'plugin-source'] }, - { depName: 'bitfun-runtime-ports', ownerFeatures: ['remote-connect', 'remote-ssh', 'remote-ssh-concrete', 'script-tool-runtime'] }, + { depName: 'bitfun-runtime-ports', ownerFeatures: ['git', 'remote-connect', 'remote-ssh', 'remote-ssh-concrete', 'script-tool-runtime'] }, { depName: 'bitfun-services-core', ownerFeatures: ['browser-control', 'git', 'hook-import', 'mcp', 'miniapp-runtime', 'process-tree', 'remote-connect', 'remote-ssh-concrete', 'review-platform', 'workspace-search'], diff --git a/scripts/core-boundaries/rules/source/forbidden-rules.mjs b/scripts/core-boundaries/rules/source/forbidden-rules.mjs index 2834c8a1a3..473fd559b8 100644 --- a/scripts/core-boundaries/rules/source/forbidden-rules.mjs +++ b/scripts/core-boundaries/rules/source/forbidden-rules.mjs @@ -6,7 +6,7 @@ export const forbiddenContentRules = [ reason: 'agent-runtime-ipc operation scope is frozen to the reviewed Shared TUI slice', patterns: [ { - regex: /^\s+(?!(?:Health|ListSessions|CreateSession|RestoreSession|DeleteSession|ForkSession|RenameSession|UpdateSessionMode|UpdateSessionModel|ReloadSessionContext|CompactSession|UndoSession|RedoSession|SearchWorkspaceReferences|WorkspaceReferencesForMessage|SubmitTurn|CancelTurn|PendingPermissions|RespondPermission|SubmitUserAnswers|Unit|Sessions|SessionCreated|SessionRestored|SessionForked|SessionReverted|WorkspaceReferenceSearch|WorkspaceReferences|TurnAccepted|TurnCancelled|None|CurrentController|AttachExisting|UncontrolledTarget|Self|RuntimeIpcSessionRequirement|RuntimeIpcOperationRules|RuntimeSessionForkRequest|AgentContextReloadRequest|AgentDialogTurnRequest|AgentMessageWorkspaceReferencesRequest|AgentSessionCompactionRequest|AgentSessionCreateRequest|AgentSessionCreateResult|AgentSessionListRequest|AgentSessionModeUpdateRequest|AgentSessionModelUpdateRequest|AgentSessionRevertRequest|AgentSessionRevertResult|AgentSessionSummary|AgentTurnCancellationRequest|AgentTurnCancellationResult|AgentWorkspaceReference|AgentWorkspaceReferenceSearchRequest|AgentWorkspaceReferenceSearchResult|SessionTranscript)\b)[A-Z][A-Za-z0-9_]*\b/, + regex: /^\s+(?!(?:Health|ListSessions|CreateSession|RestoreSession|DeleteSession|ForkSession|RenameSession|UpdateSessionMode|UpdateSessionModel|ReloadSessionContext|CompactSession|UndoSession|RedoSession|SearchWorkspaceReferences|WorkspaceReferencesForMessage|WorkspaceDiff|SubmitTurn|CancelTurn|PendingPermissions|RespondPermission|SubmitUserAnswers|Unit|Sessions|SessionCreated|SessionRestored|SessionForked|SessionReverted|WorkspaceReferenceSearch|WorkspaceReferences|TurnAccepted|TurnCancelled|None|CurrentController|AttachExisting|UncontrolledTarget|Self|RuntimeIpcSessionRequirement|RuntimeIpcOperationRules|RuntimeSessionForkRequest|AgentContextReloadRequest|AgentDialogTurnRequest|AgentMessageWorkspaceReferencesRequest|AgentSessionCompactionRequest|AgentSessionCreateRequest|AgentSessionCreateResult|AgentSessionListRequest|AgentSessionModeUpdateRequest|AgentSessionModelUpdateRequest|AgentSessionRevertRequest|AgentSessionRevertResult|AgentSessionSummary|AgentTurnCancellationRequest|AgentTurnCancellationResult|AgentWorkspaceReference|AgentWorkspaceReferenceSearchRequest|AgentWorkspaceReferenceSearchResult|SessionTranscript|WorkspaceDiffSnapshot)\b)[A-Z][A-Za-z0-9_]*\b/, message: 'agent-runtime-ipc may not add archive, replay, observer, general controller-transfer, or other operations beyond the reviewed Shared TUI slice', }, diff --git a/scripts/core-boundaries/self-test.mjs b/scripts/core-boundaries/self-test.mjs index 660f6ed07f..cb9cef74a2 100644 --- a/scripts/core-boundaries/self-test.mjs +++ b/scripts/core-boundaries/self-test.mjs @@ -740,6 +740,14 @@ export function runManifestParserSelfTest({ throw new Error(`services-integrations process-tree must delegate to ${dep}`); } } + for (const dep of ['async-trait', 'bitfun-runtime-ports']) { + const owner = servicesOptionalOwnerRule?.dependencies.find( + (dependency) => dependency.depName === dep, + ); + if (!owner?.ownerFeatures.includes('git')) { + throw new Error(`services-integrations git must own optional dependency ${dep}`); + } + } const productDomainsOptionalOwnerRule = optionalDependencyFeatureOwnerRules.find( (rule) => rule.crateName === 'product-domains', ); @@ -4972,6 +4980,8 @@ export function runManifestParserSelfTest({ runtimeIpcOperationPattern.test(' WorkspaceReferencesForMessage {') || runtimeIpcOperationPattern.test(' WorkspaceReferenceSearch {') || runtimeIpcOperationPattern.test(' WorkspaceReferences {') || + runtimeIpcOperationPattern.test(' WorkspaceDiff {') || + runtimeIpcOperationPattern.test(' WorkspaceDiffSnapshot,') || runtimeIpcOperationPattern.test(' SubmitTurn {') || runtimeIpcOperationPattern.test(' SessionForked {') || runtimeIpcOperationPattern.test(' SessionReverted {') diff --git a/src/apps/cli/src/actions.rs b/src/apps/cli/src/actions.rs index 701d927106..8cd807d130 100644 --- a/src/apps/cli/src/actions.rs +++ b/src/apps/cli/src/actions.rs @@ -88,6 +88,7 @@ pub(crate) enum ActionHandler { AcpHelp, Init, Status, + WorkspaceDiff, CompactSession, Usage, ToggleAutoApprove, @@ -118,7 +119,7 @@ pub(crate) enum ActionHandler { pub(crate) const SHARED_TUI_EMBEDDED_HANDOFF: &str = "Exit all Shared TUI clients, wait up to 30 seconds for their Runtime to stop, then use default Embedded `bitfun chat`"; pub(crate) const SHARED_TUI_HELP_NOTE: &str = - "Shared TUI: start with `bitfun chat --shared`. Multiple TUI processes reuse one workspace Runtime, while each TUI controls at most one Session and each Session has one controller. Use `/sessions` and Ctrl+D to delete an idle, non-current Session; use `/fork` to branch the current idle Session, `/rename ` to rename it, `/compact` to compact its context, `/agent`, Tab, or Shift+Tab to change its Agent mode, `/models` to change its model, and `/reload [skills|instructions]` to refresh declarative context for the next message. Model configuration, Agent/Subagent management, MCP, extension, account-sync, usage, and other management remain Embedded. Exit all Shared TUI clients and wait up to 30 seconds before returning to default Embedded `bitfun chat`."; + "Shared TUI: start with `bitfun chat --shared`. Multiple TUI processes reuse one workspace Runtime, while each TUI controls at most one Session and each Session has one controller. Use `/sessions` and Ctrl+D to delete an idle, non-current Session; use `/fork` to branch the current idle Session, `/rename ` to rename it, `/compact` to compact its context, `/diff` to review workspace changes, `/agent`, Tab, or Shift+Tab to change its Agent mode, `/models` to change its model, and `/reload [skills|instructions]` to refresh declarative context for the next message. Model configuration, Agent/Subagent management, MCP, extension, account-sync, usage, and other management remain Embedded. Exit all Shared TUI clients and wait up to 30 seconds before returning to default Embedded `bitfun chat`."; impl ActionHandler { pub(crate) const fn available_in_shared_tui(self, context: ActionContext) -> bool { @@ -136,6 +137,7 @@ impl ActionHandler { | Self::AcpHelp | Self::Init | Self::Status + | Self::WorkspaceDiff | Self::CompactSession | Self::ToggleAutoApprove | Self::OpenAgentSelector @@ -575,6 +577,21 @@ static ACTION_SPECS: &[ActionSpec] = &[ shortcut_label: None, slash_on_startup: false, }, + ActionSpec { + id: "workspace_diff", + name: "Workspace diff", + aliases: &["/diff"], + description: "Review current workspace changes", + contexts: CHAT, + availability: ActionAvailability::Idle, + handler: ActionHandler::WorkspaceDiff, + default_bindings: &[], + fallback_bindings: &[], + shortcut_field: None, + palette: palette("Session", false), + shortcut_label: None, + slash_on_startup: false, + }, ActionSpec { id: "compact_session", name: "Compact context", @@ -1944,6 +1961,22 @@ mod tests { assert!(action_for_alias("/branch", ActionContext::Chat).is_none()); } + #[test] + fn diff_uses_only_the_opencode_command_in_both_deployments() { + let action = action_by_id("workspace_diff", ActionContext::Chat) + .expect("OpenCode-compatible workspace diff action"); + + assert_eq!(action.aliases, &["/diff"]); + assert_eq!(action.handler, ActionHandler::WorkspaceDiff); + assert_eq!(action.availability, ActionAvailability::Idle); + assert!(action.default_bindings.is_empty()); + assert!(action.available(ActionState::chat(false, false))); + assert!(!action.available(ActionState::chat(true, false))); + assert!(action.available(ActionState::chat(false, false).for_shared_tui())); + assert!(action_by_id("workspace_diff", ActionContext::Startup).is_none()); + assert!(action_for_alias("/changes", ActionContext::Chat).is_none()); + } + #[test] fn undo_and_redo_use_only_the_opencode_commands_in_both_deployments() { let undo = action_by_id("undo_session", ActionContext::Chat) diff --git a/src/apps/cli/src/agent/runtime_client.rs b/src/apps/cli/src/agent/runtime_client.rs index 4cc52ba720..5d215c070c 100644 --- a/src/apps/cli/src/agent/runtime_client.rs +++ b/src/apps/cli/src/agent/runtime_client.rs @@ -22,7 +22,7 @@ use bitfun_agent_runtime::sdk::{ AgentUserAnswersRequest, AgentWorkspaceReference, AgentWorkspaceReferenceSearchRequest, AgentWorkspaceReferenceSearchResult, PermissionReply, PermissionRequest, PermissionRequestEventReceiver, PortError, PortErrorKind, RuntimeError, SessionTranscript, - SessionTranscriptRequest, SessionUsageReport, + SessionTranscriptRequest, SessionUsageReport, WorkspaceDiffSnapshot, }; use bitfun_agent_runtime_ipc::{ RuntimeIpcClient, RuntimeIpcClientError, RuntimeIpcClientEvent, RuntimeIpcErrorCode, @@ -187,6 +187,7 @@ struct CliWorkspacePaths { project: Option, execution: Option, execution_target: Option, + remote: bool, } impl CliWorkspacePaths { @@ -195,6 +196,7 @@ impl CliWorkspacePaths { project: workspace_path.clone(), execution: workspace_path, execution_target: None, + remote: false, } } @@ -225,6 +227,7 @@ impl CliWorkspacePaths { self.execution = Some(execution); self.project = Some(project); self.execution_target = binding.execution_target.clone(); + self.remote = binding.remote_connection_id.is_some() || binding.remote_ssh_host.is_some(); } fn reset_execution_to_project(&mut self) -> PathBuf { @@ -233,8 +236,31 @@ impl CliWorkspacePaths { self.execution_target = Some(SessionExecutionTarget::local( project.to_string_lossy().to_string(), )); + self.remote = false; project } + + fn workspace_diff_unavailable_reason(&self) -> Option<&'static str> { + if self.remote { + return Some("Workspace diff is unavailable for remote Sessions"); + } + let execution = self.execution(); + let project = self.project(); + if !same_workspace_location(&execution, &project) { + return Some( + "Workspace diff is unavailable when the Session uses a different worktree", + ); + } + None + } +} + +fn same_workspace_location(left: &Path, right: &Path) -> bool { + left == right + || dunce::canonicalize(left) + .ok() + .zip(dunce::canonicalize(right).ok()) + .is_some_and(|(left, right)| left == right) } /// CLI-owned client for the portable Agent Runtime SDK. @@ -868,6 +894,29 @@ impl CliAgentRuntimeClient { Ok(reverted) } + pub(crate) async fn workspace_diff(&self) -> Result { + if let Some(reason) = self + .workspace_paths + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .workspace_diff_unavailable_reason() + { + return Err(anyhow::anyhow!(reason)); + } + match &self.backend { + CliAgentRuntimeBackend::Embedded(runtime) => runtime + .workspace_diff() + .await + .map_err(|error| anyhow::anyhow!(error.into_message())), + CliAgentRuntimeBackend::Shared(client) => { + match client.request(RuntimeIpcOperation::WorkspaceDiff).await? { + RuntimeIpcOperationResult::WorkspaceDiff { snapshot } => Ok(snapshot), + _ => Err(unexpected_shared_result("workspace_diff")), + } + } + } + } + pub(crate) async fn generate_session_usage_report( &self, request: AgentSessionUsageRequest, @@ -1705,6 +1754,44 @@ mod tests { .is_none()); } + #[test] + fn workspace_diff_fails_closed_for_other_worktrees_and_remote_sessions() { + let mut paths = CliWorkspacePaths::new(Some("/project".into())); + assert_eq!(paths.workspace_diff_unavailable_reason(), None); + + paths.apply_binding(&AgentSessionWorkspaceBinding { + workspace_id: Some("workspace-1".to_string()), + workspace_path: "/managed-worktree".to_string(), + project_workspace_path: Some("/project".to_string()), + execution_target: Some(SessionExecutionTarget { + kind: SessionExecutionTargetKind::ManagedWorktree, + worktree_id: Some("worktree-1".to_string()), + root_path: "/managed-worktree".to_string(), + base_ref: Some("main".to_string()), + base_commit: Some("123456789abcdef".to_string()), + branch: None, + lifecycle: Some(WorktreeLifecycle::Managed), + }), + remote_connection_id: None, + remote_ssh_host: None, + }); + assert!(paths + .workspace_diff_unavailable_reason() + .is_some_and(|reason| reason.contains("different worktree"))); + + paths.apply_binding(&AgentSessionWorkspaceBinding { + workspace_id: None, + workspace_path: "/project".to_string(), + project_workspace_path: Some("/project".to_string()), + execution_target: Some(SessionExecutionTarget::local("/project")), + remote_connection_id: Some("remote-1".to_string()), + remote_ssh_host: Some("example.test".to_string()), + }); + assert!(paths + .workspace_diff_unavailable_reason() + .is_some_and(|reason| reason.contains("remote Sessions"))); + } + #[test] fn model_updates_use_the_runtime_sdk_without_the_core_compatibility_facade() { let source = include_str!("runtime_client.rs").replace("\r\n", "\n"); @@ -1775,6 +1862,25 @@ mod tests { assert!(fork.contains("RuntimeIpcOperationResult::SessionForked")); } + #[test] + fn workspace_diff_uses_the_same_runtime_boundary_in_both_deployments() { + let source = include_str!("runtime_client.rs").replace("\r\n", "\n"); + let workspace_diff = source + .split_once("pub(crate) async fn workspace_diff(") + .expect("workspace diff method") + .1 + .split_once("pub(crate) async fn generate_session_usage_report(") + .expect("workspace diff method boundary") + .0; + + assert!(workspace_diff.contains("workspace_diff_unavailable_reason")); + assert!(workspace_diff.contains("CliAgentRuntimeBackend::Embedded(runtime)")); + assert!(workspace_diff.contains(".workspace_diff()")); + assert!(workspace_diff.contains("CliAgentRuntimeBackend::Shared(client)")); + assert!(workspace_diff.contains("RuntimeIpcOperation::WorkspaceDiff")); + assert!(workspace_diff.contains("RuntimeIpcOperationResult::WorkspaceDiff")); + } + #[test] fn session_revert_uses_the_same_authoritative_result_in_both_deployments() { let source = include_str!("runtime_client.rs").replace("\r\n", "\n"); diff --git a/src/apps/cli/src/modes/chat.rs b/src/apps/cli/src/modes/chat.rs index 588a0e9c13..88353652ef 100644 --- a/src/apps/cli/src/modes/chat.rs +++ b/src/apps/cli/src/modes/chat.rs @@ -225,6 +225,12 @@ struct PendingWorkspaceReferenceSearch { >, } +struct PendingWorkspaceDiff { + handle: tokio::task::JoinHandle< + std::result::Result, + >, +} + const SESSION_OPERATION_SLOW_NOTICE: Duration = Duration::from_secs(15); const SHARED_TUI_CHAT_STATUS: &str = "Shared TUI preview: this view controls sessions, including deleting an idle Session, turns, the current Session name, current Session Agent mode, current Session model, and declarative context via /reload [skills|instructions]; model management remains Embedded, along with local extension, MCP, account-sync, and Agent/Subagent management."; @@ -268,6 +274,7 @@ pub(crate) struct ChatMode { /// One Session operation in flight. The event loop remains responsive while /// the Runtime owner updates or deletes Session state. pending_session_operation: Option, + pending_workspace_diff: Option, pending_workspace_reference_search: Option, workspace_reference_search_generation: u64, last_workspace_reference_query: Option, @@ -329,6 +336,7 @@ impl ChatMode { pending_mcp_op: None, pending_mcp_tasks: Vec::new(), pending_session_operation: None, + pending_workspace_diff: None, pending_workspace_reference_search: None, workspace_reference_search_generation: 0, last_workspace_reference_query: None, diff --git a/src/apps/cli/src/modes/chat/account.rs b/src/apps/cli/src/modes/chat/account.rs index aab4bf94d0..afae2629b1 100644 --- a/src/apps/cli/src/modes/chat/account.rs +++ b/src/apps/cli/src/modes/chat/account.rs @@ -197,6 +197,7 @@ impl ChatMode { || chat_view.login_form_visible() || chat_view.theme_selector_visible() || chat_view.info_popup_visible() + || chat_view.workspace_diff_visible() } /// Close all popups and clear the navigation stack @@ -219,6 +220,7 @@ impl ChatMode { chat_view.hide_login_form(); chat_view.hide_theme_selector(); chat_view.dismiss_info_popup(); + chat_view.hide_workspace_diff(); chat_view.popup_stack.clear(); } @@ -245,6 +247,7 @@ impl ChatMode { chat_view.cancel_theme_preview(); } crate::ui::chat::PopupType::InfoPopup => chat_view.dismiss_info_popup(), + crate::ui::chat::PopupType::WorkspaceDiff => chat_view.hide_workspace_diff(), } // If there's a previous popup in the stack, re-show it @@ -274,6 +277,7 @@ impl ChatMode { crate::ui::chat::PopupType::LoginForm => chat_view.reshow_login_form(), crate::ui::chat::PopupType::ThemeSelector => chat_view.reshow_theme_selector(), crate::ui::chat::PopupType::InfoPopup => {} + crate::ui::chat::PopupType::WorkspaceDiff => chat_view.reshow_workspace_diff(), } } } diff --git a/src/apps/cli/src/modes/chat/commands.rs b/src/apps/cli/src/modes/chat/commands.rs index 28f3c03f78..98cc7cea55 100644 --- a/src/apps/cli/src/modes/chat/commands.rs +++ b/src/apps/cli/src/modes/chat/commands.rs @@ -52,6 +52,34 @@ fn pending_session_operation_blocks_runtime_action( )) } +pub(crate) fn pending_workspace_diff_blocks_runtime_action( + shared_tui: bool, + pending_workspace_diff: bool, + handler: ActionHandler, +) -> bool { + shared_tui + && pending_workspace_diff + && matches!( + handler, + ActionHandler::OpenAgentSelector + | ActionHandler::SwitchAgent + | ActionHandler::SwitchAgentReverse + | ActionHandler::SelectModel + | ActionHandler::NewSession + | ActionHandler::Sessions + | ActionHandler::ForkSession + | ActionHandler::UndoSession + | ActionHandler::RedoSession + | ActionHandler::RenameSession + | ActionHandler::Reload + | ActionHandler::Init + | ActionHandler::WorkspaceDiff + | ActionHandler::CompactSession + | ActionHandler::SubmitInput + | ActionHandler::Interrupt + ) +} + fn requested_session_name(arguments: &str) -> Option { let session_name = arguments.trim(); (!session_name.is_empty()).then(|| session_name.to_string()) @@ -99,6 +127,7 @@ fn builtin_arguments_error( ActionHandler::ForkSession => Some("Usage: /fork"), ActionHandler::UndoSession => Some("Usage: /undo"), ActionHandler::RedoSession => Some("Usage: /redo"), + ActionHandler::WorkspaceDiff => Some("Usage: /diff"), _ => None, } } @@ -870,6 +899,17 @@ impl ChatMode { chat_view.set_status(Some(action.unavailable_message(state))); return Ok(None); } + if pending_workspace_diff_blocks_runtime_action( + self.agent.is_shared(), + self.pending_workspace_diff.is_some(), + action.handler, + ) { + chat_view.set_status(Some( + "Waiting for the workspace diff to finish before using the Runtime again." + .to_string(), + )); + return Ok(None); + } let pending_for_current_session = self .pending_session_operation .as_ref() @@ -976,6 +1016,23 @@ impl ChatMode { ActionHandler::Status => { chat_view.show_info_popup(session_status_text(chat_state, self.agent.is_shared())); } + ActionHandler::WorkspaceDiff => { + if self.pending_workspace_diff.is_some() { + chat_view.set_status(Some( + "Workspace diff is already loading. Please wait.".to_string(), + )); + return Ok(None); + } + chat_view.set_status(Some("Loading workspace diff...".to_string())); + let agent = self.agent.clone(); + let handle = rt_handle.spawn(async move { + agent + .workspace_diff() + .await + .map_err(|error| error.to_string()) + }); + self.pending_workspace_diff = Some(PendingWorkspaceDiff { handle }); + } ActionHandler::CompactSession => { self.start_session_compaction(chat_view, chat_state, rt_handle); } @@ -1169,6 +1226,35 @@ impl ChatMode { })); } + fn poll_workspace_diff(&mut self, chat_view: &mut ChatView) -> bool { + let Some(pending) = self.pending_workspace_diff.as_ref() else { + return false; + }; + if !pending.handle.is_finished() { + return false; + } + let pending = self + .pending_workspace_diff + .take() + .expect("workspace diff task was checked above"); + match tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(pending.handle) + }) { + Ok(Ok(snapshot)) => { + self.close_all_popups(chat_view); + chat_view.show_workspace_diff(snapshot); + chat_view.set_status(None); + } + Ok(Err(error)) => { + chat_view.set_status(Some(format!("Unable to load workspace diff: {error}"))); + } + Err(error) => { + chat_view.set_status(Some(format!("Workspace diff loading stopped: {error}"))); + } + } + true + } + fn start_session_rename( &mut self, arguments: &str, diff --git a/src/apps/cli/src/modes/chat/input.rs b/src/apps/cli/src/modes/chat/input.rs index 276b4e974a..57ada83180 100644 --- a/src/apps/cli/src/modes/chat/input.rs +++ b/src/apps/cli/src/modes/chat/input.rs @@ -88,6 +88,17 @@ impl ChatMode { } } + // Workspace diff viewer intercepts all keys when visible + if chat_view.workspace_diff_visible() { + if matches!( + chat_view.workspace_diff_handle_key(key), + crate::ui::workspace_diff::WorkspaceDiffAction::Close + ) { + self.navigate_back(chat_view); + } + return Ok(None); + } + // Info popup intercepts all keys when visible if chat_view.info_popup_visible() { match key.code { diff --git a/src/apps/cli/src/modes/chat/run.rs b/src/apps/cli/src/modes/chat/run.rs index 1e21c67779..761b1537d8 100644 --- a/src/apps/cli/src/modes/chat/run.rs +++ b/src/apps/cli/src/modes/chat/run.rs @@ -384,6 +384,9 @@ impl ChatMode { if self.poll_workspace_reference_search(&mut chat_view) { needs_redraw = true; } + if self.poll_workspace_diff(&mut chat_view) { + needs_redraw = true; + } chat_view.set_action_state( self.action_state(chat_state.is_processing, false), &self.keymap, diff --git a/src/apps/cli/src/modes/chat/tests.rs b/src/apps/cli/src/modes/chat/tests.rs index 12377c4a0d..9809c21fc1 100644 --- a/src/apps/cli/src/modes/chat/tests.rs +++ b/src/apps/cli/src/modes/chat/tests.rs @@ -1856,6 +1856,61 @@ mod tests { assert!(!source.contains("PendingSessionDelete")); } + #[test] + fn workspace_diff_load_does_not_block_the_tui_event_loop() { + let commands = include_str!("commands.rs").replace("\r\n", "\n"); + let handler = commands + .split_once("ActionHandler::WorkspaceDiff => {") + .expect("workspace diff handler") + .1 + .split_once("ActionHandler::CompactSession => {") + .expect("workspace diff handler boundary") + .0; + let run_loop = include_str!("run.rs").replace("\r\n", "\n"); + + assert!(handler.contains("rt_handle.spawn")); + assert!(handler.contains("pending_workspace_diff")); + assert!(!handler.contains("block_in_place")); + assert!(run_loop.contains("poll_workspace_diff")); + } + + #[test] + fn shared_workspace_diff_pending_state_serializes_runtime_actions_only() { + use crate::actions::ActionHandler; + use crate::modes::chat::pending_workspace_diff_blocks_runtime_action; + + assert!(pending_workspace_diff_blocks_runtime_action( + true, + true, + ActionHandler::SubmitInput + )); + assert!(pending_workspace_diff_blocks_runtime_action( + true, + true, + ActionHandler::SelectModel + )); + assert!(pending_workspace_diff_blocks_runtime_action( + true, + true, + ActionHandler::NewSession + )); + assert!(!pending_workspace_diff_blocks_runtime_action( + true, + true, + ActionHandler::SelectTheme + )); + assert!(!pending_workspace_diff_blocks_runtime_action( + false, + true, + ActionHandler::SubmitInput + )); + assert!(!pending_workspace_diff_blocks_runtime_action( + true, + false, + ActionHandler::SubmitInput + )); + } + #[test] fn pending_session_operation_routes_commands_to_their_action_guards() { assert!(session_update_blocks_typed_submission(true, "continue")); diff --git a/src/apps/cli/src/shared_runtime.rs b/src/apps/cli/src/shared_runtime.rs index 67d88b29ab..c43e54793a 100644 --- a/src/apps/cli/src/shared_runtime.rs +++ b/src/apps/cli/src/shared_runtime.rs @@ -378,6 +378,12 @@ impl RuntimeIpcRequestHandler for SharedRuntimeHandler { .await .map(|references| RuntimeIpcOperationResult::WorkspaceReferences { references }) .map_err(runtime_ipc_error), + RuntimeIpcOperation::WorkspaceDiff => self + .runtime + .workspace_diff() + .await + .map(|snapshot| RuntimeIpcOperationResult::WorkspaceDiff { snapshot }) + .map_err(runtime_ipc_error), RuntimeIpcOperation::SubmitTurn { request } => { let outcome = self .runtime diff --git a/src/apps/cli/src/ui/chat/popups.rs b/src/apps/cli/src/ui/chat/popups.rs index e56037131f..1269a7eeda 100644 --- a/src/apps/cli/src/ui/chat/popups.rs +++ b/src/apps/cli/src/ui/chat/popups.rs @@ -1,4 +1,31 @@ impl ChatView { + pub(crate) fn show_workspace_diff( + &mut self, + snapshot: bitfun_agent_runtime::sdk::WorkspaceDiffSnapshot, + ) { + self.workspace_diff.show(snapshot); + self.popup_stack.push(PopupType::WorkspaceDiff); + } + + pub(crate) fn workspace_diff_visible(&self) -> bool { + self.workspace_diff.is_visible() + } + + pub(crate) fn hide_workspace_diff(&mut self) { + self.workspace_diff.hide(); + } + + pub(crate) fn reshow_workspace_diff(&mut self) { + self.workspace_diff.reshow(); + } + + pub(crate) fn workspace_diff_handle_key( + &mut self, + key: crossterm::event::KeyEvent, + ) -> super::workspace_diff::WorkspaceDiffAction { + self.workspace_diff.handle_key_event(key) + } + // ============ Info popup methods ============ pub(crate) fn show_info_popup(&mut self, message: String) { @@ -438,10 +465,7 @@ impl ChatView { // ============ Fork selector methods ============ - pub(crate) fn show_fork_selector( - &mut self, - points: Vec, - ) { + pub(crate) fn show_fork_selector(&mut self, points: Vec) { self.fork_selector.show(points); self.popup_stack.push(PopupType::ForkSelector); } diff --git a/src/apps/cli/src/ui/chat/render.rs b/src/apps/cli/src/ui/chat/render.rs index b93f219947..4d9df7f337 100644 --- a/src/apps/cli/src/ui/chat/render.rs +++ b/src/apps/cli/src/ui/chat/render.rs @@ -108,6 +108,7 @@ impl ChatView { self.render_model_config_form(frame, chunks[1]); self.render_theme_selector(frame, chunks[1]); self.render_shortcuts(frame, chunks[4], chat_state); + self.workspace_diff.render(frame, size, &self.theme); // Render permission overlay on top of messages area if active (highest priority) if let Some(ref prompt) = chat_state.permission_prompt { diff --git a/src/apps/cli/src/ui/chat/state.rs b/src/apps/cli/src/ui/chat/state.rs index 27d1cfb483..6849874f01 100644 --- a/src/apps/cli/src/ui/chat/state.rs +++ b/src/apps/cli/src/ui/chat/state.rs @@ -28,6 +28,7 @@ use super::text_input::TextInput; use super::theme::{StyleKind, Theme}; use super::theme_selector::{ThemeItem, ThemeSelectorState}; use super::widgets::Spinner; +use super::workspace_diff::WorkspaceDiffViewState; use super::workspace_reference::{ ComposerDraft, WorkspaceReferencePopupState, WorkspaceReferenceQuery, }; @@ -51,6 +52,7 @@ pub(crate) enum PopupType { LoginForm, ThemeSelector, InfoPopup, + WorkspaceDiff, } /// Navigation stack for managing popup hierarchy @@ -206,6 +208,7 @@ pub(crate) struct ChatView { info_popup: Option, info_popup_scroll: u16, info_popup_max_scroll: u16, + workspace_diff: WorkspaceDiffViewState, /// Hovered thinking block (message_id) for mouse-over highlight hovered_thinking_block_id: Option, @@ -290,6 +293,7 @@ impl ChatView { info_popup: None, info_popup_scroll: 0, info_popup_max_scroll: 0, + workspace_diff: WorkspaceDiffViewState::new(), hovered_thinking_block_id: None, collapsed_tools: HashSet::new(), focused_block_tool: None, diff --git a/src/apps/cli/src/ui/command_palette.rs b/src/apps/cli/src/ui/command_palette.rs index 2e608608ae..96c076a43b 100644 --- a/src/apps/cli/src/ui/command_palette.rs +++ b/src/apps/cli/src/ui/command_palette.rs @@ -43,6 +43,7 @@ const DEFAULT_ITEM_ORDER: &[&str] = &[ "new_session", "sessions", "fork_session", + "workspace_diff", "compact_session", "usage", "toggle_auto_approve", diff --git a/src/apps/cli/src/ui/mod.rs b/src/apps/cli/src/ui/mod.rs index cb44ebd05e..08522eaf1e 100644 --- a/src/apps/cli/src/ui/mod.rs +++ b/src/apps/cli/src/ui/mod.rs @@ -29,6 +29,7 @@ pub(crate) mod theme; pub(crate) mod theme_selector; mod tool_cards; mod widgets; +pub(crate) mod workspace_diff; pub(crate) mod workspace_reference; use anyhow::Result; diff --git a/src/apps/cli/src/ui/startup.rs b/src/apps/cli/src/ui/startup.rs index 2b69ab9b52..bd0e3cae28 100644 --- a/src/apps/cli/src/ui/startup.rs +++ b/src/apps/cli/src/ui/startup.rs @@ -1076,6 +1076,7 @@ impl StartupPage { | ActionHandler::NativeHooks | ActionHandler::ExternalHooks | ActionHandler::Status + | ActionHandler::WorkspaceDiff | ActionHandler::CompactSession | ActionHandler::ToggleAutoApprove | ActionHandler::ToggleWorktree diff --git a/src/apps/cli/src/ui/workspace_diff.rs b/src/apps/cli/src/ui/workspace_diff.rs new file mode 100644 index 0000000000..0ea475479c --- /dev/null +++ b/src/apps/cli/src/ui/workspace_diff.rs @@ -0,0 +1,644 @@ +use bitfun_agent_runtime::sdk::{ + WorkspaceDiffContent, WorkspaceDiffFile, WorkspaceDiffFileStatus, WorkspaceDiffSnapshot, +}; +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph}, + Frame, +}; +use unicode_width::UnicodeWidthChar; + +use crate::ui::theme::{StyleKind, Theme}; + +const WIDE_LAYOUT_MIN_WIDTH: u16 = 84; +const FILE_LIST_WIDTH: u16 = 32; +const PAGE_SCROLL_LINES: usize = 10; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum WorkspaceDiffAction { + None, + Close, +} + +pub(crate) struct WorkspaceDiffViewState { + snapshot: WorkspaceDiffSnapshot, + selected_file: usize, + scroll: usize, + viewport_height: usize, + visible: bool, +} + +impl WorkspaceDiffViewState { + pub(crate) fn new() -> Self { + Self { + snapshot: WorkspaceDiffSnapshot::default(), + selected_file: 0, + scroll: 0, + viewport_height: 1, + visible: false, + } + } + + pub(crate) fn show(&mut self, snapshot: WorkspaceDiffSnapshot) { + self.snapshot = snapshot; + self.selected_file = 0; + self.scroll = 0; + self.visible = true; + } + + pub(crate) fn hide(&mut self) { + self.visible = false; + } + + pub(crate) fn reshow(&mut self) { + self.visible = true; + } + + pub(crate) fn is_visible(&self) -> bool { + self.visible + } + + #[cfg(test)] + fn selected_path(&self) -> Option<&str> { + self.selected_file().map(|file| file.path.as_str()) + } + + #[cfg(test)] + fn scroll(&self) -> usize { + self.scroll + } + + pub(crate) fn set_viewport_height(&mut self, height: usize) { + self.viewport_height = height.max(1); + self.scroll = self.scroll.min(self.max_scroll()); + } + + pub(crate) fn handle_key_event(&mut self, key: KeyEvent) -> WorkspaceDiffAction { + if !self.visible { + return WorkspaceDiffAction::None; + } + match key.code { + KeyCode::Esc => { + self.hide(); + WorkspaceDiffAction::Close + } + KeyCode::Char('n') => { + self.move_file(1); + WorkspaceDiffAction::None + } + KeyCode::Char('p') => { + self.move_file(-1); + WorkspaceDiffAction::None + } + KeyCode::Char(']') => { + self.next_hunk(); + WorkspaceDiffAction::None + } + KeyCode::Char('[') => { + self.previous_hunk(); + WorkspaceDiffAction::None + } + KeyCode::Up => { + self.scroll = self.scroll.saturating_sub(1); + WorkspaceDiffAction::None + } + KeyCode::Down => { + self.scroll_by(1); + WorkspaceDiffAction::None + } + KeyCode::PageUp => { + self.scroll = self.scroll.saturating_sub(PAGE_SCROLL_LINES); + WorkspaceDiffAction::None + } + KeyCode::PageDown => { + self.scroll_by(PAGE_SCROLL_LINES); + WorkspaceDiffAction::None + } + KeyCode::Home => { + self.scroll = 0; + WorkspaceDiffAction::None + } + KeyCode::End => { + self.scroll = self.max_scroll(); + WorkspaceDiffAction::None + } + _ => WorkspaceDiffAction::None, + } + } + + pub(crate) fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) { + if !self.visible { + return; + } + + frame.render_widget(Clear, area); + frame.render_widget( + Block::default().style(Style::default().bg(theme.background)), + area, + ); + let regions = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Min(1), + Constraint::Length(1), + ]) + .split(area); + + self.render_header(frame, regions[0], theme); + if area.width >= WIDE_LAYOUT_MIN_WIDTH { + let body = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length(FILE_LIST_WIDTH.min(area.width / 3)), + Constraint::Min(1), + ]) + .split(regions[1]); + self.render_files(frame, body[0], theme); + self.render_patch(frame, body[1], theme); + } else { + self.render_patch(frame, regions[1], theme); + } + self.render_footer(frame, regions[2], theme); + } + + fn selected_file(&self) -> Option<&WorkspaceDiffFile> { + self.snapshot.files.get(self.selected_file) + } + + fn move_file(&mut self, delta: isize) { + if self.snapshot.files.is_empty() { + return; + } + self.selected_file = (self.selected_file as isize + delta) + .rem_euclid(self.snapshot.files.len() as isize) as usize; + self.scroll = 0; + } + + fn next_hunk(&mut self) { + let hunks = self.hunk_positions(); + if let Some(position) = hunks.iter().find(|position| **position > self.scroll) { + self.scroll = (*position).min(self.max_scroll()); + } + } + + fn previous_hunk(&mut self) { + let hunks = self.hunk_positions(); + let Some(current) = hunks.iter().rposition(|position| *position < self.scroll) else { + return; + }; + self.scroll = hunks[current].min(self.max_scroll()); + } + + fn hunk_positions(&self) -> Vec { + self.patch_text() + .map(|patch| { + patch + .lines() + .enumerate() + .filter_map(|(index, line)| line.starts_with("@@").then_some(index)) + .collect() + }) + .unwrap_or_default() + } + + fn patch_text(&self) -> Option<&str> { + match &self.selected_file()?.content { + WorkspaceDiffContent::Text { patch } => Some(patch), + WorkspaceDiffContent::Binary | WorkspaceDiffContent::TooLarge => None, + } + } + + fn content_line_count(&self) -> usize { + self.patch_text() + .map(|patch| patch.lines().count()) + .unwrap_or(1) + } + + fn max_scroll(&self) -> usize { + self.content_line_count() + .saturating_sub(self.viewport_height) + } + + fn scroll_by(&mut self, amount: usize) { + self.scroll = self.scroll.saturating_add(amount).min(self.max_scroll()); + } + + fn render_header(&self, frame: &mut Frame, area: Rect, theme: &Theme) { + let additions = self + .snapshot + .files + .iter() + .map(|file| file.additions) + .sum::(); + let deletions = self + .snapshot + .files + .iter() + .map(|file| file.deletions) + .sum::(); + let mut title = vec![Span::styled( + " Workspace Diff ", + theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD), + )]; + if self.snapshot.truncated { + title.push(Span::styled( + "[truncated] ", + theme.style(StyleKind::Warning), + )); + } + title.push(Span::styled( + format!( + "{} files +{} -{}", + self.snapshot.files.len(), + additions, + deletions + ), + theme.style(StyleKind::Muted), + )); + frame.render_widget( + Paragraph::new(Line::from(title)).block( + Block::default() + .borders(Borders::ALL) + .border_style(theme.style(StyleKind::Primary)) + .style(Style::default().bg(theme.background)), + ), + area, + ); + } + + fn render_files(&self, frame: &mut Frame, area: Rect, theme: &Theme) { + let items = self.snapshot.files.iter().map(|file| { + let status = match file.status { + WorkspaceDiffFileStatus::Added => "A", + WorkspaceDiffFileStatus::Modified => "M", + WorkspaceDiffFileStatus::Deleted => "D", + WorkspaceDiffFileStatus::Renamed => "R", + WorkspaceDiffFileStatus::Conflicted => "U", + }; + ListItem::new(Line::from(vec![ + Span::styled(format!("{status} "), theme.style(StyleKind::Muted)), + Span::raw(file.path.clone()), + ])) + }); + let list = List::new(items) + .block( + Block::default() + .borders(Borders::ALL) + .title(" Files ") + .style(Style::default().bg(theme.background)), + ) + .highlight_symbol("> ") + .highlight_style( + Style::default() + .bg(theme.background_element) + .fg(theme.primary) + .add_modifier(Modifier::BOLD), + ); + let mut state = ListState::default(); + if !self.snapshot.files.is_empty() { + state.select(Some(self.selected_file)); + } + frame.render_stateful_widget(list, area, &mut state); + } + + fn render_patch(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) { + self.set_viewport_height(area.height.saturating_sub(2) as usize); + let title = self + .selected_file() + .map(file_title) + .unwrap_or_else(|| " No workspace changes ".to_string()); + let lines = match self.selected_file() { + Some(file) if file.status == WorkspaceDiffFileStatus::Conflicted => { + vec![Line::styled( + "Conflict content is not shown as a regular two-way diff.", + theme.style(StyleKind::Warning), + )] + } + Some(WorkspaceDiffFile { + content: WorkspaceDiffContent::Text { patch }, + .. + }) if patch.is_empty() => vec![Line::styled( + "Staged and worktree changes have no net patch.", + theme.style(StyleKind::Muted), + )], + Some(WorkspaceDiffFile { + content: WorkspaceDiffContent::Text { patch }, + .. + }) => patch + .lines() + .skip(self.scroll) + .take(self.viewport_height) + .map(|line| styled_patch_line(line, theme)) + .collect::>(), + Some(WorkspaceDiffFile { + content: WorkspaceDiffContent::Binary, + .. + }) => vec![Line::styled( + "Binary file changed; textual diff is unavailable.", + theme.style(StyleKind::Muted), + )], + Some(WorkspaceDiffFile { + content: WorkspaceDiffContent::TooLarge, + .. + }) => vec![Line::styled( + "Diff omitted because it exceeds the workspace diff size limit.", + theme.style(StyleKind::Warning), + )], + None => vec![Line::styled( + "Working tree is clean.", + theme.style(StyleKind::Muted), + )], + }; + frame.render_widget( + Paragraph::new(lines).block( + Block::default() + .borders(Borders::ALL) + .title(title) + .style(Style::default().bg(theme.background)), + ), + area, + ); + } + + fn render_footer(&self, frame: &mut Frame, area: Rect, theme: &Theme) { + let hint = " n/p: files [ / ]: hunks Up/Down/Page: scroll Esc: close "; + frame.render_widget( + Paragraph::new(hint).style(theme.style(StyleKind::Muted)), + area, + ); + } +} + +fn file_title(file: &WorkspaceDiffFile) -> String { + let path = file + .old_path + .as_ref() + .map(|old_path| format!("{old_path} -> {}", file.path)) + .unwrap_or_else(|| file.path.clone()); + let change_source = [ + file.staged.then_some("staged"), + file.unstaged.then_some("unstaged"), + file.untracked.then_some("untracked"), + ] + .into_iter() + .flatten() + .collect::>(); + let change_source = if change_source.is_empty() { + "workspace".to_string() + } else { + change_source.join("+") + }; + format!( + " {path} {change_source} +{} -{} ", + file.additions, file.deletions + ) +} + +fn styled_patch_line(line: &str, theme: &Theme) -> Line<'static> { + let line = expand_tabs(line); + let style = if line.starts_with("@@") { + Style::default().fg(theme.diff_hunk_header) + } else if line.starts_with("+++") || line.starts_with("---") { + theme.style(StyleKind::Muted) + } else if line.starts_with('+') { + Style::default() + .fg(theme.diff_added_fg) + .bg(theme.diff_added_bg) + } else if line.starts_with('-') { + Style::default() + .fg(theme.diff_removed_fg) + .bg(theme.diff_removed_bg) + } else { + Style::default() + }; + Line::styled(line, style) +} + +fn expand_tabs(line: &str) -> String { + const TAB_STOP: usize = 4; + + let mut expanded = String::with_capacity(line.len()); + let mut column = 0usize; + for character in line.chars() { + if character == '\t' { + let spaces = TAB_STOP - column % TAB_STOP; + expanded.extend(std::iter::repeat_n(' ', spaces)); + column += spaces; + } else { + expanded.push(character); + column += character.width().unwrap_or(0); + } + } + expanded +} + +#[cfg(test)] +mod tests { + use bitfun_agent_runtime::sdk::{ + WorkspaceDiffContent, WorkspaceDiffFile, WorkspaceDiffFileStatus, WorkspaceDiffSnapshot, + }; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + use ratatui::{backend::TestBackend, Terminal}; + + use super::{WorkspaceDiffAction, WorkspaceDiffViewState}; + use crate::ui::theme::Theme; + + fn file(path: &str, patch: &str) -> WorkspaceDiffFile { + WorkspaceDiffFile { + path: path.to_string(), + old_path: None, + status: WorkspaceDiffFileStatus::Modified, + staged: false, + unstaged: true, + untracked: false, + additions: 2, + deletions: 1, + content: WorkspaceDiffContent::Text { + patch: patch.to_string(), + }, + } + } + + fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) + } + + #[test] + fn opencode_navigation_moves_between_files_and_hunks() { + let mut view = WorkspaceDiffViewState::new(); + view.show(WorkspaceDiffSnapshot { + files: vec![ + file("a.rs", "@@ -1 +1 @@\n-a\n+b\n"), + file( + "b.rs", + "header\n@@ -1 +1 @@\n-old\n+new\n@@ -10 +10 @@\n-old2\n+new2\n", + ), + ], + truncated: false, + }); + + assert_eq!(view.selected_path(), Some("a.rs")); + assert_eq!( + view.handle_key_event(key(KeyCode::Char('n'))), + WorkspaceDiffAction::None + ); + assert_eq!(view.selected_path(), Some("b.rs")); + assert_eq!(view.scroll(), 0); + view.handle_key_event(key(KeyCode::Char(']'))); + assert_eq!(view.scroll(), 1); + view.handle_key_event(key(KeyCode::Char(']'))); + assert_eq!(view.scroll(), 4); + view.handle_key_event(key(KeyCode::Char('['))); + assert_eq!(view.scroll(), 1); + view.handle_key_event(key(KeyCode::Char('p'))); + assert_eq!(view.selected_path(), Some("a.rs")); + } + + #[test] + fn scrolling_is_bounded_and_escape_closes_the_viewer() { + let mut view = WorkspaceDiffViewState::new(); + view.show(WorkspaceDiffSnapshot { + files: vec![file("long.rs", &"line\n".repeat(30))], + truncated: false, + }); + view.set_viewport_height(10); + + view.handle_key_event(key(KeyCode::End)); + assert_eq!(view.scroll(), 20); + view.handle_key_event(key(KeyCode::Down)); + assert_eq!(view.scroll(), 20); + view.handle_key_event(key(KeyCode::Home)); + assert_eq!(view.scroll(), 0); + assert_eq!( + view.handle_key_event(key(KeyCode::Esc)), + WorkspaceDiffAction::Close + ); + assert!(!view.is_visible()); + } + + #[test] + fn responsive_render_keeps_the_patch_and_collapses_only_the_file_list() { + let snapshot = WorkspaceDiffSnapshot { + files: vec![ + file("src/a.rs", "@@ -1 +1 @@\n-old\n+new\n"), + file("src/b.rs", "@@ -1 +1 @@\n-before\n+after\n"), + ], + truncated: false, + }; + let mut wide = WorkspaceDiffViewState::new(); + wide.show(snapshot.clone()); + let mut wide_terminal = Terminal::new(TestBackend::new(100, 18)).expect("wide terminal"); + wide_terminal + .draw(|frame| wide.render(frame, frame.area(), &Theme::dark_ansi16())) + .expect("render wide diff"); + let wide_text = rendered_text(&wide_terminal); + assert!(wide_text.contains("src/b.rs")); + assert!(wide_text.contains("+new")); + + let mut narrow = WorkspaceDiffViewState::new(); + narrow.show(snapshot); + let mut narrow_terminal = Terminal::new(TestBackend::new(60, 18)).expect("narrow terminal"); + narrow_terminal + .draw(|frame| narrow.render(frame, frame.area(), &Theme::dark_ansi16())) + .expect("render narrow diff"); + let narrow_text = rendered_text(&narrow_terminal); + assert!(!narrow_text.contains("src/b.rs")); + assert!(narrow_text.contains("+new")); + assert!(narrow_text.contains("unstaged")); + } + + #[test] + fn narrow_render_keeps_the_truncated_badge_visible() { + let mut view = WorkspaceDiffViewState::new(); + view.show(WorkspaceDiffSnapshot { + files: vec![file("src/a.rs", "@@ -1 +1 @@\n-old\n+new\n")], + truncated: true, + }); + let mut terminal = Terminal::new(TestBackend::new(60, 10)).expect("narrow terminal"); + + terminal + .draw(|frame| view.render(frame, frame.area(), &Theme::dark_ansi16())) + .expect("render truncated diff"); + + assert!(rendered_text(&terminal).contains("truncated")); + } + + #[test] + fn render_can_reach_patch_lines_beyond_u16_scroll_range() { + let patch = (0..70_000) + .map(|index| format!("line {index}\n")) + .collect::(); + let mut view = WorkspaceDiffViewState::new(); + view.show(WorkspaceDiffSnapshot { + files: vec![file("many-lines.rs", &patch)], + truncated: false, + }); + let mut terminal = Terminal::new(TestBackend::new(60, 8)).expect("terminal"); + + terminal + .draw(|frame| view.render(frame, frame.area(), &Theme::dark_ansi16())) + .expect("prime viewport size"); + view.handle_key_event(key(KeyCode::End)); + + terminal + .draw(|frame| view.render(frame, frame.area(), &Theme::dark_ansi16())) + .expect("render long diff"); + + assert!(rendered_text(&terminal).contains("line 69999")); + } + + #[test] + fn render_preserves_tab_indentation_with_terminal_safe_spaces() { + let mut view = WorkspaceDiffViewState::new(); + view.show(WorkspaceDiffSnapshot { + files: vec![file("tabs.rs", "+\tindented\n")], + truncated: false, + }); + let mut terminal = Terminal::new(TestBackend::new(60, 8)).expect("terminal"); + + terminal + .draw(|frame| view.render(frame, frame.area(), &Theme::dark_ansi16())) + .expect("render tabbed diff"); + + assert!(rendered_text(&terminal).contains("+ indented")); + } + + #[test] + fn render_explains_conflicts_and_combines_change_sources() { + let mut conflict = file("conflict.rs", ""); + conflict.status = WorkspaceDiffFileStatus::Conflicted; + conflict.staged = true; + conflict.unstaged = true; + conflict.untracked = true; + let mut view = WorkspaceDiffViewState::new(); + view.show(WorkspaceDiffSnapshot { + files: vec![conflict], + truncated: false, + }); + let mut terminal = Terminal::new(TestBackend::new(72, 8)).expect("terminal"); + + terminal + .draw(|frame| view.render(frame, frame.area(), &Theme::dark_ansi16())) + .expect("render conflict"); + let text = rendered_text(&terminal); + + assert!(text.contains("staged+unstaged+untracked")); + assert!(text.contains("Conflict content is not shown as a regular two-way diff.")); + } + + fn rendered_text(terminal: &Terminal) -> String { + let buffer = terminal.backend().buffer(); + (0..buffer.area.height) + .map(|y| { + (0..buffer.area.width) + .map(|x| buffer[(x, y)].symbol()) + .collect::() + }) + .collect::>() + .join("\n") + } +} diff --git a/src/crates/adapters/agent-runtime-ipc/AGENTS-CN.md b/src/crates/adapters/agent-runtime-ipc/AGENTS-CN.md index cf779d66bf..9cb0d3642d 100644 --- a/src/crates/adapters/agent-runtime-ipc/AGENTS-CN.md +++ b/src/crates/adapters/agent-runtime-ipc/AGENTS-CN.md @@ -15,7 +15,7 @@ ## 边界 - 只导出 CLI adapter 实际使用的 workspace-private API,且 crate 不得发布,也不得把 wire 作为 SDK 合同。 -- 封闭 operation 范围为 Health、Session list/create/restore/delete/fork(restore/fork 结果包含 transcript)、当前 Session rename、Agent mode/model update、手动 context compaction、Session undo/redo、current-controller 限定的只读工作区引用搜索/持久化引用读取、声明式上下文 reload、Turn submit/cancel、pending/respond Permission 和 UserInput answers。delete 只允许作用于未被任何 Client 控制的空闲 Session。fork 要求当前 controller 且 Session 空闲:可以复制到最新持久化 Turn,也可以停在显式选中 Turn 之前;只有包含新 Session 与 transcript 的成功结果完成编码后,Server 才能把连接 lease 从源 Session 原子切换到 fork。手动 compaction 要求当前 controller 且 Session 空闲;Client 在准入前提供精确 Turn ID,使超时或断连 cleanup 可以取消同一个 owned task;Core 开始原子 context commit 后,晚到取消不能暴露错误的空闲状态。undo/redo 要求当前 controller,但可在活动 Turn 中进入,因为取消、drain 与回退写入顺序由 Core 统一负责;成功结果携带权威 transcript,并清除连接侧活动 Turn 投影。该能力只支持本地工作区,不暴露通用 checkpoint 协议。上下文 reload 可在活动 Turn 中执行,不改写该 Turn,并通过缓存保护保证下一条消息重新读取已失效的 instructions。断连 cleanup 属于内部生命周期,不是 detach operation。模型目录和默认值仍是 wire 之外的产品配置;禁止顺带加入 archive、replay、observer、通用 controller transfer、Tool/MCP/Hook 管理或其他产品配置。 +- 封闭 operation 范围为 Health、Session list/create/restore/delete/fork(restore/fork 结果包含 transcript)、当前 Session rename、Agent mode/model update、手动 context compaction、Session undo/redo、current-controller 限定的只读工作区引用搜索/持久化引用读取,以及不取得 Session lease 的 Runtime 绑定工作区只读 diff;此外还包括声明式上下文 reload、Turn submit/cancel、pending/respond Permission 和 UserInput answers。delete 只允许作用于未被任何 Client 控制的空闲 Session。fork 要求当前 controller 且 Session 空闲:可以复制到最新持久化 Turn,也可以停在显式选中 Turn 之前;只有包含新 Session 与 transcript 的成功结果完成编码后,Server 才能把连接 lease 从源 Session 原子切换到 fork。手动 compaction 要求当前 controller 且 Session 空闲;Client 在准入前提供精确 Turn ID,使超时或断连 cleanup 可以取消同一个 owned task;Core 开始原子 context commit 后,晚到取消不能暴露错误的空闲状态。undo/redo 要求当前 controller,但可在活动 Turn 中进入,因为取消、drain 与回退写入顺序由 Core 统一负责;成功结果携带权威 transcript,并清除连接侧活动 Turn 投影。该能力只支持本地工作区,不暴露通用 checkpoint 协议。上下文 reload 可在活动 Turn 中执行,不改写该 Turn,并通过缓存保护保证下一条消息重新读取已失效的 instructions。断连 cleanup 属于内部生命周期,不是 detach operation。模型目录和默认值仍是 wire 之外的产品配置;禁止顺带加入 archive、replay、observer、通用 controller transfer、Tool/MCP/Hook 管理或其他产品配置。 - 可以复用稳定 Event、Product Domain 和 Runtime Port DTO。禁止依赖 `bitfun-core`、Agent Runtime 实现、SDK Host、services、Tauri、terminal、tool runtime 或远程 transport。 - 只使用 Windows Named Pipe 或 Unix Domain Socket;禁止 TCP、HTTP、WebSocket、浏览器访问或远程 fallback。 - 这是本机同用户隔离,不是沙箱。未来产品 composition 必须提供当前用户私有 runtime 目录。 diff --git a/src/crates/adapters/agent-runtime-ipc/AGENTS.md b/src/crates/adapters/agent-runtime-ipc/AGENTS.md index 33f892bb54..8749773ffe 100644 --- a/src/crates/adapters/agent-runtime-ipc/AGENTS.md +++ b/src/crates/adapters/agent-runtime-ipc/AGENTS.md @@ -22,7 +22,7 @@ session controller leases, event delivery, connection bounds, and cleanup. It is - Export only the exact workspace-private API needed by the CLI adapter. Do not publish this crate or expose its wire as an SDK contract. -- The closed operation budget is Health, Session list/create/restore/delete/fork (including transcript on restore/fork), current-Session rename, Agent mode/model update, manual context compaction, Session undo/redo, and current-controller read-only workspace-reference search/persisted-reference lookup, +- The closed operation budget is Health, Session list/create/restore/delete/fork (including transcript on restore/fork), current-Session rename, Agent mode/model update, manual context compaction, Session undo/redo, current-controller read-only workspace-reference search/persisted-reference lookup, and a read-only diff of the Runtime-bound workspace that does not acquire a Session lease, declarative context reload, Turn submit/cancel, pending/respond Permission, and UserInput answers. Delete is limited to an idle Session not controlled by any client. Fork is a current-controller, idle-only operation. It either copies through the latest persisted Turn or stops immediately before an explicitly selected Turn. The encoded success result carries the authoritative new Session and transcript; only then may the server atomically switch the connection lease from the source Session to the fork. Manual compaction is a current-controller, idle-only Turn operation. The client supplies its exact Turn ID before admission so timeout or disconnect cleanup can cancel the same owned task; once Core begins the atomic context commit, a late cancellation does not expose a false idle state. diff --git a/src/crates/adapters/agent-runtime-ipc/src/operation.rs b/src/crates/adapters/agent-runtime-ipc/src/operation.rs index da789c23d6..d66d115228 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/operation.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/operation.rs @@ -6,6 +6,7 @@ use bitfun_runtime_ports::{ AgentSessionRevertRequest, AgentSessionRevertResult, AgentSessionSummary, AgentTurnCancellationRequest, AgentTurnCancellationResult, AgentWorkspaceReference, AgentWorkspaceReferenceSearchRequest, AgentWorkspaceReferenceSearchResult, SessionTranscript, + WorkspaceDiffSnapshot, }; use serde::{Deserialize, Serialize}; @@ -92,6 +93,7 @@ pub enum RuntimeIpcOperation { WorkspaceReferencesForMessage { request: AgentMessageWorkspaceReferencesRequest, }, + WorkspaceDiff, SubmitTurn { request: AgentDialogTurnRequest, }, @@ -131,7 +133,10 @@ impl RuntimeIpcOperation { Self::PendingPermissions { session_id } | Self::RespondPermission { session_id, .. } => Some(session_id), Self::SubmitUserAnswers { request } => Some(&request.session_id), - Self::Health | Self::ListSessions { .. } | Self::CreateSession { .. } => None, + Self::Health + | Self::ListSessions { .. } + | Self::CreateSession { .. } + | Self::WorkspaceDiff => None, } } @@ -144,6 +149,7 @@ impl RuntimeIpcOperation { Self::Health | Self::ListSessions { .. } => { RuntimeIpcOperationRules::new(None, false, false, false) } + Self::WorkspaceDiff => RuntimeIpcOperationRules::new(None, true, false, false), Self::CreateSession { .. } => RuntimeIpcOperationRules::new(None, true, true, true), Self::RestoreSession { .. } => { RuntimeIpcOperationRules::new(AttachExisting, true, true, true) @@ -258,6 +264,9 @@ pub enum RuntimeIpcOperationResult { WorkspaceReferences { references: Vec, }, + WorkspaceDiff { + snapshot: WorkspaceDiffSnapshot, + }, } #[cfg(test)] diff --git a/src/crates/adapters/agent-runtime-ipc/src/protocol.rs b/src/crates/adapters/agent-runtime-ipc/src/protocol.rs index ae9ecf75a2..8134b8905c 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/protocol.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/protocol.rs @@ -5,7 +5,7 @@ use crate::{RuntimeIpcOperation, RuntimeIpcOperationResult}; use bitfun_events::AgenticEventEnvelope; use bitfun_product_domains::tool_permissions::PermissionRequestEvent; -pub const PROTOCOL_VERSION: u32 = 10; +pub const PROTOCOL_VERSION: u32 = 11; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] diff --git a/src/crates/adapters/agent-runtime-ipc/src/tests/protocol_contracts.rs b/src/crates/adapters/agent-runtime-ipc/src/tests/protocol_contracts.rs index 7a6ecba520..41159abd7c 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/tests/protocol_contracts.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/tests/protocol_contracts.rs @@ -1,8 +1,9 @@ use crate::operation::RuntimeIpcSessionRequirement; use crate::{ serialize_frame_with_limit, InitializeRequest, RuntimeIpcError, RuntimeIpcErrorCode, - RuntimeIpcFrame, RuntimeIpcOperation, RuntimeSessionForkRequest, RuntimeSessionRenameRequest, - RuntimeUserAnswersRequest, MAX_REQUEST_FRAME_BYTES, PROTOCOL_VERSION, + RuntimeIpcFrame, RuntimeIpcOperation, RuntimeIpcOperationResult, RuntimeSessionForkRequest, + RuntimeSessionRenameRequest, RuntimeUserAnswersRequest, MAX_REQUEST_FRAME_BYTES, + PROTOCOL_VERSION, }; use bitfun_product_domains::tool_permissions::PermissionReply; @@ -11,6 +12,7 @@ use bitfun_runtime_ports::{ AgentMessageWorkspaceReferencesRequest, AgentSessionCompactionRequest, AgentSessionModeUpdateRequest, AgentSessionModelUpdateRequest, AgentSessionRevertRequest, AgentSubmissionSource, AgentWorkspaceReferenceSearchRequest, DialogSubmissionPolicy, + WorkspaceDiffContent, WorkspaceDiffFile, WorkspaceDiffFileStatus, WorkspaceDiffSnapshot, }; use serde_json::{json, Map}; @@ -104,6 +106,50 @@ fn protocol_round_trips_read_only_workspace_reference_operations() { } } +#[test] +fn protocol_round_trips_workspace_diff_as_a_read_only_workspace_operation() { + assert_eq!(PROTOCOL_VERSION, 11); + + let operation = RuntimeIpcOperation::WorkspaceDiff; + let encoded = serde_json::to_value(&operation).expect("serialize workspace diff operation"); + assert_eq!(encoded, json!({"operation": "workspace_diff"})); + let decoded: RuntimeIpcOperation = + serde_json::from_value(encoded).expect("deserialize workspace diff operation"); + assert_eq!(decoded, operation); + assert_eq!(decoded.session_id(), None); + let rules = decoded.rules(); + assert_eq!( + rules.session_requirement, + RuntimeIpcSessionRequirement::None + ); + assert!(rules.requires_idle); + assert!(!rules.serializes_session_selection); + assert!(!rules.side_effecting); + + let result = RuntimeIpcOperationResult::WorkspaceDiff { + snapshot: WorkspaceDiffSnapshot { + files: vec![WorkspaceDiffFile { + path: "src/main.rs".to_string(), + old_path: None, + status: WorkspaceDiffFileStatus::Modified, + staged: false, + unstaged: true, + untracked: false, + additions: 1, + deletions: 1, + content: WorkspaceDiffContent::Text { + patch: "@@ -1 +1 @@\n-old\n+new\n".to_string(), + }, + }], + truncated: false, + }, + }; + let encoded = serde_json::to_value(&result).expect("serialize workspace diff result"); + let decoded: RuntimeIpcOperationResult = + serde_json::from_value(encoded).expect("deserialize workspace diff result"); + assert_eq!(decoded, result); +} + #[test] fn protocol_round_trips_the_reviewed_session_mode_operation() { let operation = RuntimeIpcOperation::UpdateSessionMode { @@ -154,7 +200,7 @@ fn protocol_round_trips_the_reviewed_session_model_operation() { #[test] fn protocol_round_trips_the_current_session_rename_operation() { - assert_eq!(PROTOCOL_VERSION, 10); + assert_eq!(PROTOCOL_VERSION, 11); let operation = RuntimeIpcOperation::RenameSession { request: RuntimeSessionRenameRequest { diff --git a/src/crates/assembly/core/src/product_runtime/runtime_services.rs b/src/crates/assembly/core/src/product_runtime/runtime_services.rs index 2eb192847d..94f72e9f16 100644 --- a/src/crates/assembly/core/src/product_runtime/runtime_services.rs +++ b/src/crates/assembly/core/src/product_runtime/runtime_services.rs @@ -124,11 +124,20 @@ impl CoreLocalRuntimeServicesProvider { impl RuntimeServicesProvider for CoreLocalRuntimeServicesProvider { fn register(&self, builder: RuntimeServicesBuilder) -> RuntimeServicesBuilder { - builder + let builder = builder .with_filesystem(self.ports.filesystem()) .with_workspace(self.ports.workspace()) .with_events(self.ports.events()) - .with_clock(self.ports.clock()) + .with_clock(self.ports.clock()); + + #[cfg(feature = "service-integrations")] + let builder = builder.with_optional_git(Some(Arc::new( + bitfun_services_integrations::git::GitWorkspaceDiffPort::new( + self.ports.workspace_root(), + ), + ))); + + builder } } @@ -153,7 +162,7 @@ pub fn build_local_runtime_services( #[cfg(test)] mod local_runtime_tests { use super::build_local_runtime_services; - use bitfun_runtime_ports::RuntimeServiceCapability; + use bitfun_runtime_ports::{RuntimeServiceCapability, WorkspaceDiffContent}; #[test] fn local_runtime_services_bind_required_core_and_workspace_ports() { @@ -180,6 +189,28 @@ mod local_runtime_tests { assert!(services.clock.now_unix_millis() > 0); } + #[tokio::test] + async fn local_runtime_services_bind_git_queries_to_the_canonical_workspace() { + let workspace = tempfile::tempdir().expect("workspace"); + git2::Repository::init(workspace.path()).expect("git repository"); + std::fs::write(workspace.path().join("new.txt"), "new file\n").expect("workspace file"); + + let (_, services) = + build_local_runtime_services(workspace.path(), 8).expect("local runtime services"); + let snapshot = services + .git + .expect("git capability") + .workspace_diff() + .await + .expect("real workspace diff provider"); + + assert!(snapshot.files.iter().any(|file| { + file.path == "new.txt" + && file.untracked + && matches!(file.content, WorkspaceDiffContent::Text { .. }) + })); + } + #[test] fn local_runtime_services_reject_a_missing_workspace() { let temp = tempfile::tempdir().expect("tempdir"); diff --git a/src/crates/contracts/runtime-ports/src/lib.rs b/src/crates/contracts/runtime-ports/src/lib.rs index 971a1b5734..33d6e84308 100644 --- a/src/crates/contracts/runtime-ports/src/lib.rs +++ b/src/crates/contracts/runtime-ports/src/lib.rs @@ -811,7 +811,55 @@ pub trait RemoteExecPort: RuntimeServicePort + std::fmt::Debug { pub trait NetworkPort: RuntimeServicePort {} -pub trait GitPort: RuntimeServicePort {} +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WorkspaceDiffFileStatus { + Added, + Modified, + Deleted, + Renamed, + Conflicted, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum WorkspaceDiffContent { + Text { patch: String }, + Binary, + TooLarge, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceDiffFile { + pub path: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub old_path: Option, + pub status: WorkspaceDiffFileStatus, + pub staged: bool, + pub unstaged: bool, + pub untracked: bool, + pub additions: usize, + pub deletions: usize, + pub content: WorkspaceDiffContent, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceDiffSnapshot { + pub files: Vec, + pub truncated: bool, +} + +#[async_trait::async_trait] +pub trait GitPort: RuntimeServicePort { + async fn workspace_diff(&self) -> PortResult { + Err(PortError::new( + PortErrorKind::NotAvailable, + "workspace diff is not supported by this provider", + )) + } +} pub trait McpCatalogPort: RuntimeServicePort {} diff --git a/src/crates/contracts/runtime-ports/tests/git_port_contracts.rs b/src/crates/contracts/runtime-ports/tests/git_port_contracts.rs new file mode 100644 index 0000000000..24712dcb21 --- /dev/null +++ b/src/crates/contracts/runtime-ports/tests/git_port_contracts.rs @@ -0,0 +1,31 @@ +use bitfun_runtime_ports::{ + WorkspaceDiffContent, WorkspaceDiffFile, WorkspaceDiffFileStatus, WorkspaceDiffSnapshot, +}; + +#[test] +fn workspace_diff_snapshot_round_trips_stable_file_facts() { + let snapshot = WorkspaceDiffSnapshot { + files: vec![WorkspaceDiffFile { + path: "src/main.rs".to_string(), + old_path: None, + status: WorkspaceDiffFileStatus::Modified, + staged: true, + unstaged: true, + untracked: false, + additions: 3, + deletions: 1, + content: WorkspaceDiffContent::Text { + patch: "@@ -1 +1 @@\n-old\n+new\n".to_string(), + }, + }], + truncated: false, + }; + + let encoded = serde_json::to_value(&snapshot).expect("serialize workspace diff"); + assert_eq!(encoded["files"][0]["status"], "modified"); + assert_eq!(encoded["files"][0]["content"]["kind"], "text"); + + let decoded: WorkspaceDiffSnapshot = + serde_json::from_value(encoded).expect("deserialize workspace diff"); + assert_eq!(decoded, snapshot); +} diff --git a/src/crates/execution/agent-runtime/src/runtime.rs b/src/crates/execution/agent-runtime/src/runtime.rs index d972906186..782df7bcd4 100644 --- a/src/crates/execution/agent-runtime/src/runtime.rs +++ b/src/crates/execution/agent-runtime/src/runtime.rs @@ -32,7 +32,7 @@ use bitfun_runtime_ports::{ AgentWorkspaceReferenceSearchResult, DialogSubmitOutcome, PermissionAuditRecord, PermissionGrant, PermissionGrantKey, PluginRuntimeBinding, PortError, PortErrorKind, PortResult, RuntimeEventEnvelope, SessionTranscript, SessionTranscriptReader, - SessionTranscriptRequest, ThreadGoal, + SessionTranscriptRequest, ThreadGoal, WorkspaceDiffSnapshot, }; use bitfun_runtime_services::RuntimeServices; @@ -890,6 +890,22 @@ impl AgentRuntime { self.services.as_ref() } + pub async fn workspace_diff(&self) -> Result { + let services = self.services.as_ref().ok_or_else(|| { + PortError::new( + PortErrorKind::NotAvailable, + "runtime services are not registered", + ) + })?; + let git = services.git.as_ref().ok_or_else(|| { + PortError::new( + PortErrorKind::NotAvailable, + "Git runtime service is not registered", + ) + })?; + git.workspace_diff().await.map_err(RuntimeError::from) + } + pub fn registered_tool_names(&self) -> Vec { self.tool_registry .as_ref() diff --git a/src/crates/execution/agent-runtime/src/sdk.rs b/src/crates/execution/agent-runtime/src/sdk.rs index f7cc9fc6eb..c7db2ed7da 100644 --- a/src/crates/execution/agent-runtime/src/sdk.rs +++ b/src/crates/execution/agent-runtime/src/sdk.rs @@ -90,7 +90,8 @@ pub use bitfun_runtime_ports::{ RuntimeEventType, RuntimeServiceCapability, RuntimeServicePort, SessionStorageKind, SessionStoragePathRequest, SessionStoragePathResolution, SessionStorePort, SessionTranscript, SessionTranscriptReader, SessionTranscriptRequest, TerminalPort, ThreadGoal, ThreadGoalStatus, - TranscriptContent, TranscriptMessage, TranscriptToolCall, WorkspacePort, + TranscriptContent, TranscriptMessage, TranscriptToolCall, WorkspaceDiffContent, + WorkspaceDiffFile, WorkspaceDiffFileStatus, WorkspaceDiffSnapshot, WorkspacePort, }; pub use bitfun_runtime_services::{ CapabilityAvailability, RuntimeServices, RuntimeServicesBuilder, RuntimeServicesError, @@ -379,6 +380,10 @@ impl AgentRuntime { self.inner.services() } + pub async fn workspace_diff(&self) -> Result { + self.inner.workspace_diff().await + } + pub fn registered_tool_names(&self) -> Vec { self.inner.registered_tool_names() } diff --git a/src/crates/execution/agent-runtime/tests/sdk_smoke.rs b/src/crates/execution/agent-runtime/tests/sdk_smoke.rs index 2ac325300c..1dbc95aa10 100644 --- a/src/crates/execution/agent-runtime/tests/sdk_smoke.rs +++ b/src/crates/execution/agent-runtime/tests/sdk_smoke.rs @@ -7,13 +7,15 @@ use bitfun_agent_runtime::sdk::{ AgentRuntimeSdkCompatibility, AgentRuntimeSdkStability, AgentSessionClosePort, AgentSessionCreateRequest, AgentSessionCreateResult, AgentSubmissionPort, AgentSubmissionRequest, AgentSubmissionResult, AgentSubmissionSource, - AgentTransientSessionDiscardRequest, ClockPort, FileSystemPort, HarnessCapability, + AgentTransientSessionDiscardRequest, ClockPort, FileSystemPort, GitPort, HarnessCapability, HarnessProviderDescriptor, HarnessWorkflow, PortErrorKind, PortResult, RuntimeAgentRegistry, RuntimeAgentRegistryQuery, RuntimeError, RuntimeEventEnvelope, RuntimeEventSink, RuntimeEventType, RuntimeHookErrorPolicy, RuntimeHookKind, RuntimeHookPlan, RuntimeHookRegistry, RuntimeServiceCapability, RuntimeServicePort, RuntimeServices, RuntimeServicesBuilder, SessionSelector, SessionStorageKind, SessionStoragePathRequest, - SessionStoragePathResolution, SessionStorePort, ToolRegistry, ToolRegistryItem, WorkspacePort, + SessionStoragePathResolution, SessionStorePort, ToolRegistry, ToolRegistryItem, + WorkspaceDiffContent, WorkspaceDiffFile, WorkspaceDiffFileStatus, WorkspaceDiffSnapshot, + WorkspacePort, }; use serde_json::{json, Value}; @@ -36,6 +38,9 @@ struct FakeSdkRuntimePort { capability: RuntimeServiceCapability, } +#[derive(Debug)] +struct FakeSdkGitPort; + #[derive(Debug, Default)] struct FakeSdkRuntimeEventSink; @@ -78,6 +83,34 @@ impl RuntimeServicePort for FakeSdkRuntimePort { impl FileSystemPort for FakeSdkRuntimePort {} impl WorkspacePort for FakeSdkRuntimePort {} +#[async_trait] +impl GitPort for FakeSdkGitPort { + async fn workspace_diff(&self) -> PortResult { + Ok(WorkspaceDiffSnapshot { + files: vec![WorkspaceDiffFile { + path: "src/lib.rs".to_string(), + old_path: None, + status: WorkspaceDiffFileStatus::Modified, + staged: false, + unstaged: true, + untracked: false, + additions: 1, + deletions: 1, + content: WorkspaceDiffContent::Text { + patch: "@@ -1 +1 @@\n-old\n+new\n".to_string(), + }, + }], + truncated: false, + }) + } +} + +impl RuntimeServicePort for FakeSdkGitPort { + fn capability(&self) -> RuntimeServiceCapability { + RuntimeServiceCapability::Git + } +} + #[async_trait] impl AgentSessionClosePort for FakeSessionClosePort { async fn discard_transient_session( @@ -137,6 +170,26 @@ fn fake_sdk_services() -> RuntimeServices { .expect("fake SDK services") } +fn fake_sdk_services_with_git() -> RuntimeServices { + RuntimeServicesBuilder::new() + .with_filesystem(Arc::new(FakeSdkRuntimePort::new( + RuntimeServiceCapability::FileSystem, + ))) + .with_workspace(Arc::new(FakeSdkRuntimePort::new( + RuntimeServiceCapability::Workspace, + ))) + .with_session_store(Arc::new(FakeSdkRuntimePort::new( + RuntimeServiceCapability::SessionStore, + ))) + .with_events(Arc::new(FakeSdkRuntimeEventSink)) + .with_clock(Arc::new(FakeSdkRuntimePort::new( + RuntimeServiceCapability::Clock, + ))) + .with_optional_git(Some(Arc::new(FakeSdkGitPort))) + .build() + .expect("fake SDK services with Git") +} + #[async_trait] impl ToolRegistryItem for FakeSdkTool { fn name(&self) -> &str { @@ -300,6 +353,20 @@ async fn sdk_facade_accepts_fake_services_tools_harnesses_and_hooks_without_core .has_capability(RuntimeServiceCapability::SessionStore)); } +#[tokio::test] +async fn sdk_facade_delegates_workspace_diff_to_runtime_services() { + let runtime = AgentRuntimeBuilder::new() + .with_submission_port(Arc::new(FakeSdkAgentProvider::default())) + .with_services(fake_sdk_services_with_git()) + .build() + .expect("sdk runtime"); + + let snapshot = runtime.workspace_diff().await.expect("workspace diff"); + + assert_eq!(snapshot.files.len(), 1); + assert_eq!(snapshot.files[0].path, "src/lib.rs"); +} + #[tokio::test] async fn sdk_facade_delegates_connection_scoped_session_discard() { let provider = Arc::new(FakeSdkAgentProvider::default()); diff --git a/src/crates/services/services-integrations/Cargo.toml b/src/crates/services/services-integrations/Cargo.toml index 31376cf6fc..eb7aab7fce 100644 --- a/src/crates/services/services-integrations/Cargo.toml +++ b/src/crates/services/services-integrations/Cargo.toml @@ -105,7 +105,14 @@ canvas-runtime = [ ] debug-log = ["anyhow", "chrono", "reqwest", "uuid"] deep-research = ["bitfun-agent-runtime"] -git = ["bitfun-services-core", "chrono", "git2", "thiserror"] +git = [ + "async-trait", + "bitfun-runtime-ports", + "bitfun-services-core", + "chrono", + "git2", + "thiserror", +] file-watch = ["notify"] function-agents = [ "bitfun-product-domains/function-agents", diff --git a/src/crates/services/services-integrations/src/git/mod.rs b/src/crates/services/services-integrations/src/git/mod.rs index 4139df244c..aae63a6089 100644 --- a/src/crates/services/services-integrations/src/git/mod.rs +++ b/src/crates/services/services-integrations/src/git/mod.rs @@ -8,6 +8,7 @@ pub mod error; pub mod graph; mod managed_worktree; pub mod name_status; +mod runtime_port; pub mod service; pub mod text; pub mod types; @@ -18,6 +19,7 @@ pub use args::{build_git_changed_files_args, build_git_diff_args}; pub use error::GitError; pub use graph::{build_git_graph, build_git_graph_for_branch}; pub use name_status::parse_name_status_output; +pub use runtime_port::GitWorkspaceDiffPort; pub use service::GitService; pub use text::{parse_branch_line, parse_git_log_line}; pub use types::*; diff --git a/src/crates/services/services-integrations/src/git/runtime_port.rs b/src/crates/services/services-integrations/src/git/runtime_port.rs new file mode 100644 index 0000000000..253b44f56e --- /dev/null +++ b/src/crates/services/services-integrations/src/git/runtime_port.rs @@ -0,0 +1,330 @@ +use std::{ + collections::BTreeMap, + path::{Path, PathBuf}, +}; + +use bitfun_runtime_ports::{ + GitPort, PortError, PortErrorKind, PortResult, RuntimeServiceCapability, RuntimeServicePort, + WorkspaceDiffContent, WorkspaceDiffFile, WorkspaceDiffFileStatus, WorkspaceDiffSnapshot, +}; +use git2::{ + Delta, DiffFindOptions, DiffFlags, DiffOptions, Patch, Repository, Status, StatusOptions, +}; + +use super::GitError; + +const MAX_WORKSPACE_DIFF_FILES: usize = 256; +const MAX_WORKSPACE_DIFF_FILE_BYTES: usize = 1024 * 1024; +const MAX_WORKSPACE_DIFF_TOTAL_BYTES: usize = 3 * 1024 * 1024; + +#[derive(Debug, Clone)] +pub struct GitWorkspaceDiffPort { + workspace_root: PathBuf, +} + +impl GitWorkspaceDiffPort { + pub fn new(workspace_root: impl AsRef) -> Self { + Self { + workspace_root: workspace_root.as_ref().to_path_buf(), + } + } +} + +impl RuntimeServicePort for GitWorkspaceDiffPort { + fn capability(&self) -> RuntimeServiceCapability { + RuntimeServiceCapability::Git + } +} + +#[async_trait::async_trait] +impl GitPort for GitWorkspaceDiffPort { + async fn workspace_diff(&self) -> PortResult { + let workspace_root = self.workspace_root.clone(); + tokio::task::spawn_blocking(move || collect_workspace_diff(&workspace_root)) + .await + .map_err(|error| PortError::new(PortErrorKind::Backend, error.to_string()))? + .map_err(map_git_error) + } +} + +fn collect_workspace_diff(workspace_root: &Path) -> Result { + let repository = Repository::discover(workspace_root) + .map_err(|error| GitError::RepositoryNotFound(error.to_string()))?; + let repository_root = repository + .workdir() + .ok_or_else(|| GitError::InvalidPath("Repository has no working directory".to_string()))? + .canonicalize()?; + let canonical_workspace = workspace_root.canonicalize()?; + let workspace_prefix = canonical_workspace + .strip_prefix(&repository_root) + .map_err(|_| { + GitError::InvalidPath("Workspace is outside the discovered repository".to_string()) + })?; + + let head_tree = match repository.head() { + Ok(head) => Some(head.peel_to_tree()?), + Err(error) if error.code() == git2::ErrorCode::UnbornBranch => None, + Err(error) => return Err(error.into()), + }; + let mut options = DiffOptions::new(); + options + .include_untracked(true) + .recurse_untracked_dirs(true) + .show_untracked_content(true) + .include_typechange(true) + .max_size(MAX_WORKSPACE_DIFF_FILE_BYTES as i64); + if !workspace_prefix.as_os_str().is_empty() { + options + .disable_pathspec_match(true) + .pathspec(normalize_path(workspace_prefix)); + } + let mut statuses = collect_workspace_statuses(&repository, workspace_prefix)?; + let mut diff = + repository.diff_tree_to_workdir_with_index(head_tree.as_ref(), Some(&mut options))?; + diff.find_similar(Some(DiffFindOptions::new().renames(true)))?; + + let mut files = Vec::new(); + let mut total_patch_bytes = 0usize; + let mut patch_budget_exhausted = false; + let mut truncated = diff.deltas().len() > MAX_WORKSPACE_DIFF_FILES; + + for (index, delta) in diff.deltas().take(MAX_WORKSPACE_DIFF_FILES).enumerate() { + let new_repository_path = delta.new_file().path(); + let old_repository_path = delta.old_file().path(); + let new_path = scoped_path(new_repository_path, workspace_prefix); + let old_path = scoped_path(old_repository_path, workspace_prefix); + let (display_repository_path, display_path) = new_repository_path + .zip(new_path) + .or_else(|| old_repository_path.zip(old_path)) + .ok_or_else(|| { + GitError::ParseError("Git diff entry escaped the bound workspace".to_string()) + })?; + let path = normalize_path(display_path); + let mut status = statuses.remove(&path).unwrap_or(Status::CURRENT); + if let Some(old_path) = old_path { + status |= statuses + .remove(&normalize_path(old_path)) + .unwrap_or(Status::CURRENT); + } + if status == Status::CURRENT { + status = repository + .status_file(display_repository_path) + .unwrap_or(Status::CURRENT); + } + let file_status = workspace_diff_status( + delta.status(), + status, + old_path.is_some(), + new_path.is_some(), + ); + let (additions, deletions, content) = if file_status == WorkspaceDiffFileStatus::Conflicted + { + ( + 0, + 0, + WorkspaceDiffContent::Text { + patch: String::new(), + }, + ) + } else if patch_budget_exhausted { + (0, 0, WorkspaceDiffContent::TooLarge) + } else if delta.old_file().size() > MAX_WORKSPACE_DIFF_FILE_BYTES as u64 + || delta.new_file().size() > MAX_WORKSPACE_DIFF_FILE_BYTES as u64 + { + truncated = true; + (0, 0, WorkspaceDiffContent::TooLarge) + } else { + workspace_diff_content(&diff, index, delta.flags())? + }; + + let content = match content { + WorkspaceDiffContent::Text { patch } if patch.len() > MAX_WORKSPACE_DIFF_FILE_BYTES => { + truncated = true; + WorkspaceDiffContent::TooLarge + } + WorkspaceDiffContent::Text { patch } + if total_patch_bytes.saturating_add(patch.len()) + > MAX_WORKSPACE_DIFF_TOTAL_BYTES => + { + truncated = true; + patch_budget_exhausted = true; + WorkspaceDiffContent::TooLarge + } + WorkspaceDiffContent::Text { patch } => { + total_patch_bytes = total_patch_bytes.saturating_add(patch.len()); + WorkspaceDiffContent::Text { patch } + } + other => other, + }; + + files.push(WorkspaceDiffFile { + path, + old_path: (delta.status() == Delta::Renamed) + .then_some(old_path) + .flatten() + .map(normalize_path), + status: file_status, + staged: status.intersects(index_statuses()), + unstaged: status.intersects(unstaged_statuses()), + untracked: status.contains(Status::WT_NEW) || delta.status() == Delta::Untracked, + additions, + deletions, + content, + }); + } + + for (path, status) in statuses { + if status == Status::CURRENT { + continue; + } + if files.len() >= MAX_WORKSPACE_DIFF_FILES { + truncated = true; + break; + } + files.push(WorkspaceDiffFile { + path, + old_path: None, + status: workspace_status_from_flags(status), + staged: status.intersects(index_statuses()), + unstaged: status.intersects(unstaged_statuses()), + untracked: status.contains(Status::WT_NEW), + additions: 0, + deletions: 0, + content: WorkspaceDiffContent::Text { + patch: String::new(), + }, + }); + } + + files.sort_by(|left, right| left.path.cmp(&right.path)); + Ok(WorkspaceDiffSnapshot { files, truncated }) +} + +fn collect_workspace_statuses( + repository: &Repository, + workspace_prefix: &Path, +) -> Result, GitError> { + let mut options = StatusOptions::new(); + options + .include_untracked(true) + .recurse_untracked_dirs(true) + .renames_head_to_index(true) + .renames_index_to_workdir(true); + if !workspace_prefix.as_os_str().is_empty() { + options + .disable_pathspec_match(true) + .pathspec(normalize_path(workspace_prefix)); + } + + let statuses = repository.statuses(Some(&mut options))?; + let mut by_path = BTreeMap::new(); + for entry in statuses.iter() { + let repository_path = + PathBuf::from(String::from_utf8_lossy(entry.path_bytes()).into_owned()); + let Some(path) = scoped_path(Some(&repository_path), workspace_prefix) else { + continue; + }; + by_path + .entry(normalize_path(path)) + .and_modify(|status| *status |= entry.status()) + .or_insert(entry.status()); + } + Ok(by_path) +} + +fn scoped_path<'a>(path: Option<&'a Path>, workspace_prefix: &Path) -> Option<&'a Path> { + path?.strip_prefix(workspace_prefix).ok() +} + +fn normalize_path(path: &Path) -> String { + path.to_string_lossy().replace('\\', "/") +} + +fn workspace_diff_content( + diff: &git2::Diff<'_>, + index: usize, + flags: DiffFlags, +) -> Result<(usize, usize, WorkspaceDiffContent), GitError> { + if flags.contains(DiffFlags::BINARY) { + return Ok((0, 0, WorkspaceDiffContent::Binary)); + } + let Some(mut patch) = Patch::from_diff(diff, index)? else { + return Ok((0, 0, WorkspaceDiffContent::Binary)); + }; + let patch_delta = patch.delta(); + if patch_delta.old_file().is_binary() || patch_delta.new_file().is_binary() { + return Ok((0, 0, WorkspaceDiffContent::Binary)); + } + let (_, additions, deletions) = patch.line_stats()?; + let buffer = patch.to_buf()?; + let Ok(text) = buffer.as_str() else { + return Ok((additions, deletions, WorkspaceDiffContent::Binary)); + }; + Ok(( + additions, + deletions, + WorkspaceDiffContent::Text { + patch: text.to_string(), + }, + )) +} + +fn workspace_diff_status( + delta: Delta, + status: Status, + old_in_workspace: bool, + new_in_workspace: bool, +) -> WorkspaceDiffFileStatus { + if status.contains(Status::CONFLICTED) || delta == Delta::Conflicted { + return WorkspaceDiffFileStatus::Conflicted; + } + match delta { + Delta::Renamed if !old_in_workspace && new_in_workspace => WorkspaceDiffFileStatus::Added, + Delta::Renamed if old_in_workspace && !new_in_workspace => WorkspaceDiffFileStatus::Deleted, + Delta::Added | Delta::Untracked => WorkspaceDiffFileStatus::Added, + Delta::Deleted => WorkspaceDiffFileStatus::Deleted, + Delta::Renamed => WorkspaceDiffFileStatus::Renamed, + _ => WorkspaceDiffFileStatus::Modified, + } +} + +fn workspace_status_from_flags(status: Status) -> WorkspaceDiffFileStatus { + if status.contains(Status::CONFLICTED) { + WorkspaceDiffFileStatus::Conflicted + } else if status.intersects(Status::INDEX_NEW | Status::WT_NEW) { + WorkspaceDiffFileStatus::Added + } else if status.intersects(Status::INDEX_DELETED | Status::WT_DELETED) { + WorkspaceDiffFileStatus::Deleted + } else if status.intersects(Status::INDEX_RENAMED | Status::WT_RENAMED) { + WorkspaceDiffFileStatus::Renamed + } else { + WorkspaceDiffFileStatus::Modified + } +} + +fn index_statuses() -> Status { + Status::INDEX_NEW + | Status::INDEX_MODIFIED + | Status::INDEX_DELETED + | Status::INDEX_RENAMED + | Status::INDEX_TYPECHANGE + | Status::CONFLICTED +} + +fn unstaged_statuses() -> Status { + Status::WT_MODIFIED + | Status::WT_DELETED + | Status::WT_RENAMED + | Status::WT_TYPECHANGE + | Status::CONFLICTED +} + +fn map_git_error(error: GitError) -> PortError { + let kind = match &error { + GitError::RepositoryNotFound(_) => PortErrorKind::NotFound, + GitError::InvalidPath(_) => PortErrorKind::InvalidRequest, + GitError::CommandFailed(message) if message.contains("timed out") => PortErrorKind::Timeout, + _ => PortErrorKind::Backend, + }; + PortError::new(kind, error.to_string()) +} diff --git a/src/crates/services/services-integrations/tests/git_contracts.rs b/src/crates/services/services-integrations/tests/git_contracts.rs index 9495333b39..5f2b6211ed 100644 --- a/src/crates/services/services-integrations/tests/git_contracts.rs +++ b/src/crates/services/services-integrations/tests/git_contracts.rs @@ -1,10 +1,11 @@ #![cfg(feature = "git")] +use bitfun_runtime_ports::{GitPort, WorkspaceDiffContent, WorkspaceDiffFileStatus}; use bitfun_services_integrations::git::{ build_git_changed_files_args, build_git_diff_args, parse_branch_line, parse_git_log_line, parse_name_status_output, parse_worktree_list, GitAuthor, GitChangedFile, GitChangedFileStatus, GitChangedFilesParams, GitCommandOutput, GitCommitParams, GitDiffParams, GitError, GitGraph, - GitService, GitStatus, GitWorktreeInfo, GraphNode, GraphRef, + GitService, GitStatus, GitWorkspaceDiffPort, GitWorktreeInfo, GraphNode, GraphRef, }; use std::fs; use std::process::Command; @@ -235,6 +236,304 @@ async fn git_service_preserves_repository_status_contract() { .any(|path| path == "new-file.txt" || path == "new-file.txt/")); } +#[tokio::test] +async fn workspace_diff_port_reports_staged_unstaged_and_untracked_changes() { + let repo_dir = TempRepoDir::new("workspace-diff-port"); + run_git(repo_dir.path(), &["init"]); + run_git(repo_dir.path(), &["config", "user.name", "BitFun Tests"]); + run_git( + repo_dir.path(), + &["config", "user.email", "tests@bitfun.dev"], + ); + fs::write(repo_dir.path().join("both.txt"), "base\n").unwrap(); + run_git(repo_dir.path(), &["add", "--", "both.txt"]); + run_git(repo_dir.path(), &["commit", "-m", "base"]); + + fs::write(repo_dir.path().join("both.txt"), "base\nstaged\n").unwrap(); + run_git(repo_dir.path(), &["add", "--", "both.txt"]); + fs::write(repo_dir.path().join("both.txt"), "base\nstaged\nunstaged\n").unwrap(); + fs::write(repo_dir.path().join("new.txt"), "untracked\n").unwrap(); + + let snapshot = GitWorkspaceDiffPort::new(repo_dir.path()) + .workspace_diff() + .await + .expect("workspace diff"); + + let both = snapshot + .files + .iter() + .find(|file| file.path == "both.txt") + .expect("tracked file"); + assert_eq!(both.status, WorkspaceDiffFileStatus::Modified); + assert!(both.staged); + assert!(both.unstaged); + assert!(!both.untracked); + assert!(matches!( + &both.content, + WorkspaceDiffContent::Text { patch } if patch.contains("+staged") && patch.contains("+unstaged") + )); + + let untracked = snapshot + .files + .iter() + .find(|file| file.path == "new.txt") + .expect("untracked file"); + assert_eq!(untracked.status, WorkspaceDiffFileStatus::Added); + assert!(untracked.untracked); + assert!(matches!( + &untracked.content, + WorkspaceDiffContent::Text { patch } if patch.contains("+untracked") + )); + assert!(!snapshot.truncated); +} + +#[tokio::test] +async fn workspace_diff_port_keeps_staged_changes_when_worktree_returns_to_head() { + let repo_dir = TempRepoDir::new("workspace-diff-cancelled-worktree"); + run_git(repo_dir.path(), &["init"]); + run_git(repo_dir.path(), &["config", "user.name", "BitFun Tests"]); + run_git( + repo_dir.path(), + &["config", "user.email", "tests@bitfun.dev"], + ); + fs::write(repo_dir.path().join("both.txt"), "base\n").unwrap(); + run_git(repo_dir.path(), &["add", "--", "both.txt"]); + run_git(repo_dir.path(), &["commit", "-m", "base"]); + + fs::write(repo_dir.path().join("both.txt"), "staged\n").unwrap(); + run_git(repo_dir.path(), &["add", "--", "both.txt"]); + fs::write(repo_dir.path().join("both.txt"), "base\n").unwrap(); + + let snapshot = GitWorkspaceDiffPort::new(repo_dir.path()) + .workspace_diff() + .await + .expect("workspace diff"); + + let both = snapshot + .files + .iter() + .find(|file| file.path == "both.txt") + .expect("staged change must remain visible"); + assert!(both.staged); + assert!(both.unstaged); + assert!(matches!(&both.content, WorkspaceDiffContent::Text { .. })); +} + +#[tokio::test] +async fn workspace_diff_port_keeps_staged_additions_deleted_from_the_worktree() { + let repo_dir = TempRepoDir::new("workspace-diff-staged-add-deleted"); + run_git(repo_dir.path(), &["init"]); + fs::write(repo_dir.path().join("added.txt"), "staged\n").unwrap(); + run_git(repo_dir.path(), &["add", "--", "added.txt"]); + fs::remove_file(repo_dir.path().join("added.txt")).unwrap(); + + let snapshot = GitWorkspaceDiffPort::new(repo_dir.path()) + .workspace_diff() + .await + .expect("workspace diff"); + let added = snapshot + .files + .iter() + .find(|file| file.path == "added.txt") + .expect("status-only staged addition must remain visible"); + + assert_eq!(added.status, WorkspaceDiffFileStatus::Added); + assert!(added.staged); + assert!(added.unstaged); + assert!(!added.untracked); + assert!(matches!( + &added.content, + WorkspaceDiffContent::Text { patch } if patch.is_empty() + )); +} + +#[tokio::test] +async fn workspace_diff_port_preserves_staged_delete_and_recreated_file_sources() { + let repo_dir = TempRepoDir::new("workspace-diff-staged-delete-recreated"); + run_git(repo_dir.path(), &["init"]); + run_git(repo_dir.path(), &["config", "user.name", "BitFun Tests"]); + run_git( + repo_dir.path(), + &["config", "user.email", "tests@bitfun.dev"], + ); + fs::write(repo_dir.path().join("rebuilt.txt"), "base\n").unwrap(); + run_git(repo_dir.path(), &["add", "--", "rebuilt.txt"]); + run_git(repo_dir.path(), &["commit", "-m", "base"]); + run_git(repo_dir.path(), &["rm", "--cached", "--", "rebuilt.txt"]); + fs::write(repo_dir.path().join("rebuilt.txt"), "rebuilt\n").unwrap(); + + let snapshot = GitWorkspaceDiffPort::new(repo_dir.path()) + .workspace_diff() + .await + .expect("workspace diff"); + let rebuilt = snapshot + .files + .iter() + .find(|file| file.path == "rebuilt.txt") + .expect("recreated file"); + + assert!(rebuilt.staged); + assert!(rebuilt.untracked); +} + +#[tokio::test] +async fn workspace_diff_port_marks_conflicts_without_projecting_an_incomplete_patch() { + let repo_dir = TempRepoDir::new("workspace-diff-conflict"); + run_git(repo_dir.path(), &["init"]); + run_git(repo_dir.path(), &["config", "user.name", "BitFun Tests"]); + run_git( + repo_dir.path(), + &["config", "user.email", "tests@bitfun.dev"], + ); + fs::write(repo_dir.path().join("conflict.txt"), "base\n").unwrap(); + run_git(repo_dir.path(), &["add", "--", "conflict.txt"]); + run_git(repo_dir.path(), &["commit", "-m", "base"]); + run_git(repo_dir.path(), &["branch", "-M", "main"]); + run_git(repo_dir.path(), &["checkout", "-b", "side"]); + fs::write(repo_dir.path().join("conflict.txt"), "side\n").unwrap(); + run_git(repo_dir.path(), &["commit", "-am", "side"]); + run_git(repo_dir.path(), &["checkout", "main"]); + fs::write(repo_dir.path().join("conflict.txt"), "main\n").unwrap(); + run_git(repo_dir.path(), &["commit", "-am", "main"]); + run_git_expect_failure(repo_dir.path(), &["merge", "side"]); + + let snapshot = GitWorkspaceDiffPort::new(repo_dir.path()) + .workspace_diff() + .await + .expect("workspace diff"); + let conflict = snapshot + .files + .iter() + .find(|file| file.path == "conflict.txt") + .expect("conflicted file"); + + assert_eq!(conflict.status, WorkspaceDiffFileStatus::Conflicted); + assert!(matches!( + &conflict.content, + WorkspaceDiffContent::Text { patch } if patch.is_empty() + )); +} + +#[tokio::test] +async fn workspace_diff_port_scopes_parent_repository_paths_to_the_bound_workspace() { + let repo_dir = TempRepoDir::new("workspace-diff-scope"); + run_git(repo_dir.path(), &["init"]); + run_git(repo_dir.path(), &["config", "user.name", "BitFun Tests"]); + run_git( + repo_dir.path(), + &["config", "user.email", "tests@bitfun.dev"], + ); + fs::create_dir_all(repo_dir.path().join("scope[1]")).unwrap(); + fs::write(repo_dir.path().join("outside.txt"), "base\n").unwrap(); + fs::write( + repo_dir.path().join("scope[1]").join("inside.txt"), + "base\n", + ) + .unwrap(); + run_git(repo_dir.path(), &["add", "."]); + run_git(repo_dir.path(), &["commit", "-m", "base"]); + + fs::write(repo_dir.path().join("outside.txt"), "outside change\n").unwrap(); + fs::write( + repo_dir.path().join("scope[1]").join("inside.txt"), + "inside change\n", + ) + .unwrap(); + fs::write(repo_dir.path().join("scope[1]").join("new.txt"), "new\n").unwrap(); + + let snapshot = GitWorkspaceDiffPort::new(repo_dir.path().join("scope[1]")) + .workspace_diff() + .await + .expect("workspace diff"); + + assert_eq!( + snapshot + .files + .iter() + .map(|file| file.path.as_str()) + .collect::>(), + vec!["inside.txt", "new.txt"] + ); +} + +#[tokio::test] +async fn workspace_diff_port_omits_large_text_before_building_a_patch() { + let repo_dir = TempRepoDir::new("workspace-diff-large-text"); + run_git(repo_dir.path(), &["init"]); + fs::write( + repo_dir.path().join("large.txt"), + "x".repeat(1024 * 1024 + 1), + ) + .unwrap(); + + let snapshot = GitWorkspaceDiffPort::new(repo_dir.path()) + .workspace_diff() + .await + .expect("workspace diff"); + let large = snapshot + .files + .iter() + .find(|file| file.path == "large.txt") + .expect("large file"); + + assert_eq!(large.content, WorkspaceDiffContent::TooLarge); + assert!(snapshot.truncated); +} + +#[tokio::test] +async fn workspace_diff_port_stops_projecting_content_after_the_total_text_cap() { + let repo_dir = TempRepoDir::new("workspace-diff-total-cap"); + run_git(repo_dir.path(), &["init"]); + for name in ["a.txt", "b.txt", "c.txt", "d.txt"] { + fs::write(repo_dir.path().join(name), "x".repeat(800 * 1024)).unwrap(); + } + fs::write(repo_dir.path().join("z.bin"), [0, 1, 0, 2]).unwrap(); + + let snapshot = GitWorkspaceDiffPort::new(repo_dir.path()) + .workspace_diff() + .await + .expect("workspace diff"); + + assert!(snapshot.truncated); + assert_eq!( + snapshot + .files + .iter() + .find(|file| file.path == "d.txt") + .expect("first over-budget file") + .content, + WorkspaceDiffContent::TooLarge + ); + assert_eq!( + snapshot + .files + .iter() + .find(|file| file.path == "z.bin") + .expect("later file") + .content, + WorkspaceDiffContent::TooLarge + ); +} + +#[tokio::test] +async fn workspace_diff_port_marks_binary_content_without_text_projection() { + let repo_dir = TempRepoDir::new("workspace-diff-binary"); + run_git(repo_dir.path(), &["init"]); + fs::write(repo_dir.path().join("binary.dat"), [0, 1, 2, 0, 3]).unwrap(); + + let snapshot = GitWorkspaceDiffPort::new(repo_dir.path()) + .workspace_diff() + .await + .expect("workspace diff"); + let binary = snapshot + .files + .iter() + .find(|file| file.path == "binary.dat") + .expect("binary file"); + + assert_eq!(binary.content, WorkspaceDiffContent::Binary); +} + #[test] fn git_name_status_parser_preserves_nul_delimited_unicode_and_rename_paths() { let files = parse_name_status_output("M\0src/中文.rs\0R100\0old name.rs\0new name.rs\0"); @@ -452,6 +751,19 @@ fn run_git(repo_dir: &std::path::Path, args: &[&str]) { ); } +fn run_git_expect_failure(repo_dir: &std::path::Path, args: &[&str]) { + let output = Command::new("git") + .current_dir(repo_dir) + .args(args) + .output() + .unwrap(); + assert!( + !output.status.success(), + "git {:?} unexpectedly succeeded", + args + ); +} + #[test] fn git_worktree_info_preserves_camel_case_contract() { let worktree = GitWorktreeInfo {