From 7e233915a0a4cb99b12065d6480a5f63e05b2703 Mon Sep 17 00:00:00 2001 From: Xiaoyumuxi <3075514079@qq.com> Date: Mon, 28 Sep 2026 14:34:58 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E5=AE=8C=E5=96=84=E5=A4=9A=E4=BB=93?= =?UTF-8?q?=E5=BA=93=20Git=20=E6=8F=90=E4=BA=A4=E3=80=81=E5=AD=90=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=E8=AE=A1=E5=88=92=E4=B8=8E=E5=A4=B1=E8=B4=A5=E9=87=8D?= =?UTF-8?q?=E8=AF=95=20(#939)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: support multi-repository Git commits * fix(git): reject stale workspace repository boundaries Validate exact Git roots before inspecting commit state and updating submodule pointers. Cover removed child metadata with real-Git regressions, and align collapsed repository selection and localized commit actions. * test(git): keep polling until rejected AskPass peer closes Fix the macOS CI race where the valid prompt arrived before the untrusted frame was fully processed. Bound the polling loop while waiting for both outcomes, without changing authentication behavior. * test(git): let Git quote submodule fixture paths on Windows * refactor(git): share workspace commit planning and retry in Rust Core * fix(git): retain recovery controls when a workspace step fails * fix(git): normalize repository bindings before ownership checks * feat(git): adapt Windows to shared workspace commit workflow * fix(git): bind Windows commit lifecycle to the workspace * fix(git): respect shared retry eligibility in Windows results --------- Co-authored-by: lick <2188718831@qq.com> --- .../2026-09-27-workspace-git-commit-plans.md | 78 +++ .github/workflows/ci-windows.yml | 9 + docs/development/platform-parity-matrix.csv | 4 + docs/development/platform-parity-matrix.md | 14 +- macos/Resources/en.lproj/Localizable.strings | 31 + .../zh-Hans.lproj/Localizable.strings | 31 + .../CommitWorkflowComposition.swift | 6 +- .../Features/CommitWorkflowCoordinator.swift | 27 +- .../Lithe/Core/Rust/RustCoreBridge.swift | 14 +- .../Lithe/Core/Rust/RustGitOperations.swift | 19 +- .../Lithe/Views/Git/ChangesSidebarView.swift | 206 +++++- .../Lithe/Views/Git/CommitAreaView.swift | 82 ++- .../Views/Git/GitChangeSectionsCache.swift | 35 +- .../GitFeatureModel+WorkspaceCommit.swift | 135 ++++ .../Application/GitFeatureModel.swift | 151 +---- .../LitheGitModule/Models/GitModels.swift | 62 +- .../Models/GitWorkspaceCommitModels.swift | 120 ++++ .../LitheGitModule/Services/GitService.swift | 39 +- .../LitheGitModuleTests/GitModuleTests.swift | 282 +++++++- .../LitheTests/AppLocalizationTests.swift | 32 +- .../GitChangeSectionsCacheTests.swift | 48 +- rust/lithe-core/src/git/commit_state.rs | 244 +++++++ rust/lithe-core/src/git/history.rs | 42 ++ rust/lithe-core/src/git/mod.rs | 183 ++++- rust/lithe-core/src/git/workspace_commit.rs | 626 ++++++++++++++++++ .../src/git/workspace_commit/tests.rs | 177 +++++ rust/lithe-core/src/protocol/command.rs | 9 + rust/lithe-core/src/protocol/contracts.rs | 13 + rust/lithe-core/src/runtime/dispatcher.rs | 58 +- rust/lithe-core/src/tests/git.rs | 57 ++ .../src/tests/git_workspace_commit.rs | 528 +++++++++++++++ rust/lithe-core/src/tests/mod.rs | 1 + rust/lithe-git-host/tests/authentication.rs | 34 +- shared/contracts/application-boundary.md | 2 +- shared/contracts/rust-core-api.md | 85 +++ shared/fixtures/git/workspace-commit-v1.json | 13 + .../git/workspace-commit-workflow-v1.json | 156 +++++ shared/platform-feature-matrix.json | 156 ++++- windows/tauri/src-tauri/src/platform.rs | 85 ++- .../features/git/api/git-remotes-api.test.ts | 5 +- .../features/git/api/git-status-api.test.ts | 66 +- .../src/features/git/api/git-status-api.ts | 31 +- .../git/api/git-workspace-commit-api.test.ts | 39 ++ .../git/api/git-workspace-commit-api.ts | 35 + .../git/components/git-commit-panel.tsx | 197 +++--- .../src/features/git/components/git-view.tsx | 69 +- .../git-workspace-commit-review.test.tsx | 44 ++ .../git-workspace-commit-review.tsx | 105 +++ .../status/git-status-file-item.tsx | 4 +- .../components/status/git-status-panel.tsx | 280 +++++--- .../git-workspace-status-panel.test.tsx | 95 +++ .../git/hooks/use-git-diff-actions.ts | 122 +--- .../git-workspace-commit-host.test.tsx | 69 ++ .../git/runtime/git-workspace-commit-host.tsx | 35 + .../git/services/ai-commit-context.test.ts | 26 +- .../git/services/ai-commit-context.ts | 7 +- .../git/services/ai-commit-service.ts | 4 +- .../git-workspace-commit-workflow.test.ts | 193 ++++++ .../services/git-workspace-commit-workflow.ts | 131 ++++ .../working-tree-diff-refresh.test.ts | 30 +- .../git/services/working-tree-diff-refresh.ts | 12 +- .../git/stores/git-workspace-commit.store.ts | 24 + .../src/features/git/types/git-diff.types.ts | 2 + .../git/types/git-workspace-commit.types.ts | 57 ++ .../tauri/src/features/git/types/git.types.ts | 2 + .../git/utils/git-status-model.test.ts | 17 +- .../features/git/utils/git-status-model.ts | 42 +- .../git-workspace-commit-bindings.test.ts | 20 + .../utils/git-workspace-commit-bindings.ts | 26 + .../tauri/src/i18n/git-workspace-commit.ts | 46 ++ windows/tauri/src/i18n/locale.ts | 3 + .../src/platform/core-result-adapter.test.ts | 43 +- .../tauri/src/platform/core-result-adapter.ts | 27 +- windows/tauri/src/workbench-app.tsx | 2 + 74 files changed, 5143 insertions(+), 661 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-09-27-workspace-git-commit-plans.md create mode 100644 macos/Sources/LitheGitModule/Application/GitFeatureModel+WorkspaceCommit.swift create mode 100644 macos/Sources/LitheGitModule/Models/GitWorkspaceCommitModels.swift create mode 100644 rust/lithe-core/src/git/commit_state.rs create mode 100644 rust/lithe-core/src/git/workspace_commit.rs create mode 100644 rust/lithe-core/src/git/workspace_commit/tests.rs create mode 100644 rust/lithe-core/src/tests/git_workspace_commit.rs create mode 100644 shared/fixtures/git/workspace-commit-v1.json create mode 100644 shared/fixtures/git/workspace-commit-workflow-v1.json create mode 100644 windows/tauri/src/features/git/api/git-workspace-commit-api.test.ts create mode 100644 windows/tauri/src/features/git/api/git-workspace-commit-api.ts create mode 100644 windows/tauri/src/features/git/components/git-workspace-commit-review.test.tsx create mode 100644 windows/tauri/src/features/git/components/git-workspace-commit-review.tsx create mode 100644 windows/tauri/src/features/git/components/status/git-workspace-status-panel.test.tsx create mode 100644 windows/tauri/src/features/git/runtime/git-workspace-commit-host.test.tsx create mode 100644 windows/tauri/src/features/git/runtime/git-workspace-commit-host.tsx create mode 100644 windows/tauri/src/features/git/services/git-workspace-commit-workflow.test.ts create mode 100644 windows/tauri/src/features/git/services/git-workspace-commit-workflow.ts create mode 100644 windows/tauri/src/features/git/stores/git-workspace-commit.store.ts create mode 100644 windows/tauri/src/features/git/types/git-workspace-commit.types.ts create mode 100644 windows/tauri/src/features/git/utils/git-workspace-commit-bindings.test.ts create mode 100644 windows/tauri/src/features/git/utils/git-workspace-commit-bindings.ts create mode 100644 windows/tauri/src/i18n/git-workspace-commit.ts diff --git a/.agents/notes/implemented/feature/2026-09-27-workspace-git-commit-plans.md b/.agents/notes/implemented/feature/2026-09-27-workspace-git-commit-plans.md new file mode 100644 index 000000000..5479642e0 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-27-workspace-git-commit-plans.md @@ -0,0 +1,78 @@ +# Agent 笔记:工作区多仓库提交计划与重试 + +状态:已实现 + +## 先说结论 + +Changes 中的勾选继续代表 Git 的暂存状态;用户一次提交,系统按文件所属仓库分别执行。 +真正的子模块关系以 Git 索引中的引用条目(gitlink,记录子仓库提交号)为准,提交子仓库时默认联动更新已打开工作区内的父引用。 +确认前重新检查计划;执行失败保留已完成步骤,重试只做剩余提交或推送。 +macOS 和 Windows 都直接消费 Rust Core 的提交计划、依赖排序、失败传播、重试及 Git 检查写入;平台只负责界面和原生接线。 + +## 问题 + +只有目录包含关系,不能证明两个仓库互相依赖。真正的子模块中,父仓库管理的是提交号,子仓库才管理 `hello.ts` 的内容。 +只读取有暂存文件的仓库会遗漏干净的父仓库;确认后直接重算并执行会悄悄改变用户同意的范围;推送失败后再次提交则会重复创建提交。 + +## 决策 + +- 已发现的独立嵌套仓库不会作为父仓库的未跟踪目录参与勾选,避免“全部暂存”意外创建子模块引用。比较前必须统一规范化 Git 返回的路径和平台传入的仓库绑定,兼容 macOS 目录别名及 Windows 短路径/扩展路径。 +- Windows 的文件及仓库分组勾选也写入真实暂存区,取消原有仅限单仓库的提交入口。部分暂存文件的提交预览和 AI 说明只读取索引;工作树差异仍可从差异菜单查看。 +- 保留暂存区作为选择的唯一来源,外部 `git add` 也会反映到勾选状态。没有另建一份仅属于 UI 的提交选择。 +- Core 读取 Git 的 porcelain v2 状态,把子模块提交号变化和未提交文件分开。只有文件脏、引用未变的父条目展示提示,不参与批量勾选。 +- Core 读取所有已发现仓库的真实引用,再计算子到父的执行顺序。默认自动带上必要的父引用;确认窗口显示仓库、文件、顺序和引用更新,并允许关闭本次自动联动。 +- Core 的提交前置状态包括 HEAD、当前分支、索引对象号和暂存路径。确认时重新传入最新仓库列表,任何状态变化都更新计划并再次确认;实际写入前仍在已有仓库写入锁内核对。 +- 读取提交状态和写入子引用前确认 Git 的真实根目录。子仓库元数据消失时拒绝执行,避免 Git 向上搜索后错用父仓库。 +- 更新父引用使用 Git 的精确索引更新命令,只写已经确认的子提交号。父仓库其它未暂存文件保持原样。 +- Core 每次最多执行一次提交或推送,返回由 Core 管理的进度。平台只负责确认界面、原生认证与取消、项目生命周期、展示结果和继续调用;不得自己安排仓库、推导失败范围或重算重试。 +- Windows 按工作区保留 Core 返回的结果,任务生命周期由工作台持有,切换侧栏不停止批次;离开项目时取消当前原生请求并停止派发后续步骤,迟到结果只能更新原工作区的恢复记录。 +- 每个仓库提交或推送有独立的执行上下文。停止当前操作后继续独立仓库,依赖失败子仓库的父仓库等待重试。 +- 只选择父引用时,已发现且有本地分支的子仓库会显示为“仅推送”,不会重复提交。父仓库推送还通过 Git 自带的子模块发布检查,拒绝引用尚未发布的子提交;分离 HEAD 无法确定推送分支时保留失败供用户处理。 +- 结果按仓库保留。已提交但未推送的仓库重试时只推送;已成功的独立仓库不会再提交。重试也先展示当前计划,项目切换后旧异步结果不能写回界面或启动后续仓库操作。 + +正确示例:只勾选 `A/libs/B/hello.ts`,计划显示先提交 B,再只更新 A 中的 `libs/B` 引用;如果选择推送,B 推送成功后才执行 A。 +不要在 A 中 `git add --all` 来更新 B 的引用,这会把 A 的其它未选择文件一并提交。 + +## 考虑过的备选方案 + +- 独立于暂存区的提交勾选:没有采用。会改变已有勾选含义,且外部暂存、部分暂存都需要第二套同步规则。 +- 停止一个操作就结束整个批次:没有采用。无依赖仓库可以继续完成;只有依赖的父引用必须阻塞。 +- 确认后静默执行重算的计划:没有采用。用户可能在对话框打开期间暂存更多文件,执行范围必须重新确认。 +- 把 Git 命令输出解析放在 Swift:改为共享 Core 的类型化状态,保持 Git 自身为索引和引用的事实来源。 +- 只共享 Git 原语,把提交计划留在 Swift:没有采用。Windows 会被迫重复实现依赖和重试规则;现在两个端都直接消费 Core 生成的计划和下一步结果。 +- 在 Core 中维护常驻批次注册表:没有采用。当前只需要项目会话内恢复,使用无后台资源的 JSON 续接状态即可;每一步仍以真实 Git 状态校验防止重复提交。 + +## 后果 + +Git 的多个仓库没有跨仓库原子事务,已成功的提交不会因后续失败而回滚。Lithe 的写入锁只能约束自己的命令,外部 Git 客户端不参与该锁。 +提交钩子失败可能留下已经更新的父引用暂存项;重试必须重新读取状态。取消后的只读核对有独立的 5 秒上限,返回已完成提交的信息,不能用通用取消错误覆盖它。结果仅保留在当前项目会话中,退出项目或显式清除结果后不提供恢复记录。 +自动联动仅覆盖工作区已发现的仓库,不推断未打开的外部父仓库。读取某个仓库失败时停止建立计划,不能把读取失败当成没有依赖。 +不改变 Git Log 的已有分组和活动仓库规则。 + +## 验证 + +- `windows/tauri/src/features/git/services/git-workspace-commit-workflow.test.ts` 消费同一份共享夹具,验证计划转发、再次确认、重试、取消与迟到响应;`git-workspace-status-panel.test.tsx` 验证分组勾选写入所属仓库、忽略不可暂存的脏子模块引用。Tauri dispatcher 测试保护原生认证超时与部分结果透传。 + +- `macos/Tests/LitheGitModuleTests/GitModuleTests.swift` 消费共享夹具和预设 Core 响应,验证确认与再次确认、联动选项与重试转发、步骤驱动、复选框资格和项目切换隔离;不在测试替身中重新实现业务算法。 +- `rust/lithe-core/src/git/workspace_commit/tests.rs` 覆盖干净祖先联动、关闭联动、独立嵌套仓库、过期计划、失败依赖阻塞、仅重推和外部推进后再次推送。 +- `rust/lithe-core/src/tests/git_workspace_commit.rs` 使用真实 Git 验证过期索引拒绝、子模块状态、只更新引用且不带入父文件、首次提交前取消暂存保留后续编辑,暂存后在工作树删除的文件仍可见、子仓库元数据被删除后拒绝回退父仓库,以及共享夹具序列化。真实 Git 还验证共享计划执行、推送失败后的重试、过期步骤重放拒绝、取消与成功提交返回竞争。 +- 使用 `write-stable-tests` 的 Rust 逐例计时入口生成 HTML/JUnit;macOS 使用该 Skill 的 macOS 计时入口。 +- 运行共享契约、服务边界、功能矩阵、发行包只读边界和 Agent Notes 检查。 +- 当前实现环境是 Linux;本地执行共享 Core 和 Windows 前端测试,原生构建及测试交给对应 CI,macOS/Windows 界面实测仍需平台验收,不将逻辑测试通过记作界面已验证。 + +## 适用范围 + +- `macos/Sources/LitheGitModule/Application/GitFeatureModel+WorkspaceCommit.swift` +- `macos/Sources/LitheGitModule/Models/GitModels.swift` +- `macos/Sources/LitheGitModule/Services/GitService.swift` +- `macos/Sources/Lithe/Views/Git/CommitAreaView.swift` +- `macos/Sources/Lithe/Views/Git/ChangesSidebarView.swift` +- `rust/lithe-core/src/git/commit_state.rs` +- `rust/lithe-core/src/git/workspace_commit.rs` +- `macos/Sources/LitheGitModule/Models/GitWorkspaceCommitModels.swift` +- `shared/contracts/rust-core-api.md` + +- `windows/tauri/src/features/git/services/git-workspace-commit-workflow.ts` +- `windows/tauri/src/features/git/components/git-workspace-commit-review.tsx` +- `windows/tauri/src/features/git/components/status/git-status-panel.tsx` +- `windows/tauri/src-tauri/src/platform.rs` diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml index d0cf13e2e..55d54b65b 100644 --- a/.github/workflows/ci-windows.yml +++ b/.github/workflows/ci-windows.yml @@ -276,6 +276,15 @@ jobs: node $runner --suite-timeout-ms 60000 --report .artifacts/test-stability/windows-git-execution-journal.json -- src/features/git/services/git-execution-journal.test.ts if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Test workspace Git commit adapters and staging + shell: pwsh + run: | + $runner = ".agents/skills/write-stable-tests/scripts/run-bun-tests-with-timing.mjs" + node $runner --suite-timeout-ms 60000 --report .artifacts/test-stability/windows-workspace-commit.json -- git-workspace-commit git-status-api.test.ts git-status-model.test.ts ai-commit-context.test.ts working-tree-diff-refresh.test.ts + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + node $runner --suite-timeout-ms 60000 --report .artifacts/test-stability/windows-workspace-status.json -- git-workspace-status-panel.test.tsx + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Test terminal discovery, default shell, profiles, and tab layout shell: pwsh run: ./.agents/skills/write-stable-tests/scripts/test-stability-windows.ps1 -Scope Frontend -FrontendTestPath src/features/terminal/stores/shells.store.test.ts,src/features/terminal/utils/terminal-profiles.test.ts,src/features/terminal/utils/terminal-keyboard.test.ts,src/features/settings/lib/default-shell-options.test.ts,src/features/terminal/components/terminal-tab-strip.test.tsx diff --git a/docs/development/platform-parity-matrix.csv b/docs/development/platform-parity-matrix.csv index 1c6c29e25..5a7162ca7 100644 --- a/docs/development/platform-parity-matrix.csv +++ b/docs/development/platform-parity-matrix.csv @@ -19,6 +19,7 @@ search-project,搜索,搜索与导航,项目范围文本搜索,已实现,待验 search-global,搜索,搜索与导航,全局命令与设置搜索,已实现,待验证,已实现,待验证,Search,使用快捷键搜索命令、设置和工作区内容,确认结果来源和跳转行为。,,macos/Sources/Lithe/Views/Search,windows/tauri/src/features/global-search; windows/tauri/src/features/command-palette search-replace,搜索,搜索与导航,项目替换与取消,已实现,待验证,已实现,待验证,Search,执行替换预览、确认替换和取消操作,确认未保存文件与异常文件不会被静默覆盖。,,macos/Sources/Lithe/Views/Search; macos/Sources/Lithe/Services/Language,windows/tauri/src/features/file-search git-status-commit,版本控制,Git,状态、暂存与提交,已实现,待验证,已实现,待验证,Git,修改、暂存、取消暂存并提交文件,确认状态、提交消息和错误回显。 Windows 另验证原生 UNC/verbatim 输入、中文/空格/长路径仓库往返及 linked worktree;末尾点/空格必须明确拒绝,外部提交/切换须触发元数据刷新。,,macos/Sources/Lithe/Views/Git; macos/Sources/Lithe/Services,windows/tauri/src/features/git; shared/contracts/application-boundary.md; windows/tauri/src/features/git/api/git-repository-path.ts; rust/lithe-core/tests/git_path_roundtrip.rs; shared/fixtures/git/windows-paths.json +git-multi-repository-change-groups,版本控制,Git,多仓库变更折叠分组,已实现,待验证,已实现,待验证,Git,在同一工作区打开多个 Git 仓库,确认变更按仓库折叠分组;只剩一个仓库有变更时仍显示仓库名。仓库级和文件级勾选与 Git 暂存区同步;子模块只有未提交文件时显示提示,不能勾选未变化的引用。,两端直接消费共享 Rust Core 的计划、依赖排序、执行结果与重试。平台负责真实暂存勾选、确认与结果界面、认证取消和项目生命周期。原生界面实测待平台验收。,macos/Sources/Lithe/Views/Git/ChangesSidebarView.swift; macos/Sources/Lithe/Views/Git/GitChangeSectionsCache.swift; macos/Sources/LitheGitModule/Models/GitModels.swift; rust/lithe-core/src/git/mod.rs,windows/tauri/src/features/git/components/status/git-status-panel.tsx; windows/tauri/src/features/git/components/git-commit-panel.tsx; windows/tauri/src/features/git/components/git-workspace-commit-review.tsx; windows/tauri/src/features/git/services/git-workspace-commit-workflow.ts; windows/tauri/src/features/git/services/git-workspace-commit-workflow.test.ts; windows/tauri/src/features/git/components/status/git-workspace-status-panel.test.tsx; windows/tauri/src-tauri/src/platform.rs; shared/fixtures/git/workspace-commit-workflow-v1.json git-branches-remotes,版本控制,Git,分支、标签与远程,已实现,待验证,已实现,待验证,Git,创建、切换、合并分支并查看标签和远程,确认冲突与认证失败可恢复。,,macos/Sources/Lithe/Views/Git,windows/tauri/src/features/git git-diff-review,版本控制,Git,Diff 与变更审查,已实现,待验证,已实现,待验证,Git,验证新增、删除、重命名、二进制和多文件 Diff 的展示与定位;从源代码管理打开已修改和未跟踪文件的工作区 Diff 后保持静止,确认 Diff 不会自动关闭,且只在文件不再出现在 Git 状态中时关闭。,,macos/Sources/Lithe/Views/Git; macos/Sources/Lithe/Views/Diff,windows/tauri/src/features/git; windows/tauri/src/features/viewer git-history,版本控制,Git,提交历史与图谱,已实现,待验证,已实现,待验证,Git,分页浏览提交历史、分支图谱和提交详情,确认日期、作者和文件列表一致;分支最新提交的图谱连线从提交圆点开始,不超出到圆点上方;在双端使用同一包含本地分支、远端引用、标签和合并提交的仓库,核对永久图布局、图头引用排序、合并边投影及跨越 30 行的紧凑长边;在本地分支新增提交后确认分支颜色保持稳定,且不会因共用屏幕泳道直接沿用父分支颜色。双端保留各自调色板,不要求 RGB 值一致。,,macos/Sources/Lithe/Views/Git; macos/Sources/LitheGitModule/Services/GitGraphLayoutService.swift; macos/Sources/LitheGitModule/Services/GitGraphHeadOrdering.swift; macos/Sources/Lithe/Views/Git/GitGraphColor.swift,windows/tauri/src/features/git; windows/tauri/src/features/git/utils/git-graph-layout.ts; windows/tauri/src/features/git/utils/git-graph-colors.ts; windows/tauri/src/features/git/utils/git-graph-layout.test.ts @@ -93,3 +94,6 @@ editor-file-encoding-reopen,编辑器,文本编辑,按指定编码重新打开 editor-file-encoding-save,编辑器,文本编辑,按指定编码保存文本文件,已实现,待验证,已实现,待验证,Editor,在 macOS 和 Windows 实机验证 UTF-8、UTF-8 BOM 与 GBK/GB18030 的自动识别,并分别验证 Shift JIS、Windows-1252 的指定编码重新打开、编码转换保存、脏文件选择和外部修改保护。,,macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift; macos/Sources/Lithe/Views/Editor/StandaloneEditorView.swift; macos/Sources/Lithe/Application/Features/DocumentFeatureModel.swift; macos/Sources/Lithe/Platform/MacOS/FileSystem/MacDocumentEncoding.swift,windows/tauri/src/features/command-palette/components/encoding-picker.tsx; windows/tauri/src/features/editor/stores/editor-app.store.ts; windows/tauri/crates/project/src/document_file.rs php-optional-plugin,语言支持,PHP,PHP 按需安装与插件生命周期,已实现,待验证,已实现,待验证,PHP Support,在干净安装上确认没有 PHP 插件运行时;显式安装并启用后验证补全、诊断,安装中取消、运行中禁用、关闭工作区和卸载后确认无插件进程,用户工具保留。 Windows 从独立 .lithe-extension 文件导入,默认禁用;启用后重启确认能恢复,卸载后重启确认不恢复。,,Plugins/mac/Official/PhpSupport; scripts/official-plugin-distribution.mjs; macos/Sources/Lithe/Platform/MacOS/Plugins,Plugins/win/Official/PhpSupport; windows/tauri/src/extensions/registry/extension-store-lifecycle.ts; windows/tauri/src-tauri/src/language_tools.rs; windows/tauri/src/extensions/packages/local-extension-package.ts; scripts/verify-windows-plugin-isolation.mjs php-run-test,语言支持,PHP,PHP 插件运行与 PHPUnit 测试,已实现,待验证,部分实现,待验证,PHP Support,macOS 运行 PHP 文件和单条/整套 PHPUnit;Windows 运行字符串/数组 Composer script 和整套 PHPUnit。禁用后菜单消失、运行进程退出,特殊文件名保持原样。Windows 暂不支持单方法发现。,,Plugins/mac/Official/PhpSupport/Sources/LithePhpSupportModule/Capabilities/PhpExecutionCapability.swift; macos/Tests/LitheTests/RealPhpIntegrationTests.swift,Plugins/win/Official/PhpSupport/plugin.ts; windows/tauri/src/extensions/run; windows/tauri/src/extensions/ui/services/ui-extension-worker-runtime.test.ts +git-workspace-staged-commit,版本控制,Git,按文件所属仓库批量提交及推送,保留每仓库结果,已实现,待验证,已实现,待验证,Git,勾选两个独立仓库文件,一次提交并推送,验证各自 HEAD 和远程;停止一个操作后其余独立仓库继续,已成功仓库不会回滚。,两端直接消费共享 Rust Core 的计划、依赖排序、执行结果与重试。平台负责真实暂存勾选、确认与结果界面、认证取消和项目生命周期。原生界面实测待平台验收。,macos/Sources/LitheGitModule/Application/GitFeatureModel+WorkspaceCommit.swift; macos/Sources/Lithe/Views/Git/CommitAreaView.swift; macos/Tests/LitheGitModuleTests/GitModuleTests.swift; rust/lithe-core/src/git/commit_state.rs; rust/lithe-core/src/tests/git_workspace_commit.rs; rust/lithe-core/src/git/workspace_commit.rs; rust/lithe-core/src/git/workspace_commit/tests.rs; shared/fixtures/git/workspace-commit-workflow-v1.json,windows/tauri/src/features/git/components/status/git-status-panel.tsx; windows/tauri/src/features/git/components/git-commit-panel.tsx; windows/tauri/src/features/git/components/git-workspace-commit-review.tsx; windows/tauri/src/features/git/services/git-workspace-commit-workflow.ts; windows/tauri/src/features/git/services/git-workspace-commit-workflow.test.ts; windows/tauri/src/features/git/components/status/git-workspace-status-panel.test.tsx; windows/tauri/src-tauri/src/platform.rs; shared/fixtures/git/workspace-commit-workflow-v1.json; windows/tauri/src/features/git/runtime/git-workspace-commit-host.tsx; windows/tauri/src/features/git/runtime/git-workspace-commit-host.test.tsx +git-submodule-commit-plan,版本控制,Git,子模块先提交与推送、自动更新父引用、计划变化再次确认,已实现,待验证,已实现,待验证,Git,只勾选孙仓库文件,确认计划自动列出父祖仓库的引用更新且可关闭;确认期间改变暂存内容或分支,必须显示新计划再次确认。父仓库未暂存文件不能被带入;只勾选父引用时先推送子仓库现有提交,Git 发布检查应拒绝未发布的子引用。子仓库元数据被外部删除后必须拒绝读取和写入,不能向上回退父仓库。,两端直接消费共享 Rust Core 的计划、依赖排序、执行结果与重试。平台负责真实暂存勾选、确认与结果界面、认证取消和项目生命周期。原生界面实测待平台验收。,macos/Sources/LitheGitModule/Application/GitFeatureModel+WorkspaceCommit.swift; macos/Sources/Lithe/Views/Git/CommitAreaView.swift; macos/Tests/LitheGitModuleTests/GitModuleTests.swift; rust/lithe-core/src/git/commit_state.rs; rust/lithe-core/src/tests/git_workspace_commit.rs; rust/lithe-core/src/git/workspace_commit.rs; rust/lithe-core/src/git/workspace_commit/tests.rs; shared/fixtures/git/workspace-commit-workflow-v1.json,windows/tauri/src/features/git/components/status/git-status-panel.tsx; windows/tauri/src/features/git/components/git-commit-panel.tsx; windows/tauri/src/features/git/components/git-workspace-commit-review.tsx; windows/tauri/src/features/git/services/git-workspace-commit-workflow.ts; windows/tauri/src/features/git/services/git-workspace-commit-workflow.test.ts; windows/tauri/src/features/git/components/status/git-workspace-status-panel.test.tsx; windows/tauri/src-tauri/src/platform.rs; shared/fixtures/git/workspace-commit-workflow-v1.json; windows/tauri/src/features/git/runtime/git-workspace-commit-host.tsx; windows/tauri/src/features/git/runtime/git-workspace-commit-host.test.tsx +git-workspace-commit-retry,版本控制,Git,失败后只重试未完成的提交或推送步骤,已实现,待验证,已实现,待验证,Git,让子仓库推送失败,确认父仓库被阻塞、独立仓库成功;重试计划应显示子仓库只推送,验证子仓库提交数不增加,随后才提交和推送父仓库。,两端直接消费共享 Rust Core 的计划、依赖排序、执行结果与重试。平台负责真实暂存勾选、确认与结果界面、认证取消和项目生命周期。原生界面实测待平台验收。,macos/Sources/LitheGitModule/Application/GitFeatureModel+WorkspaceCommit.swift; macos/Sources/Lithe/Views/Git/CommitAreaView.swift; macos/Tests/LitheGitModuleTests/GitModuleTests.swift; rust/lithe-core/src/git/commit_state.rs; rust/lithe-core/src/tests/git_workspace_commit.rs; rust/lithe-core/src/git/workspace_commit.rs; rust/lithe-core/src/git/workspace_commit/tests.rs; shared/fixtures/git/workspace-commit-workflow-v1.json,windows/tauri/src/features/git/components/status/git-status-panel.tsx; windows/tauri/src/features/git/components/git-commit-panel.tsx; windows/tauri/src/features/git/components/git-workspace-commit-review.tsx; windows/tauri/src/features/git/services/git-workspace-commit-workflow.ts; windows/tauri/src/features/git/services/git-workspace-commit-workflow.test.ts; windows/tauri/src/features/git/components/status/git-workspace-status-panel.test.tsx; windows/tauri/src-tauri/src/platform.rs; shared/fixtures/git/workspace-commit-workflow-v1.json; windows/tauri/src/features/git/runtime/git-workspace-commit-host.tsx; windows/tauri/src/features/git/runtime/git-workspace-commit-host.test.tsx diff --git a/docs/development/platform-parity-matrix.md b/docs/development/platform-parity-matrix.md index e55e4495b..98ecdf960 100644 --- a/docs/development/platform-parity-matrix.md +++ b/docs/development/platform-parity-matrix.md @@ -2,11 +2,11 @@ > 本页由 `shared/platform-feature-matrix.json` 自动生成。不要直接编辑本文件;新增或变更功能时更新源数据,再运行 `node scripts/generate-platform-feature-matrix.mjs`。 -- 最后复核:2026-09-25 +- 最后复核:2026-09-27 - 盘点状态:initial-static-inventory(根据 macOS Views/Application/Services、Windows features/extensions 和共享契约的代码入口进行初版盘点;未替代真实运行验收。) -- 功能项:94 -- macOS:实现:✅ 81 已实现,🟡 3 部分实现,❌ 7 未实现,🧩 3 平台专属;验证:✔️ 0 已验证,🔍 84 待验证,— 10 不适用 -- Windows:实现:✅ 82 已实现,🟡 8 部分实现,❌ 4 未实现,🧩 0 平台专属;验证:✔️ 0 已验证,🔍 90 待验证,— 4 不适用 +- 功能项:98 +- macOS:实现:✅ 85 已实现,🟡 3 部分实现,❌ 7 未实现,🧩 3 平台专属;验证:✔️ 0 已验证,🔍 88 待验证,— 10 不适用 +- Windows:实现:✅ 86 已实现,🟡 8 部分实现,❌ 4 未实现,🧩 0 平台专属;验证:✔️ 0 已验证,🔍 94 待验证,— 4 不适用 ## 实现状态定义 @@ -96,17 +96,21 @@
-版本控制 · 7 个能力点 +版本控制 · 11 个能力点 | 功能组 | 能力点 | macOS | Windows | 负责人 | 验证方式 | 备注 | | --- | --- | --- | --- | --- | --- | --- | | Git | **状态、暂存与提交**
git-status-commit | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Git`、`macos/Sources/Lithe/Services` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git`、`shared/contracts/application-boundary.md`、`windows/tauri/src/features/git/api/git-repository-path.ts`、`rust/lithe-core/tests/git_path_roundtrip.rs`、`shared/fixtures/git/windows-paths.json` | Git | 修改、暂存、取消暂存并提交文件,确认状态、提交消息和错误回显。 Windows 另验证原生 UNC/verbatim 输入、中文/空格/长路径仓库往返及 linked worktree;末尾点/空格必须明确拒绝,外部提交/切换须触发元数据刷新。 | | +| Git | **多仓库变更折叠分组**
git-multi-repository-change-groups | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Git/ChangesSidebarView.swift`、`macos/Sources/Lithe/Views/Git/GitChangeSectionsCache.swift`、`macos/Sources/LitheGitModule/Models/GitModels.swift`、`rust/lithe-core/src/git/mod.rs` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git/components/status/git-status-panel.tsx`、`windows/tauri/src/features/git/components/git-commit-panel.tsx`、`windows/tauri/src/features/git/components/git-workspace-commit-review.tsx`、`windows/tauri/src/features/git/services/git-workspace-commit-workflow.ts`、`windows/tauri/src/features/git/services/git-workspace-commit-workflow.test.ts`、`windows/tauri/src/features/git/components/status/git-workspace-status-panel.test.tsx`、`windows/tauri/src-tauri/src/platform.rs`、`shared/fixtures/git/workspace-commit-workflow-v1.json` | Git | 在同一工作区打开多个 Git 仓库,确认变更按仓库折叠分组;只剩一个仓库有变更时仍显示仓库名。仓库级和文件级勾选与 Git 暂存区同步;子模块只有未提交文件时显示提示,不能勾选未变化的引用。 | 两端直接消费共享 Rust Core 的计划、依赖排序、执行结果与重试。平台负责真实暂存勾选、确认与结果界面、认证取消和项目生命周期。原生界面实测待平台验收。 | | Git | **分支、标签与远程**
git-branches-remotes | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Git` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git` | Git | 创建、切换、合并分支并查看标签和远程,确认冲突与认证失败可恢复。 | | | Git | **Diff 与变更审查**
git-diff-review | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Git`、`macos/Sources/Lithe/Views/Diff` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git`、`windows/tauri/src/features/viewer` | Git | 验证新增、删除、重命名、二进制和多文件 Diff 的展示与定位;从源代码管理打开已修改和未跟踪文件的工作区 Diff 后保持静止,确认 Diff 不会自动关闭,且只在文件不再出现在 Git 状态中时关闭。 | | | Git | **提交历史与图谱**
git-history | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Git`、`macos/Sources/LitheGitModule/Services/GitGraphLayoutService.swift`、`macos/Sources/LitheGitModule/Services/GitGraphHeadOrdering.swift`、`macos/Sources/Lithe/Views/Git/GitGraphColor.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git`、`windows/tauri/src/features/git/utils/git-graph-layout.ts`、`windows/tauri/src/features/git/utils/git-graph-colors.ts`、`windows/tauri/src/features/git/utils/git-graph-layout.test.ts` | Git | 分页浏览提交历史、分支图谱和提交详情,确认日期、作者和文件列表一致;分支最新提交的图谱连线从提交圆点开始,不超出到圆点上方;在双端使用同一包含本地分支、远端引用、标签和合并提交的仓库,核对永久图布局、图头引用排序、合并边投影及跨越 30 行的紧凑长边;在本地分支新增提交后确认分支颜色保持稳定,且不会因共用屏幕泳道直接沿用父分支颜色。双端保留各自调色板,不要求 RGB 值一致。 | | | Git | **Rebase 与 Stash**
git-rebase-stash | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Git` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git` | Git | 执行交互式 Rebase 和 Stash 保存/恢复,确认中断、冲突和继续操作。 | | | Git | **Worktree 管理**
git-worktrees | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Git` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git` | Git | 列出、创建、切换和删除 Worktree,确认路径、分支和安全检查。 | | | Git | **多仓库引用面板:分组、配色与引用操作**
git-multi-repository-references | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Git` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git` | Git | 在多仓库工作区打开 Git Log:确认按仓库分组、仓库配色、非活动仓库分组只读,点其它仓库的引用会切换活动仓库且只加载一次,Pull 弹窗可选择远程分支与策略。 | | +| Git | **按文件所属仓库批量提交及推送,保留每仓库结果**
git-workspace-staged-commit | ✅ 已实现
🔍 待验证
`macos/Sources/LitheGitModule/Application/GitFeatureModel+WorkspaceCommit.swift`、`macos/Sources/Lithe/Views/Git/CommitAreaView.swift`、`macos/Tests/LitheGitModuleTests/GitModuleTests.swift`、`rust/lithe-core/src/git/commit_state.rs`、`rust/lithe-core/src/tests/git_workspace_commit.rs`、`rust/lithe-core/src/git/workspace_commit.rs`、`rust/lithe-core/src/git/workspace_commit/tests.rs`、`shared/fixtures/git/workspace-commit-workflow-v1.json` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git/components/status/git-status-panel.tsx`、`windows/tauri/src/features/git/components/git-commit-panel.tsx`、`windows/tauri/src/features/git/components/git-workspace-commit-review.tsx`、`windows/tauri/src/features/git/services/git-workspace-commit-workflow.ts`、`windows/tauri/src/features/git/services/git-workspace-commit-workflow.test.ts`、`windows/tauri/src/features/git/components/status/git-workspace-status-panel.test.tsx`、`windows/tauri/src-tauri/src/platform.rs`、`shared/fixtures/git/workspace-commit-workflow-v1.json`、`windows/tauri/src/features/git/runtime/git-workspace-commit-host.tsx`、`windows/tauri/src/features/git/runtime/git-workspace-commit-host.test.tsx` | Git | 勾选两个独立仓库文件,一次提交并推送,验证各自 HEAD 和远程;停止一个操作后其余独立仓库继续,已成功仓库不会回滚。 | 两端直接消费共享 Rust Core 的计划、依赖排序、执行结果与重试。平台负责真实暂存勾选、确认与结果界面、认证取消和项目生命周期。原生界面实测待平台验收。 | +| Git | **子模块先提交与推送、自动更新父引用、计划变化再次确认**
git-submodule-commit-plan | ✅ 已实现
🔍 待验证
`macos/Sources/LitheGitModule/Application/GitFeatureModel+WorkspaceCommit.swift`、`macos/Sources/Lithe/Views/Git/CommitAreaView.swift`、`macos/Tests/LitheGitModuleTests/GitModuleTests.swift`、`rust/lithe-core/src/git/commit_state.rs`、`rust/lithe-core/src/tests/git_workspace_commit.rs`、`rust/lithe-core/src/git/workspace_commit.rs`、`rust/lithe-core/src/git/workspace_commit/tests.rs`、`shared/fixtures/git/workspace-commit-workflow-v1.json` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git/components/status/git-status-panel.tsx`、`windows/tauri/src/features/git/components/git-commit-panel.tsx`、`windows/tauri/src/features/git/components/git-workspace-commit-review.tsx`、`windows/tauri/src/features/git/services/git-workspace-commit-workflow.ts`、`windows/tauri/src/features/git/services/git-workspace-commit-workflow.test.ts`、`windows/tauri/src/features/git/components/status/git-workspace-status-panel.test.tsx`、`windows/tauri/src-tauri/src/platform.rs`、`shared/fixtures/git/workspace-commit-workflow-v1.json`、`windows/tauri/src/features/git/runtime/git-workspace-commit-host.tsx`、`windows/tauri/src/features/git/runtime/git-workspace-commit-host.test.tsx` | Git | 只勾选孙仓库文件,确认计划自动列出父祖仓库的引用更新且可关闭;确认期间改变暂存内容或分支,必须显示新计划再次确认。父仓库未暂存文件不能被带入;只勾选父引用时先推送子仓库现有提交,Git 发布检查应拒绝未发布的子引用。子仓库元数据被外部删除后必须拒绝读取和写入,不能向上回退父仓库。 | 两端直接消费共享 Rust Core 的计划、依赖排序、执行结果与重试。平台负责真实暂存勾选、确认与结果界面、认证取消和项目生命周期。原生界面实测待平台验收。 | +| Git | **失败后只重试未完成的提交或推送步骤**
git-workspace-commit-retry | ✅ 已实现
🔍 待验证
`macos/Sources/LitheGitModule/Application/GitFeatureModel+WorkspaceCommit.swift`、`macos/Sources/Lithe/Views/Git/CommitAreaView.swift`、`macos/Tests/LitheGitModuleTests/GitModuleTests.swift`、`rust/lithe-core/src/git/commit_state.rs`、`rust/lithe-core/src/tests/git_workspace_commit.rs`、`rust/lithe-core/src/git/workspace_commit.rs`、`rust/lithe-core/src/git/workspace_commit/tests.rs`、`shared/fixtures/git/workspace-commit-workflow-v1.json` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git/components/status/git-status-panel.tsx`、`windows/tauri/src/features/git/components/git-commit-panel.tsx`、`windows/tauri/src/features/git/components/git-workspace-commit-review.tsx`、`windows/tauri/src/features/git/services/git-workspace-commit-workflow.ts`、`windows/tauri/src/features/git/services/git-workspace-commit-workflow.test.ts`、`windows/tauri/src/features/git/components/status/git-workspace-status-panel.test.tsx`、`windows/tauri/src-tauri/src/platform.rs`、`shared/fixtures/git/workspace-commit-workflow-v1.json`、`windows/tauri/src/features/git/runtime/git-workspace-commit-host.tsx`、`windows/tauri/src/features/git/runtime/git-workspace-commit-host.test.tsx` | Git | 让子仓库推送失败,确认父仓库被阻塞、独立仓库成功;重试计划应显示子仓库只推送,验证子仓库提交数不增加,随后才提交和推送父仓库。 | 两端直接消费共享 Rust Core 的计划、依赖排序、执行结果与重试。平台负责真实暂存勾选、确认与结果界面、认证取消和项目生命周期。原生界面实测待平台验收。 |
diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index 26547b07d..cdce0783d 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -1230,3 +1230,34 @@ "Context usage" = "Context usage"; "Context: %@" = "Context: %@"; "%@ · %@ / %@ context tokens" = "%@ · %@ / %@ context tokens"; + +/* Workspace repository commits */ +"Review remaining steps" = "Review remaining steps"; +"Review repository commits" = "Review repository commits"; +"Each repository has its own commit. Completed steps are kept if another repository fails." = "Each repository has its own commit. Completed steps are kept if another repository fails."; +"Review and Retry Unfinished Steps…" = "Review and Retry Unfinished Steps…"; +"Dismiss Results" = "Dismiss Results"; +"Update parent repository references" = "Update parent repository references"; +"Each submodule is pushed before its parent." = "Each submodule is pushed before its parent."; +"Uncommitted submodule changes" = "Uncommitted submodule changes"; +"Commit changed files in the submodule first" = "Commit changed files in the submodule first"; +"Amend applies to repositories with selected files." = "Amend applies to repositories with selected files."; +"Commit message: %@" = "Commit message: %@"; +"Push only" = "Push only"; +"Commit and push" = "Commit and push"; +"Committed; push pending" = "Committed; push pending"; +"Committed and pushed" = "Committed and pushed"; +"Waiting for submodule" = "Waiting for submodule"; +"Update %@/%@ after %@" = "Update %@/%@ after %@"; +"Repository changed; review and retry" = "Repository changed; review and retry"; +"Committed" = "Committed"; +"Collapse repository" = "Collapse repository"; +"Expand repository" = "Expand repository"; +"Unstage all files in repository" = "Unstage all files in repository"; +"Stage all files in repository" = "Stage all files in repository"; +"Pending" = "Pending"; +"Not included in the updated plan" = "Not included in the updated plan"; +"Committed; push failed" = "Committed; push failed"; +"HEAD advanced; review before continuing." = "HEAD advanced; review before continuing."; +"Could not verify the commit outcome. Review before retrying." = "Could not verify the commit outcome. Review before retrying."; +"Repository needs attention" = "Repository needs attention"; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index 6c80e14da..be5395fbb 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -2458,3 +2458,34 @@ "Context usage" = "上下文用量"; "Context: %@" = "上下文: %@"; "%@ · %@ / %@ context tokens" = "%@ · %@ / %@ 上下文 token"; + +/* Workspace repository commits */ +"Review remaining steps" = "确认剩余步骤"; +"Review repository commits" = "确认各仓库提交"; +"Each repository has its own commit. Completed steps are kept if another repository fails." = "每个仓库会分别提交。某个仓库失败时,已完成的步骤仍会保留。"; +"Review and Retry Unfinished Steps…" = "查看并重试未完成的步骤…"; +"Dismiss Results" = "清除结果"; +"Update parent repository references" = "更新父仓库引用"; +"Each submodule is pushed before its parent." = "先推送子模块,再推送父仓库。"; +"Uncommitted submodule changes" = "子模块有未提交的修改"; +"Commit changed files in the submodule first" = "请先在子模块中提交修改的文件"; +"Amend applies to repositories with selected files." = "修订提交仅作用于有已选文件的仓库。"; +"Commit message: %@" = "提交说明:%@"; +"Push only" = "仅推送"; +"Commit and push" = "提交并推送"; +"Committed; push pending" = "已提交,等待推送"; +"Committed and pushed" = "已提交并推送"; +"Waiting for submodule" = "等待子模块完成"; +"Update %@/%@ after %@" = "在 %@/%@ 更新引用,等待 %@ 完成"; +"Repository changed; review and retry" = "仓库状态已改变,请检查并重试"; +"Committed" = "已提交"; +"Collapse repository" = "折叠仓库"; +"Expand repository" = "展开仓库"; +"Unstage all files in repository" = "取消暂存仓库的所有文件"; +"Stage all files in repository" = "暂存仓库的所有文件"; +"Pending" = "等待执行"; +"Not included in the updated plan" = "未包含在更新后的计划中"; +"Committed; push failed" = "已提交,推送失败"; +"HEAD advanced; review before continuing." = "提交记录已推进,请检查后再继续。"; +"Could not verify the commit outcome. Review before retrying." = "无法确认提交结果,请检查后再重试。"; +"Repository needs attention" = "仓库需要处理"; diff --git a/macos/Sources/Lithe/Application/Composition/CommitWorkflowComposition.swift b/macos/Sources/Lithe/Application/Composition/CommitWorkflowComposition.swift index 8f76ee7b5..fa86891a4 100644 --- a/macos/Sources/Lithe/Application/Composition/CommitWorkflowComposition.swift +++ b/macos/Sources/Lithe/Application/Composition/CommitWorkflowComposition.swift @@ -3,8 +3,12 @@ import LitheGitModule import LitheModuleAPI extension GitFeatureModel: CommitWorkflowGit { + var pendingCommitDraft: (message: String, amend: Bool)? { + pendingSubmoduleCommitPlan.map { ($0.message, $0.amend) } + } + var stagedChangeIDs: Set { - Set(activeRepositoryChanges.filter(\.isStaged).map(\.id)) + Set(gitChanges.filter(\.isStaged).map(\.id)) } } diff --git a/macos/Sources/Lithe/Application/Features/CommitWorkflowCoordinator.swift b/macos/Sources/Lithe/Application/Features/CommitWorkflowCoordinator.swift index a4fa2ad48..425d0ae06 100644 --- a/macos/Sources/Lithe/Application/Features/CommitWorkflowCoordinator.swift +++ b/macos/Sources/Lithe/Application/Features/CommitWorkflowCoordinator.swift @@ -4,10 +4,16 @@ import LitheCoreContracts /// The Git operations needed by the application-owned commit workflow. @MainActor protocol CommitWorkflowGit: AnyObject { + /// Staged changes from every repository discovered in the workspace. var stagedChangeIDs: Set { get } + var pendingCommitDraft: (message: String, amend: Bool)? { get } func stagedCommitMessageInput() async -> CommitMessageInput? + /// Commits each repository's staged changes independently while sharing the + /// commit message. The Git feature owns repository grouping and failure + /// reporting so the coordinator remains unaware of Git's storage model. func commitStagedChanges(message: String, amend: Bool) async -> Bool func commitAndPushStagedChanges(message: String, amend: Bool) async -> Bool + func confirmPendingSubmoduleCommit() async -> Bool } /// Coordinates submission and AI generation without owning Git or AI services. @@ -35,6 +41,14 @@ final class CommitWorkflowCoordinator { } func commit(push: Bool = false) async { + await submitCommit(push: push, confirmSubmodulePlan: false) + } + + func confirmPendingSubmoduleCommit() async { + await submitCommit(push: false, confirmSubmodulePlan: true) + } + + private func submitCommit(push: Bool, confirmSubmodulePlan: Bool) async { guard !isSubmitting else { return } isSubmitting = true defer { isSubmitting = false } @@ -43,14 +57,19 @@ final class CommitWorkflowCoordinator { let amend = draft.amend guard let git = await activateGit(), generation == workspaceGeneration(), !Task.isCancelled else { return } - let succeeded = if push { - await git.commitAndPushStagedChanges(message: message, amend: amend) + let submittedMessage = confirmSubmodulePlan ? git.pendingCommitDraft?.message ?? message : message + let submittedAmend = confirmSubmodulePlan ? git.pendingCommitDraft?.amend ?? amend : amend + let succeeded: Bool + if confirmSubmodulePlan { + succeeded = await git.confirmPendingSubmoduleCommit() + } else if push { + succeeded = await git.commitAndPushStagedChanges(message: message, amend: amend) } else { - await git.commitStagedChanges(message: message, amend: amend) + succeeded = await git.commitStagedChanges(message: message, amend: amend) } guard succeeded, generation == workspaceGeneration() else { return } // Preserve edits made while Git was running, even after a successful commit. - if draft.message == message && draft.amend == amend { + if draft.message.trimmingCharacters(in: .whitespacesAndNewlines) == submittedMessage.trimmingCharacters(in: .whitespacesAndNewlines) && draft.amend == submittedAmend { draft.clearAfterCommit() } } diff --git a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift index d2c19e924..f82ab67af 100644 --- a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift +++ b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift @@ -1330,6 +1330,8 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { let staged: Bool let worktree: Bool let untracked: Bool + let submodule: GitSubmoduleStatus? + let canToggleStaging: Bool? } struct GitStatusPayload: Decodable, Sendable { @@ -1352,7 +1354,9 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { path: change.path, originalPath: change.originalPath, indexStatus: status.first ?? " ", - workTreeStatus: status.dropFirst().first ?? " " + workTreeStatus: status.dropFirst().first ?? " ", + submodule: change.submodule, + canToggleStaging: change.canToggleStaging ?? true ) } ) @@ -2093,7 +2097,9 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { } private struct GitStatusRequest: Encodable { + let repositoryRoots: [String] let root: String + let includeIndexOnlyChanges = true } private struct WorkspaceRepositoriesRequest: Encodable { @@ -2835,10 +2841,10 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { ) } - func gitStatus(at rootURL: URL) -> GitStatusPayload? { + func gitStatus(at rootURL: URL, repositoryRoots: [URL] = []) -> GitStatusPayload? { execute( command: "git.status", - payload: GitStatusRequest(root: rootURL.standardizedFileURL.path) + payload: GitStatusRequest(repositoryRoots: repositoryRoots.map { $0.standardizedFileURL.path }, root: rootURL.standardizedFileURL.path) ) } @@ -2860,7 +2866,7 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { func gitWorktrees(at rootURL: URL) -> GitWorktreesPayload? { execute( command: "git.worktrees", - payload: GitStatusRequest(root: rootURL.standardizedFileURL.path) + payload: GitStatusRequest(repositoryRoots: [], root: rootURL.standardizedFileURL.path) ) } diff --git a/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift b/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift index 0555655e3..b460a373c 100644 --- a/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift +++ b/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift @@ -140,6 +140,19 @@ struct RustGitOperations: GitOperations, Sendable { write(at: change.repositoryRoot, operation: "discardAll", paths: change.pathspecs) } + func prepareWorkspaceCommit(_ request: GitWorkspaceCommitRequest) -> Result { + let result: Result = core.executeResult( + command: "git.workspaceCommitPrepare", payload: request) + return result.mapError { GitWorkspaceCommitFailure($0.userMessage) } + } + + func stepWorkspaceCommit(_ session: GitWorkspaceCommitSession) -> Result { + struct Request: Encodable { let session: GitWorkspaceCommitSession } + let result: Result = core.executeResult( + command: "git.workspaceCommitStep", payload: Request(session: session)) + return result.mapError { GitWorkspaceCommitFailure($0.userMessage) } + } + func commit(at rootURL: URL, message: String, amend: Bool) -> GitProcessResult? { write(at: rootURL, operation: "commit", message: message, amend: amend) } @@ -455,8 +468,10 @@ struct RustGitOperations: GitOperations, Sendable { write(at: rootURL, operation: "deleteTag", name: name) } - func snapshot(at rootURL: URL) -> GitSnapshot? { - core.gitStatus(at: rootURL)?.makeSnapshot(at: rootURL) + func snapshot(at rootURL: URL) -> GitSnapshot? { snapshot(at: rootURL, repositoryRoots: []) } + + func snapshot(at rootURL: URL, repositoryRoots: [URL]) -> GitSnapshot? { + core.gitStatus(at: rootURL, repositoryRoots: repositoryRoots)?.makeSnapshot(at: rootURL) } func repositories(in workspaceURL: URL) -> [URL] { diff --git a/macos/Sources/Lithe/Views/Git/ChangesSidebarView.swift b/macos/Sources/Lithe/Views/Git/ChangesSidebarView.swift index 70233a03d..c710492da 100644 --- a/macos/Sources/Lithe/Views/Git/ChangesSidebarView.swift +++ b/macos/Sources/Lithe/Views/Git/ChangesSidebarView.swift @@ -21,6 +21,7 @@ struct ChangesSidebarView: View { @State private var selectedTab = CommitTab.commit @State private var trackedExpanded = true @State private var untrackedExpanded = true + @State private var repositoryExpanded: [String: Bool] = [:] @State private var stashMessage = "WIP" @State private var includeUntracked = true @State private var selectedStash: GitStash? @@ -516,33 +517,148 @@ struct ChangesSidebarView: View { .font(LitheTheme.uiFont) .foregroundStyle(LitheTheme.secondaryText) .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if feature.availableRepositoryRoots.count > 1 { + multiRepositoryChangeList } else { - GeometryReader { geometry in - ScrollView(.vertical) { - LazyVStack(alignment: .leading, spacing: 0) { - changeSection( - "Changes", - changes: trackedChanges, - expanded: $trackedExpanded, - showsParentPaths: geometry.size.width >= 300, - joinsNextHeader: !trackedExpanded && !addedChanges.isEmpty - ) - changeSection( - "Unversioned Files", - changes: addedChanges, - expanded: $untrackedExpanded, - showsParentPaths: geometry.size.width >= 300, - joinsPreviousHeader: !trackedExpanded && !trackedChanges.isEmpty - ) - } - .padding(.horizontal, 8) - .padding(.vertical, 8) - .frame(maxWidth: .infinity, alignment: .topLeading) + singleRepositoryChangeList + } + } + .frame(maxHeight: .infinity) + } + + private var singleRepositoryChangeList: some View { + GeometryReader { geometry in + ScrollView(.vertical) { + LazyVStack(alignment: .leading, spacing: 0) { + changeSection( + "Changes", + changes: trackedChanges, + expanded: $trackedExpanded, + showsParentPaths: geometry.size.width >= 300, + joinsNextHeader: !trackedExpanded && !addedChanges.isEmpty + ) + changeSection( + "Unversioned Files", + changes: addedChanges, + expanded: $untrackedExpanded, + showsParentPaths: geometry.size.width >= 300, + joinsPreviousHeader: !trackedExpanded && !trackedChanges.isEmpty + ) + } + .padding(.horizontal, 8) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .topLeading) + } + } + } + + private var multiRepositoryChangeList: some View { + GeometryReader { geometry in + ScrollView(.vertical) { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(changeSections.repositories) { repository in + repositoryChangeSection( + repository, + showsParentPaths: geometry.size.width >= 300 + ) } } + .padding(.horizontal, 8) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .topLeading) } } - .frame(maxHeight: .infinity) + } + + private func repositoryChangeSection( + _ repository: GitChangeSectionsCache.RepositorySection, + showsParentPaths: Bool + ) -> some View { + let repositoryID = repository.id + let isExpanded = repositoryExpanded[repositoryID] ?? true + + return VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 7) { + Button { + repositoryExpanded[repositoryID] = !isExpanded + } label: { + Image(systemName: isExpanded ? "chevron.down" : "chevron.right") + .font(.system(size: 8, weight: .bold)) + .frame(width: 10, height: 26) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help(LocalizedStringKey(isExpanded ? "Collapse repository" : "Expand repository")) + + Button { + setStaging(repository.changes, !allChangesStaged(repository.changes)) + } label: { + Image(systemName: stagingSymbol(for: repository.changes)) + .font(.system(size: 16)) + .foregroundStyle( + repository.changes.contains(where: isEffectivelyStaged) + ? LitheTheme.accent + : LitheTheme.secondaryText + ) + .frame(width: 18, height: 26) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(feature.isCommitting || !repository.changes.contains(where: \.canToggleStaging)) + .help(LocalizedStringKey( + allChangesStaged(repository.changes) + ? "Unstage all files in repository" + : "Stage all files in repository" + )) + + Button { + repositoryExpanded[repositoryID] = !isExpanded + } label: { + HStack(spacing: 6) { + Image(systemName: "folder.fill") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(GitRepositoryColor.color( + for: repository.root, + in: feature.availableRepositoryRoots + )) + Text(repositoryDisplayName(repository.root)) + .font(.system(size: 12.5, weight: .semibold)) + .foregroundStyle(LitheTheme.primaryText) + .lineLimit(1) + Text("\(repository.changes.count)") + .font(.system(size: 11)) + .foregroundStyle(LitheTheme.secondaryText) + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, minHeight: 26, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help(repository.root.path) + } + .padding(.horizontal, 7) + .frame(maxWidth: .infinity) + .background(LitheTheme.subtleSelection.opacity(0.45)) + + if isExpanded { + ForEach(Array(repository.changes.enumerated()), id: \.element.id) { index, change in + changeRow( + change, + showsParentPath: showsParentPaths, + includesRepositoryRootInParentPath: false, + leadingInset: 12, + joinsPrevious: index > 0 && selection.ids.contains(repository.changes[index - 1].id), + joinsNext: index + 1 < repository.changes.count + && selection.ids.contains(repository.changes[index + 1].id) + ) + } + } + } + .overlay(alignment: .bottom) { + Rectangle() + .fill(LitheTheme.divider.opacity(0.72)) + .frame(height: 1) + } } @ViewBuilder @@ -551,6 +667,7 @@ struct ChangesSidebarView: View { changes: [GitChange], expanded: Binding, showsParentPaths: Bool, + leadingInset: CGFloat = 0, joinsPreviousHeader: Bool = false, joinsNextHeader: Bool = false ) -> some View { @@ -577,6 +694,7 @@ struct ChangesSidebarView: View { .contentShape(Rectangle()) } .buttonStyle(.plain) + .disabled(feature.isCommitting || !changes.contains(where: \.canToggleStaging)) .help(LocalizedStringKey(allChangesStaged(changes) ? "Unstage all files" : "Stage all files")) Button { @@ -597,6 +715,7 @@ struct ChangesSidebarView: View { .buttonStyle(.plain) } .padding(.horizontal, 7) + .padding(.leading, leadingInset) .frame(maxWidth: .infinity) .frame(height: changeRowHeight) .background { @@ -617,6 +736,7 @@ struct ChangesSidebarView: View { changeRow( change, showsParentPath: showsParentPaths, + leadingInset: leadingInset, joinsPrevious: index > 0 && selection.ids.contains(changes[index - 1].id), joinsNext: index + 1 < changes.count && selection.ids.contains(changes[index + 1].id) ) @@ -628,6 +748,8 @@ struct ChangesSidebarView: View { private func changeRow( _ change: GitChange, showsParentPath: Bool, + includesRepositoryRootInParentPath: Bool = true, + leadingInset: CGFloat = 0, joinsPrevious: Bool, joinsNext: Bool ) -> some View { @@ -643,7 +765,10 @@ struct ChangesSidebarView: View { .contentShape(Rectangle()) } .buttonStyle(LitheTreeRowButtonStyle()) - .help(LocalizedStringKey(isEffectivelyStaged(change) ? "Unstage file" : "Stage file")) + .disabled(feature.isCommitting || !change.canToggleStaging) + .help(LocalizedStringKey(change.canToggleStaging + ? (isEffectivelyStaged(change) ? "Unstage file" : "Stage file") + : "Commit changed files in the submodule first")) Button { selectRow(change) @@ -662,7 +787,14 @@ struct ChangesSidebarView: View { .strikethrough(change.kind == .deleted, color: statusColor(change)) .lineLimit(1) .layoutPriority(1) - let parent = parentPathText(change) + if !change.canToggleStaging { + Text("Uncommitted submodule changes") + .font(.caption).foregroundStyle(LitheTheme.secondaryText) + } + let parent = parentPathText( + change, + includesRepositoryRoot: includesRepositoryRootInParentPath + ) if showsParentPath, !parent.isEmpty { Text(parent) .font(.system(size: 10.5)) @@ -677,7 +809,7 @@ struct ChangesSidebarView: View { } .buttonStyle(LitheTreeRowButtonStyle()) } - .padding(.leading, 30) + .padding(.leading, 30 + leadingInset) .padding(.trailing, 6) .frame(maxWidth: .infinity) .frame(height: changeRowHeight) @@ -702,12 +834,24 @@ struct ChangesSidebarView: View { } } + private func repositoryDisplayName(_ root: URL) -> String { + let name = root.lastPathComponent + return name.isEmpty ? root.path : name + } + private var selectedChanges: [GitChange] { selection.actionTargets(in: displayedChanges) } private var visibleChangeIDs: [String] { - ((trackedExpanded ? trackedChanges : []) + (untrackedExpanded ? addedChanges : [])).map(\.id) + guard feature.availableRepositoryRoots.count > 1 else { + return ((trackedExpanded ? trackedChanges : []) + (untrackedExpanded ? addedChanges : [])).map(\.id) + } + + return changeSections.repositories.flatMap { repository in + guard repositoryExpanded[repository.id] ?? true else { return [String]() } + return repository.changes.map(\.id) + } } private func selectRow(_ change: GitChange) { @@ -811,7 +955,8 @@ struct ChangesSidebarView: View { } private func allChangesStaged(_ changes: [GitChange]) -> Bool { - changes.allSatisfy(isEffectivelyStaged) + let selectable = changes.filter(\.canToggleStaging) + return !selectable.isEmpty && selectable.allSatisfy(isEffectivelyStaged) } private func stagingSymbol(for changes: [GitChange]) -> String { @@ -854,9 +999,12 @@ struct ChangesSidebarView: View { return "\(oldName) → \(change.url.lastPathComponent)" } - private func parentPathText(_ change: GitChange) -> String { + private func parentPathText( + _ change: GitChange, + includesRepositoryRoot: Bool = true + ) -> String { let parent = (change.path as NSString).deletingLastPathComponent - let prefix = feature.availableRepositoryRoots.count > 1 + let prefix = includesRepositoryRoot && feature.availableRepositoryRoots.count > 1 ? change.repositoryRoot.path + "/" : "" guard let originalPath = change.originalPath else { return prefix + parent } let originalParent = (originalPath as NSString).deletingLastPathComponent diff --git a/macos/Sources/Lithe/Views/Git/CommitAreaView.swift b/macos/Sources/Lithe/Views/Git/CommitAreaView.swift index 4f77c4f31..be51b9e89 100644 --- a/macos/Sources/Lithe/Views/Git/CommitAreaView.swift +++ b/macos/Sources/Lithe/Views/Git/CommitAreaView.swift @@ -63,6 +63,25 @@ struct CommitAreaView: View { .allowsHitTesting(false) } + if !feature.workspaceCommitResults.isEmpty { + ScrollView { + VStack(alignment: .leading, spacing: 3) { + ForEach(feature.workspaceCommitResults) { result in + (Text("\(result.root.lastPathComponent): ") + Text(LocalizedStringKey(result.detail)) + + Text(verbatim: result.diagnostic.isEmpty ? "" : ": \(result.diagnostic)")) + .font(.caption).help(result.root.path) + } + }.frame(maxWidth: .infinity, alignment: .leading) + }.frame(maxHeight: 90) + Button("Dismiss Results") { feature.dismissWorkspaceCommitResults() } + .disabled(feature.isCommitting) + if feature.canRetryWorkspaceCommit { + Button("Review and Retry Unfinished Steps…") { + Task { await feature.prepareWorkspaceCommitRetry() } + }.disabled(feature.isCommitting) + } + } + HStack(spacing: 8) { Button { Task { await commitWorkflow.commit() } @@ -115,16 +134,75 @@ struct CommitAreaView: View { } message: { Text("The generated message will replace the text currently in the editor.") } + .sheet(isPresented: Binding( + get: { feature.pendingSubmoduleCommitPlan != nil }, + set: { if !$0 { feature.cancelPendingSubmoduleCommit() } } + )) { + WorkspaceCommitPlanView(feature: feature, commitWorkflow: commitWorkflow) + } } private var stagedChanges: [GitChange] { - feature.activeRepositoryChanges.filter(\.isStaged) + // Commit operates on every repository in the workspace, not only the + // repository selected by the branch toolbar. + feature.gitChanges.filter(\.isStaged) } private var canCommit: Bool { !stagedChanges.isEmpty && !draft.message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && - !feature.isCommitting + !feature.isCommitting && !feature.isStagingChanges && feature.pendingSubmoduleCommitPlan == nil && !feature.canRetryWorkspaceCommit } } + +/// Observe the plan directly so a changed selection updates the open sheet. +private struct WorkspaceCommitPlanView: View { + @ObservedObject var feature: GitFeatureModel + let commitWorkflow: CommitWorkflowCoordinator + + var body: some View { + if let plan = feature.pendingSubmoduleCommitPlan { + VStack(alignment: .leading, spacing: 12) { + Text(LocalizedStringKey(plan.isRetry ? "Review remaining steps" : "Review repository commits")).font(.headline) + Text("Each repository has its own commit. Completed steps are kept if another repository fails.") + Text("Commit message: \(plan.message)").font(.caption) + if plan.amend { Text("Amend applies to repositories with selected files.").font(.caption) } + ScrollView { + VStack(alignment: .leading, spacing: 8) { + ForEach(Array(plan.orderedRoots.enumerated()), id: \.element) { index, root in + VStack(alignment: .leading, spacing: 2) { + let action = plan.committedRoots.contains(root) ? "Push only" : (plan.push ? "Commit and push" : "Commit") + (Text("\(index + 1). ") + Text(LocalizedStringKey(action)) + Text(": \(root.path)")) + if let state = plan.states[root] { + Text("\(state.branch ?? "Detached HEAD") · \(state.head?.prefix(10) ?? "New repository")") + .font(.caption).foregroundStyle(.secondary) + if !plan.committedRoots.contains(root) { + ForEach(state.stagedPaths, id: \.self) { path in + Text(path).font(.caption) + } + } + } + } + } + ForEach(plan.propagatedRelations, id: \.self) { relation in + Text("Update \(relation.parent.lastPathComponent)/\(relation.path) after \(relation.child.lastPathComponent)") + .font(.caption) + } + }.frame(maxWidth: .infinity, alignment: .leading) + }.frame(maxHeight: 260) + Toggle("Update parent repository references", isOn: Binding( + get: { plan.includeParentReferences }, + set: { include in Task { await feature.setCommitPlanParentReferences(include) } } + )).disabled(feature.isCommitting) + if plan.push { Text("Each submodule is pushed before its parent.").font(.caption) } + HStack { + Spacer() + Button("Cancel") { feature.cancelPendingSubmoduleCommit() } + Button("Continue") { Task { await commitWorkflow.confirmPendingSubmoduleCommit() } } + .keyboardShortcut(.defaultAction) + }.disabled(feature.isCommitting) + }.padding(20).frame(width: 540) + } + } +} diff --git a/macos/Sources/Lithe/Views/Git/GitChangeSectionsCache.swift b/macos/Sources/Lithe/Views/Git/GitChangeSectionsCache.swift index 832c0626f..a554fd833 100644 --- a/macos/Sources/Lithe/Views/Git/GitChangeSectionsCache.swift +++ b/macos/Sources/Lithe/Views/Git/GitChangeSectionsCache.swift @@ -1,3 +1,4 @@ +import Foundation import LitheGitModule /// Splits the working-tree changes into the sections the sidebar renders, once @@ -11,12 +12,22 @@ import LitheGitModule /// invalidate the view that reads it. @MainActor final class GitChangeSectionsCache { + struct RepositorySection: Identifiable { + let root: URL + let changes: [GitChange] + let tracked: [GitChange] + let added: [GitChange] + + var id: String { root.standardizedFileURL.path } + } + struct Sections { /// All changes, minus anything hidden by an active conflict filter. let displayed: [GitChange] let tracked: [GitChange] let added: [GitChange] let staged: [GitChange] + let repositories: [RepositorySection] } private var cachedChanges: [GitChange] = [] @@ -35,6 +46,10 @@ final class GitChangeSectionsCache { var tracked: [GitChange] = [] var added: [GitChange] = [] var staged: [GitChange] = [] + var repositoryOrder: [String] = [] + var repositoryChanges: [String: [GitChange]] = [:] + var repositoryTracked: [String: [GitChange]] = [:] + var repositoryAdded: [String: [GitChange]] = [:] displayed.reserveCapacity(changes.count) for change in changes { @@ -45,18 +60,36 @@ final class GitChangeSectionsCache { continue } displayed.append(change) + let repositoryID = change.repositoryRoot.standardizedFileURL.path + if repositoryChanges[repositoryID] == nil { + repositoryOrder.append(repositoryID) + } + repositoryChanges[repositoryID, default: []].append(change) if change.kind == .added { added.append(change) + repositoryAdded[repositoryID, default: []].append(change) } else { tracked.append(change) + repositoryTracked[repositoryID, default: []].append(change) } } + let repositories = repositoryOrder.compactMap { repositoryID -> RepositorySection? in + guard let changes = repositoryChanges[repositoryID], + let root = changes.first?.repositoryRoot else { return nil } + return RepositorySection( + root: root, + changes: changes, + tracked: repositoryTracked[repositoryID] ?? [], + added: repositoryAdded[repositoryID] ?? [] + ) + } let sections = Sections( displayed: displayed, tracked: tracked, added: added, - staged: staged + staged: staged, + repositories: repositories ) cachedChanges = changes cachedFilterPaths = conflictFilterPaths diff --git a/macos/Sources/LitheGitModule/Application/GitFeatureModel+WorkspaceCommit.swift b/macos/Sources/LitheGitModule/Application/GitFeatureModel+WorkspaceCommit.swift new file mode 100644 index 000000000..656f1b391 --- /dev/null +++ b/macos/Sources/LitheGitModule/Application/GitFeatureModel+WorkspaceCommit.swift @@ -0,0 +1,135 @@ +import Foundation + +// Decisions: .agents/notes/implemented/feature/2026-09-27-workspace-git-commit-plans.md +extension GitFeatureModel { + package func dismissWorkspaceCommitResults() { + guard !isCommitting, pendingSubmoduleCommitPlan == nil else { return } + workspaceCommitAttempt = nil + workspaceCommitResults = [] + } + + package var canRetryWorkspaceCommit: Bool { + workspaceCommitAttempt?.canRetry == true + } + + package func commitStagedChanges(message: String, amend: Bool) async -> Bool { + await prepareWorkspaceCommit(message: message, amend: amend, push: false) + } + + @discardableResult + package func commitAndPushStagedChanges(message: String, amend: Bool) async -> Bool { + await prepareWorkspaceCommit(message: message, amend: amend, push: true) + } + + private func prepareWorkspaceCommit(message: String, amend: Bool, push: Bool) async -> Bool { + guard !isCommitting, pendingSubmoduleCommitPlan == nil else { return false } + let message = message.trimmingCharacters(in: .whitespacesAndNewlines) + guard !message.isEmpty else { notify?("Enter a commit message"); return false } + isCommitting = true + let generation = workspaceCommitGeneration + defer { if generation == workspaceCommitGeneration { isCommitting = false } } + guard let plan = await makeWorkspaceCommitPlan(message: message, amend: amend, push: push, + includeParentReferences: true, retry: false), generation == workspaceCommitGeneration else { return false } + workspaceCommitAttempt = nil + workspaceCommitResults = [] + if plan.preparation.requiresConfirmation { + pendingSubmoduleCommitPlan = plan + return false + } + return await executeWorkspaceCommit(plan, generation: generation) + } + + package func cancelPendingSubmoduleCommit() { pendingSubmoduleCommitPlan = nil } + + /// Rebuild even when a dialog is already visible: confirmation authorizes + /// this exact plan, never a new selection silently substituted for it. + @discardableResult + package func confirmPendingSubmoduleCommit() async -> Bool { + guard !isCommitting, let reviewed = pendingSubmoduleCommitPlan else { return false } + isCommitting = true + let generation = workspaceCommitGeneration + defer { if generation == workspaceCommitGeneration { isCommitting = false } } + guard let current = await makeWorkspaceCommitPlan(message: reviewed.message, amend: reviewed.amend, + push: reviewed.push, includeParentReferences: reviewed.includeParentReferences, retry: reviewed.isRetry, reviewed: reviewed.core), + generation == workspaceCommitGeneration, pendingSubmoduleCommitPlan?.id == reviewed.id else { return false } + guard !current.preparation.reviewChanged else { + pendingSubmoduleCommitPlan = current + notify?("The commit plan changed. Review the updated repositories and references before continuing.") + return false + } + pendingSubmoduleCommitPlan = nil + return await executeWorkspaceCommit(current, generation: generation) + } + + package func setCommitPlanParentReferences(_ include: Bool) async { + guard !isCommitting, let plan = pendingSubmoduleCommitPlan else { return } + isCommitting = true + let generation = workspaceCommitGeneration + defer { if generation == workspaceCommitGeneration { isCommitting = false } } + let replacement = await makeWorkspaceCommitPlan(message: plan.message, amend: plan.amend, push: plan.push, + includeParentReferences: include, retry: plan.isRetry) + guard generation == workspaceCommitGeneration, pendingSubmoduleCommitPlan?.id == plan.id else { return } + pendingSubmoduleCommitPlan = replacement + } + + /// Retry is explicit and always reviewable, including when only a push remains. + package func prepareWorkspaceCommitRetry() async { + guard !isCommitting, pendingSubmoduleCommitPlan == nil, let attempt = workspaceCommitAttempt else { return } + isCommitting = true + let generation = workspaceCommitGeneration + defer { if generation == workspaceCommitGeneration { isCommitting = false } } + let plan = await makeWorkspaceCommitPlan(message: attempt.plan.message, amend: attempt.plan.amend, + push: attempt.plan.push, includeParentReferences: attempt.plan.includeParentReferences, retry: true) + guard generation == workspaceCommitGeneration else { return } + pendingSubmoduleCommitPlan = plan + } + + private func makeWorkspaceCommitPlan(message: String, amend: Bool, push: Bool, + includeParentReferences: Bool, retry: Bool, reviewed: GitWorkspaceCommitPlan? = nil) async -> GitSubmoduleCommitPlan? { + guard !isStagingChanges else { notify?("Wait for staging to finish before reviewing the commit plan."); return nil } + guard let workspace = workspaceURLProvider?() else { return nil } + let generation = workspaceCommitGeneration + let base = workspace.standardizedFileURL.pathComponents + let bindings = availableRepositoryRoots.map { root in + let components = root.standardizedFileURL.pathComponents + let shared = zip(base, components).prefix { $0 == $1 }.count + let relative = Array(repeating: "..", count: base.count - shared) + Array(components.dropFirst(shared)) + return GitWorkspaceRepositoryBinding(id: relative.isEmpty ? "." : relative.joined(separator: "/"), + root: root.standardizedFileURL.path) + } + let request = GitWorkspaceCommitRequest(repositories: bindings, + message: message, amend: amend, push: push, includeParentReferences: includeParentReferences, + previous: retry ? workspaceCommitAttempt : nil, reviewed: reviewed) + let response = await withGitOperation { await service.prepareWorkspaceCommit(request) } + guard generation == workspaceCommitGeneration, !Task.isCancelled else { return nil } + switch response { + case .success(let preparation): return GitSubmoduleCommitPlan(preparation: preparation) + case .failure(let error): notify?(error.message); return nil + } + } + + private func executeWorkspaceCommit(_ plan: GitSubmoduleCommitPlan, generation: UUID) async -> Bool { + var session = plan.preparation.session + workspaceCommitAttempt = session + workspaceCommitResults = session.displayedResults + while !session.finished { + guard generation == workspaceCommitGeneration, !Task.isCancelled else { return false } + // Core chooses the repository and operation. Native code only owns + // execution UI, authentication, cancellation and workspace lifetime. + let current = session + let response = await withGitOperation { await service.stepWorkspaceCommit(current) } + guard generation == workspaceCommitGeneration else { return false } + switch response { + case .success(let next): session = next + case .failure(let error): notify?(error.message); return false + } + workspaceCommitAttempt = session + workspaceCommitResults = session.displayedResults + } + await refreshGit() + guard generation == workspaceCommitGeneration else { return false } + notify?(session.succeeded ? (plan.push ? "Committed and pushed all selected repositories" : "Committed changes") + : "Some repositories need attention. Completed steps are saved; retry to continue.") + return session.succeeded + } +} diff --git a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift index 25946b71d..05114b1bc 100644 --- a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift +++ b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift @@ -185,7 +185,13 @@ package final class GitFeatureModel: ObservableObject { /// Set whenever Git is mid-merge, mid-rebase, mid-cherry-pick, or mid-revert. @Published package var gitOperationState: GitOperationState? @Published package var isResolvingGitOperation = false - @Published package private(set) var isCommitting = false + @Published package internal(set) var isCommitting = false + /// Set when the staged selection spans a real parent/submodule edge. The + /// commit UI must explain the child-first order before continuing. + @Published package internal(set) var pendingSubmoduleCommitPlan: GitSubmoduleCommitPlan? + @Published package internal(set) var workspaceCommitResults: [GitRepositoryCommitResult] = [] + var workspaceCommitAttempt: GitWorkspaceCommitSession? + var workspaceCommitGeneration = UUID() @Published package private(set) var gitBlameLines: [URL: [GitBlameLine]] = [:] @Published package private(set) var gitLineChangeMarkers: [URL: [GitLineChangeMarker]] = [:] @Published package private(set) var gitReferences: [GitReference] = [] { @@ -276,7 +282,7 @@ package final class GitFeatureModel: ObservableObject { @Published package private(set) var isCloningRepository = false @Published package private(set) var availableRepositoryRoots: [URL] = [] - private let service: GitService + let service: GitService private let executionJournal: GitExecutionJournal? private var executionJournalSubscription: AnyCancellable? private var journalEntryIDs: Set = [] @@ -286,16 +292,16 @@ package final class GitFeatureModel: ObservableObject { private var selectedGitCommitFilesGeneration: UInt64 = 0 private var gitLogFilterGeneration = UUID() private let shelveService: ShelveService? - private let snapshotProvider: @Sendable (URL) async -> GitSnapshot? + private let snapshotProvider: @Sendable (URL, [URL]) async -> GitSnapshot? private let stashesProvider: @Sendable (URL) async -> [GitStash] private let operationStateProvider: @Sendable (URL) async -> GitOperationState? private let worktreesProvider: @Sendable (URL) async -> [GitWorktree]? private let repositoryRootsProvider: @Sendable (URL) async -> [URL] private var requestedRepositoryRoot: URL? private let diffDocumentProvider: @Sendable (GitChange, GitDiffWhitespaceMode) async -> DiffDocument - private var workspaceURLProvider: (@MainActor () -> URL?)? + var workspaceURLProvider: (@MainActor () -> URL?)? private var isGitLogVisibleProvider: (@MainActor () -> Bool)? - private var notify: (@MainActor (String) -> Void)? + var notify: (@MainActor (String) -> Void)? private var onStateRefreshed: (@MainActor () async -> Void)? private var saveChangesPolicy: (@MainActor () -> GitSaveChangesPolicy)? private var onGitOperationBegan: (@MainActor () -> Void)? @@ -350,7 +356,11 @@ package final class GitFeatureModel: ObservableObject { self.executionJournal = executionJournal commitFilesLoader = GitCommitFilesLoader(service: service) self.shelveService = shelveService - self.snapshotProvider = snapshotProvider ?? { await service.snapshot(for: $0) } + if let snapshotProvider { + self.snapshotProvider = { root, _ in await snapshotProvider(root) } + } else { + self.snapshotProvider = { root, roots in await service.snapshot(for: root, repositoryRoots: roots) } + } self.stashesProvider = stashesProvider ?? { await service.stashes(at: $0) } self.operationStateProvider = operationStateProvider ?? { await service.operationState(at: $0) } self.worktreesProvider = worktreesProvider ?? { await service.worktrees(at: $0) } @@ -460,6 +470,10 @@ package final class GitFeatureModel: ObservableObject { pendingDiscardChange = nil pendingDiscardHunk = nil isCommitting = false + pendingSubmoduleCommitPlan = nil + workspaceCommitAttempt = nil + workspaceCommitResults = [] + workspaceCommitGeneration = UUID() gitBlameLines = [:] gitLineChangeMarkers = [:] loadingLineChangeURLs = [] @@ -700,13 +714,13 @@ package final class GitFeatureModel: ObservableObject { repositoryRoots: [URL] ) async -> GitSnapshot? { if repositoryRoots.isEmpty { - return await snapshotProvider(workspaceURL) + return await snapshotProvider(workspaceURL, []) } var snapshots: [GitSnapshot] = [] for repositoryRoot in repositoryRoots { guard !Task.isCancelled else { return nil } - if let snapshot = await snapshotProvider(repositoryRoot) { + if let snapshot = await snapshotProvider(repositoryRoot, repositoryRoots) { snapshots.append(snapshot) } } @@ -878,7 +892,7 @@ package final class GitFeatureModel: ObservableObject { return saveChangesPolicy?() ?? .stash } - private func withGitOperation( + func withGitOperation( title: String? = nil, plannedArguments: [String]? = nil, _ operation: () async -> T ) async -> T { @@ -1101,7 +1115,10 @@ package final class GitFeatureModel: ObservableObject { /// deliberately bypasses the selected file's working-tree diff so a file /// with both staged and unstaged edits is represented correctly. package func stagedCommitMessageInput() async -> CommitMessageInput? { - let stagedChanges = activeRepositoryChanges.filter(\.isStaged) + // `gitChanges` is the workspace aggregate. Keeping this input + // aggregate in the same way as the commit operation prevents the AI + // button from silently ignoring staged files in child repositories. + let stagedChanges = gitChanges.filter(\.isStaged) guard !stagedChanges.isEmpty else { return nil } var files: [CommitMessageFileInput] = [] @@ -1128,14 +1145,14 @@ package final class GitFeatureModel: ObservableObject { } package func stageSelectedChange() async { - guard let selectedChange else { return } + guard !isCommitting, let selectedChange, selectedChange.canToggleStaging else { return } let result = await withGitOperation { await service.stage(selectedChange) } showResult(result, success: "Staged \(selectedChange.path)") await refreshGit() } package func unstageSelectedChange() async { - guard let selectedChange else { return } + guard !isCommitting, let selectedChange else { return } let result = await withGitOperation { await service.unstage(selectedChange) } showResult(result, success: "Unstaged \(selectedChange.path)") await refreshGit() @@ -1270,107 +1287,6 @@ package final class GitFeatureModel: ObservableObject { } } - /// Paths still holding conflict markers. Committing during a merge or rebase - /// would finish that operation, so an unresolved file has to stop the commit - /// rather than be recorded with its `<<<<<<<` markers intact. - private var conflictedPaths: [String] { - activeRepositoryChanges.filter(\.isConflicted).map(\.path) - } - - private func blockCommitWhenConflicted() -> Bool { - let paths = conflictedPaths - guard !paths.isEmpty else { return false } - notify?("Resolve the conflicts first: \(paths.joined(separator: ", "))") - return true - } - - /// Refuses a commit whose staged content still carries conflict markers. - /// - /// Separate from `blockCommitWhenConflicted`: Git stops marking a file as - /// conflicted the moment it is staged, so a user who stages before deleting the - /// `<<<<<<<` lines would otherwise commit them. This reads the staged blobs. - private func blockCommitWhenMarkersRemain() async -> Bool { - guard let gitRepositoryRoot else { return false } - let paths = await service.conflictMarkerPaths(at: gitRepositoryRoot) - guard !paths.isEmpty else { return false } - notify?("Conflict markers remain in: \(paths.joined(separator: ", "))") - return true - } - - package func commitStagedChanges(message rawMessage: String, amend: Bool) async -> Bool { - guard let gitRepositoryRoot else { return false } - let message = rawMessage.trimmingCharacters(in: .whitespacesAndNewlines) - guard !message.isEmpty else { - notify?("Enter a commit message") - return false - } - guard !blockCommitWhenConflicted() else { return false } - guard await !blockCommitWhenMarkersRemain() else { return false } - - isCommitting = true - let result = await withGitOperation { - await service.commit(at: gitRepositoryRoot, message: message, amend: amend) - } - isCommitting = false - if result.succeeded { - notify?("Changes committed") - } else { - notify?(trimmedMessage(result)) - } - await refreshGit() - return result.succeeded - } - - @discardableResult - package func commitAndPushStagedChanges(message rawMessage: String, amend: Bool) async -> Bool { - guard let gitRepositoryRoot else { return false } - let message = rawMessage.trimmingCharacters(in: .whitespacesAndNewlines) - guard !message.isEmpty else { - notify?("Enter a commit message") - return false - } - guard activeRepositoryChanges.contains(where: \.isStaged) else { - notify?("Stage at least one change before committing") - return false - } - guard !blockCommitWhenConflicted() else { return false } - guard await !blockCommitWhenMarkersRemain() else { return false } - - isCommitting = true - let commitResult = await withGitOperation { - await service.commit( - at: gitRepositoryRoot, - message: message, - amend: amend - ) - } - guard commitResult.succeeded else { - isCommitting = false - notify?(trimmedMessage(commitResult)) - await refreshGit() - return false - } - - guard let currentReference = currentGitReference else { - isCommitting = false - notify?("Committed changes, but detached HEAD cannot be pushed") - await refreshGit() - return true - } - - let pushResult = await withGitOperation { - await service.push(currentReference, at: gitRepositoryRoot) - } - isCommitting = false - if pushResult.succeeded { - notify?("Committed and pushed \(currentReference.shortName)") - } else { - notify?("Committed changes, but push failed: \(trimmedMessage(pushResult))") - } - await refreshGit() - return true - } - func reconcilePendingStagingStates(with changes: [GitChange]) { let changesByID = Dictionary(uniqueKeysWithValues: changes.map { ($0.id, $0) }) pendingStagingStates = pendingStagingStates.filter { id, staged in @@ -1378,20 +1294,23 @@ package final class GitFeatureModel: ObservableObject { } } + package var isStagingChanges: Bool { !pendingStagingStates.isEmpty } + package func effectiveStagingState(for change: GitChange) -> Bool { pendingStagingStates[change.id] ?? change.isStaged } package func beginToggleStaging(_ change: GitChange) -> Bool? { - guard pendingStagingStates[change.id] == nil else { return nil } + guard !isCommitting, change.canToggleStaging, pendingStagingStates[change.id] == nil else { return nil } let staged = !change.isStaged pendingStagingStates[change.id] = staged return staged } package func beginSetStaging(_ changes: [GitChange], staged: Bool) -> [GitChange] { + guard !isCommitting else { return [] } let pendingChanges = changes.filter { - pendingStagingStates[$0.id] == nil && $0.isStaged != staged + $0.canToggleStaging && pendingStagingStates[$0.id] == nil && $0.isStaged != staged } for change in pendingChanges { pendingStagingStates[change.id] = staged @@ -3563,7 +3482,7 @@ package final class GitFeatureModel: ObservableObject { return "\(fallback): \(warning.message)" } - private func trimmedMessage(_ result: GitService.CommandResult) -> String { + func trimmedMessage(_ result: GitService.CommandResult) -> String { let message = result.output.trimmingCharacters(in: .whitespacesAndNewlines) return message.isEmpty ? "Git operation failed" : message } diff --git a/macos/Sources/LitheGitModule/Models/GitModels.swift b/macos/Sources/LitheGitModule/Models/GitModels.swift index 9aba267ad..509699776 100644 --- a/macos/Sources/LitheGitModule/Models/GitModels.swift +++ b/macos/Sources/LitheGitModule/Models/GitModels.swift @@ -237,9 +237,52 @@ package struct GitReference: Identifiable, Hashable, Sendable { } } -/// One repository's references when a workspace aggregates several Git -/// repositories. Only references are aggregated here; history, diff, and the -/// console stay scoped to the active repository. +/// A real Git submodule edge between two discovered repository roots. +/// +/// Nested paths alone are not enough to establish this relationship: a workspace +/// may contain independent repositories below another repository. `path` is the +/// parent repository's gitlink path, relative to `parent`. +package struct GitRepositorySubmoduleRelation: Hashable, Sendable { + package let parent: URL + package let child: URL + package let path: String + + package init(parent: URL, child: URL, path: String) { + self.parent = parent.standardizedFileURL + self.child = child.standardizedFileURL + self.path = path + } +} + +/// Shared Core payload: Git owns the index fingerprint and gitlink interpretation. +package struct GitCommitState: Codable, Equatable, Sendable { + package let head: String? + package let branch: String? + package let indexEntries: String + package let gitlinks: [GitCommitGitlink] + package let stagedPaths: [String] + package let conflictedPaths: [String] + package init(head: String?, branch: String?, indexEntries: String, gitlinks: [GitCommitGitlink], stagedPaths: [String], conflictedPaths: [String] = []) { + self.head = head; self.branch = branch; self.indexEntries = indexEntries + self.gitlinks = gitlinks; self.stagedPaths = stagedPaths; self.conflictedPaths = conflictedPaths + } +} + +package struct GitCommitGitlink: Codable, Equatable, Sendable { + package let path: String + package let revision: String + package init(path: String, revision: String) { self.path = path; self.revision = revision } +} + +package struct GitRepositoryCommitResult: Identifiable, Sendable { + package let root: URL + package var committed = false + package var pushed = false + package var detail = "Pending" + package var diagnostic = "" + package var id: String { root.path } +} + package struct GitRepositoryReferences: Hashable, Sendable { package let repositoryRoot: URL package let references: [GitReference] @@ -786,7 +829,9 @@ package struct GitChange: Identifiable, Hashable, Sendable { package let originalPath: String? package let indexStatus: Character package let workTreeStatus: Character - package init(repositoryRoot: URL, path: String, originalPath: String?, indexStatus: Character, workTreeStatus: Character) { self.repositoryRoot = repositoryRoot; self.path = path; self.originalPath = originalPath; self.indexStatus = indexStatus; self.workTreeStatus = workTreeStatus } + package let submodule: GitSubmoduleStatus? + package let canToggleStaging: Bool + package init(repositoryRoot: URL, path: String, originalPath: String?, indexStatus: Character, workTreeStatus: Character, submodule: GitSubmoduleStatus? = nil, canToggleStaging: Bool = true) { self.canToggleStaging = canToggleStaging; self.submodule = submodule; self.repositoryRoot = repositoryRoot; self.path = path; self.originalPath = originalPath; self.indexStatus = indexStatus; self.workTreeStatus = workTreeStatus } package var id: String { "\(repositoryRoot.standardizedFileURL.path):\(originalPath ?? "")->\(path)" @@ -1549,3 +1594,12 @@ package enum DiffParser { return (old, new) } } + +package struct GitSubmoduleStatus: Codable, Hashable, Sendable { + package let commitChanged: Bool + package let trackedChanges: Bool + package let untrackedChanges: Bool + package init(commitChanged: Bool, trackedChanges: Bool, untrackedChanges: Bool) { + self.commitChanged = commitChanged; self.trackedChanges = trackedChanges; self.untrackedChanges = untrackedChanges + } +} diff --git a/macos/Sources/LitheGitModule/Models/GitWorkspaceCommitModels.swift b/macos/Sources/LitheGitModule/Models/GitWorkspaceCommitModels.swift new file mode 100644 index 000000000..a26f8192b --- /dev/null +++ b/macos/Sources/LitheGitModule/Models/GitWorkspaceCommitModels.swift @@ -0,0 +1,120 @@ +import Foundation + +// Wire DTOs are owned by Rust's git::workspace_commit. Native projections below +// translate identifiers and status keys for presentation; they do not plan work. +package struct GitWorkspaceRepositoryBinding: Codable, Equatable, Sendable { + package let id: String + package let root: String +} + +package struct GitWorkspaceCommitRelation: Codable, Equatable, Sendable { + package let parent: String + package let child: String + package let path: String +} + +package struct GitWorkspaceCommitPlan: Codable, Equatable, Sendable { + package let repositories: [GitWorkspaceRepositoryBinding] + package let message: String + package let amend: Bool + package let push: Bool + package let includeParentReferences: Bool + package let isRetry: Bool + package let orderedIds: [String] + package let propagatedRelations: [GitWorkspaceCommitRelation] + package let dependencyRelations: [GitWorkspaceCommitRelation] + package let states: [String: GitCommitState] + package let committedIds: Set + package let pendingPushIds: Set + + func root(_ id: String) -> URL? { + repositories.first { $0.id == id }.map { URL(fileURLWithPath: $0.root).standardizedFileURL } + } +} + +package struct GitWorkspaceRepositoryResult: Codable, Sendable { + package var committed: Bool + package var pushed: Bool + package var status: String + package var detail: String + + var displayDetail: String { + let label: String + switch status { + case "pending": label = "Pending" + case "notIncluded": label = "Not included in the updated plan" + case "waitingForSubmodule": label = "Waiting for submodule" + case "committed": label = "Committed" + case "committedPushPending": label = "Committed; push pending" + case "committedAndPushed": label = "Committed and pushed" + case "pushFailed": label = "Committed; push failed" + case "headAdvanced": label = "HEAD advanced; review before continuing." + case "outcomeUnknown": label = "Could not verify the commit outcome. Review before retrying." + default: label = "Repository needs attention" + } + return label + } +} + +package struct GitWorkspaceCommitSession: Codable, Sendable { + package let plan: GitWorkspaceCommitPlan + package var states: [String: GitCommitState] + package var results: [String: GitWorkspaceRepositoryResult] + package var blocked: Set + package var cursor: Int + package var commandFailed: Bool + package var finished: Bool + package var succeeded: Bool + package var canRetry: Bool + + var displayedResults: [GitRepositoryCommitResult] { + results.compactMap { id, result in + plan.root(id).map { GitRepositoryCommitResult(root: $0, committed: result.committed, + pushed: result.pushed, detail: result.displayDetail, diagnostic: result.detail) } + }.sorted { $0.root.path < $1.root.path } + } +} + +package struct GitWorkspaceCommitRequest: Encodable, Sendable { + package let repositories: [GitWorkspaceRepositoryBinding] + package let message: String + package let amend: Bool + package let push: Bool + package let includeParentReferences: Bool + package let previous: GitWorkspaceCommitSession? + package let reviewed: GitWorkspaceCommitPlan? +} + +package struct GitWorkspaceCommitPreparation: Codable, Sendable { + package let session: GitWorkspaceCommitSession + package let reviewChanged: Bool + package let requiresConfirmation: Bool +} + +package struct GitWorkspaceCommitFailure: Error, Sendable { + package let message: String + package init(_ message: String) { self.message = message } +} + +/// View identity and native URL projections of the shared review model. +package struct GitSubmoduleCommitPlan: Identifiable, Sendable { + package let id = UUID() + package let preparation: GitWorkspaceCommitPreparation + var core: GitWorkspaceCommitPlan { preparation.session.plan } + package var message: String { core.message } + package var amend: Bool { core.amend } + package var push: Bool { core.push } + package var includeParentReferences: Bool { core.includeParentReferences } + package var isRetry: Bool { core.isRetry } + package var orderedRoots: [URL] { core.orderedIds.compactMap { core.root($0) } } + package var committedRoots: Set { Set(core.committedIds.compactMap { core.root($0) }) } + package var states: [URL: GitCommitState] { + Dictionary(uniqueKeysWithValues: core.states.compactMap { id, state in core.root(id).map { ($0, state) } }) + } + package var propagatedRelations: [GitRepositorySubmoduleRelation] { + core.propagatedRelations.compactMap { relation in + guard let parent = core.root(relation.parent), let child = core.root(relation.child) else { return nil } + return GitRepositorySubmoduleRelation(parent: parent, child: child, path: relation.path) + } + } +} diff --git a/macos/Sources/LitheGitModule/Services/GitService.swift b/macos/Sources/LitheGitModule/Services/GitService.swift index 18bcff000..47a19de71 100644 --- a/macos/Sources/LitheGitModule/Services/GitService.swift +++ b/macos/Sources/LitheGitModule/Services/GitService.swift @@ -12,6 +12,9 @@ package struct NullGitPerformanceLogger: GitPerformanceLogger { } package protocol GitOperations: Sendable { + func prepareWorkspaceCommit(_ request: GitWorkspaceCommitRequest) -> Result + func stepWorkspaceCommit(_ session: GitWorkspaceCommitSession) -> Result + func consolePresentation(_ request: GitConsolePresentationRequest) -> GitConsolePresentation? func executionSettings(_ request: GitConfigurationEdit, save: Bool) -> Result func remoteURL(at rootURL: URL, remote: String) -> String? @@ -26,6 +29,7 @@ package protocol GitOperations: Sendable { ) -> GitProcessResult func snapshot(at rootURL: URL) -> GitSnapshot? + func snapshot(at rootURL: URL, repositoryRoots: [URL]) -> GitSnapshot? func repositories(in workspaceURL: URL) -> [URL] func watchContext(at rootURL: URL) -> GitWatchContext? func worktrees(at rootURL: URL) -> [GitWorktree]? @@ -174,6 +178,14 @@ package protocol GitOperations: Sendable { } package extension GitOperations { + func prepareWorkspaceCommit(_ request: GitWorkspaceCommitRequest) -> Result { + .failure(GitWorkspaceCommitFailure("Workspace commit planning is unavailable")) + } + func stepWorkspaceCommit(_ session: GitWorkspaceCommitSession) -> Result { + .failure(GitWorkspaceCommitFailure("Workspace commit execution is unavailable")) + } + func snapshot(at rootURL: URL, repositoryRoots: [URL]) -> GitSnapshot? { snapshot(at: rootURL) } + func consolePresentation(_ request: GitConsolePresentationRequest) -> GitConsolePresentation? { nil } func executionSettings(_ request: GitConfigurationEdit, save: Bool) -> Result { .failure(GitFetchFailure("Git configuration inspection is unavailable.")) } func remoteURL(at rootURL: URL, remote: String) -> String? { nil } @@ -362,14 +374,37 @@ package struct GitService: Sendable { } } - func snapshot(for workspace: URL) async -> GitSnapshot? { - await read(priority: .utility) { $0.snapshot(at: workspace) } + func snapshot(for workspace: URL, repositoryRoots: [URL] = []) async -> GitSnapshot? { + await read(priority: .utility) { $0.snapshot(at: workspace, repositoryRoots: repositoryRoots) } } func repositories(in workspace: URL) async -> [URL] { await read(priority: .utility) { $0.repositories(in: workspace) } ?? [] } + func prepareWorkspaceCommit(_ request: GitWorkspaceCommitRequest) async -> Result { + await workspaceCommitOperation { $0.prepareWorkspaceCommit(request) } + } + + func stepWorkspaceCommit(_ session: GitWorkspaceCommitSession) async -> Result { + await workspaceCommitOperation { $0.stepWorkspaceCommit(session) } + } + + private func workspaceCommitOperation( + _ operation: @escaping @Sendable (any GitOperations) -> Result + ) async -> Result { + let operations = self.operations + let execution = GitExecutionContext.current + return await withTaskCancellationHandler { + await Task.detached(priority: .userInitiated) { + GitExecutionContext.$current.withValue(execution) { operation(operations) } + }.value + } onCancel: { + execution?.requestCancellation() + if let execution { _ = operations.cancel(operationID: execution.operationID) } + } + } + func worktrees(at repositoryRoot: URL) async -> [GitWorktree]? { await read(priority: .utility) { $0.worktrees(at: repositoryRoot) } } diff --git a/macos/Tests/LitheGitModuleTests/GitModuleTests.swift b/macos/Tests/LitheGitModuleTests/GitModuleTests.swift index aee25042d..1133a2aa1 100644 --- a/macos/Tests/LitheGitModuleTests/GitModuleTests.swift +++ b/macos/Tests/LitheGitModuleTests/GitModuleTests.swift @@ -597,8 +597,6 @@ struct GitModuleTests { #expect(feature.gitChanges == [firstChange, secondChange]) #expect(feature.currentBranch == "main") #expect(feature.activeRepositoryChanges == [firstChange]) - // A conflict or staged entry in another repository must not block this commit. - #expect(await feature.commitStagedChanges(message: "First repository", amend: false)) #expect(firstChange.id != secondChange.id) #expect(feature.gitTreeStatus.change(relativePath: firstChange.url.path) == firstChange) #expect(feature.gitTreeStatus.change(relativePath: secondChange.url.path) == secondChange) @@ -606,10 +604,165 @@ struct GitModuleTests { #expect(feature.gitRepositoryRoot == secondRoot) #expect(feature.currentBranch == "develop") #expect(feature.activeRepositoryChanges == [secondChange]) - #expect(await !feature.commitStagedChanges(message: "Conflicted repository", amend: false)) #expect(feature.gitChanges == [firstChange, secondChange]) } + @Test + func sharedStagingEligibilityDisablesParentCheckbox() { + let feature = GitFeatureModel(service: GitService(operations: TestGitOperations())) + let change = GitChange(repositoryRoot: URL(fileURLWithPath: "/workspace"), path: "libs/B", + originalPath: nil, indexStatus: " ", workTreeStatus: "M", + submodule: GitSubmoduleStatus(commitChanged: false, trackedChanges: true, untrackedChanges: false), canToggleStaging: false) + #expect(feature.beginToggleStaging(change) == nil) + #expect(feature.beginSetStaging([change], staged: true).isEmpty) + } + + @Test + func workspaceCommitConfirmationUsesTheSharedPlanAndForwardsOptions() async throws { + let preparation = try workspacePreparation() + let probe = WorkspaceCommitProbe(preparations: [preparation, preparation], steps: [completedWorkspace(preparation)]) + let feature = workspaceCommitFeature(probe: probe) + defer { feature.reset() } + await feature.refreshGit() + #expect(await !feature.commitAndPushStagedChanges(message: "commit", amend: true)) + #expect(probe.stepCount == 0) + #expect(feature.pendingSubmoduleCommitPlan?.orderedRoots.map(\.lastPathComponent) == ["B", "A"]) + #expect(await feature.confirmPendingSubmoduleCommit()) + #expect(probe.stepCount == 1) + #expect(probe.requests.first?.amend == true) + #expect(probe.requests.first?.push == true) + #expect(probe.requests.first?.repositories.map(\.id) == ["A", "A/B"]) + #expect(probe.requests.last?.reviewed == preparation.session.plan) + #expect(feature.workspaceCommitResults.allSatisfy { $0.committed && $0.pushed }) + } + + @Test + func changedSharedCommitPlanRequiresAnotherConfirmationBeforeAnyStep() async throws { + let original = try workspacePreparation() + let changed = GitWorkspaceCommitPreparation(session: original.session, reviewChanged: true, requiresConfirmation: true) + let probe = WorkspaceCommitProbe(preparations: [original, changed, original], steps: [completedWorkspace(original)]) + let feature = workspaceCommitFeature(probe: probe) + defer { feature.reset() } + await feature.refreshGit() + #expect(await !feature.commitStagedChanges(message: "commit", amend: false)) + let oldID = feature.pendingSubmoduleCommitPlan?.id + #expect(await !feature.confirmPendingSubmoduleCommit()) + #expect(probe.stepCount == 0) + #expect(feature.pendingSubmoduleCommitPlan?.id != oldID) + #expect(await feature.confirmPendingSubmoduleCommit()) + #expect(probe.stepCount == 1) + } + + @Test + func parentReferenceToggleAndRetryAreForwardedToCore() async throws { + let original = try workspacePreparation() + var failed = original.session + failed.finished = true + failed.results["A/B"] = GitWorkspaceRepositoryResult(committed: true, pushed: false, status: "pushFailed", detail: "stopped") + failed.results["A"] = GitWorkspaceRepositoryResult(committed: false, pushed: false, status: "waitingForSubmodule", detail: "") + let probe = WorkspaceCommitProbe(preparations: [original, original, original, original], steps: [failed]) + let feature = workspaceCommitFeature(probe: probe) + defer { feature.reset() } + await feature.refreshGit() + #expect(await !feature.commitAndPushStagedChanges(message: "commit", amend: false)) + await feature.setCommitPlanParentReferences(false) + #expect(probe.requests.last?.includeParentReferences == false) + #expect(await !feature.confirmPendingSubmoduleCommit()) + #expect(feature.canRetryWorkspaceCommit) + #expect(feature.workspaceCommitResults.first { $0.root.lastPathComponent == "A" }?.detail == "Waiting for submodule") + await feature.prepareWorkspaceCommitRetry() + #expect(probe.requests.last?.previous?.results["A/B"]?.committed == true) + #expect(feature.pendingSubmoduleCommitPlan != nil) + feature.cancelPendingSubmoduleCommit() + #expect(feature.pendingSubmoduleCommitPlan == nil) + #expect(feature.canRetryWorkspaceCommit) + } + + @Test + func workspaceWithoutRequiredConfirmationDrivesCoreStepsUntilFinished() async throws { + let original = try workspacePreparation() + let immediate = GitWorkspaceCommitPreparation(session: original.session, reviewChanged: false, requiresConfirmation: false) + var progress = original.session + progress.cursor = 1 + let probe = WorkspaceCommitProbe(preparations: [immediate], steps: [progress, completedWorkspace(original)]) + let feature = workspaceCommitFeature(probe: probe) + defer { feature.reset() } + await feature.refreshGit() + #expect(await feature.commitStagedChanges(message: "commit", amend: false)) + #expect(probe.stepCount == 2) + #expect(!feature.canRetryWorkspaceCommit) + feature.dismissWorkspaceCommitResults() + #expect(feature.workspaceCommitResults.isEmpty) + } + + @Test + func firstCoreStepFailureKeepsRecoveryActionsVisible() async throws { + let original = try workspacePreparation() + let immediate = GitWorkspaceCommitPreparation(session: original.session, reviewChanged: false, requiresConfirmation: false) + let probe = WorkspaceCommitProbe(preparations: [immediate]) + let feature = workspaceCommitFeature(probe: probe) + defer { feature.reset() } + await feature.refreshGit() + #expect(await !feature.commitStagedChanges(message: "commit", amend: false)) + #expect(feature.canRetryWorkspaceCommit) + #expect(!feature.workspaceCommitResults.isEmpty) + #expect(!feature.isCommitting) + feature.dismissWorkspaceCommitResults() + #expect(!feature.canRetryWorkspaceCommit) + #expect(feature.workspaceCommitResults.isEmpty) + } + + @Test + func workspaceResetDuringCommitDoesNotStartTheNextStepOrPublishOldResults() async throws { + let original = try workspacePreparation() + let immediate = GitWorkspaceCommitPreparation(session: original.session, reviewChanged: false, requiresConfirmation: false) + var progress = original.session + progress.cursor = 1 + let started = GitModuleTestGate() + let release = GitModuleTestGate() + let probe = WorkspaceCommitProbe(preparations: [immediate], steps: [progress], started: started, release: release) + let feature = workspaceCommitFeature(probe: probe) + await feature.refreshGit() + let task = Task { await feature.commitStagedChanges(message: "commit", amend: false) } + defer { release.open(); task.cancel(); feature.reset() } + #expect(await started.waitUntilOpen()) + feature.reset() + release.open() + #expect(await !task.value) + #expect(probe.stepCount == 1) + #expect(feature.workspaceCommitResults.isEmpty) + #expect(!feature.isCommitting) + } + + private func workspacePreparation() throws -> GitWorkspaceCommitPreparation { + struct Fixture: Decodable { let preparation: GitWorkspaceCommitPreparation } + let fixtureURL = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent().deletingLastPathComponent() + .deletingLastPathComponent().deletingLastPathComponent() + .appendingPathComponent("shared/fixtures/git/workspace-commit-workflow-v1.json") + return try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: fixtureURL)).preparation + } + + private func completedWorkspace(_ preparation: GitWorkspaceCommitPreparation) -> GitWorkspaceCommitSession { + var session = preparation.session + session.finished = true; session.succeeded = true; session.canRetry = false + session.cursor = session.plan.orderedIds.count + session.results = ["A": GitWorkspaceRepositoryResult(committed: true, pushed: true, status: "committedAndPushed", detail: ""), + "A/B": GitWorkspaceRepositoryResult(committed: true, pushed: true, status: "committedAndPushed", detail: "")] + return session + } + + private func workspaceCommitFeature(roots: [URL] = [URL(fileURLWithPath: "/workspace/A"), URL(fileURLWithPath: "/workspace/A/B")], + changes: [GitChange] = [], probe: WorkspaceCommitProbe = WorkspaceCommitProbe()) -> GitFeatureModel { + let feature = GitFeatureModel(service: GitService(operations: TestGitOperations( + snapshotsByRoot: Dictionary(uniqueKeysWithValues: roots.map { root in + (root.path, GitSnapshot(repositoryRoot: root, branch: "main", changes: changes.filter { $0.repositoryRoot == root })) + }), repositoryRoots: roots, workspaceCommitProbe: probe))) + feature.configure(workspaceURLProvider: { URL(fileURLWithPath: "/workspace") }, isGitLogVisibleProvider: { false }, + notify: { _ in }, onStateRefreshed: {}) + return feature + } + @Test func linkedWorktreeDetectionUsesPathComponentBoundaries() { let primary = URL(fileURLWithPath: "/workspace/op-platform", isDirectory: true) @@ -3393,6 +3546,54 @@ private final class GitPerformanceLogRecorder: GitPerformanceLogger, @unchecked } } +/// Records each repository commit so multi-repository submission can assert +/// that one Git commit is issued per independent index. +private final class CommitCallRecorder: @unchecked Sendable { + struct Call: Equatable { + let root: URL + let message: String + let amend: Bool + } + + private let lock = NSLock() + private var calls: [Call] = [] + + func record(_ call: Call) { + lock.lock() + calls.append(call) + lock.unlock() + } + + var recorded: [Call] { + lock.lock() + defer { lock.unlock() } + return calls + } +} + +/// Records the parent gitlink restage issued after a child submodule commit. +private final class StageCallRecorder: @unchecked Sendable { + struct Call: Equatable { + let root: URL + let path: String + } + + private let lock = NSLock() + private var calls: [Call] = [] + + func record(_ change: GitChange) { + lock.lock() + calls.append(Call(root: change.repositoryRoot, path: change.path)) + lock.unlock() + } + + var recorded: [Call] { + lock.lock() + defer { lock.unlock() } + return calls + } +} + /// Records tag create/delete arguments so restore flows can be asserted on /// the exact parameters the feature model replays. private final class TagCallRecorder: @unchecked Sendable { @@ -3568,6 +3769,10 @@ private struct TestGitOperations: GitOperations { private let applyPatchHandler: (@Sendable (String, URL, String) -> GitProcessResult?)? private let stageResult: GitProcessResult? private let commitResult: GitProcessResult? + private let commitCallRecorder: CommitCallRecorder? + private let stageCallRecorder: StageCallRecorder? + private let gitlinkPathsByRoot: [String: [String]] + private let workspaceCommitProbe: WorkspaceCommitProbe private let revertHandler: (@Sendable (String) -> GitProcessResult?)? private let runGate: TestGitRunGate? private let filesRecorder: GitFilesCallRecorder? @@ -3609,6 +3814,10 @@ private struct TestGitOperations: GitOperations { applyPatchHandler: (@Sendable (String, URL, String) -> GitProcessResult?)? = nil, stageResult: GitProcessResult? = nil, commitResult: GitProcessResult? = nil, + commitCallRecorder: CommitCallRecorder? = nil, + stageCallRecorder: StageCallRecorder? = nil, + gitlinkPathsByRoot: [String: [String]] = [:], + workspaceCommitProbe: WorkspaceCommitProbe? = nil, revertHandler: (@Sendable (String) -> GitProcessResult?)? = nil, runGate: TestGitRunGate? = nil, filesRecorder: GitFilesCallRecorder? = nil, @@ -3649,6 +3858,10 @@ private struct TestGitOperations: GitOperations { self.applyPatchHandler = applyPatchHandler self.stageResult = stageResult self.commitResult = commitResult + self.commitCallRecorder = commitCallRecorder + self.stageCallRecorder = stageCallRecorder + self.gitlinkPathsByRoot = gitlinkPathsByRoot + self.workspaceCommitProbe = workspaceCommitProbe ?? WorkspaceCommitProbe() self.revertHandler = revertHandler self.runGate = runGate self.filesRecorder = filesRecorder @@ -3673,10 +3886,17 @@ private struct TestGitOperations: GitOperations { func run(arguments: [String], workingDirectory: String, input: String?) -> GitProcessResult { runGate?.blockFirstRun() + let standardOutput: String + if arguments == ["ls-files", "--stage", "-z"] { + let paths = gitlinkPathsByRoot[URL(fileURLWithPath: workingDirectory).standardizedFileURL.path] ?? [] + standardOutput = paths.map { "160000 abc123 0\t\($0)\0" }.joined() + } else { + standardOutput = "git version 2.55.0\n" + } return GitProcessResult( arguments: arguments, - output: "git version 2.55.0\n", - standardOutput: "git version 2.55.0\n", + output: standardOutput, + standardOutput: standardOutput, standardError: "", exitCode: 0 ) @@ -3773,11 +3993,23 @@ private struct TestGitOperations: GitOperations { func comparison(from reference: GitReference, to target: GitReference, at rootURL: URL) -> GitBranchComparison? { typedComparisonValue } func stashes(at rootURL: URL) -> [GitStash]? { nil } func blame(at rootURL: URL, relativePath: String) -> [GitBlameLine]? { nil } - func stage(_ change: GitChange) -> GitProcessResult? { stageResult } + func stage(_ change: GitChange) -> GitProcessResult? { + stageCallRecorder?.record(change) + return stageResult + } func unstage(_ change: GitChange) -> GitProcessResult? { nil } func discard(_ change: GitChange) -> GitProcessResult? { discardHandler?(change) } func discardAll(_ change: GitChange) -> GitProcessResult? { nil } - func commit(at rootURL: URL, message: String, amend: Bool) -> GitProcessResult? { commitResult } + func prepareWorkspaceCommit(_ request: GitWorkspaceCommitRequest) -> Result { + workspaceCommitProbe.prepare(request) + } + func stepWorkspaceCommit(_ session: GitWorkspaceCommitSession) -> Result { + workspaceCommitProbe.step() + } + func commit(at rootURL: URL, message: String, amend: Bool) -> GitProcessResult? { + commitCallRecorder?.record(CommitCallRecorder.Call(root: rootURL, message: message, amend: amend)) + return commitResult + } func cherryPick(_ hash: String, at rootURL: URL) -> GitProcessResult? { nil } func revert(_ hash: String, at rootURL: URL) -> GitProcessResult? { revertHandler?(hash) } func resetCurrentBranch(to hash: String, mode: String, at rootURL: URL) -> GitProcessResult? { nil } @@ -3840,7 +4072,7 @@ private struct TestGitOperations: GitOperations { func abortOperation(at rootURL: URL) -> GitProcessResult? { nil } func skipOperationStep(at rootURL: URL) -> GitProcessResult? { nil } func checkoutRevision(_ revision: String, at rootURL: URL) -> GitProcessResult? { nil } - func push(_ reference: GitReference, at rootURL: URL) -> GitProcessResult? { nil } + func push(_ reference: GitReference, at rootURL: URL) -> GitProcessResult? { GitProcessResult(output: "Pushed", exitCode: 0) } func cloneRepository(from remote: String, to destination: URL) -> GitProcessResult? { nil } func stash(message: String, includeUntracked: Bool, at rootURL: URL) -> GitProcessResult? { nil } func applyStash(_ stash: GitStash, at rootURL: URL) -> GitProcessResult? { nil } @@ -3856,3 +4088,37 @@ private struct TestGitOperations: GitOperations { return deleteTagResults?.next() ?? deleteTagResult } } + +/// Synchronous GitOperations test state; the production service reads it on workers. +/// Scripted Core responses only: ordering, dependencies and retry policy are +/// exercised in Rust instead of reimplemented in this native test double. +private final class WorkspaceCommitProbe: @unchecked Sendable { + private let lock = NSLock() + private var preparations: [GitWorkspaceCommitPreparation] + private var steps: [GitWorkspaceCommitSession] + private var recordedRequests: [GitWorkspaceCommitRequest] = [] + private var recordedSteps = 0 + private let started: GitModuleTestGate? + private let release: GitModuleTestGate? + init(preparations: [GitWorkspaceCommitPreparation] = [], steps: [GitWorkspaceCommitSession] = [], + started: GitModuleTestGate? = nil, release: GitModuleTestGate? = nil) { + self.preparations = preparations; self.steps = steps + self.started = started; self.release = release + } + func prepare(_ request: GitWorkspaceCommitRequest) -> Result { + lock.lock(); defer { lock.unlock() } + recordedRequests.append(request) + guard !preparations.isEmpty else { return .failure(GitWorkspaceCommitFailure("No scripted preparation")) } + return .success(preparations.removeFirst()) + } + func step() -> Result { + started?.open() + guard release?.waitSynchronously() ?? true else { return .failure(GitWorkspaceCommitFailure("Test gate timed out")) } + lock.lock(); defer { lock.unlock() } + recordedSteps += 1 + guard !steps.isEmpty else { return .failure(GitWorkspaceCommitFailure("No scripted step")) } + return .success(steps.removeFirst()) + } + var requests: [GitWorkspaceCommitRequest] { lock.lock(); defer { lock.unlock() }; return recordedRequests } + var stepCount: Int { lock.lock(); defer { lock.unlock() }; return recordedSteps } +} diff --git a/macos/Tests/LitheTests/AppLocalizationTests.swift b/macos/Tests/LitheTests/AppLocalizationTests.swift index 730a3a412..d49e6ca59 100644 --- a/macos/Tests/LitheTests/AppLocalizationTests.swift +++ b/macos/Tests/LitheTests/AppLocalizationTests.swift @@ -315,7 +315,37 @@ struct GitLocalizationTests { "Show worktree repositories", "Hide worktree repositories", "Copy Branch Name", "Tracking Branch", "Stop Tracking Branch", "No Remote Branches", "Soft Reset (Keep Changes Staged)", "Mixed Reset (Keep Changes Unstaged)", - "Hard Reset (Discard Changes)" + "Hard Reset (Discard Changes)", + "Review remaining steps", + "Review repository commits", + "Each repository has its own commit. Completed steps are kept if another repository fails.", + "Review and Retry Unfinished Steps…", + "Dismiss Results", + "Update parent repository references", + "Each submodule is pushed before its parent.", + "Uncommitted submodule changes", + "Commit changed files in the submodule first", + "Amend applies to repositories with selected files.", + "Commit message: %@", + "Push only", + "Commit and push", + "Committed; push pending", + "Committed and pushed", + "Waiting for submodule", + "Pending", + "Not included in the updated plan", + "Committed; push failed", + "HEAD advanced; review before continuing.", + "Could not verify the commit outcome. Review before retrying.", + "Repository needs attention", + + "Update %@/%@ after %@", + "Repository changed; review and retry", + "Committed", + "Collapse repository", + "Expand repository", + "Unstage all files in repository", + "Stage all files in repository" ] let pattern = try NSRegularExpression(pattern: #"%(?:\d+\$)?(?:lld|ld|d|@)"#) for key in keys { diff --git a/macos/Tests/LitheTests/GitChangeSectionsCacheTests.swift b/macos/Tests/LitheTests/GitChangeSectionsCacheTests.swift index 4c4618961..b31f0fa75 100644 --- a/macos/Tests/LitheTests/GitChangeSectionsCacheTests.swift +++ b/macos/Tests/LitheTests/GitChangeSectionsCacheTests.swift @@ -13,11 +13,12 @@ struct GitChangeSectionsCacheTests { private func change( _ path: String, + at repositoryRoot: URL? = nil, indexStatus: Character = " ", workTreeStatus: Character = "M" ) -> GitChange { GitChange( - repositoryRoot: root, + repositoryRoot: repositoryRoot ?? root, path: path, originalPath: nil, indexStatus: indexStatus, @@ -94,4 +95,49 @@ struct GitChangeSectionsCacheTests { #expect(filtered.displayed.map(\.path) == ["a.swift"]) } + @Test + func groupsDisplayedChangesByRepositoryInStableInputOrder() { + let secondRoot = URL(fileURLWithPath: "/tmp/second-repo") + let firstTracked = change("src/Main.swift") + let secondAdded = change( + "README.md", + at: secondRoot, + indexStatus: "?", + workTreeStatus: "?" + ) + let firstAdded = change( + "notes.md", + indexStatus: "?", + workTreeStatus: "?" + ) + let cache = GitChangeSectionsCache() + + let sections = cache.sections( + changes: [firstTracked, secondAdded, firstAdded], + conflictFilterPaths: [] + ) + + #expect(sections.repositories.map(\.root) == [root, secondRoot]) + #expect(sections.repositories[0].changes.map(\.path) == ["src/Main.swift", "notes.md"]) + #expect(sections.repositories[0].tracked.map(\.path) == ["src/Main.swift"]) + #expect(sections.repositories[0].added.map(\.path) == ["notes.md"]) + #expect(sections.repositories[1].changes.map(\.path) == ["README.md"]) + } + + @Test + func conflictFilterRemovesEmptyRepositories() { + let secondRoot = URL(fileURLWithPath: "/tmp/second-repo") + let first = change("first.swift") + let second = change("second.swift", at: secondRoot) + let cache = GitChangeSectionsCache() + + let sections = cache.sections( + changes: [first, second], + conflictFilterPaths: [second.path] + ) + + #expect(sections.repositories.map(\.root) == [secondRoot]) + #expect(sections.repositories[0].changes.map(\.path) == ["second.swift"]) + } + } diff --git a/rust/lithe-core/src/git/commit_state.rs b/rust/lithe-core/src/git/commit_state.rs new file mode 100644 index 000000000..7c7320dc6 --- /dev/null +++ b/rust/lithe-core/src/git/commit_state.rs @@ -0,0 +1,244 @@ +//! Shared commit preconditions and exact submodule pointer updates. + +use super::{execute_git, execute_git_readonly, validate_paths, validate_root, GitStatusRequest}; +use crate::protocol::{CoreError, ErrorCode}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Immutable Git state reviewed before a workspace commit or retry. +pub struct GitCommitState { + pub head: Option, + pub branch: Option, + /// Git's NUL-delimited index entries, including object IDs and conflict stages. + pub index_entries: String, + pub gitlinks: Vec, + pub staged_paths: Vec, + pub conflicted_paths: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +/// An exact submodule commit recorded at a parent-relative path. +pub struct GitCommitGitlink { + pub path: String, + pub revision: String, +} + +fn read(root: &str, arguments: &[&str]) -> Result { + let result = execute_git_readonly( + root, + &arguments.iter().map(|s| s.to_string()).collect::>(), + None, + )?; + if result.exit_code != 0 { + return Err( + CoreError::new(ErrorCode::ProcessFailed, "Could not inspect commit state") + .with_details(result.output), + ); + } + Ok(result.stdout) +} + +fn ensure_repository_root(root: &str) -> Result<(), CoreError> { + // Git walks up to a parent repository when a nested .git disappears. A + // reviewed workspace root must still own its index before any guarded write. + let actual = read(root, &["rev-parse", "--show-toplevel"])?; + let actual = actual.strip_suffix('\n').unwrap_or(&actual); + if validate_root(actual)? != validate_root(root)? { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Repository boundary changed; refresh the workspace", + )); + } + Ok(()) +} + +/// Reads HEAD and index through Git, without refreshing or modifying the index. +pub fn inspect(request: GitStatusRequest) -> Result { + let root = validate_root(&request.root)?; + inspect_root(&root) +} + +/// Confirms an unborn branch without treating corrupt refs as empty history. +pub(super) fn is_unborn(root: &str) -> Result { + head_state(root).map(|(head, _)| head.is_none()) +} + +fn head_state(root: &str) -> Result<(Option, Option), CoreError> { + // `--verify HEAD` cannot distinguish an unborn branch from a corrupt ref. + // show-ref's documented exit 1 means the symbolic branch does not exist. + let symbolic = execute_git_readonly( + root, + &["symbolic-ref".into(), "--quiet".into(), "HEAD".into()], + None, + )?; + let branch = if symbolic.exit_code == 0 { + Some(symbolic.stdout.trim().to_string()) + } else if symbolic.exit_code == 1 { + None + } else { + return Err( + CoreError::new(ErrorCode::ProcessFailed, "Could not inspect HEAD") + .with_details(symbolic.output), + ); + }; + let head = if let Some(branch) = &branch { + let exists = execute_git_readonly( + root, + &[ + "show-ref".into(), + "--verify".into(), + "--quiet".into(), + branch.clone(), + ], + None, + )?; + match exists.exit_code { + 0 => Some( + read(root, &["rev-parse", "--verify", "HEAD^{commit}"])? + .trim() + .to_string(), + ), + 1 => None, + _ => { + return Err( + CoreError::new(ErrorCode::ProcessFailed, "Could not inspect branch") + .with_details(exists.output), + ) + } + } + } else { + Some( + read(root, &["rev-parse", "--verify", "HEAD^{commit}"])? + .trim() + .to_string(), + ) + }; + Ok((head, branch)) +} + +pub(super) fn inspect_root(root: &str) -> Result { + ensure_repository_root(root)?; + let (head, branch) = head_state(root)?; + let index_entries = read(root, &["ls-files", "--stage", "-z"])?; + let gitlinks = index_entries + .split('\0') + .filter_map(|record| { + let (header, path) = record.split_once('\t')?; + let mut fields = header.split_whitespace(); + if fields.next()? != "160000" { + return None; + } + let revision = fields.next()?.to_string(); + if fields.next()? != "0" { + return None; + } + Some(GitCommitGitlink { + path: path.to_string(), + revision, + }) + }) + .collect(); + let conflicted_paths = index_entries + .split('\0') + .filter_map(|record| { + let (header, path) = record.split_once('\t')?; + let stage = header.split_whitespace().nth(2)?; + (stage != "0").then(|| path.to_string()) + }) + .collect::>() + .into_iter() + .collect(); + let staged_paths = read( + root, + &[ + "diff", + "--cached", + "--name-only", + "--ignore-submodules=none", + "-z", + ], + )? + .split('\0') + .filter(|path| !path.is_empty()) + .map(str::to_string) + .collect(); + Ok(GitCommitState { + head, + branch, + index_entries, + gitlinks, + staged_paths, + conflicted_paths, + }) +} + +/// Validates the reviewed state under the typed writer lease before changing +/// any parent pointers. Unrelated staged files retain their exact index content. +pub(super) fn prepare( + root: &str, + expected: &GitCommitState, + updates: &[GitCommitGitlink], +) -> Result<(), CoreError> { + if inspect_root(root)? != *expected { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Commit plan changed; review it again", + )); + } + if !expected.conflicted_paths.is_empty() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Resolve repository conflicts before committing", + )); + } + for update in updates { + validate_paths(std::slice::from_ref(&update.path))?; + if !expected + .gitlinks + .iter() + .any(|entry| entry.path == update.path) + || !matches!(update.revision.len(), 40 | 64) + || !update.revision.bytes().all(|byte| byte.is_ascii_hexdigit()) + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Invalid submodule pointer update", + )); + } + let child = std::path::Path::new(root).join(&update.path); + ensure_repository_root(&child.to_string_lossy())?; + let actual = read( + &child.to_string_lossy(), + &["rev-parse", "--verify", "HEAD^{commit}"], + )?; + if actual.trim() != update.revision { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Submodule changed; review the commit plan again", + )); + } + } + if !updates.is_empty() { + // One index transaction; use NUL records so spaces, tabs and newlines in + // paths remain literal. Never run `git add` on unrelated parent files. + let input = updates + .iter() + .map(|update| format!("160000 {}\t{}\0", update.revision, update.path)) + .collect::(); + let result = execute_git( + root, + &["update-index".into(), "-z".into(), "--index-info".into()], + Some(input), + )?; + if result.exit_code != 0 { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Could not update submodule references", + ) + .with_details(result.output)); + } + } + Ok(()) +} diff --git a/rust/lithe-core/src/git/history.rs b/rust/lithe-core/src/git/history.rs index 2f912c0fe..04d1c9c94 100644 --- a/rust/lithe-core/src/git/history.rs +++ b/rust/lithe-core/src/git/history.rs @@ -215,6 +215,29 @@ pub fn history_page(request: GitHistoryPageRequest) -> Result, } #[derive(Debug, Deserialize)] @@ -418,7 +429,7 @@ pub struct GitPushTagExpectationRequest { pub object_id: String, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Default, Deserialize)] #[serde(rename_all = "camelCase")] /// Typed mutation request translated into a controlled Git invocation. pub struct GitWriteRequest { @@ -468,6 +479,12 @@ pub struct GitWriteRequest { pub no_checkout: bool, #[serde(default)] pub amend: bool, + /// Reviewed HEAD/index required by a planned workspace commit. + #[serde(default)] + pub expected_commit_state: Option, + /// Exact child commits to record; requires expected_commit_state. + #[serde(default)] + pub gitlink_updates: Vec, #[serde(default)] pub force: bool, /// Tag scope for push: `none`, `all`, or `reachable`. @@ -476,6 +493,9 @@ pub struct GitWriteRequest { /// Optional reviewed preview snapshot that must still match before pushing. #[serde(default)] pub expected_push: Option, + /// Verify submodule commits are published before a workspace branch push. + #[serde(default)] + pub check_submodules: bool, #[serde(default)] pub auto_stash: bool, /// Mandatory immutable preview for undo, reword, squash, and drop. @@ -878,6 +898,21 @@ fn write_with_trace(request: GitWriteRequest) -> Result Result { let paths = validate_paths(&request.paths)?; let pathspec_input = nul_pathspec_input(&paths); + + // An unborn repository has no HEAD for `restore --staged` or + // `reset HEAD` to resolve. Its index can only contain newly added + // paths, so remove those entries from the index while preserving + // the working tree files. + let head = execute_git( + &root, + &["rev-parse".into(), "--verify".into(), "HEAD".into()], + None, + )?; + if head.exit_code != 0 && commit_state::is_unborn(&root)? { + arguments = vec![ + "rm".into(), + "--cached".into(), + "--force".into(), + "--ignore-unmatch".into(), + "--pathspec-from-file=-".into(), + "--pathspec-file-nul".into(), + ]; + return execute_git(&root, &arguments, Some(pathspec_input)); + } + let restore_arguments = vec![ "restore".into(), "--staged".into(), @@ -959,6 +1016,20 @@ fn write_with_trace(request: GitWriteRequest) -> Result arguments = vec!["add".into(), "--all".into()], "commit" => { let message = required_text(request.message.as_deref(), "commit message")?; + if request.expected_commit_state.is_some() && !request.paths.is_empty() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Planned commits use the reviewed index", + )); + } + if let Some(expected) = &request.expected_commit_state { + commit_state::prepare(&root, expected, &request.gitlink_updates)?; + } else if !request.gitlink_updates.is_empty() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Submodule updates require a reviewed commit state", + )); + } if !request.paths.is_empty() { let paths = validate_paths(&request.paths)?; return commit_selected_paths(&root, paths, message, request.amend); @@ -1178,6 +1249,14 @@ fn write_with_trace(request: GitWriteRequest) -> Result { + if let Some(expected) = &request.expected_commit_state { + if commit_state::inspect_root(&root)? != *expected { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Push state changed; review the commit plan again", + )); + } + } let reference = optional_write_request_reference(&root, &request)?; return push( &root, @@ -1185,6 +1264,7 @@ fn write_with_trace(request: GitWriteRequest) -> Result return checkout(&root, request), @@ -4866,6 +4946,7 @@ fn push( force: bool, push_tags: Option<&str>, expected_push: Option<&GitPushExpectationRequest>, + check_submodules: bool, ) -> Result { let tag_scope = push_tags.unwrap_or("none"); let tag_argument = match tag_scope { @@ -4885,6 +4966,9 @@ fn push( validate_push_expectation(root, &target, tag_scope, expected_push)?; } let mut arguments = vec!["push".to_string()]; + if check_submodules { + arguments.push("--recurse-submodules=check".into()); + } if force { // Bind reviewed pushes to the observed remote OID so an unseen remote update is rejected. arguments.push(match expected_push { @@ -6473,7 +6557,8 @@ pub fn status(request: GitStatusRequest) -> Result "-c", "core.quotepath=false", "status", - "--porcelain=v1", + "--porcelain=v2", + "--ignore-submodules=none", "-z", "--untracked-files=all", ], @@ -6484,7 +6569,24 @@ pub fn status(request: GitStatusRequest) -> Result .with_details(String::from_utf8_lossy(&status_output.stderr)), ); } - let changes = parse_status(&status_output.stdout); + let mut changes = parse_status(&status_output.stdout, request.include_index_only_changes); + // Native bindings may use macOS /var aliases or Windows short/verbatim + // paths. Compare the same canonical identity as Git's reported root. + let known_roots = request + .repository_roots + .iter() + .map(|root| canonicalize_or_original(Path::new(root)).map_err(git_path_error)) + .collect::, _>>()?; + // An embedded independent repository must not become a gitlink through a + // parent Stage All. Gitlinks already tracked by the parent remain visible. + changes.retain(|change| { + !change.untracked + || !known_roots.iter().any(|other| { + other != &repository_root + && other.starts_with(&repository_root) + && repository_root.join(&change.path).starts_with(other) + }) + }); let (ahead, behind) = tracking_counts(&repository_root); Ok(GitStatusResponse { repository_root: Some(relative_or_absolute(&repository_root, &root)), @@ -6589,43 +6691,74 @@ fn run_git(directory: &Path, arguments: &[&str]) -> Result Vec { +fn parse_status(output: &[u8], include_index_only_changes: bool) -> Vec { let mut changes = Vec::new(); - let records = output + let mut records = output .split(|byte| *byte == 0) - .filter(|record| !record.is_empty()) - .collect::>(); - let mut index = 0; - while index < records.len() { - let record = String::from_utf8_lossy(records[index]).to_string(); - let bytes = record.as_bytes(); - if bytes.len() < 3 { - index += 1; + .filter(|record| !record.is_empty()); + while let Some(record) = records.next() { + let record = String::from_utf8_lossy(record); + let (status, submodule, path, original_path) = match record.as_bytes().first() { + Some(b'?') => ("??".to_string(), None, record[2..].to_string(), None), + Some(kind @ (b'1' | b'2' | b'u')) => { + // v2 headers have fixed field counts; the remaining path may + // contain whitespace. A rename's old path is the next NUL record. + let count = match kind { + b'1' => 9, + b'2' => 10, + _ => 11, + }; + let fields = record.splitn(count, ' ').collect::>(); + if fields.len() != count { + continue; + } + let status = fields[1].replace('.', " "); + let submodule = if fields[2].starts_with('S') { + Some(crate::protocol::GitSubmoduleStatus { + commit_changed: fields[2].as_bytes().get(1) == Some(&b'C'), + tracked_changes: fields[2].as_bytes().get(2) == Some(&b'M'), + untracked_changes: fields[2].as_bytes().get(3) == Some(&b'U'), + }) + } else { + None + }; + let original = if *kind == b'2' { + records + .next() + .map(|path| String::from_utf8_lossy(path).to_string()) + } else { + None + }; + (status, submodule, fields[count - 1].to_string(), original) + } + _ => continue, + }; + let bytes = status.as_bytes(); + if bytes.len() != 2 { continue; } let x = bytes[0] as char; let y = bytes[1] as char; - // The commit checkbox represents the final worktree snapshot. A path - // added only to the index and then deleted is identical to HEAD. - if x == 'A' && y == 'D' { - index += 1; + // Preserve the existing final-worktree projection used by Windows. + if !include_index_only_changes && x == 'A' && y == 'D' { continue; } - let path = record[3..].to_string(); - let mut original_path = None; - if (matches!(x, 'R' | 'C') || matches!(y, 'R' | 'C')) && index + 1 < records.len() { - original_path = Some(String::from_utf8_lossy(records[index + 1]).to_string()); - index += 1; - } + let can_toggle_staging = (x != ' ' && x != '?') + || submodule.as_ref().is_none_or(|s| s.commit_changed) + || x == 'U' + || y == 'U' + || y == 'D' + || (x == 'A' && y == 'A'); changes.push(GitChange { + can_toggle_staging, path, original_path, - status: format!("{}{}", x, y), + status, staged: x != ' ' && x != '?', worktree: y != ' ' && y != '?', untracked: x == '?' && y == '?', + submodule, }); - index += 1; } changes.sort_by(|left, right| left.path.cmp(&right.path)); changes diff --git a/rust/lithe-core/src/git/workspace_commit.rs b/rust/lithe-core/src/git/workspace_commit.rs new file mode 100644 index 000000000..916af50b0 --- /dev/null +++ b/rust/lithe-core/src/git/workspace_commit.rs @@ -0,0 +1,626 @@ +//! Shared workspace commit planning, guarded execution, and partial-success recovery. +// Decisions: .agents/notes/implemented/feature/2026-09-27-workspace-git-commit-plans.md + +use super::{commit_state, GitCommitGitlink, GitCommitState, GitWriteRequest}; +use crate::protocol::{CoreError, ErrorCode}; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; +use std::time::Duration; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Platform binding for a workspace-relative repository identity. +pub struct RepositoryBinding { + /// Relative path with `/` separators, including `..` for an enclosing repository. + pub id: String, + /// Native execution location; never used as a portable identifier. + pub root: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +/// A mode-160000 index entry between two discovered repositories. +pub struct Relation { + pub parent: String, + pub child: String, + /// Gitlink path relative to the parent repository. + pub path: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Exact repositories, options, and Git state presented for review. +pub struct Plan { + pub repositories: Vec, + pub message: String, + pub amend: bool, + pub push: bool, + pub include_parent_references: bool, + pub is_retry: bool, + /// Child-before-parent order, preserving input order for independent roots. + pub ordered_ids: Vec, + pub propagated_relations: Vec, + pub dependency_relations: Vec, + pub states: BTreeMap, + pub committed_ids: BTreeSet, + pub pending_push_ids: BTreeSet, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Per-repository outcome; successful mutations are never rolled back as a batch. +pub struct RepositoryResult { + pub committed: bool, + pub pushed: bool, + /// Stable presentation key, separate from optional Git diagnostics. + pub status: String, + pub detail: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +/// In-memory continuation returned unchanged by clients to the next step or retry. +/// It owns no native resources and is discarded when the workspace closes. +pub struct Session { + pub plan: Plan, + /// Last observed states, including successful commits and failed-hook index changes. + pub states: BTreeMap, + pub results: BTreeMap, + pub blocked: BTreeSet, + /// Next repository; a successful commit keeps this position for its separate push. + pub cursor: usize, + pub command_failed: bool, + pub finished: bool, + pub succeeded: bool, + pub can_retry: bool, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Builds a fresh plan; retry and reviewed snapshots are optional continuations. +pub struct PrepareRequest { + pub repositories: Vec, + pub message: String, + #[serde(default)] + pub amend: bool, + #[serde(default)] + pub push: bool, + pub include_parent_references: bool, + #[serde(default)] + pub previous: Option, + #[serde(default)] + pub reviewed: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +/// Review decision and initial continuation, both computed by Core. +pub struct Preparation { + pub session: Session, + pub review_changed: bool, + pub requires_confirmation: bool, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Executes at most one commit or push using a fresh host operation context. +pub struct StepRequest { + pub session: Session, +} + +fn invalid(message: &str) -> CoreError { + CoreError::new(ErrorCode::InvalidRequest, message) +} + +fn validate_bindings(bindings: &[RepositoryBinding]) -> Result<(), CoreError> { + let mut ids = BTreeSet::new(); + let mut roots = BTreeSet::new(); + for binding in bindings { + if binding.id.is_empty() + || binding.id.starts_with('/') + || binding.id.contains('\\') + || binding.id.contains('\0') + || !ids.insert(&binding.id) + { + return Err(invalid( + "Repository identifiers must be unique workspace-relative paths", + )); + } + if binding.root.is_empty() || !roots.insert(Path::new(&binding.root)) { + return Err(invalid("A repository cannot appear twice in a commit plan")); + } + } + Ok(()) +} + +/// Inspects every discovered repository before calculating dependencies and retry scope. +pub fn prepare(mut request: PrepareRequest) -> Result { + for binding in &mut request.repositories { + binding.root = super::validate_root(&binding.root)?; + } + validate_bindings(&request.repositories)?; + let mut states = BTreeMap::new(); + for binding in &request.repositories { + states.insert( + binding.id.clone(), + commit_state::inspect_root(&binding.root)?, + ); + } + build_plan(request, states) +} + +fn build_plan( + request: PrepareRequest, + states: BTreeMap, +) -> Result { + let message = request.message.trim().to_string(); + if message.is_empty() { + return Err(invalid("Enter a commit message")); + } + let previous = request.previous.as_ref(); + let mut results = previous.map(|s| s.results.clone()).unwrap_or_default(); + if let Some(previous) = previous { + for id in &previous.plan.ordered_ids { + let result = previous.results.get(id).cloned().unwrap_or_default(); + let unfinished = !result.committed || (request.push && !result.pushed); + let binding = request.repositories.iter().find(|b| &b.id == id); + if unfinished + && (binding.is_none() + || binding != previous.plan.repositories.iter().find(|b| &b.id == id)) + { + return Err(invalid("A repository with unfinished steps is no longer in this workspace. Restore it before retrying.")); + } + if !result.committed + && result.status != "outcomeUnknown" + && states + .get(id) + .zip(previous.states.get(id)) + .is_some_and(|(current, old)| current.head != old.head) + { + return Err(invalid("An unfinished repository HEAD changed. Inspect its history and dismiss the previous batch before starting a new commit.")); + } + if result.status == "outcomeUnknown" { + // A cancelled hook may finish the commit before its process exits. + // Never repeat that commit merely because cleanup inspection failed. + let current = states + .get(id) + .ok_or_else(|| invalid("Restore the repository before retrying"))?; + if current.head != previous.states.get(id).and_then(|s| s.head.clone()) + && current.head.is_some() + { + results.entry(id.clone()).or_default().committed = true; + } + } + } + } + let mut committed: BTreeSet<_> = results + .iter() + .filter(|(_, r)| r.committed) + .map(|(id, _)| id.clone()) + .collect(); + let mut pending_push: BTreeSet<_> = results + .iter() + .filter(|(id, result)| { + result.committed + && states.contains_key(*id) + && request.push + && (!result.pushed + || previous + .and_then(|s| s.states.get(*id)) + .zip(states.get(*id)) + .is_none_or(|(old, new)| old.head != new.head || old.branch != new.branch)) + }) + .map(|(id, _)| id.clone()) + .collect(); + let mut selected: BTreeSet<_> = states + .iter() + .filter(|(id, state)| !committed.contains(*id) && !state.staged_paths.is_empty()) + .map(|(id, _)| id.clone()) + .collect(); + selected.extend(pending_push.iter().cloned()); + let relations = relations(&request.repositories, &states)?; + let previously_propagated = |relation: &Relation| { + previous.is_some_and(|s| s.plan.propagated_relations.contains(relation)) + }; + if request.include_parent_references { + for relation in &relations { + if committed.contains(&relation.child) + && previously_propagated(relation) + && !committed.contains(&relation.parent) + { + selected.insert(relation.parent.clone()); + } + } + loop { + let before = selected.len(); + for relation in &relations { + if selected.contains(&relation.child) && !committed.contains(&relation.parent) { + selected.insert(relation.parent.clone()); + } + } + if before == selected.len() { + break; + } + } + } + if selected.is_empty() { + return Err(invalid("Stage at least one change before committing")); + } + let propagation: Vec<_> = relations + .iter() + .filter(|r| { + ((selected.contains(&r.child) && !committed.contains(&r.child)) + || previously_propagated(r)) + && selected.contains(&r.parent) + && !committed.contains(&r.parent) + && (request.include_parent_references + || states[&r.parent].staged_paths.contains(&r.path)) + }) + .cloned() + .collect(); + let mut dependencies = propagation.clone(); + if request.push { + // Iterate to a fixed point: a push-only child can itself reference a grandchild. + loop { + let before = selected.len(); + for relation in &relations { + if selected.contains(&relation.parent) + && (states[&relation.parent] + .staged_paths + .contains(&relation.path) + || previous.is_some_and(|s| s.plan.dependency_relations.contains(relation))) + && states[&relation.child].branch.is_some() + { + if selected.insert(relation.child.clone()) { + committed.insert(relation.child.clone()); + pending_push.insert(relation.child.clone()); + } + if !dependencies.contains(relation) { + dependencies.push(relation.clone()); + } + } + } + if before == selected.len() { + break; + } + } + } + let order = commit_order( + request + .repositories + .iter() + .filter(|b| selected.contains(&b.id)) + .map(|b| b.id.clone()) + .collect(), + &dependencies, + )?; + let plan = Plan { + repositories: request.repositories, + message, + amend: request.amend, + push: request.push, + include_parent_references: request.include_parent_references, + is_retry: previous.is_some(), + ordered_ids: order, + propagated_relations: propagation, + dependency_relations: dependencies, + states: states.clone(), + committed_ids: committed, + pending_push_ids: pending_push, + }; + let review_changed = request + .reviewed + .as_ref() + .is_some_and(|reviewed| reviewed != &plan); + let requires_confirmation = plan.is_retry || !plan.dependency_relations.is_empty(); + for id in &plan.ordered_ids { + let result = results.entry(id.clone()).or_default(); + result.committed = plan.committed_ids.contains(id); + if plan.pending_push_ids.contains(id) { + result.pushed = false; + } + result.status = "pending".into(); + result.detail.clear(); + } + for (id, result) in &mut results { + if !plan.ordered_ids.contains(id) && !result.committed { + result.status = "notIncluded".into(); + result.detail.clear(); + } + } + Ok(Preparation { + session: Session { + plan, + states, + results, + blocked: BTreeSet::new(), + cursor: 0, + command_failed: false, + finished: false, + succeeded: false, + can_retry: true, + }, + review_changed, + requires_confirmation, + }) +} + +fn relations( + bindings: &[RepositoryBinding], + states: &BTreeMap, +) -> Result, CoreError> { + let mut relations = Vec::new(); + for parent in bindings { + for link in &states[&parent.id].gitlinks { + super::validate_paths(std::slice::from_ref(&link.path))?; + let target = Path::new(&parent.root).join(&link.path); + if let Some(child) = bindings + .iter() + .find(|b| b.id != parent.id && Path::new(&b.root) == target) + { + relations.push(Relation { + parent: parent.id.clone(), + child: child.id.clone(), + path: link.path.clone(), + }); + } + } + } + Ok(relations) +} + +fn commit_order( + mut remaining: Vec, + relations: &[Relation], +) -> Result, CoreError> { + let mut ordered = Vec::new(); + while !remaining.is_empty() { + let index = remaining + .iter() + .position(|candidate| { + !relations + .iter() + .any(|r| &r.parent == candidate && remaining.contains(&r.child)) + }) + .ok_or_else(|| invalid("Cyclic repository dependencies cannot be committed"))?; + ordered.push(remaining.remove(index)); + } + Ok(ordered) +} + +fn block_parents(session: &mut Session, child: &str) { + let mut children = vec![child.to_string()]; + while let Some(child) = children.pop() { + for relation in &session.plan.dependency_relations { + if relation.child == child && session.blocked.insert(relation.parent.clone()) { + children.push(relation.parent.clone()); + } + } + } +} + +fn fail(session: &mut Session, id: &str, status: &str, detail: String) { + let result = session.results.entry(id.into()).or_default(); + result.status = status.into(); + result.detail = detail; + session.command_failed = true; + block_parents(session, id); + session.cursor += 1; +} + +fn finish(session: &mut Session) { + session.finished = session.cursor >= session.plan.ordered_ids.len(); + session.can_retry = session.plan.ordered_ids.iter().any(|id| { + session + .results + .get(id) + .is_none_or(|r| !r.committed || (session.plan.push && !r.pushed)) + }); + session.succeeded = session.finished && !session.command_failed && !session.can_retry; +} + +/// Runs one mutation, reconciles its outcome even after cancellation, and returns +/// the continuation. The caller supplies a fresh operation ID for each next step. +pub fn step(request: StepRequest) -> Result { + let mut session = request.session; + validate_session(&session)?; + if session.finished { + return Ok(session); + } + let id = session.plan.ordered_ids[session.cursor].clone(); + if session.blocked.contains(&id) { + fail(&mut session, &id, "waitingForSubmodule", String::new()); + finish(&mut session); + return Ok(session); + } + let root = session + .plan + .repositories + .iter() + .find(|b| b.id == id) + .unwrap() + .root + .clone(); + let expected = session.states.get(&id).cloned(); + let preparation = prepare_step(&session, &id, &root); + let write = match preparation { + Ok(Some(write)) => write, + Ok(None) => { + session.cursor += 1; + finish(&mut session); + return Ok(session); + } + Err(error) => { + fail(&mut session, &id, "reviewRequired", error.message); + finish(&mut session); + return Ok(session); + } + }; + let committing = write.operation == "commit"; + let result = super::write(write); + let succeeded = result + .as_ref() + .is_ok_and(|r| r.exit_code == 0 && r.operation_error.is_none()); + let detail = match &result { + Ok(r) => r + .operation_error + .as_ref() + .map(|e| e.message.clone()) + .unwrap_or_else(|| r.output.trim().to_string()), + Err(e) => e.message.clone(), + }; + if committing { + // Read-only cleanup has its own bounded deadline, preserving a commit + // that completed just before cancellation without starting another write. + let after = + crate::protocol::cancellation::with_cleanup_deadline(Duration::from_secs(5), || { + commit_state::inspect_root(&root) + }); + let advanced = after.as_ref().is_ok_and(|s| { + s.head.is_some() && s.head != expected.as_ref().and_then(|e| e.head.clone()) + }); + if let Ok(after) = &after { + session.states.insert(id.clone(), after.clone()); + } + let outcome = session.results.get_mut(&id).unwrap(); + outcome.committed = succeeded || advanced; + outcome.status = if session.plan.push { + "committedPushPending" + } else { + "committed" + } + .into(); + if !succeeded || after.is_err() { + let status = if after.is_err() { + "outcomeUnknown" + } else if advanced { + "headAdvanced" + } else { + "commitFailed" + }; + fail( + &mut session, + &id, + status, + if let Err(error) = after { + error.message + } else { + detail + }, + ); + } else if !session.plan.push { + session.cursor += 1; + } + } else { + let outcome = session.results.get_mut(&id).unwrap(); + outcome.pushed = succeeded; + outcome.status = "committedAndPushed".into(); + if succeeded { + session.cursor += 1; + } else { + fail(&mut session, &id, "pushFailed", detail); + } + } + finish(&mut session); + Ok(session) +} + +fn validate_session(session: &Session) -> Result<(), CoreError> { + validate_bindings(&session.plan.repositories)?; + let ids: BTreeSet<_> = session.plan.repositories.iter().map(|b| &b.id).collect(); + let order: BTreeSet<_> = session.plan.ordered_ids.iter().collect(); + if order.len() != session.plan.ordered_ids.len() + || !order.is_subset(&ids) + || session.cursor > order.len() + || (!session.finished && session.cursor == order.len()) + || order + .iter() + .any(|id| !session.states.contains_key(*id) || !session.results.contains_key(*id)) + || session + .plan + .dependency_relations + .iter() + .chain(&session.plan.propagated_relations) + .any(|r| !ids.contains(&r.parent) || !ids.contains(&r.child)) + { + return Err(invalid("Invalid workspace commit continuation")); + } + if commit_order( + session.plan.ordered_ids.clone(), + &session.plan.dependency_relations, + )? != session.plan.ordered_ids + { + return Err(invalid( + "Workspace commit dependencies must precede their parents", + )); + } + Ok(()) +} + +fn prepare_step( + session: &Session, + id: &str, + root: &str, +) -> Result, CoreError> { + let expected = &session.states[id]; + if commit_state::inspect_root(root)? != *expected { + return Err(invalid("Repository changed; review and retry")); + } + let mut request = GitWriteRequest { + root: root.into(), + expected_commit_state: Some(expected.clone()), + ..Default::default() + }; + if !session.results[id].committed { + if !expected.conflicted_paths.is_empty() { + return Err(invalid("Resolve conflicts before committing")); + } + let markers = super::staged_conflict_marker_paths(root)?; + if !markers.is_empty() { + return Err(invalid(&format!( + "Resolve conflict markers: {}", + markers.join(", ") + ))); + } + for relation in session + .plan + .propagated_relations + .iter() + .filter(|r| r.parent == id) + { + let child = session + .plan + .repositories + .iter() + .find(|b| b.id == relation.child) + .unwrap(); + let actual = commit_state::inspect_root(&child.root)?; + if !session.results.get(&child.id).is_some_and(|r| r.committed) + || actual.head.is_none() + || actual.head != session.states.get(&child.id).and_then(|s| s.head.clone()) + { + return Err(invalid("Submodule changed; review and retry")); + } + request.gitlink_updates.push(GitCommitGitlink { + path: relation.path.clone(), + revision: actual.head.unwrap(), + }); + } + request.operation = "commit".into(); + request.message = Some(session.plan.message.clone()); + request.amend = session.plan.amend && !expected.staged_paths.is_empty(); + } else if session.plan.push && !session.results[id].pushed { + request.operation = "push".into(); + request.reference = Some(expected.branch.clone().ok_or_else(|| { + invalid("Committed; branch changed or detached. Review and retry push.") + })?); + request.check_submodules = true; + } else { + return Ok(None); + } + Ok(Some(request)) +} + +#[cfg(test)] +mod tests; diff --git a/rust/lithe-core/src/git/workspace_commit/tests.rs b/rust/lithe-core/src/git/workspace_commit/tests.rs new file mode 100644 index 000000000..14b2afa2d --- /dev/null +++ b/rust/lithe-core/src/git/workspace_commit/tests.rs @@ -0,0 +1,177 @@ +//! Deterministic policy regressions migrated from the native feature model. +use super::*; +use serde_json::json; + +fn state(staged: &[&str], links: &[&str]) -> GitCommitState { + GitCommitState { + head: Some("initial".into()), + branch: Some("refs/heads/main".into()), + index_entries: staged.join("\0"), + gitlinks: links + .iter() + .map(|p| GitCommitGitlink { + path: (*p).into(), + revision: "initial".into(), + }) + .collect(), + staged_paths: staged.iter().map(|p| (*p).into()).collect(), + conflicted_paths: vec![], + } +} + +fn input() -> (PrepareRequest, BTreeMap) { + let request: PrepareRequest = serde_json::from_value(json!({ + "repositories": [{"id":"A","root":"/workspace/A"}, {"id":"A/B","root":"/workspace/A/B"}, + {"id":"independent","root":"/workspace/independent"}], + "message":"commit", "push":true, "includeParentReferences":true + })) + .unwrap(); + let states = [ + ("A", state(&[], &["B"])), + ("A/B", state(&["hello.ts"], &[])), + ("independent", state(&["file"], &[])), + ] + .into_iter() + .map(|(id, s)| (id.into(), s)) + .collect(); + (request, states) +} + +#[test] +fn true_gitlinks_order_children_and_include_clean_ancestors() { + let (mut request, mut states) = input(); + request.repositories.push(RepositoryBinding { + id: "A/B/C".into(), + root: "/workspace/A/B/C".into(), + }); + states.insert("A/B".into(), state(&[], &["C"])); + states.insert("A/B/C".into(), state(&["hello.ts"], &[])); + let prepared = build_plan(request, states).unwrap(); + assert_eq!( + prepared.session.plan.ordered_ids, + ["independent", "A/B/C", "A/B", "A"] + ); + assert_eq!(prepared.session.plan.propagated_relations.len(), 2); + assert!(prepared.requires_confirmation); +} + +#[test] +fn independent_nested_paths_do_not_create_dependencies_and_parent_opt_out_is_honored() { + let (mut request, states) = input(); + request.include_parent_references = false; + let prepared = build_plan(request, states).unwrap(); + assert_eq!(prepared.session.plan.ordered_ids, ["A/B", "independent"]); + assert!(prepared.session.plan.dependency_relations.is_empty()); + let (request, mut states) = input(); + states.insert("A".into(), state(&["parent.txt"], &[])); + assert_eq!( + build_plan(request, states) + .unwrap() + .session + .plan + .ordered_ids, + ["A", "A/B", "independent"] + ); +} + +#[test] +fn changed_index_or_new_selection_requires_reconfirmation() { + let (request, states) = input(); + let original = build_plan(request, states.clone()).unwrap(); + let (mut request, mut states) = input(); + request.reviewed = Some(original.session.plan); + states.get_mut("independent").unwrap().index_entries = "new selection".into(); + assert!(build_plan(request, states).unwrap().review_changed); +} + +fn failed_push() -> (Session, BTreeMap) { + let (request, mut states) = input(); + let mut session = build_plan(request, states.clone()).unwrap().session; + for id in ["A/B", "independent"] { + let result = session.results.get_mut(id).unwrap(); + result.committed = true; + result.pushed = id == "independent"; + states.insert(id.into(), state(&[], &[])); + } + session.states = states.clone(); + block_parents(&mut session, "A/B"); + assert_eq!(session.blocked, BTreeSet::from(["A".into()])); + (session, states) +} + +#[test] +fn child_push_failure_blocks_only_ancestors_and_retry_does_not_recommit_successes() { + let (session, states) = failed_push(); + let (mut request, _) = input(); + request.previous = Some(session); + let prepared = build_plan(request, states).unwrap(); + assert_eq!(prepared.session.plan.ordered_ids, ["A/B", "A"]); + assert!(prepared.session.results["A/B"].committed); + assert!(!prepared.session.results["A/B"].pushed); + assert!(prepared.session.blocked.is_empty()); + assert!(prepared.requires_confirmation); +} + +#[test] +fn parent_pointer_selection_adds_a_push_only_child() { + let (request, mut states) = input(); + states.insert("A".into(), state(&["B"], &["B"])); + states.insert("A/B".into(), state(&[], &[])); + states.insert("independent".into(), state(&[], &[])); + let prepared = build_plan(request, states).unwrap(); + assert_eq!(prepared.session.plan.ordered_ids, ["A/B", "A"]); + assert!(prepared.session.plan.propagated_relations.is_empty()); + assert!(prepared.session.results["A/B"].committed); + assert!(!prepared.session.results["A/B"].pushed); +} + +#[test] +fn retry_republishes_an_externally_advanced_child_before_its_clean_parent() { + let (mut session, mut states) = failed_push(); + session.results.get_mut("A/B").unwrap().pushed = true; + states.get_mut("A/B").unwrap().head = Some("external".into()); + let (mut request, _) = input(); + request.previous = Some(session); + let prepared = build_plan(request, states).unwrap(); + assert_eq!(prepared.session.plan.ordered_ids, ["A/B", "A"]); + assert!(prepared.session.plan.pending_push_ids.contains("A/B")); + assert!(prepared.session.results["A/B"].committed); +} + +#[test] +fn missing_unfinished_roots_and_cycles_fail_closed() { + let (session, mut states) = failed_push(); + let (mut request, _) = input(); + request.previous = Some(session); + request.repositories.retain(|b| b.id != "A/B"); + states.remove("A/B"); + assert!(build_plan(request, states).is_err()); + let cycle = vec![ + Relation { + parent: "A".into(), + child: "B".into(), + path: "B".into(), + }, + Relation { + parent: "B".into(), + child: "A".into(), + path: "A".into(), + }, + ]; + assert!(commit_order(vec!["A".into(), "B".into()], &cycle).is_err()); +} + +#[test] +fn shared_workflow_fixture_is_the_complete_portable_contract() { + let fixture: serde_json::Value = serde_json::from_str(include_str!( + "../../../../../shared/fixtures/git/workspace-commit-workflow-v1.json" + )) + .unwrap(); + let request = serde_json::from_value(fixture["request"].clone()).unwrap(); + let states = serde_json::from_value(fixture["states"].clone()).unwrap(); + let prepared = build_plan(request, states).unwrap(); + assert_eq!( + serde_json::to_value(prepared).unwrap(), + fixture["preparation"] + ); +} diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index e217a2c91..8df12504c 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -226,6 +226,12 @@ pub enum CoreCommand { MybatisIndex, /// Reads normalized repository and working-tree state (`git.status`). GitStatus, + /// Reads HEAD and index preconditions for workspace commits (`git.commitState`). + GitCommitState, + /// Builds a reviewed multi-repository plan (`git.workspaceCommitPrepare`). + GitWorkspaceCommitPrepare, + /// Executes one guarded workspace commit/push (`git.workspaceCommitStep`). + GitWorkspaceCommitStep, /// Resolves paths a Git-aware watcher must observe (`git.watchContext`). GitWatchContext, /// Lists worktrees registered for the repository (`git.worktrees`). @@ -419,6 +425,9 @@ impl CoreCommand { "spring.index" => Some(Self::SpringIndex), "mybatis.index" => Some(Self::MybatisIndex), "git.status" => Some(Self::GitStatus), + "git.commitState" => Some(Self::GitCommitState), + "git.workspaceCommitPrepare" => Some(Self::GitWorkspaceCommitPrepare), + "git.workspaceCommitStep" => Some(Self::GitWorkspaceCommitStep), "git.watchContext" => Some(Self::GitWatchContext), "git.worktrees" => Some(Self::GitWorktrees), "git.pullRequestContext" => Some(Self::GitPullRequestContext), diff --git a/rust/lithe-core/src/protocol/contracts.rs b/rust/lithe-core/src/protocol/contracts.rs index 2b5b429fb..dc03e484a 100644 --- a/rust/lithe-core/src/protocol/contracts.rs +++ b/rust/lithe-core/src/protocol/contracts.rs @@ -532,6 +532,19 @@ pub struct GitChange { pub staged: bool, pub worktree: bool, pub untracked: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub submodule: Option, + /// False for parent gitlinks with only uncommitted child content. + pub can_toggle_staging: bool, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Git porcelain v2 distinguishes a new gitlink target from child worktree dirt. +pub struct GitSubmoduleStatus { + pub commit_changed: bool, + pub tracked_changes: bool, + pub untracked_changes: bool, } #[derive(Debug, Clone, Serialize)] diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index a386a2a2b..95d2bc233 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -105,6 +105,7 @@ fn execute(request: &str) -> CoreResponse { ); }; + let preserve_workspace_outcome = matches!(command, CoreCommand::GitWorkspaceCommitStep); let response = match command { CoreCommand::Ping => CoreResponse::success( id, @@ -1601,6 +1602,59 @@ fn execute(request: &str) -> CoreResponse { Err(error) => CoreResponse::failure(id, error), } } + CoreCommand::GitWorkspaceCommitPrepare => { + let result = + serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid workspace commit request", + ) + .with_details(error.to_string()) + }) + .and_then(git::workspace_commit::prepare); + match result { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Workspace commit response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::GitWorkspaceCommitStep => { + let result = + serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid workspace commit request", + ) + .with_details(error.to_string()) + }) + .and_then(git::workspace_commit::step); + match result { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Workspace commit response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::GitCommitState => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid commit state request") + .with_details(error.to_string()) + }) + .and_then(git::commit_state) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Commit state should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } CoreCommand::GitStatus => match serde_json::from_value::(parsed.payload) .map_err(|error| { CoreError::new(ErrorCode::InvalidRequest, "Invalid Git status request") @@ -2300,7 +2354,9 @@ fn execute(request: &str) -> CoreResponse { } } }; - if response.is_success() { + // Workspace steps already reconcile cancelled writes under a bounded cleanup + // deadline. Preserve that continuation so a retry cannot duplicate a commit. + if response.is_success() && !preserve_workspace_outcome { match crate::protocol::cancellation::check() { Ok(()) => response, Err(error) => CoreResponse::failure(response_id, error), diff --git a/rust/lithe-core/src/tests/git.rs b/rust/lithe-core/src/tests/git.rs index d822bb262..31075dfec 100644 --- a/rust/lithe-core/src/tests/git.rs +++ b/rust/lithe-core/src/tests/git.rs @@ -1019,6 +1019,33 @@ fn git_history_rewrite_rejects_a_dirty_working_tree() { fs::remove_dir_all(root).expect("temporary repository should be removable"); } +#[test] +fn git_write_unstages_paths_in_an_unborn_repository() { + let root = git_write_repository("git-write-unstage-unborn"); + let run = |arguments: &[&str]| history_git(&root, arguments); + fs::write(root.join("pom.xml"), "\n").expect("test file should be writable"); + assert_eq!( + git_write_request(&root, "stage", serde_json::json!({"paths": ["pom.xml"]}),)["ok"], + true + ); + assert_eq!( + String::from_utf8_lossy(&run(&["status", "--porcelain"]).stdout), + "A pom.xml\n" + ); + + let response = git_write_request(&root, "unstage", serde_json::json!({"paths": ["pom.xml"]})); + assert_eq!(response["ok"], true, "{response}"); + assert_eq!( + String::from_utf8_lossy(&run(&["status", "--porcelain"]).stdout), + "?? pom.xml\n" + ); + assert_eq!( + fs::read_to_string(root.join("pom.xml")).expect("test file should remain"), + "\n" + ); + fs::remove_dir_all(root).expect("temporary repository should be removable"); +} + #[test] fn git_write_executes_stage_and_discard_mutations() { let root = git_write_repository("git-write-stage-discard"); @@ -3387,6 +3414,36 @@ fn git_history_returns_bounded_recent_checkout_order_and_stable_fallback() { ); } +#[test] +fn git_history_page_treats_unborn_head_as_empty_history() { + struct RemoveOnDrop(std::path::PathBuf); + + impl Drop for RemoveOnDrop { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + let root = temporary_root("git-history-unborn-head"); + let _cleanup = RemoveOnDrop(root.clone()); + fs::create_dir_all(&root).expect("temporary repository should be creatable"); + assert!(history_git(&root, &["init", "-q"]).status.success()); + + let request = serde_json::json!({ + "id": "history-unborn-head", + "command": "git.historyPage", + "payload": {"root": root, "reference": "HEAD", "limit": 10} + }); + let response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&request).expect("history request should encode"), + )) + .expect("history response should be JSON"); + + assert_eq!(response["ok"], true, "{response:?}"); + assert_eq!(response["data"]["commits"], serde_json::json!([])); + assert_eq!(response["data"]["hasMore"], false); +} + #[test] fn git_history_page_returns_disjoint_incremental_pages() { struct RemoveOnDrop(std::path::PathBuf); diff --git a/rust/lithe-core/src/tests/git_workspace_commit.rs b/rust/lithe-core/src/tests/git_workspace_commit.rs new file mode 100644 index 000000000..2b6570ff1 --- /dev/null +++ b/rust/lithe-core/src/tests/git_workspace_commit.rs @@ -0,0 +1,528 @@ +//! Real-Git workspace commit regressions, isolated by the timed Rust harness. + +use super::support::temporary_root; +use crate::execute_json; +use serde_json::{json, Value}; +use std::fs; +use std::path::{Path, PathBuf}; + +struct Repository(PathBuf); +impl Drop for Repository { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn request(root: &Path, command: &str, mut payload: Value) -> Value { + payload["root"] = json!(root); + serde_json::from_str(&execute_json( + &json!({"id":"workspace-commit-test", "command":command, "payload":payload}).to_string(), + )) + .unwrap() +} + +fn git(root: &Path, arguments: &[&str]) -> String { + // The shared host owns the subprocess deadline and process-tree cleanup. + let response = request(root, "git.command", json!({"arguments": arguments})); + assert_eq!(response["ok"], true, "{response}"); + assert_eq!(response["data"]["exitCode"], 0, "{arguments:?}: {response}"); + response["data"]["stdout"].as_str().unwrap().to_string() +} + +fn init(root: &Path) { + fs::create_dir_all(root).unwrap(); + git(root, &["init", "-q"]); + git(root, &["config", "user.name", "Workspace Test"]); + git(root, &["config", "user.email", "workspace@example.invalid"]); + git(root, &["config", "commit.gpgsign", "false"]); + git(root, &["config", "core.autocrlf", "false"]); +} + +fn repository(name: &str) -> Repository { + let result = Repository(temporary_root(name)); + init(&result.0); + result +} + +fn state(root: &Path) -> Value { + let result = request(root, "git.commitState", json!({})); + assert_eq!(result["ok"], true, "{result}"); + result["data"].clone() +} + +#[test] +fn git_workspace_commit_rejects_a_changed_index_before_writing() { + let repo = repository("workspace-commit-stale"); + fs::write(repo.0.join("first.txt"), "first").unwrap(); + git(&repo.0, &["add", "first.txt"]); + let expected = state(&repo.0); + assert!(expected["head"].is_null()); + fs::write(repo.0.join("second.txt"), "second").unwrap(); + git(&repo.0, &["add", "second.txt"]); + let result = request( + &repo.0, + "git.write", + json!({"operation":"commit", "message":"reviewed", "expectedCommitState":expected}), + ); + assert_eq!( + result["data"]["operationError"]["code"], "invalid_request", + "{result}" + ); + assert!(state(&repo.0)["head"].is_null()); + assert_eq!( + git(&repo.0, &["diff", "--cached", "--name-only"]), + "first.txt\nsecond.txt\n" + ); +} + +#[test] +fn git_workspace_commit_distinguishes_child_dirt_and_updates_only_the_gitlink() { + let repo = repository("workspace-commit-submodule"); + let child = repo.0.join("libs/B"); + init(&child); + fs::write(child.join("hello.ts"), "one").unwrap(); + git(&child, &["add", "hello.ts"]); + git(&child, &["commit", "-qm", "child initial"]); + fs::write(repo.0.join("parent.txt"), "parent one").unwrap(); + fs::write( + repo.0.join(".gitmodules"), + "[submodule \"B\"]\npath = libs/B\nurl = ./libs/B\n", + ) + .unwrap(); + git(&repo.0, &["add", "."]); + git(&repo.0, &["commit", "-qm", "parent initial"]); + fs::write(child.join("hello.ts"), "two").unwrap(); + let dirty = request(&repo.0, "git.status", json!({})); + assert_eq!(dirty["data"]["changes"][0]["path"], "libs/B"); + assert_eq!( + dirty["data"]["changes"][0]["submodule"]["trackedChanges"], + true + ); + assert_eq!( + dirty["data"]["changes"][0]["submodule"]["commitChanged"], + false + ); + assert_eq!(dirty["data"]["changes"].as_array().unwrap().len(), 1); + assert_eq!(dirty["data"]["changes"][0]["canToggleStaging"], false); + fs::write(repo.0.join("parent.txt"), "must stay unstaged").unwrap(); + let expected = state(&repo.0); + git(&child, &["add", "hello.ts"]); + let child_commit = request( + &child, + "git.write", + json!({"operation":"commit", "message":"child update", "expectedCommitState":state(&child)}), + ); + assert_eq!(child_commit["data"]["exitCode"], 0, "{child_commit}"); + let child_state = state(&child); + let advanced = request(&repo.0, "git.status", json!({})); + assert_eq!( + advanced["data"]["changes"][0]["submodule"]["commitChanged"], + true + ); + let result = request( + &repo.0, + "git.write", + json!({"operation":"commit", "message":"parent reference", "expectedCommitState":expected, + "gitlinkUpdates":[{"path":"libs/B", "revision":child_state["head"]}]}), + ); + assert_eq!(result["ok"], true, "{result}"); + assert_eq!(result["data"]["exitCode"], 0, "{result}"); + assert_eq!(git(&repo.0, &["show", "HEAD:parent.txt"]), "parent one"); + assert_eq!( + git(&repo.0, &["rev-parse", "HEAD:libs/B"]).trim(), + child_state["head"].as_str().unwrap() + ); + assert_eq!( + fs::read_to_string(repo.0.join("parent.txt")).unwrap(), + "must stay unstaged" + ); +} + +#[test] +fn git_workspace_commit_unstage_preserves_edits_after_initial_staging() { + let repo = repository("workspace-commit-unstage"); + fs::write(repo.0.join("new.txt"), "staged").unwrap(); + git(&repo.0, &["add", "new.txt"]); + fs::write(repo.0.join("new.txt"), "newer worktree").unwrap(); + let result = request( + &repo.0, + "git.write", + json!({"operation":"unstage", "paths":["new.txt"]}), + ); + assert_eq!(result["data"]["exitCode"], 0, "{result}"); + assert!(state(&repo.0)["stagedPaths"].as_array().unwrap().is_empty()); + assert_eq!( + fs::read_to_string(repo.0.join("new.txt")).unwrap(), + "newer worktree" + ); +} + +#[test] +fn git_workspace_commit_state_matches_the_shared_fixture() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/git/workspace-commit-v1.json" + )) + .unwrap(); + let state: crate::git::GitCommitState = + serde_json::from_value(fixture["commitState"].clone()).unwrap(); + assert_eq!(serde_json::to_value(state).unwrap(), fixture["commitState"]); +} + +#[test] +fn git_workspace_commit_status_keeps_index_only_files_visible_when_requested() { + let repo = repository("workspace-commit-index-only"); + fs::write(repo.0.join("new.txt"), "staged content").unwrap(); + git(&repo.0, &["add", "new.txt"]); + fs::remove_file(repo.0.join("new.txt")).unwrap(); + let legacy = request(&repo.0, "git.status", json!({})); + assert!(legacy["data"]["changes"].as_array().unwrap().is_empty()); + let staged = request( + &repo.0, + "git.status", + json!({"includeIndexOnlyChanges":true}), + ); + assert_eq!(staged["data"]["changes"][0]["status"], "AD"); + assert_eq!(staged["data"]["changes"][0]["staged"], true); + assert_eq!(state(&repo.0)["stagedPaths"], json!(["new.txt"])); +} + +#[test] +fn git_workspace_commit_does_not_treat_corrupt_refs_as_an_unborn_repository() { + let repo = repository("workspace-commit-corrupt-ref"); + let branch = git(&repo.0, &["symbolic-ref", "HEAD"]).trim().to_string(); + let reference = repo.0.join(".git").join(branch); + fs::create_dir_all(reference.parent().unwrap()).unwrap(); + fs::write(reference, "not-an-object-id\n").unwrap(); + assert_eq!(request(&repo.0, "git.commitState", json!({}))["ok"], false); + let history = request( + &repo.0, + "git.historyPage", + json!({"reference":"HEAD", "limit":10}), + ); + assert_eq!(history["ok"], false, "{history}"); +} + +#[test] +fn git_workspace_commit_push_checks_submodule_publication_before_updating_the_remote() { + let parent = repository("workspace-commit-push-parent"); + let child = parent.0.join("libs/B"); + init(&child); + let child_remote = Repository(temporary_root("workspace-commit-child-remote")); + let parent_remote = Repository(temporary_root("workspace-commit-parent-remote")); + for remote in [&child_remote, &parent_remote] { + fs::create_dir_all(&remote.0).unwrap(); + git(&remote.0, &["init", "--bare", "-q"]); + } + fs::write(child.join("hello.ts"), "initial").unwrap(); + git(&child, &["add", "hello.ts"]); + git(&child, &["commit", "-qm", "initial child"]); + git(&child, &["branch", "-M", "main"]); + git( + &child, + &["remote", "add", "origin", child_remote.0.to_str().unwrap()], + ); + git(&child, &["push", "-qu", "origin", "main"]); + // Let Git quote native paths; literal Windows backslashes are escapes in + // config syntax and must not be interpolated into .gitmodules directly. + git( + &parent.0, + &[ + "config", + "--file", + ".gitmodules", + "submodule.B.path", + "libs/B", + ], + ); + git( + &parent.0, + &[ + "config", + "--file", + ".gitmodules", + "submodule.B.url", + child_remote.0.to_str().unwrap(), + ], + ); + git(&parent.0, &["add", "."]); + git(&parent.0, &["commit", "-qm", "initial parent"]); + git(&parent.0, &["branch", "-M", "main"]); + git( + &parent.0, + &["remote", "add", "origin", parent_remote.0.to_str().unwrap()], + ); + git(&parent.0, &["push", "-qu", "origin", "main"]); + let remote_before = git(&parent_remote.0, &["rev-parse", "refs/heads/main"]); + fs::write(child.join("hello.ts"), "not yet published").unwrap(); + git(&child, &["commit", "-qam", "child update"]); + git(&parent.0, &["add", "libs/B"]); + git(&parent.0, &["commit", "-qm", "parent pointer"]); + let push = |root: &Path| { + request( + root, + "git.write", + json!({"operation":"push", "reference":"refs/heads/main", + "checkSubmodules":true, "expectedCommitState":state(root)}), + ) + }; + let blocked = push(&parent.0); + assert_ne!(blocked["data"]["exitCode"], 0, "{blocked}"); + assert_eq!( + git(&parent_remote.0, &["rev-parse", "refs/heads/main"]), + remote_before + ); + let pushed_child = push(&child); + assert_eq!(pushed_child["data"]["exitCode"], 0, "{pushed_child}"); + let pushed_parent = push(&parent.0); + assert_eq!(pushed_parent["data"]["exitCode"], 0, "{pushed_parent}"); + assert_eq!( + git(&parent_remote.0, &["rev-parse", "refs/heads/main"]).trim(), + state(&parent.0)["head"].as_str().unwrap() + ); +} + +#[test] +fn git_workspace_commit_rejects_a_removed_child_repository_instead_of_using_its_parent() { + let parent = repository("workspace-commit-removed-child"); + let child = parent.0.join("child"); + init(&child); + // Both repositories have valid staged content; losing the child boundary + // must invalidate its state instead of reading any state from the parent. + for root in [&parent.0, &child] { + fs::write(root.join("selected.txt"), "same staged content").unwrap(); + git(root, &["add", "selected.txt"]); + } + let expected = state(&child); + fs::remove_dir_all(child.join(".git")).unwrap(); + let result = request( + &child, + "git.write", + json!({"operation":"commit", "message":"must not commit parent", "expectedCommitState":expected}), + ); + assert!( + result["ok"] == false || !result["data"]["operationError"].is_null(), + "{result}" + ); + assert!(state(&parent.0)["head"].is_null()); + assert_eq!(request(&child, "git.commitState", json!({}))["ok"], false); +} + +#[test] +fn git_workspace_commit_rejects_a_removed_submodule_before_updating_its_pointer() { + let parent = repository("workspace-commit-removed-submodule"); + let child = parent.0.join("child"); + init(&child); + fs::write(child.join("selected.txt"), "child content").unwrap(); + git(&child, &["add", "."]); + git(&child, &["commit", "-qm", "child initial"]); + git(&parent.0, &["add", "child"]); + git(&parent.0, &["commit", "-qm", "parent initial"]); + let expected = state(&parent.0); + fs::remove_dir_all(child.join(".git")).unwrap(); + // Without boundary validation, rev-parse in the child returns the parent's + // HEAD and incorrectly permits that revision to replace the child pointer. + let result = request( + &parent.0, + "git.write", + json!({"operation":"commit", "message":"must not replace child", + "expectedCommitState":expected, + "gitlinkUpdates":[{"path":"child", "revision":expected["head"]}]}), + ); + assert_eq!( + result["data"]["operationError"]["code"], "invalid_request", + "{result}" + ); + assert_eq!(state(&parent.0), expected); +} + +fn prepare_workspace(root: &Path, repositories: &[(&str, &Path)], push: bool) -> Value { + let repositories: Vec<_> = repositories + .iter() + .map(|(id, path)| json!({"id":id,"root":path})) + .collect(); + let response = request( + root, + "git.workspaceCommitPrepare", + json!({"repositories":repositories,"message":"batch","push":push,"includeParentReferences":true}), + ); + assert_eq!(response["ok"], true, "{response}"); + response["data"].clone() +} + +fn next_workspace(root: &Path, session: Value) -> Value { + let response = request(root, "git.workspaceCommitStep", json!({"session":session})); + assert_eq!(response["ok"], true, "{response}"); + response["data"].clone() +} + +fn staged_file(root: &Path, path: &str, content: &str) { + fs::write(root.join(path), content).unwrap(); + git(root, &["add", path]); +} + +#[test] +fn git_workspace_workflow_commits_independent_repositories_and_rejects_replayed_steps() { + let repo = repository("workspace-workflow-independent"); + let child = repo.0.join("independent"); + init(&child); + staged_file(&repo.0, "parent.txt", "one"); + staged_file(&child, "child.txt", "one"); + let prepared = prepare_workspace(&repo.0, &[(".", &repo.0), ("independent", &child)], false); + assert_eq!(prepared["requiresConfirmation"], false); + let original = prepared["session"].clone(); + let first = next_workspace(&repo.0, original.clone()); + assert_eq!(first["results"]["."]["committed"], true); + assert!(state(&child)["head"].is_null()); + let replay = next_workspace(&repo.0, original); + assert_eq!(replay["results"]["."]["status"], "reviewRequired"); + let finished = next_workspace(&repo.0, first); + assert_eq!(finished["succeeded"], true, "{finished}"); + assert_eq!(git(&repo.0, &["rev-list", "--count", "HEAD"]).trim(), "1"); + assert_eq!(git(&child, &["rev-list", "--count", "HEAD"]).trim(), "1"); +} + +#[test] +fn git_workspace_workflow_updates_clean_parent_without_including_unstaged_parent_files() { + let repo = repository("workspace-workflow-parent"); + let child = repo.0.join("B"); + init(&child); + staged_file(&child, "hello.ts", "one"); + git(&child, &["commit", "-qm", "initial"]); + staged_file(&repo.0, "parent.txt", "one"); + git(&repo.0, &["add", "B"]); + git(&repo.0, &["commit", "-qm", "initial"]); + staged_file(&child, "hello.ts", "two"); + fs::write(repo.0.join("parent.txt"), "unstaged").unwrap(); + let prepared = prepare_workspace(&repo.0, &[(".", &repo.0), ("B", &child)], false); + assert_eq!(prepared["session"]["plan"]["orderedIds"], json!(["B", "."])); + let child_done = next_workspace(&repo.0, prepared["session"].clone()); + let finished = next_workspace(&repo.0, child_done); + assert_eq!(finished["succeeded"], true, "{finished}"); + assert_eq!( + git(&repo.0, &["rev-parse", "HEAD:B"]), + git(&child, &["rev-parse", "HEAD"]) + ); + assert_eq!(git(&repo.0, &["show", "HEAD:parent.txt"]), "one"); +} + +#[test] +fn git_workspace_workflow_failed_push_continues_independent_and_retries_without_duplicate_commits() +{ + let repo = repository("workspace-workflow-retry"); + let child = repo.0.join("B"); + init(&child); + staged_file(&repo.0, "a.txt", "one"); + staged_file(&child, "b.txt", "one"); + // No remote: the first repository's push fails through Git's real target resolver. + let prepared = prepare_workspace(&repo.0, &[(".", &repo.0), ("B", &child)], true); + let mut session = prepared["session"].clone(); + for _ in 0..4 { + session = next_workspace(&repo.0, session); + } + assert_eq!(session["finished"], true); + assert_eq!(session["canRetry"], true); + assert_eq!(session["results"]["."]["committed"], true); + assert_eq!(session["results"]["B"]["committed"], true); + let mut retry = prepared["session"]["plan"].clone(); + retry["previous"] = session; + let response = request(&repo.0, "git.workspaceCommitPrepare", retry); + assert_eq!(response["ok"], true, "{response}"); + let next = next_workspace(&repo.0, response["data"]["session"].clone()); + assert_eq!(next["cursor"], 1); // One push, never another commit. + assert_eq!(git(&repo.0, &["rev-list", "--count", "HEAD"]).trim(), "1"); +} + +#[test] +fn git_workspace_workflow_confirmation_detects_external_staging_without_writing() { + let repo = repository("workspace-workflow-confirm"); + staged_file(&repo.0, "one.txt", "one"); + let prepared = prepare_workspace(&repo.0, &[(".", &repo.0)], false); + staged_file(&repo.0, "two.txt", "two"); + let mut review = prepared["session"]["plan"].clone(); + review["reviewed"] = review.clone(); + let response = request(&repo.0, "git.workspaceCommitPrepare", review); + assert_eq!(response["data"]["reviewChanged"], true, "{response}"); + assert!(state(&repo.0)["head"].is_null()); +} + +#[test] +fn git_workspace_status_excludes_discovered_independent_nested_roots() { + let repo = repository("workspace-status-ownership"); + let child = repo.0.join("B"); + init(&child); + staged_file(&child, "hello.ts", "one"); + git(&child, &["commit", "-qm", "initial"]); + let response = request( + &repo.0, + "git.status", + json!({"repositoryRoots":[repo.0,child]}), + ); + assert_eq!(response["data"]["changes"], json!([]), "{response}"); + assert_eq!( + request(&repo.0, "git.status", json!({}))["data"]["changes"][0]["path"], + "B/" + ); +} + +#[test] +fn git_workspace_workflow_retains_a_completed_commit_when_cancellation_races_its_return() { + use std::sync::{Arc, Mutex}; + let repo = repository("workspace-workflow-cancel"); + let child = repo.0.join("B"); + init(&child); + staged_file(&repo.0, "a.txt", "one"); + staged_file(&child, "b.txt", "one"); + let prepared = prepare_workspace(&repo.0, &[(".", &repo.0), ("B", &child)], false); + let invocation = Arc::new(Mutex::new(None)); + let observed = invocation.clone(); + // Cancel synchronously at the native commit's completion event: the ref has + // advanced, but the JSON response and cleanup inspection have not returned. + let response=crate::execute_json_with_events(&json!({"id":"workspace-cancel-after-commit", + "command":"git.workspaceCommitStep","payload":{"session":prepared["session"]},"timeoutMilliseconds":10000}).to_string(), + Arc::new(move |event| { + let event:Value=serde_json::from_str(event).unwrap(); + if event["type"]=="started" && event["arguments"].as_array().is_some_and(|args| args.iter().any(|arg| arg=="commit")) { + *observed.lock().unwrap()=event["invocationId"].as_u64(); + } + if event["type"]=="finished" && event["invocationId"].as_u64()==*observed.lock().unwrap() { + assert!(crate::cancel_operation("workspace-cancel-after-commit")); + } + })); + let response: Value = serde_json::from_str(&response).unwrap(); + assert!(invocation.lock().unwrap().is_some()); + assert_eq!(response["ok"], true, "{response}"); + assert_eq!( + response["data"]["results"]["."]["committed"], true, + "{response}" + ); + assert!(!crate::cancel_operation("workspace-cancel-after-commit")); + let finished = next_workspace(&repo.0, response["data"].clone()); + assert_eq!(finished["succeeded"], true, "{finished}"); + assert_eq!(git(&repo.0, &["rev-list", "--count", "HEAD"]).trim(), "1"); +} + +#[test] +#[cfg(unix)] +fn git_workspace_status_normalizes_native_repository_binding_aliases() { + let repo = repository("workspace-status-alias"); + let child = repo.0.join("B"); + init(&child); + staged_file(&child, "hello.ts", "one"); + git(&child, &["commit", "-qm", "initial"]); + let alias = repo.0.join("binding-link"); + std::os::unix::fs::symlink(&child, &alias).unwrap(); + let response = request( + &repo.0, + "git.status", + json!({"repositoryRoots":[repo.0,alias]}), + ); + assert_eq!(response["ok"], true, "{response}"); + let paths: Vec<_> = response["data"]["changes"] + .as_array() + .unwrap() + .iter() + .map(|c| c["path"].as_str().unwrap()) + .collect(); + // The parent still owns the symlink itself; only B's independent files are excluded. + assert_eq!(paths, ["binding-link"]); +} diff --git a/rust/lithe-core/src/tests/mod.rs b/rust/lithe-core/src/tests/mod.rs index 1bfe83350..0e1c75ede 100644 --- a/rust/lithe-core/src/tests/mod.rs +++ b/rust/lithe-core/src/tests/mod.rs @@ -5,6 +5,7 @@ mod git_fetch; mod git_history_rewrite; mod git_patch_exchange; mod git_repository_setup; +mod git_workspace_commit; mod github; mod languages; mod mybatis; diff --git a/rust/lithe-git-host/tests/authentication.rs b/rust/lithe-git-host/tests/authentication.rs index 8164c9c9f..fbab62a7a 100644 --- a/rust/lithe-git-host/tests/authentication.rs +++ b/rust/lithe-git-host/tests/authentication.rs @@ -26,9 +26,6 @@ fn prompt_response_is_single_use_and_cancellation_drops_pending_challenges() { assert_eq!(env["SSH_ASKPASS"], env["GIT_ASKPASS"]); assert_eq!(env["LITHE_GIT_ASKPASS_MODE"], "1"); let mut untrusted = TcpStream::connect(&env["LITHE_GIT_ASKPASS_ADDRESS"]).unwrap(); - untrusted - .set_read_timeout(Some(Duration::from_secs(1))) - .unwrap(); untrusted .set_write_timeout(Some(Duration::from_secs(1))) .unwrap(); @@ -38,26 +35,39 @@ fn prompt_response_is_single_use_and_cancellation_drops_pending_challenges() { serde_json::json!({"token": "wrong-fixture-token", "prompt": "untrusted"}) ) .unwrap(); + untrusted.set_nonblocking(true).unwrap(); let mut peer = TcpStream::connect(&env["LITHE_GIT_ASKPASS_ADDRESS"]).unwrap(); peer.set_read_timeout(Some(Duration::from_secs(1))).unwrap(); peer.set_write_timeout(Some(Duration::from_secs(1))) .unwrap(); writeln!(peer, "{}", serde_json::json!({"token": env["LITHE_GIT_ASKPASS_TOKEN"], "prompt": "Password for fixture:"})).unwrap(); - let deadline = Instant::now() + Duration::from_secs(1); - let challenge = loop { - if let Some(challenge) = session.poll().unwrap().pop() { - break challenge; + let deadline = Instant::now() + Duration::from_secs(3); + let mut challenge = None; + let mut rejection_observed = false; + // The two TCP frames may arrive in either order or in fragments. Continue + // pumping the owned session until both the prompt and rejection complete; + // blocking on the rejected peer early would stop the only transport driver. + while challenge.is_none() || !rejection_observed { + for received in session.poll().unwrap() { + assert!(challenge.is_none(), "Unexpected additional AskPass prompt"); + challenge = Some(received); + } + if !rejection_observed { + match untrusted.read(&mut [0u8; 1]) { + Ok(0) => rejection_observed = true, + Ok(_) => panic!("Untrusted peer received a response"), + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {} + Err(error) => panic!("Could not observe untrusted peer rejection: {error}"), + } } assert!( Instant::now() < deadline, - "AskPass frame was not received before deadline" + "AskPass prompt and peer rejection did not complete before deadline" ); std::thread::yield_now(); - }; + } + let challenge = challenge.unwrap(); assert!(challenge.secret); - let mut rejected = String::new(); - untrusted.read_to_string(&mut rejected).unwrap(); - assert!(rejected.is_empty()); assert!(!respond( &challenge.request_id, Some("invalid\nresponse".into()) diff --git a/shared/contracts/application-boundary.md b/shared/contracts/application-boundary.md index f24a6e3cf..0db6dc985 100644 --- a/shared/contracts/application-boundary.md +++ b/shared/contracts/application-boundary.md @@ -25,7 +25,7 @@ verification scripts are the executable source of boundary checks. | Workspace | visible snapshot, relative paths, file metadata, deterministic ordering | workspace root selection, native dialogs, and watchers | | Documents | relative-path validation, decoded text, selected encoding, dirty/save state, and conflict outcome | native byte conversion, atomic write, and external-change notifications | | Search | query matching, deterministic result ordering, symbols, and replacement preview | workspace lifecycle and optional index persistence | -| Git | changes, commits, branches, diffs, reviewed history actions and recovery, worktree listing and safe management, worktree-aware PR publication context, validation, and mutation results | Git executable discovery, credentials, process environment, opening checkout paths | +| Git | workspace commit plans, dependency ordering, guarded steps and partial-success retry, changes, commits, branches, diffs, reviewed history actions and recovery, worktree listing and safe management, worktree-aware PR publication context, validation, and mutation results | Git executable discovery, credentials, process environment, opening checkout paths | | GitHub | remote parsing, trusted request plans, normalized branch comparisons and pull requests/reviews/comments, deterministic ordering, and stable errors | OAuth configuration, HTTPS, browser opening, and operating-system credential storage | | [AI commit messages](ai-commit.md) | provider configuration parsing, commit rules, bounded diff evidence, request plans, and response text | local configuration discovery, credentials, HTTP, cancellation, and draft UI | | Agent conversation (ACP) | supported-agent catalog, Node.js/npm detection, adapter install with the user's npm and numbers-only live download progress, CLI provenance and owner-preserving updates, per-agent API-key and model delivery, ACP v1 connection per workspace and agent, agent-owned session history (list/load), session config options, user-selected file references as ACP resource links, streamed tool evidence, permission decisions, acknowledged cancellation with bounded recovery, and process-tree lifecycle in the shared Rust host | provider and agent settings, API-key storage, credential-independent local default-model reading through AI configuration ports, data directory, workspace selection, module enablement, conversation UI, workspace/Agent-scoped local history annotations (favorites, title overrides and recoverable hidden rows), and user-selected Markdown export destinations | diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 405f861c1..be8c1715c 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -335,6 +335,7 @@ package manager owns the download and Lithe does not infer bytes from logs. | `git.initialize` | Initialize a directory outside existing repositories without staging or committing | | `git.configureIdentity` | Save or clear one local/global `user.name` or `user.email` override | | `git.status` | Resolve the repository, current branch, and working-tree changes | +| `git.commitState` | Read exact HEAD, symbolic branch and index preconditions for a workspace commit | | `git.watchContext` | Resolve the repository and absolute Git metadata roots needed by native file watchers | | `git.worktrees` | Return deterministic registered-worktree metadata without scanning each checkout | | `git.pullRequestContext` | Resolve worktree-aware PR branch defaults, publication state, and uncommitted-change state | @@ -2073,3 +2074,87 @@ failure is visible but does not globally block unrelated targets; callers still build the selected target before launching. A successful preparation does not promise compilation success. Shared examples live in `shared/fixtures/lsp/project-preparation-v1.json`. + +### Workspace commit preconditions + +`git.commitState` accepts `{ root }` and returns `{ head, branch, indexEntries, +gitlinks, stagedPaths, conflictedPaths }`. `head` is null only for an unborn branch; `branch` is +null for detached HEAD. `indexEntries` is Git's opaque NUL-delimited staged index +listing, including blob IDs and conflict stages; clients compare it without +parsing it. `gitlinks` lists stage-0 mode-160000 entries as `{ path, revision }`. +Read failures are errors, never an empty relationship list. +The requested root must still be Git's exact working-tree root. Removing a +nested repository's metadata must fail instead of falling back to its parent; +gitlink updates also verify the child boundary before reading its HEAD. + +`git.write` / `commit` optionally accepts `expectedCommitState` and +`gitlinkUpdates: [{ path, revision }]`. Gitlink updates cannot accompany other +operations or path-selected commits. Push also accepts `expectedCommitState` +to reject a changed repository under its writer lease. Workspace pushes set +`checkSubmodules: true`, invoking Git's `--recurse-submodules=check` so missing child +commits block a parent push even when only the parent pointer was selected. Under the existing repository writer lease, +Core verifies HEAD/index, validates all child HEAD revisions, then updates only +those parent index entries in a single `update-index --index-info` transaction +before the regular commit. Any changed precondition returns `invalid_request` +through the existing operation error envelope. Unrelated unstaged parent files +are not added. A failing hook may leave the pointer staged: clients must retain +partial progress and re-read state before retrying. External Git processes do +not participate in Lithe's lease; cross-repository commits are not atomic. + +`git.status.changes[]` additionally carries optional `submodule` with +`commitChanged`, `trackedChanges`, and `untrackedChanges`, normalized from Git +porcelain v2. The existing two-character `status` remains compatible. The optional request flag +`includeIndexOnlyChanges` retains staged additions deleted only from the working +tree (`AD`); both products enable it so every staged file remains visible. Omission +preserves the legacy final-worktree projection. Child dirt +alone is informational in the parent; only a changed commit pointer (or an +already-staged change) is eligible for the parent's staging checkbox. + +`git.status` accepts optional `repositoryRoots` (native bindings of discovered +repositories). Untracked paths owned by a nested root are excluded from the +parent list. Each change returns `canToggleStaging`; platforms render that +eligibility instead of reinterpreting submodule dirt. + +`git.workspaceCommitPrepare` owns the complete multi-repository policy. It accepts +`repositories: [{ id, root }]`, `message`, `amend`, `push`, +`includeParentReferences`, optional `previous` session for retry, and optional +`reviewed` plan for confirmation. `id` is a workspace-relative path with `/` +separators (`..` is allowed for enclosing repositories). Windows manually selected +roots on another volume use a stable `external//` virtual +workspace ID. `root` is the native +execution binding, never a portable identity. The response is +`{ session, reviewChanged, requiresConfirmation }`. Core reads all repositories, +finds real gitlink relationships, includes clean parents when requested, orders +children first, and adds push-only child work where needed. Cycles fail closed. +A changed reviewed plan must be displayed and confirmed again before any step. + +`git.workspaceCommitStep` accepts `{ session }` and returns the next session, +executing at most one commit or push. Session fields are `plan`, last observed +`states`, per-ID `results`, `blocked`, `cursor`, `commandFailed`, `finished`, +`succeeded`, and `canRetry`. They are Core-owned continuations: clients return +them unchanged and must not independently choose roots, reorder work, or infer +completion. `plan` includes bindings, options, `orderedIds`, propagation and +dependency relations, reviewed states, `committedIds`, and `pendingPushIds`. +Each result separates `committed`, `pushed`, stable `status`, and Git `detail`. +Status keys are `pending`, `notIncluded`, `waitingForSubmodule`, `reviewRequired`, +`committed`, `committedPushPending`, `committedAndPushed`, `commitFailed`, +`pushFailed`, `headAdvanced`, and `outcomeUnknown`. + +Each step uses a fresh host operation ID and the existing Git writer lease, +process runner, authentication and event stream. Cancellation blocks dependent +parents while independent roots may continue under subsequent operation IDs. +A read-only cleanup deadline of five seconds reconciles a commit whose HEAD may +have advanced before cancellation. This command preserves the reconciled session +instead of replacing it with a generic late-cancellation envelope. Transport +errors before a continuation is returned must never be treated as success. +Retry re-inspects current state, preserves completed commits, and re-pushes +externally advanced completed branches before updating dependent parents. + +Sessions own no background resources and are retained only for the current +workspace lifetime. Native clients discard old responses after workspace changes, +show confirmation/progress, and drive steps until `finished`. macOS uses this +shared workflow, as does Windows through its workspace-scoped continuation adapter +and real staging checkboxes. The native products must not duplicate planning or retry policy. +See `shared/fixtures/git/workspace-commit-v1.json` for primitive payloads and +`shared/fixtures/git/workspace-commit-workflow-v1.json` for the complete planning +and continuation fixture consumed by Rust, Swift, TypeScript and Tauri adapter tests. diff --git a/shared/fixtures/git/workspace-commit-v1.json b/shared/fixtures/git/workspace-commit-v1.json new file mode 100644 index 000000000..3e1809709 --- /dev/null +++ b/shared/fixtures/git/workspace-commit-v1.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "commitState": { + "head": "1111111111111111111111111111111111111111", + "branch": "refs/heads/main", + "indexEntries": "160000 2222222222222222222222222222222222222222 0\tlibs/B\u0000", + "gitlinks": [{ "path": "libs/B", "revision": "2222222222222222222222222222222222222222" }], + "stagedPaths": [], + "conflictedPaths": [] + }, + "dirtySubmodule": { "commitChanged": false, "trackedChanges": true, "untrackedChanges": false }, + "advancedSubmodule": { "commitChanged": true, "trackedChanges": false, "untrackedChanges": false } +} diff --git a/shared/fixtures/git/workspace-commit-workflow-v1.json b/shared/fixtures/git/workspace-commit-workflow-v1.json new file mode 100644 index 000000000..11a89dce6 --- /dev/null +++ b/shared/fixtures/git/workspace-commit-workflow-v1.json @@ -0,0 +1,156 @@ +{ + "request": { + "repositories": [ + { + "id": "A", + "root": "/workspace/A" + }, + { + "id": "A/B", + "root": "/workspace/A/B" + } + ], + "message": "commit", + "amend": false, + "push": true, + "includeParentReferences": true + }, + "states": { + "A": { + "head": "initial", + "branch": "refs/heads/main", + "indexEntries": "", + "gitlinks": [ + { + "path": "B", + "revision": "initial" + } + ], + "stagedPaths": [], + "conflictedPaths": [] + }, + "A/B": { + "head": "initial", + "branch": "refs/heads/main", + "indexEntries": "hello.ts", + "gitlinks": [], + "stagedPaths": [ + "hello.ts" + ], + "conflictedPaths": [] + } + }, + "preparation": { + "session": { + "plan": { + "repositories": [ + { + "id": "A", + "root": "/workspace/A" + }, + { + "id": "A/B", + "root": "/workspace/A/B" + } + ], + "message": "commit", + "amend": false, + "push": true, + "includeParentReferences": true, + "isRetry": false, + "orderedIds": [ + "A/B", + "A" + ], + "propagatedRelations": [ + { + "parent": "A", + "child": "A/B", + "path": "B" + } + ], + "dependencyRelations": [ + { + "parent": "A", + "child": "A/B", + "path": "B" + } + ], + "states": { + "A": { + "head": "initial", + "branch": "refs/heads/main", + "indexEntries": "", + "gitlinks": [ + { + "path": "B", + "revision": "initial" + } + ], + "stagedPaths": [], + "conflictedPaths": [] + }, + "A/B": { + "head": "initial", + "branch": "refs/heads/main", + "indexEntries": "hello.ts", + "gitlinks": [], + "stagedPaths": [ + "hello.ts" + ], + "conflictedPaths": [] + } + }, + "committedIds": [], + "pendingPushIds": [] + }, + "states": { + "A": { + "head": "initial", + "branch": "refs/heads/main", + "indexEntries": "", + "gitlinks": [ + { + "path": "B", + "revision": "initial" + } + ], + "stagedPaths": [], + "conflictedPaths": [] + }, + "A/B": { + "head": "initial", + "branch": "refs/heads/main", + "indexEntries": "hello.ts", + "gitlinks": [], + "stagedPaths": [ + "hello.ts" + ], + "conflictedPaths": [] + } + }, + "results": { + "A": { + "committed": false, + "pushed": false, + "status": "pending", + "detail": "" + }, + "A/B": { + "committed": false, + "pushed": false, + "status": "pending", + "detail": "" + } + }, + "blocked": [], + "cursor": 0, + "commandFailed": false, + "finished": false, + "succeeded": false, + "canRetry": true + }, + "reviewChanged": false, + "requiresConfirmation": true + } +} \ No newline at end of file diff --git a/shared/platform-feature-matrix.json b/shared/platform-feature-matrix.json index f8308ed12..544ccfc4b 100644 --- a/shared/platform-feature-matrix.json +++ b/shared/platform-feature-matrix.json @@ -1,6 +1,6 @@ { "schemaVersion": 3, - "lastReviewed": "2026-09-25", + "lastReviewed": "2026-09-27", "review": { "status": "initial-static-inventory", "method": "根据 macOS Views/Application/Services、Windows features/extensions 和共享契约的代码入口进行初版盘点;未替代真实运行验收。", @@ -67,7 +67,9 @@ "windows": { "implementationStatus": "missing", "verificationStatus": "not-applicable", - "evidence": ["windows/tauri/src/features"] + "evidence": [ + "windows/tauri/src/features" + ] }, "owner": "Agent", "verification": "使用无本机模块配置的 macOS 测试账户启动并打开项目,确认 Agent 入口可见、默认开关关闭、面板显示设置指引;开启再关闭后入口和面板保留且不重复。确认读取入口不调用模块 factory、关闭状态无 Agent 连接或进程。Windows 尚无 Agent 对话 UI。" @@ -547,6 +549,39 @@ "owner": "Git", "verification": "修改、暂存、取消暂存并提交文件,确认状态、提交消息和错误回显。 Windows 另验证原生 UNC/verbatim 输入、中文/空格/长路径仓库往返及 linked worktree;末尾点/空格必须明确拒绝,外部提交/切换须触发元数据刷新。" }, + { + "id": "git-multi-repository-change-groups", + "area": "版本控制", + "group": "Git", + "capability": "多仓库变更折叠分组", + "macos": { + "evidence": [ + "macos/Sources/Lithe/Views/Git/ChangesSidebarView.swift", + "macos/Sources/Lithe/Views/Git/GitChangeSectionsCache.swift", + "macos/Sources/LitheGitModule/Models/GitModels.swift", + "rust/lithe-core/src/git/mod.rs" + ], + "implementationStatus": "implemented", + "verificationStatus": "pending" + }, + "windows": { + "implementationStatus": "implemented", + "verificationStatus": "pending", + "evidence": [ + "windows/tauri/src/features/git/components/status/git-status-panel.tsx", + "windows/tauri/src/features/git/components/git-commit-panel.tsx", + "windows/tauri/src/features/git/components/git-workspace-commit-review.tsx", + "windows/tauri/src/features/git/services/git-workspace-commit-workflow.ts", + "windows/tauri/src/features/git/services/git-workspace-commit-workflow.test.ts", + "windows/tauri/src/features/git/components/status/git-workspace-status-panel.test.tsx", + "windows/tauri/src-tauri/src/platform.rs", + "shared/fixtures/git/workspace-commit-workflow-v1.json" + ] + }, + "owner": "Git", + "verification": "在同一工作区打开多个 Git 仓库,确认变更按仓库折叠分组;只剩一个仓库有变更时仍显示仓库名。仓库级和文件级勾选与 Git 暂存区同步;子模块只有未提交文件时显示提示,不能勾选未变化的引用。", + "notes": "两端直接消费共享 Rust Core 的计划、依赖排序、执行结果与重试。平台负责真实暂存勾选、确认与结果界面、认证取消和项目生命周期。原生界面实测待平台验收。" + }, { "id": "git-branches-remotes", "area": "版本控制", @@ -2330,6 +2365,123 @@ }, "owner": "PHP Support", "verification": "macOS 运行 PHP 文件和单条/整套 PHPUnit;Windows 运行字符串/数组 Composer script 和整套 PHPUnit。禁用后菜单消失、运行进程退出,特殊文件名保持原样。Windows 暂不支持单方法发现。" + }, + { + "id": "git-workspace-staged-commit", + "area": "版本控制", + "group": "Git", + "capability": "按文件所属仓库批量提交及推送,保留每仓库结果", + "macos": { + "implementationStatus": "implemented", + "verificationStatus": "pending", + "evidence": [ + "macos/Sources/LitheGitModule/Application/GitFeatureModel+WorkspaceCommit.swift", + "macos/Sources/Lithe/Views/Git/CommitAreaView.swift", + "macos/Tests/LitheGitModuleTests/GitModuleTests.swift", + "rust/lithe-core/src/git/commit_state.rs", + "rust/lithe-core/src/tests/git_workspace_commit.rs", + "rust/lithe-core/src/git/workspace_commit.rs", + "rust/lithe-core/src/git/workspace_commit/tests.rs", + "shared/fixtures/git/workspace-commit-workflow-v1.json" + ] + }, + "windows": { + "implementationStatus": "implemented", + "verificationStatus": "pending", + "evidence": [ + "windows/tauri/src/features/git/components/status/git-status-panel.tsx", + "windows/tauri/src/features/git/components/git-commit-panel.tsx", + "windows/tauri/src/features/git/components/git-workspace-commit-review.tsx", + "windows/tauri/src/features/git/services/git-workspace-commit-workflow.ts", + "windows/tauri/src/features/git/services/git-workspace-commit-workflow.test.ts", + "windows/tauri/src/features/git/components/status/git-workspace-status-panel.test.tsx", + "windows/tauri/src-tauri/src/platform.rs", + "shared/fixtures/git/workspace-commit-workflow-v1.json", + "windows/tauri/src/features/git/runtime/git-workspace-commit-host.tsx", + "windows/tauri/src/features/git/runtime/git-workspace-commit-host.test.tsx" + ] + }, + "owner": "Git", + "verification": "勾选两个独立仓库文件,一次提交并推送,验证各自 HEAD 和远程;停止一个操作后其余独立仓库继续,已成功仓库不会回滚。", + "notes": "两端直接消费共享 Rust Core 的计划、依赖排序、执行结果与重试。平台负责真实暂存勾选、确认与结果界面、认证取消和项目生命周期。原生界面实测待平台验收。" + }, + { + "id": "git-submodule-commit-plan", + "area": "版本控制", + "group": "Git", + "capability": "子模块先提交与推送、自动更新父引用、计划变化再次确认", + "macos": { + "implementationStatus": "implemented", + "verificationStatus": "pending", + "evidence": [ + "macos/Sources/LitheGitModule/Application/GitFeatureModel+WorkspaceCommit.swift", + "macos/Sources/Lithe/Views/Git/CommitAreaView.swift", + "macos/Tests/LitheGitModuleTests/GitModuleTests.swift", + "rust/lithe-core/src/git/commit_state.rs", + "rust/lithe-core/src/tests/git_workspace_commit.rs", + "rust/lithe-core/src/git/workspace_commit.rs", + "rust/lithe-core/src/git/workspace_commit/tests.rs", + "shared/fixtures/git/workspace-commit-workflow-v1.json" + ] + }, + "windows": { + "implementationStatus": "implemented", + "verificationStatus": "pending", + "evidence": [ + "windows/tauri/src/features/git/components/status/git-status-panel.tsx", + "windows/tauri/src/features/git/components/git-commit-panel.tsx", + "windows/tauri/src/features/git/components/git-workspace-commit-review.tsx", + "windows/tauri/src/features/git/services/git-workspace-commit-workflow.ts", + "windows/tauri/src/features/git/services/git-workspace-commit-workflow.test.ts", + "windows/tauri/src/features/git/components/status/git-workspace-status-panel.test.tsx", + "windows/tauri/src-tauri/src/platform.rs", + "shared/fixtures/git/workspace-commit-workflow-v1.json", + "windows/tauri/src/features/git/runtime/git-workspace-commit-host.tsx", + "windows/tauri/src/features/git/runtime/git-workspace-commit-host.test.tsx" + ] + }, + "owner": "Git", + "verification": "只勾选孙仓库文件,确认计划自动列出父祖仓库的引用更新且可关闭;确认期间改变暂存内容或分支,必须显示新计划再次确认。父仓库未暂存文件不能被带入;只勾选父引用时先推送子仓库现有提交,Git 发布检查应拒绝未发布的子引用。子仓库元数据被外部删除后必须拒绝读取和写入,不能向上回退父仓库。", + "notes": "两端直接消费共享 Rust Core 的计划、依赖排序、执行结果与重试。平台负责真实暂存勾选、确认与结果界面、认证取消和项目生命周期。原生界面实测待平台验收。" + }, + { + "id": "git-workspace-commit-retry", + "area": "版本控制", + "group": "Git", + "capability": "失败后只重试未完成的提交或推送步骤", + "macos": { + "implementationStatus": "implemented", + "verificationStatus": "pending", + "evidence": [ + "macos/Sources/LitheGitModule/Application/GitFeatureModel+WorkspaceCommit.swift", + "macos/Sources/Lithe/Views/Git/CommitAreaView.swift", + "macos/Tests/LitheGitModuleTests/GitModuleTests.swift", + "rust/lithe-core/src/git/commit_state.rs", + "rust/lithe-core/src/tests/git_workspace_commit.rs", + "rust/lithe-core/src/git/workspace_commit.rs", + "rust/lithe-core/src/git/workspace_commit/tests.rs", + "shared/fixtures/git/workspace-commit-workflow-v1.json" + ] + }, + "windows": { + "implementationStatus": "implemented", + "verificationStatus": "pending", + "evidence": [ + "windows/tauri/src/features/git/components/status/git-status-panel.tsx", + "windows/tauri/src/features/git/components/git-commit-panel.tsx", + "windows/tauri/src/features/git/components/git-workspace-commit-review.tsx", + "windows/tauri/src/features/git/services/git-workspace-commit-workflow.ts", + "windows/tauri/src/features/git/services/git-workspace-commit-workflow.test.ts", + "windows/tauri/src/features/git/components/status/git-workspace-status-panel.test.tsx", + "windows/tauri/src-tauri/src/platform.rs", + "shared/fixtures/git/workspace-commit-workflow-v1.json", + "windows/tauri/src/features/git/runtime/git-workspace-commit-host.tsx", + "windows/tauri/src/features/git/runtime/git-workspace-commit-host.test.tsx" + ] + }, + "owner": "Git", + "verification": "让子仓库推送失败,确认父仓库被阻塞、独立仓库成功;重试计划应显示子仓库只推送,验证子仓库提交数不增加,随后才提交和推送父仓库。", + "notes": "两端直接消费共享 Rust Core 的计划、依赖排序、执行结果与重试。平台负责真实暂存勾选、确认与结果界面、认证取消和项目生命周期。原生界面实测待平台验收。" } ] } diff --git a/windows/tauri/src-tauri/src/platform.rs b/windows/tauri/src-tauri/src/platform.rs index fbb962815..eec98c44e 100644 --- a/windows/tauri/src-tauri/src/platform.rs +++ b/windows/tauri/src-tauri/src/platform.rs @@ -30,25 +30,7 @@ pub async fn platform_invoke( .map(ToString::to_string) .unwrap_or_else(|| format!("windows-{}", REQUEST_ID.fetch_add(1, Ordering::Relaxed))); let (core_command, payload) = translate(&command, args)?; - let interactive_git = matches!( - core_command.as_str(), - "git.write" - | "git.commit" - | "git.apply" - | "git.patchApply" - | "git.rebaseStart" - | "git.rebaseControl" - | "git.executionConfigure" - | "git.initialize" - | "git.configureIdentity" - ) || matches!( - command.as_str(), - "git_add_remote" - | "git_remove_remote" - | "git_create_tag" - | "git_delete_tag" - | "git.command" - ); + let interactive_git = is_interactive_git(&core_command, &command); // Observe every Git request at the shared boundary. Core suppresses its // parser-only probes, so new operation entry points cannot miss the console. let observe_git = observes_git_execution(&core_command); @@ -100,6 +82,29 @@ pub async fn platform_invoke( core_response(&envelope, preserve_history_rewrite, preserve_stash_restore) } +fn is_interactive_git(core_command: &str, command: &str) -> bool { + matches!( + core_command, + "git.write" + | "git.workspaceCommitStep" + | "git.commit" + | "git.apply" + | "git.patchApply" + | "git.rebaseStart" + | "git.rebaseControl" + | "git.executionConfigure" + | "git.initialize" + | "git.configureIdentity" + ) || matches!( + command, + "git_add_remote" + | "git_remove_remote" + | "git_create_tag" + | "git_delete_tag" + | "git.command" + ) +} + fn observes_git_execution(command: &str) -> bool { command.starts_with("git.") && command != "git.authRespond" @@ -752,6 +757,8 @@ mod tests { "git.executionConfigure", "git.snapshot", "git.futureOperation", + "git.workspaceCommitPrepare", + "git.workspaceCommitStep", ] { assert!(super::observes_git_execution(command), "{command}"); } @@ -760,6 +767,46 @@ mod tests { assert!(!super::observes_git_execution("workspace.scan")); } + #[test] + fn workspace_commit_steps_use_interactive_authentication_and_write_timeout() { + assert!(super::is_interactive_git( + "git.workspaceCommitStep", + "git.workspaceCommitStep" + )); + assert!(!super::is_interactive_git( + "git.workspaceCommitPrepare", + "git.workspaceCommitPrepare" + )); + } + + #[test] + fn workspace_commit_continuations_and_partial_outcomes_cross_the_native_boundary_unchanged() { + let fixture: serde_json::Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/git/workspace-commit-workflow-v1.json" + )) + .unwrap(); + let mut session = fixture["preparation"]["session"].clone(); + session["commandFailed"] = serde_json::json!(true); + let (command, payload) = super::translate( + "git.workspaceCommitStep", + serde_json::json!({ + "operationId": "step", "session": session + }), + ) + .unwrap(); + assert_eq!(command, "git.workspaceCommitStep"); + assert_eq!(payload, serde_json::json!({ "session": session })); + assert_eq!( + super::core_response( + &serde_json::json!({ "ok": true, "data": session }), + false, + false + ) + .unwrap(), + session + ); + } + use super::{ command_data_error, core_response, is_reviewed_history_rewrite, local_branch_reference, translate, diff --git a/windows/tauri/src/features/git/api/git-remotes-api.test.ts b/windows/tauri/src/features/git/api/git-remotes-api.test.ts index d145a8fa0..29358287f 100644 --- a/windows/tauri/src/features/git/api/git-remotes-api.test.ts +++ b/windows/tauri/src/features/git/api/git-remotes-api.test.ts @@ -93,7 +93,10 @@ describe("Git remote Pull API", () => { pullResult: { status: "cancelled" }, }); expect(invoke).not.toHaveBeenCalledWith("git_pull", expect.anything()); - expect(invoke).toHaveBeenCalledWith("git_status", { repoPath: "C:/repo" }); + expect(invoke).toHaveBeenCalledWith( + "git_status", + expect.objectContaining({ repoPath: "C:/repo" }), + ); expect(invoke).toHaveBeenCalledWith("git_log", { repoPath: "C:/repo", limit: 50 }); expect(invoke).toHaveBeenCalledWith("git_branches", { repoPath: "C:/repo" }); expect(invoke).toHaveBeenCalledWith("git_get_remotes", { repoPath: "C:/repo" }); diff --git a/windows/tauri/src/features/git/api/git-status-api.test.ts b/windows/tauri/src/features/git/api/git-status-api.test.ts index f8ba942f5..bcb49ac8e 100644 --- a/windows/tauri/src/features/git/api/git-status-api.test.ts +++ b/windows/tauri/src/features/git/api/git-status-api.test.ts @@ -56,18 +56,12 @@ afterEach(() => invokeSpy.mockRestore()); describe("Git status batch mutations", () => { const expectSingleGitWrite = () => { - expect( - invoke.mock.calls.filter(([command]) => command === "git.write"), - ).toHaveLength(1); + expect(invoke.mock.calls.filter(([command]) => command === "git.write")).toHaveLength(1); }; test("stages a directory selection with one shared Core invocation", async () => { await expect( - setFilesStaged( - "C:/repo", - ["src/first.ts", "src/second.ts", "src/first.ts"], - true, - ), + setFilesStaged("C:/repo", ["src/first.ts", "src/second.ts", "src/first.ts"], true), ).resolves.toBe(true); expectSingleGitWrite(); @@ -79,9 +73,9 @@ describe("Git status batch mutations", () => { }); test("unstages every selected path with one shared Core invocation", async () => { - await expect( - setFilesStaged("C:/repo", ["src/first.ts", "src/second.ts"], false), - ).resolves.toBe(true); + await expect(setFilesStaged("C:/repo", ["src/first.ts", "src/second.ts"], false)).resolves.toBe( + true, + ); expectSingleGitWrite(); expect(invoke).toHaveBeenLastCalledWith("git.write", { @@ -105,9 +99,7 @@ describe("Git status batch mutations", () => { }); test("adds selected paths to the repository gitignore", async () => { - await expect( - addPathsToGitignore("C:/repo", ["generated/", "local.env"]), - ).resolves.toBe(true); + await expect(addPathsToGitignore("C:/repo", ["generated/", "local.env"])).resolves.toBe(true); expectSingleGitWrite(); expect(invoke).toHaveBeenLastCalledWith("git.write", { @@ -118,9 +110,7 @@ describe("Git status batch mutations", () => { }); test("adds selected paths to the local Git exclude file", async () => { - await expect( - addPathsToLocalGitExclude("C:/repo", ["generated/"]), - ).resolves.toBe(true); + await expect(addPathsToLocalGitExclude("C:/repo", ["generated/"])).resolves.toBe(true); expectSingleGitWrite(); expect(invoke).toHaveBeenLastCalledWith("git.write", { @@ -134,7 +124,10 @@ describe("Git status batch mutations", () => { describe("Workspace Git status", () => { test("aggregates changed files from every discovered repository", async () => { await expect( - getWorkspaceGitStatus(["C:/workspace/service-a", "C:/workspace/service-b"], "C:/workspace/service-b"), + getWorkspaceGitStatus( + ["C:/workspace/service-a", "C:/workspace/service-b"], + "C:/workspace/service-b", + ), ).resolves.toEqual({ branch: "develop", ahead: 0, @@ -161,11 +154,18 @@ describe("Workspace Git status", () => { }); }); +test("workspace status sends all roots to Core for file ownership and preserves the index", async () => { + const roots = ["C:/workspace/service-a", "C:/workspace/service-b"]; + await getWorkspaceGitStatus(roots); + const queries = invoke.mock.calls.filter(([command]) => command === "git_status"); + expect(queries).toHaveLength(2); + for (const [, args] of queries) + expect(args).toMatchObject({ repositoryRoots: roots, includeIndexOnlyChanges: true }); +}); + describe("Git status review diffs", () => { test("reviews a partially staged path against HEAD before selected-path commit", async () => { - await expect( - getWorkingTreePathDiff("C:/repo", "src/partially-staged.ts"), - ).resolves.toBeNull(); + await expect(getWorkingTreePathDiff("C:/repo", "src/partially-staged.ts")).resolves.toBeNull(); expect(invoke).toHaveBeenLastCalledWith("git_diff_file", { repoPath: "C:/repo", @@ -177,7 +177,9 @@ describe("Git status review diffs", () => { function deferred() { let resolve!: () => void; - const promise = new Promise((done) => { resolve = done; }); + const promise = new Promise((done) => { + resolve = done; + }); return { promise, resolve }; } @@ -268,22 +270,26 @@ describe("Git staging write coordination", () => { describe("Git status query failures", () => { test("bootstrap preserves root-relative paths while reading every discovered repository", async () => { const snapshot = await getWorkspaceRootGitStatus("C:/repo", [ - "C:/repo", "C:/workspace/service-a", "C:/workspace/service-b", + "C:/repo", + "C:/workspace/service-a", + "C:/workspace/service-b", ]); expect(snapshot?.files.map((file) => file.path)).toEqual(["src/App.tsx"]); const queriedRepos = invoke.mock.calls .filter(([command]) => command === "git_status") .map(([, args]) => args?.repoPath); expect(queriedRepos.sort()).toEqual([ - "C:/repo", "C:/workspace/service-a", "C:/workspace/service-b", + "C:/repo", + "C:/workspace/service-a", + "C:/workspace/service-b", ]); }); test("bootstrap reports a child repository failure even when the root status succeeds", async () => { unavailableRepo = "C:/workspace/service-b"; - await expect(getWorkspaceRootGitStatus("C:/repo", [ - "C:/repo", "C:/workspace/service-b", - ])).rejects.toThrow("no snapshot"); + await expect( + getWorkspaceRootGitStatus("C:/repo", ["C:/repo", "C:/workspace/service-b"]), + ).rejects.toThrow("no snapshot"); }); test("rejects an empty snapshot for a selected repository and recovers on retry", async () => { @@ -295,9 +301,9 @@ describe("Git status query failures", () => { test("does not return a partial workspace when one repository is unavailable", async () => { unavailableRepo = "C:/workspace/service-b"; - await expect(getWorkspaceGitStatus([ - "C:/workspace/service-a", "C:/workspace/service-b", - ])).rejects.toThrow("no snapshot"); + await expect( + getWorkspaceGitStatus(["C:/workspace/service-a", "C:/workspace/service-b"]), + ).rejects.toThrow("no snapshot"); }); test("propagates native query failures to workspace refresh", async () => { diff --git a/windows/tauri/src/features/git/api/git-status-api.ts b/windows/tauri/src/features/git/api/git-status-api.ts index d27f9a90d..2ca4cb5d8 100644 --- a/windows/tauri/src/features/git/api/git-status-api.ts +++ b/windows/tauri/src/features/git/api/git-status-api.ts @@ -42,14 +42,18 @@ export const getGitStatus = async (repoPath: string): Promise // Keep failures distinct from a missing repository for workspace refreshes. // Optional status consumers retain the nullable getGitStatus API. -const queryGitStatus = async (repoPath: string, source: GitExecutionSource = "unknown"): Promise => { +const queryGitStatus = async ( + repoPath: string, + source: GitExecutionSource = "unknown", + repositoryRoots: readonly string[] = [], +): Promise => { const resolvedRepoPath = await resolveRepositoryPath(repoPath); if (!resolvedRepoPath) { return null; } - const requestKey = `${resolvedRepoPath}\0${source}`; + const requestKey = `${resolvedRepoPath}\0${source}\0${JSON.stringify(repositoryRoots)}`; const existingRequest = inFlightGitStatusRequests.get(requestKey); if (existingRequest) { return existingRequest; @@ -59,12 +63,22 @@ const queryGitStatus = async (repoPath: string, source: GitExecutionSource = "un if (!gitStatusGenerations.has(resolvedRepoPath)) { gitStatusGenerations.set(resolvedRepoPath, generation); } - const request = (source === "unknown" - ? tauriInvoke("git_status", { repoPath: resolvedRepoPath }) - : tauriInvoke("git_status", { repoPath: resolvedRepoPath }, { gitExecutionSource: source })) + const request = ( + source === "unknown" + ? tauriInvoke("git_status", { + repoPath: resolvedRepoPath, + includeIndexOnlyChanges: true, + repositoryRoots, + }) + : tauriInvoke( + "git_status", + { repoPath: resolvedRepoPath, includeIndexOnlyChanges: true, repositoryRoots }, + { gitExecutionSource: source }, + ) + ) .then((status) => { if (generation !== (gitStatusGenerations.get(resolvedRepoPath) ?? 0)) { - return queryGitStatus(resolvedRepoPath, source); + return queryGitStatus(resolvedRepoPath, source, repositoryRoots); } return status; }) @@ -124,7 +138,7 @@ export const getWorkspaceGitStatus = async ( // These paths are already discovered/selected repositories. A null response // is an unavailable snapshot, not evidence that the workspace has no changes. const readStatus = async (repoPath: string): Promise => { - const status = await queryGitStatus(repoPath, source); + const status = await queryGitStatus(repoPath, source, normalizedRepoPaths); if (!status) throw new Error("Git status query returned no snapshot"); return status; }; @@ -151,8 +165,7 @@ export const getWorkspaceGitStatus = async ( }); const activeStatus = - statuses.find((entry) => entry.repoPath === activeRepoPath)?.status ?? - statuses[0]!.status; + statuses.find((entry) => entry.repoPath === activeRepoPath)?.status ?? statuses[0]!.status; return { branch: activeStatus.branch, ahead: activeStatus.ahead, diff --git a/windows/tauri/src/features/git/api/git-workspace-commit-api.test.ts b/windows/tauri/src/features/git/api/git-workspace-commit-api.test.ts new file mode 100644 index 000000000..aff76c9fd --- /dev/null +++ b/windows/tauri/src/features/git/api/git-workspace-commit-api.test.ts @@ -0,0 +1,39 @@ +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import * as native from "@/platform/tauri-core"; +import fixture from "../../../../../../shared/fixtures/git/workspace-commit-workflow-v1.json"; +import { + cancelWorkspaceCommit, + prepareWorkspaceCommit, + stepWorkspaceCommit, +} from "./git-workspace-commit-api"; + +let invoke: ReturnType>; +beforeEach(() => { + invoke = spyOn(native, "invoke").mockResolvedValue(fixture.preparation); +}); +afterEach(() => invoke.mockRestore()); +test("workspace prepare forwards native bindings and user execution provenance", async () => { + expect(await prepareWorkspaceCommit(fixture.request, "prepare")).toEqual(fixture.preparation); + expect(invoke).toHaveBeenCalledWith( + "git.workspaceCommitPrepare", + { ...fixture.request, operationId: "prepare" }, + { gitExecutionSource: "user" }, + ); +}); +test("workspace step preserves Core partial result rather than converting it to an error", async () => { + const session = { + ...fixture.preparation.session, + commandFailed: true, + finished: true, + canRetry: true, + }; + invoke.mockResolvedValueOnce(session); + expect(await stepWorkspaceCommit(fixture.preparation.session, "step")).toEqual(session); + expect(invoke).toHaveBeenCalledWith( + "git.workspaceCommitStep", + { session: fixture.preparation.session, operationId: "step" }, + { gitExecutionSource: "user" }, + ); + await cancelWorkspaceCommit("step"); + expect(invoke).toHaveBeenLastCalledWith("core_cancel", { operationId: "step" }); +}); diff --git a/windows/tauri/src/features/git/api/git-workspace-commit-api.ts b/windows/tauri/src/features/git/api/git-workspace-commit-api.ts new file mode 100644 index 000000000..9e825dd89 --- /dev/null +++ b/windows/tauri/src/features/git/api/git-workspace-commit-api.ts @@ -0,0 +1,35 @@ +import { invoke } from "@/platform/tauri-core"; +import { emitGitChanged } from "../events/git-events"; +import type { + WorkspaceCommitPreparation, + WorkspaceCommitRequest, + WorkspaceCommitSession, +} from "../types/git-workspace-commit.types"; + +export const prepareWorkspaceCommit = (request: WorkspaceCommitRequest, operationId: string) => + invoke( + "git.workspaceCommitPrepare", + { ...request, operationId }, + { gitExecutionSource: "user" }, + ); + +export async function stepWorkspaceCommit(session: WorkspaceCommitSession, operationId: string) { + try { + return await invoke( + "git.workspaceCommitStep", + { session, operationId }, + { gitExecutionSource: "user" }, + ); + } finally { + for (const { root } of session.plan.repositories) { + emitGitChanged({ + repoPath: root, + scopes: ["working-tree", "history", "refs"], + source: "workspace-commit", + }); + } + } +} + +export const cancelWorkspaceCommit = (operationId: string) => + invoke("core_cancel", { operationId }); diff --git a/windows/tauri/src/features/git/components/git-commit-panel.tsx b/windows/tauri/src/features/git/components/git-commit-panel.tsx index db5b65a99..81864fb35 100644 --- a/windows/tauri/src/features/git/components/git-commit-panel.tsx +++ b/windows/tauri/src/features/git/components/git-commit-panel.tsx @@ -7,8 +7,12 @@ import { GearSixIcon as SettingsIcon, } from "@/ui/icons"; import type React from "react"; -import { useEffect, useLayoutEffect, useRef, useState } from "react"; -import { toast } from "sonner"; +import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react"; +import { useWorkspaceCommitStore } from "../stores/git-workspace-commit.store"; +import { workspaceCommitBindings } from "../utils/git-workspace-commit-bindings"; +import { GitWorkspaceCommitReview } from "./git-workspace-commit-review"; +import { workspaceCommitEnglish } from "@/i18n/git-workspace-commit"; +import type { TranslationKey } from "@/i18n/locale"; import { useSettingsStore } from "@/features/settings/stores/settings.store"; import { useTranslation } from "@/i18n/locale-provider"; import { Button } from "@/ui/button"; @@ -26,25 +30,25 @@ import { import { generateCommitDraft } from "../services/ai-commit-workflow"; import { showConfirmDialog } from "@/ui/dialog"; import { useUIState } from "@/features/window/stores/ui-state.store"; -import { commitSelectedChanges } from "../api/git-commits-api"; import { showGitPushDialog } from "../services/git-push-dialog-service"; -import { useGitBlameStore } from "../stores/git-blame.store"; -import { useGitStore } from "../stores/git.store"; -import type { GitFile } from "../types/git.types"; import { - getGitFileRepositoryPath, - resolveGitFileMutationPaths, -} from "../utils/git-status-selection"; + useActiveWorkspaceId, + useWorkspaceReady, + useWorkspaceStoreScopeId, +} from "@/features/workspace/stores/create-workspace-scoped-store"; +import type { GitFile } from "../types/git.types"; interface GitCommitPanelProps { selectedFiles: GitFile[]; + workspacePath: string; + repositoryPaths: string[]; + isStaging?: boolean; commitMessage: string; onCommitMessageChange: (message: string) => void; currentBranch?: string; repoPath?: string; ahead?: number; behind?: number; - onCommitSuccess?: () => void; onPull?: () => Promise | void; isPulling?: boolean; isPullLocked?: boolean; @@ -56,13 +60,15 @@ const COMMIT_TEXTAREA_MAX_HEIGHT = 128; const GitCommitPanel = ({ selectedFiles, + workspacePath, + repositoryPaths, + isStaging = false, commitMessage, onCommitMessageChange, currentBranch, repoPath, ahead = 0, behind = 0, - onCommitSuccess, onPull, isPulling = false, isPullLocked = false, @@ -84,7 +90,14 @@ const GitCommitPanel = ({ generationRef.current = null; }; }, [selection]); - const [isCommitting, setIsCommitting] = useState(false); + const workflow = useWorkspaceCommitStore((state) => state.workflow); + const batch = useSyncExternalStore(workflow.subscribe, workflow.getState, workflow.getState); + const isCommitting = batch.busy; + const activeWorkspaceId = useActiveWorkspaceId(); + const workspaceId = useWorkspaceStoreScopeId() ?? activeWorkspaceId; + const workspaceReady = useWorkspaceReady(workspaceId); + const isCurrentWorkspace = workspaceReady && workspaceId === activeWorkspaceId; + const setDraftOwner = useWorkspaceCommitStore((state) => state.setDraftOwner); const [isGenerating, setIsGenerating] = useState(false); const [isCommitActionMenuOpen, setIsCommitActionMenuOpen] = useState(false); const [remoteAction, setRemoteAction] = useState<"push" | null>(null); @@ -92,7 +105,6 @@ const GitCommitPanel = ({ const commitMenuAnchorRef = useRef(null); const commitTextareaRef = useRef(null); const selectedFilesCount = selectedFiles.length; - const operationState = useGitStore((state) => state.operationState); useEffect(() => { if (focusRequest <= 0) return; @@ -145,76 +157,46 @@ const GitCommitPanel = ({ }; const handleCommit = async (pushAfterCommit = false) => { + if ( + !isCurrentWorkspace || + isStaging || + batch.busy || + batch.review || + (batch.session && !batch.session.succeeded) + ) + return; if (selectedFilesCount === 0) { setError(t("git.selectFilesToCommit")); return; } if (!repoPath || !commitMessage.trim()) return; - const selectedRepoPaths = new Set( - selectedFiles.map((file) => getGitFileRepositoryPath(file, repoPath) ?? repoPath), - ); - if (selectedRepoPaths.size > 1 || !selectedRepoPaths.has(repoPath)) { - setError(t("git.selectSingleRepositoryForCommit")); - return; - } - - // A conflicted merge/rebase must be resolved before the merge commit can - // be finalized; guard here so Git's raw refusal never reaches the user. - const activeOperation = useGitStore.getState().operationState; - const conflictedPaths = activeOperation?.conflictedPaths ?? []; - if (activeOperation && conflictedPaths.length > 0) { - setError(t("git.resolveConflictsFirst", { paths: conflictedPaths.join(", ") })); - return; - } - if (activeOperation) { - setError(t("git.finishOperationBeforeCommit")); - return; - } - - setIsCommitting(true); + setDraftOwner(repoPath); setError(null); + await workflow.prepare({ + repositories: workspaceCommitBindings(workspacePath, repositoryPaths), + message: commitMessage.trim(), + amend: false, + push: pushAfterCommit, + includeParentReferences: true, + }); + }; - try { - const warnings = await commitSelectedChanges( - repoPath, - commitMessage.trim(), - resolveGitFileMutationPaths(selectedFiles), - ); - useGitBlameStore.getState().actions.clearAllBlame(); - onCommitMessageChange(""); - for (const warning of warnings) { - toast.warning( - warning.code === "git_index_reconcile_failed" - ? t("git.commitIndexReconcileFailed") - : warning.message, - ); - } - if (pushAfterCommit) { - setRemoteAction("push"); - try { - await showGitPushDialog(repoPath); - } catch (pushError) { - setError(pushError instanceof Error ? pushError.message : t("git.pushFailed")); - } finally { - setRemoteAction(null); - } - } - onCommitSuccess?.(); - } catch (error) { - setError( - error instanceof Error - ? error.message - : typeof error === "string" - ? error - : t("ai.unknownError"), - ); - } finally { - setIsCommitting(false); - } + const handleRetry = () => { + const previous = batch.session; + if (!previous?.canRetry || isStaging || !isCurrentWorkspace) return; + setError(null); + return workflow.prepare({ + repositories: workspaceCommitBindings(workspacePath, repositoryPaths), + message: previous.plan.message, + amend: previous.plan.amend, + push: previous.plan.push, + includeParentReferences: previous.plan.includeParentReferences, + previous, + }); }; const handlePush = async () => { - if (!repoPath) return; + if (!repoPath || isCommitting) return; setRemoteAction("push"); setError(null); @@ -234,9 +216,12 @@ const GitCommitPanel = ({ }; const isCommitDisabled = + !isCurrentWorkspace || + isStaging || selectedFilesCount === 0 || !commitMessage.trim() || - Boolean(operationState) || + Boolean(batch.review) || + Boolean(batch.session && !batch.session.succeeded) || isCommitting || isGenerating; const isGenerateDisabled = @@ -261,7 +246,7 @@ const GitCommitPanel = ({ return ( <> - {error && ( + {(error || batch.error) && (
- {error} + {error || batch.error} +
+ )} + + {batch.session && ( +
+ {Object.entries(batch.session.results).map(([id, result]) => { + const key = `git.workspaceCommit.${result.status}`; + const label = + key in workspaceCommitEnglish + ? (key as TranslationKey) + : "git.workspaceCommit.attention"; + return ( +
+ {id}: {t(label)} + {result.detail &&

{result.detail}

} +
+ ); + })} + {!isCommitting && ( +
+ {batch.session.canRetry && ( + + )} + +
+ )}
)} + {isCommitting && ( + + )} + {batch.review && isCurrentWorkspace && ( + + void workflow.confirm(workspaceCommitBindings(workspacePath, repositoryPaths)) + } + onClose={workflow.closeReview} + onIncludeParents={(include) => + void workflow.setIncludeParentReferences( + include, + workspaceCommitBindings(workspacePath, repositoryPaths), + ) + } + /> + )}