From 05de8e879e88683e53b55632b84cb989be7e53df Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sun, 30 Aug 2026 23:43:09 +0800 Subject: [PATCH 1/7] docs(release): add v0.3.7 notes --- docs/releases/v0.3.7.md | 75 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 docs/releases/v0.3.7.md diff --git a/docs/releases/v0.3.7.md b/docs/releases/v0.3.7.md new file mode 100644 index 00000000..d59d948d --- /dev/null +++ b/docs/releases/v0.3.7.md @@ -0,0 +1,75 @@ +## 中文 + +Lithe 0.3.7 是一次功能与稳定性更新,汇总了近期 macOS Git 工作流、编辑器导航、Java/Spring 支持和 Windows 适配方面的改进。 + +### 下载 + +- **项目主页:** [Lithe IDEA](https://github.com/1lck/Lithe-IDEA) +- **macOS Apple Silicon:** [下载 DMG](https://github.com/1lck/Lithe-IDEA/releases/download/v0.3.7/Lithe-0.3.7-arm64.dmg) +- **macOS Intel:** [下载 DMG](https://github.com/1lck/Lithe-IDEA/releases/download/v0.3.7/Lithe-0.3.7-x86_64.dmg) +- **Windows x64(预览版):** [下载安装程序](https://github.com/1lck/Lithe-IDEA/releases/download/v0.3.7/Lithe-0.3.7-windows-x64.exe) +- **全部下载:** [查看 Release Assets](https://github.com/1lck/Lithe-IDEA/releases/tag/v0.3.7) + +> Windows 版本目前仍处于预览阶段。安装程序在未配置 Authenticode 证书时可能没有数字签名,Windows 可能会显示安全提示。 + +### 重点更新 + +- Git 工作流按 IntelliJ IDEA 风格重新整理,项目和分支切换弹窗更紧凑,并支持最近分支、命名空间分组、远程分组折叠和搜索。 +- Git 提交、推送、更新和分支操作入口更加清晰;首次推送、远程选择、上游分支和本地未提交改动的提示更加准确。 +- 编辑器新增并完善跳转到行、Git 日志筛选和提交文件浏览,统一行列边界处理并补充回归测试。 +- 改进 Java 项目就绪、Maven 配置、Spring 注解识别和语言服务启动流程,减少项目模型未准备完成时的误报。 +- 改进 Windows Git、运行、文件粘贴和 CDN 配置边界处理,并同步 Rust Core 与共享协议适配。 + +### 升级说明 + +- **macOS DMG:** 退出正在运行的 Lithe,打开对应架构的 DMG,然后将 Lithe 拖入“应用程序”并替换旧版本。 +- **Homebrew:** 运行 `brew update && brew upgrade --cask lithe`。 +- **Windows:** 退出正在运行的 Lithe,然后运行 Windows x64 安装程序并按提示完成安装。 + +### 兼容性与已知问题 + +- macOS 需要 macOS 13 或更高版本。 +- Java 项目功能需要 JDK 17 或更高版本,推荐使用 JDK 17 或 JDK 21。 +- macOS 和 Windows 正式安装包均包含 JDTLS,无需单独安装。 +- Windows 版本仍处于预览阶段,部分平台能力和界面细节可能与 macOS 不完全一致;未配置 Authenticode 证书时安装程序可能显示安全提示。 + +查看 [v0.3.6 到 v0.3.7 的完整变更](https://github.com/1lck/Lithe-IDEA/compare/v0.3.6...v0.3.7)。 + +--- + +## English + +Lithe 0.3.7 is a feature and reliability update covering the recent macOS Git workflow, editor navigation, Java/Spring support, and Windows integration improvements. + +### Downloads + +- **Project homepage:** [Lithe IDEA](https://github.com/1lck/Lithe-IDEA) +- **macOS Apple Silicon:** [Download DMG](https://github.com/1lck/Lithe-IDEA/releases/download/v0.3.7/Lithe-0.3.7-arm64.dmg) +- **macOS Intel:** [Download DMG](https://github.com/1lck/Lithe-IDEA/releases/download/v0.3.7/Lithe-0.3.7-x86_64.dmg) +- **Windows x64 preview:** [Download installer](https://github.com/1lck/Lithe-IDEA/releases/download/v0.3.7/Lithe-0.3.7-windows-x64.exe) +- **All downloads:** [View Release Assets](https://github.com/1lck/Lithe-IDEA/releases/tag/v0.3.7) + +> The Windows build remains a preview. If an Authenticode certificate is not configured, the installer may be unsigned and Windows may show a security warning. + +### Highlights + +- Reorganized the Git workflow around IntelliJ IDEA conventions with more compact project and branch switchers, recent branches, namespace groups, collapsible remote groups, and search. +- Clarified Git commit, push, update, and branch actions with more accurate guidance for first pushes, remote selection, upstream branches, and uncommitted changes. +- Added and refined go-to-line, Git log filtering, and commit file browsing, with consistent line and column boundaries plus regression coverage. +- Improved Java project readiness, Maven configuration, Spring annotation recognition, and language-server startup to reduce false diagnostics before the project model is ready. +- Improved Windows Git, run, file-paste, and CDN configuration boundaries while keeping Rust Core and shared protocol adapters aligned. + +### Upgrade instructions + +- **macOS DMG:** Quit Lithe, open the DMG for your Mac architecture, and replace the existing application in the Applications folder. +- **Homebrew:** Run `brew update && brew upgrade --cask lithe`. +- **Windows:** Quit Lithe, run the Windows x64 installer, and follow the installation prompts. + +### Compatibility and known issues + +- macOS requires macOS 13 or later. +- Java project features require JDK 17 or newer. JDK 17 or JDK 21 is recommended. +- Release packages for macOS and Windows include JDTLS, so it does not need to be installed separately. +- The Windows build remains a preview, and some platform capabilities and interface details may differ from macOS. The installer may show a security warning when Authenticode signing is not configured. + +Review the [full changes from v0.3.6 to v0.3.7](https://github.com/1lck/Lithe-IDEA/compare/v0.3.6...v0.3.7). From 109bf7c6c61a113c6e053361b6605e930788f332 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 30 Aug 2026 16:01:48 +0000 Subject: [PATCH 2/7] lithe 0.3.7 --- Casks/lithe.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Casks/lithe.rb b/Casks/lithe.rb index c67a1abb..5293f4af 100644 --- a/Casks/lithe.rb +++ b/Casks/lithe.rb @@ -1,9 +1,9 @@ cask "lithe" do arch arm: "arm64", intel: "x86_64" - version "0.3.6" - sha256 arm: "dcccaa81cba397e9b8701f8ed35207bf490ced1704fde74983dd93c8bda1531d", - intel: "963445bdcaf1e1fdbcdd954b095a1064b76a9b656b149d4e8d35bc4d93e7c6c2" + version "0.3.7" + sha256 arm: "f1d6da22a9c4ecd4a0f2c25777308ae480e6e90144ef11281b073ea77a95d0f0", + intel: "c9482e08fa163d94b176716a853c66e953505a78fa8620e6d881ae0f6e6e0f52" url "https://github.com/1lck/Lithe-IDEA/releases/download/v#{version}/Lithe-#{version}-#{arch}.dmg" name "Lithe" From 05a1737629a2c0a91bce1ea21837539747f86b32 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Mon, 31 Aug 2026 15:52:26 +0800 Subject: [PATCH 3/7] docs(release): add v0.3.8 notes --- docs/releases/v0.3.8.md | 73 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 docs/releases/v0.3.8.md diff --git a/docs/releases/v0.3.8.md b/docs/releases/v0.3.8.md new file mode 100644 index 00000000..8707e29d --- /dev/null +++ b/docs/releases/v0.3.8.md @@ -0,0 +1,73 @@ +## 中文 + +Lithe 0.3.8 是一次 Java 开发体验与跨平台稳定性更新,完善了 macOS 调试工作流,并为 Windows 提供统一的 Maven 工具窗口与更可靠的运行链路。 + +### 下载 + +- **项目主页:** [Lithe IDEA](https://github.com/1lck/Lithe-IDEA) +- **macOS Apple Silicon:** [下载 DMG](https://github.com/1lck/Lithe-IDEA/releases/download/v0.3.8/Lithe-0.3.8-arm64.dmg) +- **macOS Intel:** [下载 DMG](https://github.com/1lck/Lithe-IDEA/releases/download/v0.3.8/Lithe-0.3.8-x86_64.dmg) +- **Windows x64(预览版):** [下载安装程序](https://github.com/1lck/Lithe-IDEA/releases/download/v0.3.8/Lithe-0.3.8-windows-x64.exe) +- **全部下载:** [查看 Release Assets](https://github.com/1lck/Lithe-IDEA/releases/tag/v0.3.8) + +> Windows 版本目前仍处于预览阶段。安装程序在未配置 Authenticode 证书时可能没有数字签名,Windows 可能会显示安全提示。 + +### 重点更新 + +- macOS Java 调试支持断点管理、断点迁移、变量与作用域检查、表达式求值、线程与调用栈查看、步进过滤器、运行到光标,以及调试控制台和测试调试流程。 +- Windows 新增统一的 Maven 工具窗口,支持 Maven 项目重新加载、生命周期与目标操作,并将 Maven 上下文与运行配置加载流程对齐。 +- 改进工作区重开、文件保存、Maven 项目导入和语言服务启动时的就绪状态与过期结果处理,减少竞态导致的错误诊断。 +- 补充调试、Maven、运行配置、编辑器导航和跨平台协议的回归测试。 + +### 升级说明 + +- **macOS DMG:** 退出正在运行的 Lithe,打开对应架构的 DMG,然后将 Lithe 拖入“应用程序”并替换旧版本。 +- **Homebrew:** 运行 `brew update && brew upgrade --cask lithe`。 +- **Windows:** 退出正在运行的 Lithe,然后运行 Windows x64 安装程序并按提示完成安装。 + +### 兼容性与已知问题 + +- macOS 需要 macOS 13 或更高版本。 +- Java 项目功能需要 JDK 17 或更高版本,推荐使用 JDK 17 或 JDK 21。 +- macOS 和 Windows 正式安装包均包含 JDTLS,无需单独安装。 +- Windows 版本仍处于预览阶段,部分平台能力和界面细节可能与 macOS 不完全一致;未配置 Authenticode 证书时安装程序可能显示安全提示。 + +查看 [v0.3.7 到 v0.3.8 的完整变更](https://github.com/1lck/Lithe-IDEA/compare/v0.3.7...v0.3.8)。 + +--- + +## English + +Lithe 0.3.8 is a Java development and cross-platform reliability update that expands the macOS debugging workflow and adds a unified Maven tool window with a more dependable run path on Windows. + +### Downloads + +- **Project homepage:** [Lithe IDEA](https://github.com/1lck/Lithe-IDEA) +- **macOS Apple Silicon:** [Download DMG](https://github.com/1lck/Lithe-IDEA/releases/download/v0.3.8/Lithe-0.3.8-arm64.dmg) +- **macOS Intel:** [Download DMG](https://github.com/1lck/Lithe-IDEA/releases/download/v0.3.8/Lithe-0.3.8-x86_64.dmg) +- **Windows x64 preview:** [Download installer](https://github.com/1lck/Lithe-IDEA/releases/download/v0.3.8/Lithe-0.3.8-windows-x64.exe) +- **All downloads:** [View Release Assets](https://github.com/1lck/Lithe-IDEA/releases/tag/v0.3.8) + +> The Windows build remains a preview. If an Authenticode certificate is not configured, the installer may be unsigned and Windows may show a security warning. + +### Highlights + +- Expanded macOS Java debugging with breakpoint management and relocation, variable and scope inspection, expression evaluation, thread and stack inspection, stepping filters, run-to-cursor, debugging console support, and Java test debugging. +- Added a unified Maven tool window on Windows with Maven workspace reload, lifecycle and goal actions, and aligned Maven context and run-configuration loading. +- Improved readiness tracking and stale-result handling during workspace reopen, file saves, Maven import, and language-server startup to reduce race-related diagnostics. +- Added regression coverage for debugging, Maven, run configurations, editor navigation, and cross-platform protocol behavior. + +### Upgrade instructions + +- **macOS DMG:** Quit Lithe, open the DMG for your Mac architecture, and replace the existing application in the Applications folder. +- **Homebrew:** Run `brew update && brew upgrade --cask lithe`. +- **Windows:** Quit Lithe, run the Windows x64 installer, and follow the installation prompts. + +### Compatibility and known issues + +- macOS requires macOS 13 or later. +- Java project features require JDK 17 or newer. JDK 17 or JDK 21 is recommended. +- Release packages for macOS and Windows include JDTLS, so it does not need to be installed separately. +- The Windows build remains a preview, and some platform capabilities and interface details may differ from macOS. The installer may show a security warning when Authenticode signing is not configured. + +Review the [full changes from v0.3.7 to v0.3.8](https://github.com/1lck/Lithe-IDEA/compare/v0.3.7...v0.3.8). From d61d567692e300a0273881816195372ca4f3d6ee Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 08:07:48 +0000 Subject: [PATCH 4/7] lithe 0.3.8 --- Casks/lithe.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Casks/lithe.rb b/Casks/lithe.rb index 5293f4af..6a99ac14 100644 --- a/Casks/lithe.rb +++ b/Casks/lithe.rb @@ -1,9 +1,9 @@ cask "lithe" do arch arm: "arm64", intel: "x86_64" - version "0.3.7" - sha256 arm: "f1d6da22a9c4ecd4a0f2c25777308ae480e6e90144ef11281b073ea77a95d0f0", - intel: "c9482e08fa163d94b176716a853c66e953505a78fa8620e6d881ae0f6e6e0f52" + version "0.3.8" + sha256 arm: "3a85a6058ca641ad6ffc17efbe4e0dbc4a220ecb1cd3e8f670294b70aa1c1f90", + intel: "3947cc2070958daf7dfa47abf414cb72d4d255bb1005f39162d1a43bdceb0715" url "https://github.com/1lck/Lithe-IDEA/releases/download/v#{version}/Lithe-#{version}-#{arch}.dmg" name "Lithe" From 669f30e068acdf9ed34c0b7681b4e0112c587048 Mon Sep 17 00:00:00 2001 From: Lichenkang <2188718831@qq.com> Date: Tue, 1 Sep 2026 22:15:21 +0800 Subject: [PATCH 5/7] Preview (#393) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(git): 完善源代码管理与引用工作流 * feat(vcs): 完善源代码管理交互与推送流程 - 优化变更树选择、目录聚合差异与文件状态交互 - 完善提交历史多选、引用菜单及 ahead/behind 标识 - 新增推送预览、force-with-lease 与标签推送选项 - 扩展 Rust Core Git 契约、共享 fixture 与覆盖测试 * fix(vcs): 门禁修复 * fix(vcs): 门禁修复 * fix(vcs): 修复选择提交与远程操作一致性 提交选中文件时保留并在失败后恢复原始索引,同时针对最终提交快照检查冲突标记并对齐差异预览。 遵循 Git 推送远端配置,保留完整远程引用;历史修改失败后刷新仓库状态,并在未选择文件时禁用提交按钮。 * fix(macos): restore switcher popover integration * fix(vcs): 隔离并发推送测试目录 * test(vcs): 覆盖部分暂存文件完整差异 * feat(vcs): 完善推送预览校验与拉取恢复流程 * fix(macos): serialize run entry activation with module teardown * fix(vcs): 修复提交与远程操作审查问题 * fix(ci): make Swift test watchdog stall-aware * fix(ci): sample the full descendant tree on Swift runner stall The testing helper detaches into its own process group, so the previous group-scoped walk only sampled the shell wrapper waiting on its child. Walk the ppid tree instead and capture a listing plus a thread-stack sample of every descendant before terminating the run. Co-Authored-By: Claude Fable 5 * fix(vcs): 修复跨平台 Git 工作流一致性问题 - 使用临时索引提交选中文件,保证审阅快照、重命名和大路径集语义一致 - 校验推送预览与远端引用身份,统一拉取、检出及冲突恢复状态 - 聚合多提交与工作树差异,限制异步批处理并完善 Windows 状态刷新和本地化 * fix(vcs): 原子创建远程引用工作树 * fix(vcs): 修正引用数量与分支同步状态 * fix(run): launch Maven test mains with test classpath * fix(windows): normalize verbatim paths for JDTLS changes * fix(windows): handle case-insensitive UNC prefixes * feat: automate issue claim comments Closes #373 --------- Co-authored-by: mirakyux Co-authored-by: Yao Jingxi <23722032@bjtu.edu.cn> Co-authored-by: Claude Fable 5 --- .../references/macos-swift.md | 9 +- .../scripts/run-swift-tests-with-timing.mjs | 183 +- .../scripts/test-stability-macos.sh | 8 +- .../scripts/test-verify-test-stability.mjs | 203 ++ .github/lithe-issue-claim/README.md | 12 + .github/lithe-issue-claim/logic.mjs | 72 + .github/workflows/lithe-issue-claim.yml | 173 + .../Lithe/Core/Rust/RustCoreBridge.swift | 58 +- .../Lithe/Core/Rust/RustGitOperations.swift | 102 +- .../AppModel/AppModel+ExecutionModules.swift | 6 + .../Lithe/Models/AppModel/AppModel.swift | 99 +- .../Lithe/Views/Workbench/WorkbenchView.swift | 320 +- .../Application/GitFeatureModel.swift | 25 +- .../LitheGitModule/Ports/GitPorts.swift | 17 +- .../LitheGitModule/Services/GitService.swift | 40 +- .../LitheGitModuleTests/GitModuleTests.swift | 26 +- .../GitReferenceOperationsTests.swift | 102 + .../WorkbenchRenderingSafetyTests.swift | 21 + .../lithe-core/src/execution/configuration.rs | 45 +- rust/lithe-core/src/git/mod.rs | 2956 +++++++++++++++-- rust/lithe-core/src/git/mutations.rs | 11 +- rust/lithe-core/src/languages/java.rs | 15 +- rust/lithe-core/src/protocol/command.rs | 11 + rust/lithe-core/src/protocol/contracts.rs | 54 + rust/lithe-core/src/runtime/dispatcher.rs | 22 +- rust/lithe-core/src/tests/git.rs | 1677 +++++++++- rust/lithe-core/src/tests/languages.rs | 5 + .../lithe-core/src/tests/run_configuration.rs | 197 +- rust/lithe-core/tests/git_push.rs | 390 +++ scripts/test-lithe-issue-claim.mjs | 60 + shared/contracts/rust-core-api.md | 148 +- .../maven-java-main-source-sets-v1.json | 38 + shared/fixtures/git/diff.json | 12 + shared/fixtures/git/history-response-v1.json | 16 +- shared/fixtures/git/push-preview-v1.json | 27 + shared/fixtures/git/write.json | 113 + shared/fixtures/java/basic.json | 4 +- windows/tauri/src-tauri/src/platform.rs | 320 +- .../components/command-palette.tsx | 5 +- .../command-palette/constants/git-actions.tsx | 44 +- .../editor/components/code-editor.tsx | 3 + .../editor/components/monaco-editor.tsx | 8 +- .../editor/components/toolbar/breadcrumb.tsx | 6 +- .../hooks/use-file-tree-presentation.ts | 31 + .../styles/file-explorer-tree.css | 19 +- .../features/git/api/git-branches-api.test.ts | 132 + .../src/features/git/api/git-branches-api.ts | 148 +- .../features/git/api/git-commits-api.test.ts | 99 + .../src/features/git/api/git-commits-api.ts | 100 +- .../src/features/git/api/git-diff-api.ts | 215 +- .../git/api/git-integration-api.test.ts | 145 + .../features/git/api/git-integration-api.ts | 52 +- .../src/features/git/api/git-push-api.test.ts | 98 + .../src/features/git/api/git-push-api.ts | 90 + .../features/git/api/git-reference-payload.ts | 14 + .../features/git/api/git-remotes-api.test.ts | 68 +- .../src/features/git/api/git-remotes-api.ts | 44 +- .../src/features/git/api/git-repo-api.test.ts | 26 + .../src/features/git/api/git-repo-api.ts | 18 +- .../features/git/api/git-status-api.test.ts | 110 + .../src/features/git/api/git-status-api.ts | 92 +- .../git/api/git-worktrees-api.test.ts | 76 + .../src/features/git/api/git-worktrees-api.ts | 30 +- .../components/diff/git-diff-editor-stack.tsx | 80 +- .../components/diff/git-diff-line.test.tsx | 14 +- .../git/components/diff/git-diff-line.tsx | 4 +- .../git/components/diff/git-diff-text.tsx | 2 + .../git/components/git-actions-menu.tsx | 11 +- .../git/components/git-branch-manager.tsx | 45 +- .../git/components/git-commit-history.tsx | 178 +- .../git/components/git-commit-panel.tsx | 305 +- .../components/git-pull-strategy-dialog.tsx | 35 +- .../git/components/git-push-dialog.tsx | 422 +++ .../components/git-tracking-counts.test.tsx | 46 + .../git/components/git-tracking-counts.tsx | 56 + .../src/features/git/components/git-view.tsx | 481 +-- .../log/git-commit-file-tree.test.ts | 21 + .../components/log/git-commit-file-tree.tsx | 250 +- .../components/log/git-commit-inspector.tsx | 70 +- .../git/components/log/git-commit-table.tsx | 156 +- .../components/log/git-log-tool-window.tsx | 471 ++- .../components/log/git-reference-tree.test.ts | 50 - .../git/components/log/git-reference-tree.tsx | 473 ++- .../status/git-status-file-item.tsx | 76 +- .../components/status/git-status-panel.tsx | 1096 ++++-- .../git/hooks/git-pull-workflow.test.ts | 8 +- .../features/git/hooks/git-pull-workflow.ts | 30 +- .../git/hooks/use-git-diff-actions.ts | 410 ++- .../git/hooks/use-git-history-mutations.ts | 132 + .../git/hooks/use-git-pull-workflow.ts | 42 +- .../git/services/git-push-dialog-service.ts | 33 + .../git/services/working-tree-diff-loader.ts | 41 +- .../services/working-tree-file-diff.test.ts | 33 +- .../git/services/working-tree-file-diff.ts | 15 +- .../src/features/git/stores/git.store.test.ts | 31 + .../src/features/git/stores/git.store.ts | 28 + .../src/features/git/types/git-diff.types.ts | 2 + .../tauri/src/features/git/types/git.types.ts | 46 +- .../git/utils/git-async-batch.test.ts | 60 + .../src/features/git/utils/git-async-batch.ts | 29 + .../utils/git-commit-selection-diff.test.ts | 141 + .../git/utils/git-commit-selection-diff.ts | 92 + .../git/utils/git-file-status-visuals.test.ts | 23 + .../git/utils/git-file-status-visuals.ts | 24 + .../git/utils/git-history-selection.test.ts | 54 + .../git/utils/git-history-selection.ts | 79 + .../git-pull-result-presentation.test.ts | 56 + .../git/utils/git-pull-result-presentation.ts | 69 + .../git/utils/git-reference-actions.test.ts | 71 + .../git/utils/git-reference-actions.ts | 81 + .../git/utils/git-reference-tree.test.ts | 19 +- .../features/git/utils/git-reference-tree.ts | 7 + .../git/utils/git-status-deletion.test.ts | 35 + .../features/git/utils/git-status-deletion.ts | 31 + .../git/utils/git-status-model.test.ts | 61 + .../features/git/utils/git-status-model.ts | 79 +- .../git/utils/git-status-selection.test.ts | 85 + .../git/utils/git-status-selection.ts | 74 + .../git/utils/multi-file-diff.test.ts | 60 + .../src/features/git/utils/multi-file-diff.ts | 89 + .../git/utils/request-generation.test.ts | 36 + .../features/git/utils/request-generation.ts | 16 + .../footer/footer-git-branch-item.tsx | 2 + .../panes/components/empty-editor-state.tsx | 41 +- .../sidebar/components/sidebar-tree.tsx | 1 + windows/tauri/src/i18n/locale.ts | 310 +- .../platform/core-result-adapter.diff.test.ts | 31 +- .../core-result-adapter.history.test.ts | 8 + .../src/platform/core-result-adapter.test.ts | 58 + .../tauri/src/platform/core-result-adapter.ts | 110 +- windows/tauri/src/utils/path-helpers.test.ts | 29 + windows/tauri/src/utils/path-helpers.ts | 12 +- windows/tauri/src/workbench-app.tsx | 4 + 133 files changed, 14163 insertions(+), 2277 deletions(-) create mode 100644 .github/lithe-issue-claim/README.md create mode 100644 .github/lithe-issue-claim/logic.mjs create mode 100644 .github/workflows/lithe-issue-claim.yml create mode 100644 macos/Tests/LitheTests/GitReferenceOperationsTests.swift create mode 100644 rust/lithe-core/tests/git_push.rs create mode 100644 scripts/test-lithe-issue-claim.mjs create mode 100644 shared/fixtures/execution/maven-java-main-source-sets-v1.json create mode 100644 shared/fixtures/git/push-preview-v1.json create mode 100644 windows/tauri/src/features/file-explorer/hooks/use-file-tree-presentation.ts create mode 100644 windows/tauri/src/features/git/api/git-branches-api.test.ts create mode 100644 windows/tauri/src/features/git/api/git-commits-api.test.ts create mode 100644 windows/tauri/src/features/git/api/git-push-api.test.ts create mode 100644 windows/tauri/src/features/git/api/git-push-api.ts create mode 100644 windows/tauri/src/features/git/api/git-reference-payload.ts create mode 100644 windows/tauri/src/features/git/api/git-status-api.test.ts create mode 100644 windows/tauri/src/features/git/api/git-worktrees-api.test.ts create mode 100644 windows/tauri/src/features/git/components/git-push-dialog.tsx create mode 100644 windows/tauri/src/features/git/components/git-tracking-counts.test.tsx create mode 100644 windows/tauri/src/features/git/components/git-tracking-counts.tsx create mode 100644 windows/tauri/src/features/git/components/log/git-commit-file-tree.test.ts delete mode 100644 windows/tauri/src/features/git/components/log/git-reference-tree.test.ts create mode 100644 windows/tauri/src/features/git/hooks/use-git-history-mutations.ts create mode 100644 windows/tauri/src/features/git/services/git-push-dialog-service.ts create mode 100644 windows/tauri/src/features/git/utils/git-async-batch.test.ts create mode 100644 windows/tauri/src/features/git/utils/git-async-batch.ts create mode 100644 windows/tauri/src/features/git/utils/git-commit-selection-diff.test.ts create mode 100644 windows/tauri/src/features/git/utils/git-commit-selection-diff.ts create mode 100644 windows/tauri/src/features/git/utils/git-file-status-visuals.test.ts create mode 100644 windows/tauri/src/features/git/utils/git-file-status-visuals.ts create mode 100644 windows/tauri/src/features/git/utils/git-history-selection.test.ts create mode 100644 windows/tauri/src/features/git/utils/git-history-selection.ts create mode 100644 windows/tauri/src/features/git/utils/git-pull-result-presentation.test.ts create mode 100644 windows/tauri/src/features/git/utils/git-pull-result-presentation.ts create mode 100644 windows/tauri/src/features/git/utils/git-reference-actions.test.ts create mode 100644 windows/tauri/src/features/git/utils/git-reference-actions.ts create mode 100644 windows/tauri/src/features/git/utils/git-status-deletion.test.ts create mode 100644 windows/tauri/src/features/git/utils/git-status-deletion.ts create mode 100644 windows/tauri/src/features/git/utils/git-status-model.test.ts create mode 100644 windows/tauri/src/features/git/utils/git-status-selection.test.ts create mode 100644 windows/tauri/src/features/git/utils/git-status-selection.ts create mode 100644 windows/tauri/src/features/git/utils/multi-file-diff.test.ts create mode 100644 windows/tauri/src/features/git/utils/multi-file-diff.ts create mode 100644 windows/tauri/src/features/git/utils/request-generation.test.ts create mode 100644 windows/tauri/src/features/git/utils/request-generation.ts create mode 100644 windows/tauri/src/utils/path-helpers.test.ts diff --git a/.agents/skills/write-stable-tests/references/macos-swift.md b/.agents/skills/write-stable-tests/references/macos-swift.md index 83eeb2db..058de7fd 100644 --- a/.agents/skills/write-stable-tests/references/macos-swift.md +++ b/.agents/skills/write-stable-tests/references/macos-swift.md @@ -30,8 +30,13 @@ loaded, or invoked anywhere in this path. Use `./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh`. It forces serial execution so the currently running test is unambiguous, records every Swift Testing/XCTest case, -warns about slow cases, and terminates the suite when one case exceeds its local -budget. Reports are written below `.artifacts/test-stability/`. Each run +warns about slow cases, and fails the run when a reported duration exceeds its +local budget. Because the runner writes to a block-buffered pipe, a finish line +can arrive late or be lost; the harness therefore never kills the runner on a +per-test timer. Instead a stall watchdog (`--stall-timeout-seconds`, default +120) terminates the runner only when it produces no output at all, and reports +the tests still awaiting a result without asserting a single culprit. Reports +are written below `.artifacts/test-stability/`. Each run produces JSON and raw logs for diagnosis, JUnit XML for CI tooling, and a self-contained HTML report for module and performance review. diff --git a/.agents/skills/write-stable-tests/scripts/run-swift-tests-with-timing.mjs b/.agents/skills/write-stable-tests/scripts/run-swift-tests-with-timing.mjs index a0e1b7cd..6bfb2519 100755 --- a/.agents/skills/write-stable-tests/scripts/run-swift-tests-with-timing.mjs +++ b/.agents/skills/write-stable-tests/scripts/run-swift-tests-with-timing.mjs @@ -1,6 +1,7 @@ #!/usr/bin/env node import { mkdirSync, writeFileSync, createWriteStream } from "node:fs"; +import { spawnSync } from "node:child_process"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { writeTestReportArtifacts } from "./generate-test-report.mjs"; @@ -65,6 +66,7 @@ function parseArguments(arguments_) { const options = { warnMs: 1000, maxMs: 15000, + stallTimeoutMs: 120000, suiteTimeoutMs: 600000, report: path.join(REPOSITORY_ROOT, ".artifacts/test-stability/macos-swift.json"), command: arguments_[separator + 1], @@ -74,7 +76,9 @@ function parseArguments(arguments_) { const argument = arguments_[index]; if (argument === "--warn-ms") options.warnMs = positiveInteger(arguments_[++index], "--warn-ms"); else if (argument === "--max-ms") options.maxMs = positiveInteger(arguments_[++index], "--max-ms"); - else if (argument === "--suite-timeout-ms") { + else if (argument === "--stall-timeout-ms") { + options.stallTimeoutMs = positiveInteger(arguments_[++index], "--stall-timeout-ms"); + } else if (argument === "--suite-timeout-ms") { options.suiteTimeoutMs = positiveInteger(arguments_[++index], "--suite-timeout-ms"); } else if (argument === "--report") options.report = path.resolve(arguments_[++index]); else throw new Error(`Unknown argument: ${argument}`); @@ -90,34 +94,107 @@ export async function run(options, { runProcessImpl = runProcess } = {}) { const active = new Map(); const records = []; let currentSuite = null; - let timedOutTest = null; - let testTimer = null; + let stalled = false; + let stallTimer = null; let terminateChild = () => {}; + let childPid = null; + let lastOutputAt = null; + let lastTestEventAt = null; + let stallSamplePath = null; + // Killing the runner from a per-test timer is unsound: swift test writes to a + // block-buffered pipe, so a finish line can sit (or be split mid-line) in the + // child's buffer long after the test completed, and the timer would blame an + // innocent test. Instead, per-test budgets are enforced after the run from + // the durations swift-testing itself reports, and this stall watchdog only + // guards against the runner producing no output at all. + const stallTimeoutMs = options.stallTimeoutMs ?? 120000; + // Best-effort thread-stack snapshot of the hung runner, taken before the + // SIGTERM destroys the evidence of where it was stuck. The direct child is a + // shell wrapper, so walk its descendant tree: the genuinely hung process + // (swift-test or the testing helper, which detaches into its own process + // group) is a descendant, and the wrapper's stack would only show it waiting + // on its child. + const captureStallSample = () => { + if (process.platform !== "darwin" || !childPid) return; + let treePids = [String(childPid)]; + let processListing = ""; + try { + const everyProcess = spawnSync( + "ps", + ["-axo", "pid=,ppid=,pgid=,etime=,command="], + { encoding: "utf8", timeout: 5000 }, + ); + const rows = (everyProcess.stdout ?? "") + .split("\n") + .map((line) => { + const [pid, ppid] = line.trim().split(/\s+/); + return { pid, ppid, line }; + }) + .filter((row) => row.pid); + const wanted = new Set([String(childPid)]); + // Multiple passes handle arbitrary depth without recursion. + for (let pass = 0; pass < 10; pass += 1) { + const before = wanted.size; + for (const row of rows) if (wanted.has(row.ppid)) wanted.add(row.pid); + if (wanted.size === before) break; + } + const treeRows = rows.filter((row) => wanted.has(row.pid)); + if (treeRows.length > 0) { + processListing = treeRows.map((row) => row.line).join("\n"); + treePids = treeRows.map((row) => row.pid); + } + } catch { + // Fall back to sampling only the direct child. + } + const samplePath = options.report.replace(/\.json$/i, ".stall-sample.txt"); + const sections = [ + `Process tree under pid ${childPid} at stall (pid ppid pgid etime command):`, + processListing || "(process listing unavailable)", + ]; + // Bound the diagnostics pass; each sample blocks for its full duration. + for (const pid of treePids.slice(0, 6)) { + const sample = spawnSync("sample", [pid, "2"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 15000, + }); + if (sample.status === 0 && sample.stdout) { + sections.push(`===== sample of pid ${pid} =====`, sample.stdout); + } + } + try { + writeFileSync(samplePath, `${sections.join("\n\n")}\n`); + stallSamplePath = samplePath; + } catch { + // Sampling is diagnostics only; never let it break termination. + } + }; + const armStallTimer = () => { + if (stallTimer) clearTimeout(stallTimer); + stallTimer = setTimeout(() => { + stalled = true; + captureStallSample(); + terminateChild(); + }, stallTimeoutMs); + }; const recordLine = (line, stream) => { - log.write(`${stream}: ${line}\n`); + lastOutputAt = new Date().toISOString(); + log.write(`${lastOutputAt} ${stream}: ${line}\n`); + armStallTimer(); const suiteEvent = parseSwiftSuiteLine(line); if (suiteEvent?.event === "started") currentSuite = suiteEvent.name; else if (suiteEvent && suiteEvent.name === currentSuite) currentSuite = null; const event = parseSwiftTimingLine(line); if (!event) return; + lastTestEventAt = lastOutputAt; if (event.event === "started") { - const startedAt = performance.now(); - active.set(event.name, { startedAt, suite: currentSuite }); - if (testTimer) clearTimeout(testTimer); - testTimer = setTimeout(() => { - timedOutTest = { name: event.name, suite: currentSuite }; - terminateChild(); - }, options.maxMs); + active.set(event.name, { startedAt: performance.now(), suite: currentSuite }); return; } const activeTest = active.get(event.name); active.delete(event.name); - if (testTimer) { - clearTimeout(testTimer); - testTimer = null; - } records.push({ name: event.name, ...(activeTest?.suite ? { suite: activeTest.suite } : {}), @@ -130,12 +207,14 @@ export async function run(options, { runProcessImpl = runProcess } = {}) { }; const startedAt = new Date().toISOString(); + armStallTimer(); const childPromise = runProcessImpl({ command: options.command, args: options.commandArguments, cwd: REPOSITORY_ROOT, timeoutMs: options.suiteTimeoutMs, - onSpawn: ({ terminate }) => { + onSpawn: ({ pid, terminate }) => { + childPid = pid ?? null; terminateChild = terminate; }, onStdoutLine: (line) => recordLine(line, "stdout"), @@ -144,31 +223,56 @@ export async function run(options, { runProcessImpl = runProcess } = {}) { streamStderr: true, }); - const result = await childPromise; - if (testTimer) clearTimeout(testTimer); - await new Promise((resolve, reject) => { - log.once("error", reject); - log.end(resolve); - }); - - if (timedOutTest && !records.some((record) => record.name === timedOutTest.name)) { - records.push({ - name: timedOutTest.name, - ...(timedOutTest.suite ? { suite: timedOutTest.suite } : {}), - status: "timeout", - durationMs: options.maxMs, + // Clear the watchdog and settle the log stream even when spawn fails and the + // await throws; a leaked ref'd timer would keep this process alive for the + // full timeout, and an unsettled stream emits an unhandled error event. + let result; + let logError = null; + try { + result = await childPromise; + } finally { + if (stallTimer) clearTimeout(stallTimer); + stallTimer = null; + await new Promise((resolve) => { + log.once("error", (error) => { + logError ??= error; + resolve(); + }); + log.end(resolve); }); } - for (const [name, activeTest] of active) { + if (logError) throw logError; + + // Tests still in `active` either never finished or had their finish line cut + // off in the killed child's stdio buffer; report them without asserting that + // any single one of them is the culprit. + const unfinished = [...active.entries()]; + for (const [name, activeTest] of unfinished) { if (!records.some((record) => record.name === name)) { records.push({ name, ...(activeTest.suite ? { suite: activeTest.suite } : {}), - status: result.timedOut ? "timeout" : "incomplete", - durationMs: options.maxMs, + status: result.timedOut || stalled ? "timeout" : "incomplete", + durationMs: Math.round(performance.now() - activeTest.startedAt), }); } } + if (stalled) { + const unfinishedNames = unfinished.map(([name]) => name); + records.push({ + name: "Swift test runner stall", + suite: "Swift test runner", + status: "timeout", + durationMs: stallTimeoutMs, + details: + `The Swift runner produced no output for ${stallTimeoutMs}ms. ` + + (unfinishedNames.length > 0 + ? `Tests without a reported result: ${unfinishedNames.join(", ")}. ` + : "Every parsed test had reported a result; the runner likely hung during teardown or exit. ") + + `Last output at ${lastOutputAt ?? "never"}; last parsed test event at ${lastTestEventAt ?? "never"}.` + + (stallSamplePath ? ` Thread-stack sample of the hung runner: ${stallSamplePath}.` : ""), + }); + } if (result.timedOut && !records.some((record) => record.status === "timeout")) { records.push({ name: "Swift test suite timeout", @@ -190,11 +294,14 @@ export async function run(options, { runProcessImpl = runProcess } = {}) { command: [options.command, ...options.commandArguments], warnMs: options.warnMs, maxMs: options.maxMs, + stallTimeoutMs, suiteTimeoutMs: options.suiteTimeoutMs, process: { exitCode: result.code, signal: result.signal, timedOut: result.timedOut, + stalled, + terminationConfirmed: result.terminationConfirmed, durationMs: Math.round(result.durationMs), }, tests: records, @@ -207,10 +314,16 @@ export async function run(options, { runProcessImpl = runProcess } = {}) { console.log(`SLOW ${record.durationMs}ms ${record.name}`); } - if (timedOutTest) { - throw new Error(`Swift test exceeded ${options.maxMs}ms: ${timedOutTest.name}`); - } if (result.timedOut) throw new Error(`Swift test suite exceeded ${options.suiteTimeoutMs}ms.`); + if (stalled) { + const unfinishedNames = unfinished.map(([name]) => name); + throw new Error( + `Swift test runner produced no output for ${stallTimeoutMs}ms` + + (unfinishedNames.length > 0 + ? `; tests without a reported result: ${unfinishedNames.join(", ")}.` + : "; every parsed test had reported a result, so the runner likely hung during teardown or exit."), + ); + } if (records.length === 0) throw new Error("The Swift runner did not report any individual test durations."); if (overBudget.length > 0) throw new Error(`${overBudget.length} Swift test(s) exceeded the local budget.`); if (result.code !== 0) throw new Error(`Swift test command exited with code ${result.code}.`); diff --git a/.agents/skills/write-stable-tests/scripts/test-stability-macos.sh b/.agents/skills/write-stable-tests/scripts/test-stability-macos.sh index d4a9e4cc..e64f62c9 100755 --- a/.agents/skills/write-stable-tests/scripts/test-stability-macos.sh +++ b/.agents/skills/write-stable-tests/scripts/test-stability-macos.sh @@ -5,6 +5,7 @@ SCRIPT_DIR="${0:A:h}" ROOT_DIR="$(cd -- "$SCRIPT_DIR/../../../.." && pwd)" WARN_SECONDS=1 MAX_SECONDS=15 +STALL_TIMEOUT_SECONDS=120 SUITE_TIMEOUT_SECONDS=600 REPORT="$ROOT_DIR/.artifacts/test-stability/macos-swift.json" SWIFT_ARGS=() @@ -19,6 +20,10 @@ while (( $# > 0 )); do MAX_SECONDS="$2" shift 2 ;; + --stall-timeout-seconds) + STALL_TIMEOUT_SECONDS="$2" + shift 2 + ;; --suite-timeout-seconds) SUITE_TIMEOUT_SECONDS="$2" shift 2 @@ -48,7 +53,7 @@ done for argument in "${SWIFT_ARGS[@]}"; do if [[ "$argument" == "--parallel" ]]; then - print -u2 -- "--parallel is not allowed: per-test watchdog attribution requires serial execution." + print -u2 -- "--parallel is not allowed: per-test duration attribution requires serial execution." exit 2 fi done @@ -57,6 +62,7 @@ done node "$SCRIPT_DIR/run-swift-tests-with-timing.mjs" \ --warn-ms "$(( WARN_SECONDS * 1000 ))" \ --max-ms "$(( MAX_SECONDS * 1000 ))" \ + --stall-timeout-ms "$(( STALL_TIMEOUT_SECONDS * 1000 ))" \ --suite-timeout-ms "$(( SUITE_TIMEOUT_SECONDS * 1000 ))" \ --report "$REPORT" \ -- "$ROOT_DIR/scripts/test-macos.sh" --no-parallel "${SWIFT_ARGS[@]}" diff --git a/.agents/skills/write-stable-tests/scripts/test-verify-test-stability.mjs b/.agents/skills/write-stable-tests/scripts/test-verify-test-stability.mjs index 19d8f39b..74715ed4 100755 --- a/.agents/skills/write-stable-tests/scripts/test-verify-test-stability.mjs +++ b/.agents/skills/write-stable-tests/scripts/test-verify-test-stability.mjs @@ -254,6 +254,209 @@ try { rmSync(swiftTimeoutRoot, { recursive: true, force: true }); } +// Regression coverage for the CI misattribution incident: block-buffered pipes +// can swallow a finish line, so a stall must be reported as runner silence with +// the unfinished tests listed, never as "test X exceeded the budget". +const swiftStallRoot = mkdtempSync(path.join(os.tmpdir(), "lithe-test-stability-swift-stall-")); +try { + const reportPath = path.join(swiftStallRoot, "swift-stall.json"); + await assert.rejects( + runSwiftTestsWithTiming( + { + warnMs: 50, + maxMs: 200, + stallTimeoutMs: 100, + suiteTimeoutMs: 5000, + report: reportPath, + command: "swift", + commandArguments: ["test"], + }, + { + runProcessImpl: async ({ onStdoutLine, onSpawn }) => { + let resolveTerminated; + const terminated = new Promise((resolve) => { + resolveTerminated = resolve; + }); + onSpawn({ + terminate: async () => { + resolveTerminated(); + return true; + }, + }); + onStdoutLine('◇ Suite "Keyboard shortcuts" started.'); + onStdoutLine("◇ Test fast() started."); + onStdoutLine("✔ Test fast() passed after 0.001 seconds."); + onStdoutLine("◇ Test truncatedFinishLine() started."); + // The finish line for truncatedFinishLine() never arrives, as when the + // runner's stdio buffer is lost; the stall watchdog must fire. + await terminated; + return { + code: null, + signal: "SIGTERM", + timedOut: false, + terminationConfirmed: true, + durationMs: 150, + stdout: "", + stderr: "", + }; + }, + }, + ), + /produced no output for 100ms; tests without a reported result: truncatedFinishLine\(\)/, + ); + const stallReport = JSON.parse(readFileSync(reportPath, "utf8")); + assert.equal(stallReport.process.stalled, true); + assert.deepEqual( + stallReport.tests.map(({ name, status }) => ({ name, status })), + [ + { name: "fast()", status: "passed" }, + { name: "truncatedFinishLine()", status: "timeout" }, + { name: "Swift test runner stall", status: "timeout" }, + ], + ); +} finally { + rmSync(swiftStallRoot, { recursive: true, force: true }); +} + +// A stall after every test reported a result points at teardown/exit instead of +// blaming any test. +const swiftTeardownStallRoot = mkdtempSync( + path.join(os.tmpdir(), "lithe-test-stability-swift-teardown-stall-"), +); +try { + const reportPath = path.join(swiftTeardownStallRoot, "swift-teardown-stall.json"); + await assert.rejects( + runSwiftTestsWithTiming( + { + warnMs: 50, + maxMs: 200, + stallTimeoutMs: 100, + suiteTimeoutMs: 5000, + report: reportPath, + command: "swift", + commandArguments: ["test"], + }, + { + runProcessImpl: async ({ onStdoutLine, onSpawn }) => { + let resolveTerminated; + const terminated = new Promise((resolve) => { + resolveTerminated = resolve; + }); + onSpawn({ + terminate: async () => { + resolveTerminated(); + return true; + }, + }); + onStdoutLine("◇ Test fast() started."); + onStdoutLine("✔ Test fast() passed after 0.001 seconds."); + await terminated; + return { + code: null, + signal: "SIGTERM", + timedOut: false, + terminationConfirmed: true, + durationMs: 150, + stdout: "", + stderr: "", + }; + }, + }, + ), + /produced no output for 100ms; every parsed test had reported a result/, + ); + const teardownReport = JSON.parse(readFileSync(reportPath, "utf8")); + assert.equal(teardownReport.process.stalled, true); + assert.deepEqual( + teardownReport.tests.map(({ name, status }) => ({ name, status })), + [ + { name: "fast()", status: "passed" }, + { name: "Swift test runner stall", status: "timeout" }, + ], + ); +} finally { + rmSync(swiftTeardownStallRoot, { recursive: true, force: true }); +} + +// The per-test budget is enforced from the durations swift-testing reports: a +// test that finishes over maxMs must fail the run even though the runner +// exited cleanly and no watchdog fired. +const swiftBudgetRoot = mkdtempSync(path.join(os.tmpdir(), "lithe-test-stability-swift-budget-")); +try { + const reportPath = path.join(swiftBudgetRoot, "swift-budget.json"); + await assert.rejects( + runSwiftTestsWithTiming( + { + warnMs: 50, + maxMs: 200, + stallTimeoutMs: 5000, + suiteTimeoutMs: 10000, + report: reportPath, + command: "swift", + commandArguments: ["test"], + }, + { + runProcessImpl: async ({ onStdoutLine, onSpawn }) => { + onSpawn({ terminate: async () => true }); + onStdoutLine("◇ Test overBudget() started."); + onStdoutLine("✔ Test overBudget() passed after 0.250 seconds."); + return { + code: 0, + signal: null, + timedOut: false, + terminationConfirmed: true, + durationMs: 300, + stdout: "", + stderr: "", + }; + }, + }, + ), + /1 Swift test\(s\) exceeded the local budget/, + ); + const budgetReport = JSON.parse(readFileSync(reportPath, "utf8")); + assert.deepEqual( + budgetReport.tests.map(({ name, status, durationMs }) => ({ name, status, durationMs })), + [{ name: "overBudget()", status: "passed", durationMs: 250 }], + ); +} finally { + rmSync(swiftBudgetRoot, { recursive: true, force: true }); +} + +// A spawn failure must reject promptly and clear the stall watchdog; a leaked +// ref'd timer would keep the harness process alive for the full stall timeout. +{ + const spawnFailureRoot = mkdtempSync( + path.join(os.tmpdir(), "lithe-test-stability-swift-spawn-failure-"), + ); + try { + await assert.rejects( + runSwiftTestsWithTiming( + { + warnMs: 50, + maxMs: 200, + stallTimeoutMs: 600000, + suiteTimeoutMs: 10000, + report: path.join(spawnFailureRoot, "swift-spawn-failure.json"), + command: "swift", + commandArguments: ["test"], + }, + { + runProcessImpl: async () => { + throw new Error("spawn ENOENT"); + }, + }, + ), + /spawn ENOENT/, + ); + // If the watchdog leaked, the 600s timer would hold this test process open + // long past its CI budget; reaching this line with a cleared event loop is + // asserted implicitly by the suite finishing on time. + } finally { + rmSync(spawnFailureRoot, { recursive: true, force: true }); + } +} + const rustCompileFailureRoot = mkdtempSync( path.join( os.tmpdir(), diff --git a/.github/lithe-issue-claim/README.md b/.github/lithe-issue-claim/README.md new file mode 100644 index 00000000..aa0994a9 --- /dev/null +++ b/.github/lithe-issue-claim/README.md @@ -0,0 +1,12 @@ +# Issue 认领机器人 + +`.github/workflows/lithe-issue-claim.yml` 只处理普通 Issue 的新评论,不处理 +Pull Request 评论。评论内容必须精确匹配 `/assign` 或 `/unassign`。 + +- `/assign` 在 Issue 没有负责人时尝试把评论者设为唯一 assignee,并添加 `claimed` 标签;若 GitHub 拒绝 assignee 写入,则用机器人评论 marker + 标签记录可见的 fallback 认领。 +- 已有负责人时不会抢占;当前负责人重复认领也不会重复写入。 +- `/unassign` 只能由当前负责人执行;仓库 owner、member 或 collaborator 可以代为释放。 +- 每天定时检查认领状态。认领满 30 天且没有认领者本人发布有效进度评论时,添加 `stale-claim` 标签并提醒。 +- 提醒后 7 天仍没有有效进度,自动释放 assignee 和状态标签。 + +“有效进度”目前定义为认领者在认领后发布的普通评论;`/assign` 和 `/unassign` 命令本身不算进展。 diff --git a/.github/lithe-issue-claim/logic.mjs b/.github/lithe-issue-claim/logic.mjs new file mode 100644 index 00000000..c33d7f04 --- /dev/null +++ b/.github/lithe-issue-claim/logic.mjs @@ -0,0 +1,72 @@ +export const CLAIM_LABEL = 'claimed'; +export const STALE_LABEL = 'stale-claim'; +export const CLAIM_MARKER = '`; +} + +export function warningMarker(login, isoDate) { + return `${WARNING_MARKER}${login}:${isoDate} -->`; +} + +export function releaseMarker(login, isoDate) { + return `${RELEASE_MARKER}${login}:${isoDate} -->`; +} + +export function isActiveClaim(claim, release) { + return Boolean(claim && (!release || release.login !== claim.login || new Date(release.isoDate) <= new Date(claim.isoDate))); +} + +export function releaseMatchesClaim(release, claim) { + return Boolean(release && claim && release.login === claim.login && new Date(release.isoDate) > new Date(claim.isoDate)); +} + +export function needsReleaseRecovery(claim, release, assignees = []) { + return Boolean(releaseMatchesClaim(release, claim) && assignees.includes(claim.login)); +} + +export function parseMarker(body, prefix) { + const match = (body ?? '').match(new RegExp(`${prefix.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')}([^: >]+):([^ >]+)`)); + return match ? { login: match[1], isoDate: match[2] } : null; +} + +export function daysSince(isoDate, now = new Date()) { + return (now.getTime() - new Date(isoDate).getTime()) / 86_400_000; +} + +export function claimStatus(claim, warning, now = new Date()) { + if (!claim) return 'unclaimed'; + if (warning) return daysSince(warning.isoDate, now) >= WARNING_GRACE_DAYS ? 'release' : 'stale'; + return daysSince(claim.isoDate, now) >= CLAIM_TTL_DAYS ? 'warn' : 'active'; +} + +export function hasProgressSince(comments, login, claimDate) { + return latestProgressDate(comments, login, claimDate) !== null; +} + +export function latestProgressDate(comments, login, claimDate) { + const dates = comments.filter((comment) => + comment.user?.login === login && + new Date(comment.created_at).getTime() > new Date(claimDate).getTime() && + parseCommand(comment.body) === null + ).map((comment) => comment.created_at); + return dates.length ? dates.sort().at(-1) : null; +} diff --git a/.github/workflows/lithe-issue-claim.yml b/.github/workflows/lithe-issue-claim.yml new file mode 100644 index 00000000..7d1ec18e --- /dev/null +++ b/.github/workflows/lithe-issue-claim.yml @@ -0,0 +1,173 @@ +name: Lithe issue claim + +on: + issue_comment: + types: [created] + schedule: + - cron: '17 2 * * *' + workflow_dispatch: + +permissions: + issues: write + +concurrency: + group: lithe-issue-claim-${{ github.repository }} + cancel-in-progress: false + +jobs: + command: + if: github.event_name == 'issue_comment' && github.event.issue.pull_request == null + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 1 + persist-credentials: false + - uses: actions/github-script@v7 + env: + CLAIM_LOGIC: ${{ github.workspace }}/.github/lithe-issue-claim/logic.mjs + with: + github-token: ${{ github.token }} + script: | + const logic = await import(process.env.CLAIM_LOGIC); + const { owner, repo } = context.repo; + const issue_number = context.issue.number; + const command = logic.parseCommand(context.payload.comment.body); + if (!command) return; + const login = context.payload.comment.user.login; + const issue = (await github.rest.issues.get({ owner, repo, issue_number })).data; + let assignee = issue.assignees?.[0]?.login; + const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number, per_page: 100 }); + const trusted = (c) => c.user?.type === 'Bot' && c.user?.login === 'github-actions[bot]'; + const recordedClaim = [...comments].reverse().filter(trusted).map((c) => logic.parseMarker(c.body, logic.CLAIM_MARKER)).find(Boolean); + const recordedRelease = [...comments].reverse().filter(trusted).map((c) => logic.parseMarker(c.body, logic.RELEASE_MARKER)).find((release) => logic.releaseMatchesClaim(release, recordedClaim)); + const activeClaim = logic.isActiveClaim(recordedClaim, recordedRelease) ? recordedClaim : null; + const maintainer = logic.isMaintainer(context.payload.comment.author_association); + const comment = async (body) => github.rest.issues.createComment({ owner, repo, issue_number, body }); + if (command === '/assign') { + if (assignee === login) return comment(`@${login} 已经认领了这个 Issue。`); + if (assignee && recordedClaim?.login === assignee && !activeClaim) { + try { + await github.rest.issues.removeAssignees({ owner, repo, issue_number, assignees: [assignee] }); + assignee = null; + await github.rest.issues.removeLabel({ owner, repo, issue_number, name: logic.STALE_LABEL }).catch(() => {}); + } catch (error) { + core.warning(`Unable to clear released assignee ${assignee}: ${error.message}`); + return comment('认领没有完成:当前 Issue 的旧负责人状态尚未清理,请稍后重试或联系维护者。'); + } + } + if (assignee) return comment(`这个 Issue 当前由 @${assignee} 负责,不能抢占。`); + if (activeClaim?.login === login) return comment(`@${login} 已经登记认领了这个 Issue。`); + if (activeClaim && activeClaim.login !== login) return comment(`这个 Issue 当前由 @${activeClaim.login} 负责,不能抢占。`); + let official = false; + try { + await github.rest.issues.addAssignees({ owner, repo, issue_number, assignees: [login] }); + const current = (await github.rest.issues.get({ owner, repo, issue_number })).data.assignees?.[0]?.login; + official = current === login; + } catch (error) { + core.warning(`Unable to set assignee for ${login}; using recorded claim fallback: ${error.message}`); + } + const marker = logic.claimMarker(login, new Date().toISOString()); + try { + await github.rest.issues.removeLabel({ owner, repo, issue_number, name: logic.STALE_LABEL }).catch(() => {}); + await github.rest.issues.addLabels({ owner, repo, issue_number, labels: [logic.CLAIM_LABEL] }); + await comment(`${marker}\n@${login} ${official ? '已认领这个 Issue。' : '已登记认领这个 Issue,等待维护者确认 GitHub 负责人字段。'}`); + } catch (error) { + if (official) await github.rest.issues.removeAssignees({ owner, repo, issue_number, assignees: [login] }).catch(() => {}); + await github.rest.issues.removeLabel({ owner, repo, issue_number, name: logic.CLAIM_LABEL }).catch(() => {}); + core.warning(`Unable to complete claim state for ${login}: ${error.message}`); + return comment('认领没有完成:机器人无法写入完整状态,请稍后重试或联系维护者。'); + } + return; + } + const effectiveOwner = assignee || activeClaim?.login; + if (!effectiveOwner) return comment('这个 Issue 当前没有负责人。'); + if (effectiveOwner !== login && !maintainer) return comment(`只有当前负责人 @${effectiveOwner} 或维护者可以释放认领。`); + try { + await comment(`${logic.releaseMarker(effectiveOwner, new Date().toISOString())}\n@${effectiveOwner} 已释放这个 Issue,其他贡献者可以继续认领。`); + } catch (error) { + core.warning(`Unable to record release for ${effectiveOwner}: ${error.message}`); + return comment('释放没有完成:机器人无法记录释放状态,请稍后重试或联系维护者。'); + } + if (assignee) await github.rest.issues.removeAssignees({ owner, repo, issue_number, assignees: [assignee] }).catch((error) => core.warning(`Unable to remove assignee: ${error.message}`)); + await github.rest.issues.removeLabel({ owner, repo, issue_number, name: logic.CLAIM_LABEL }).catch(() => {}); + await github.rest.issues.removeLabel({ owner, repo, issue_number, name: logic.STALE_LABEL }).catch(() => {}); + return; + + stale: + if: github.event_name != 'issue_comment' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 1 + persist-credentials: false + - uses: actions/github-script@v7 + env: + CLAIM_LOGIC: ${{ github.workspace }}/.github/lithe-issue-claim/logic.mjs + with: + github-token: ${{ github.token }} + script: | + const logic = await import(process.env.CLAIM_LOGIC); + const { owner, repo } = context.repo; + const issues = await github.paginate(github.rest.issues.listForRepo, { owner, repo, state: 'open', labels: logic.CLAIM_LABEL, per_page: 100 }); + for (const issue of issues.filter((item) => !item.pull_request)) { + const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: issue.number, per_page: 100 }); + const trusted = (c) => c.user?.type === 'Bot' && c.user?.login === 'github-actions[bot]'; + const claim = [...comments].reverse().filter(trusted).map((c) => logic.parseMarker(c.body, logic.CLAIM_MARKER)).find(Boolean); + if (!claim) { + await github.rest.issues.removeLabel({ owner, repo, issue_number: issue.number, name: logic.CLAIM_LABEL }).catch(() => {}); + continue; + } + const release = [...comments].reverse().filter(trusted).map((c) => logic.parseMarker(c.body, logic.RELEASE_MARKER)).find((candidate) => logic.releaseMatchesClaim(candidate, claim)); + if (!logic.isActiveClaim(claim, release)) { + const current = (await github.rest.issues.get({ owner, repo, issue_number: issue.number })).data; + let stillOwned = current.assignees?.some((a) => a.login === claim.login); + if (logic.needsReleaseRecovery(claim, release, current.assignees?.map((a) => a.login))) { + try { + await github.rest.issues.removeAssignees({ owner, repo, issue_number: issue.number, assignees: [claim.login] }); + stillOwned = false; + } catch (error) { + core.warning(`Unable to recover released assignee: ${error.message}`); + continue; + } + } + if (!stillOwned) { + await github.rest.issues.removeLabel({ owner, repo, issue_number: issue.number, name: logic.CLAIM_LABEL }).catch(() => {}); + await github.rest.issues.removeLabel({ owner, repo, issue_number: issue.number, name: logic.STALE_LABEL }).catch(() => {}); + } + continue; + } + const official = issue.assignees?.some((a) => a.login === claim.login); + const progressDate = logic.latestProgressDate(comments, claim.login, claim.isoDate); + const activityDate = progressDate || claim.isoDate; + const now = new Date(); + const warning = [...comments].reverse().filter(trusted).map((c) => logic.parseMarker(c.body, logic.WARNING_MARKER)).find((m) => m?.login === claim.login && new Date(m.isoDate) > new Date(activityDate) && new Date(m.isoDate) <= now); + if (progressDate && !warning) await github.rest.issues.removeLabel({ owner, repo, issue_number: issue.number, name: logic.STALE_LABEL }).catch(() => {}); + const status = logic.claimStatus({ ...claim, isoDate: activityDate }, warning); + if (status === 'warn') { + await github.rest.issues.addLabels({ owner, repo, issue_number: issue.number, labels: [logic.STALE_LABEL] }); + await github.rest.issues.createComment({ owner, repo, issue_number: issue.number, body: `${logic.warningMarker(claim.login, new Date().toISOString())}\n@${claim.login} 已认领此 Issue 30 天,但暂未看到进展。请在 7 天内更新进度或使用 /unassign 释放。` }); + } else if (status === 'release') { + const latestComments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: issue.number, per_page: 100 }); + const latestClaim = [...latestComments].reverse().filter(trusted).map((c) => logic.parseMarker(c.body, logic.CLAIM_MARKER)).find(Boolean); + if (latestClaim?.login !== claim.login || latestClaim?.isoDate !== claim.isoDate) continue; + const releaseBody = `${logic.releaseMarker(claim.login, new Date().toISOString())}\n@${claim.login} 已连续 7 天未更新进度,认领已自动释放,其他贡献者可以继续申请。`; + const existingRelease = latestComments.filter(trusted).map((c) => logic.parseMarker(c.body, logic.RELEASE_MARKER)).some((release) => logic.releaseMatchesClaim(release, claim)); + if (!existingRelease) await github.rest.issues.createComment({ owner, repo, issue_number: issue.number, body: releaseBody }); + const current = (await github.rest.issues.get({ owner, repo, issue_number: issue.number })).data; + const stillOwned = current.assignees?.some((a) => a.login === claim.login); + if (stillOwned) { + try { + await github.rest.issues.removeAssignees({ owner, repo, issue_number: issue.number, assignees: [claim.login] }); + } catch (error) { + core.warning(`Unable to remove stale assignee: ${error.message}`); + continue; + } + } + await github.rest.issues.removeLabel({ owner, repo, issue_number: issue.number, name: logic.CLAIM_LABEL }).catch(() => {}); + await github.rest.issues.removeLabel({ owner, repo, issue_number: issue.number, name: logic.STALE_LABEL }).catch(() => {}); + } + } diff --git a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift index c544c1f7..8e3247df 100644 --- a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift +++ b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift @@ -818,6 +818,12 @@ struct RustCoreBridge: Sendable { let conflictedPaths: [String] } + struct Warning: Decodable, Sendable { + let code: String + let message: String + let details: String? + } + let arguments: [String]? let output: String let stdout: String? @@ -826,6 +832,7 @@ struct RustCoreBridge: Sendable { let invocations: [Invocation]? let operationError: OperationError? let stashRestore: StashRestore? + let warnings: [Warning]? } struct GitDiffPayload: Decodable, Sendable { @@ -1720,11 +1727,24 @@ struct RustCoreBridge: Sendable { let input: String? } + private struct GitReferenceRequest: Encodable { + let fullName: String + let shortName: String + let kind: String + + init(_ reference: GitReference) { + fullName = reference.fullName + shortName = reference.shortName + kind = reference.kind.rawValue + } + } + private struct GitWriteRequest: Encodable { let root: String let operation: String let paths: [String] let reference: String? + let gitReference: GitReferenceRequest? let referenceKind: String? let revision: String? let name: String? @@ -1743,6 +1763,8 @@ struct RustCoreBridge: Sendable { let root: String let pathspecs: [String] let reference: String? + let gitReference: GitReferenceRequest? + let targetGitReference: GitReferenceRequest? let commit: String? let staged: Bool let untracked: Bool @@ -1774,7 +1796,9 @@ struct RustCoreBridge: Sendable { private struct GitComparisonRequest: Encodable { let root: String - let reference: String + let reference: String? + let gitReference: GitReferenceRequest? + let targetGitReference: GitReferenceRequest? } private struct GitStashesRequest: Encodable { @@ -1783,7 +1807,7 @@ struct RustCoreBridge: Sendable { private struct GitCheckoutPreflightRequest: Encodable { let root: String - let reference: String + let gitReference: GitReferenceRequest } private struct GitOperationStateRequest: Encodable { @@ -1800,7 +1824,8 @@ struct RustCoreBridge: Sendable { private struct GitIntegrationPreflightRequest: Encodable { let root: String - let reference: String + let reference: String? + let gitReference: GitReferenceRequest? let operation: String } @@ -2613,6 +2638,7 @@ struct RustCoreBridge: Sendable { operation: String, paths: [String] = [], reference: String? = nil, + gitReference: GitReference? = nil, referenceKind: String? = nil, revision: String? = nil, name: String? = nil, @@ -2633,6 +2659,7 @@ struct RustCoreBridge: Sendable { operation: operation, paths: paths, reference: reference, + gitReference: gitReference.map(GitReferenceRequest.init), referenceKind: referenceKind, revision: revision, name: name, @@ -2649,12 +2676,12 @@ struct RustCoreBridge: Sendable { ) } - func gitCheckoutPreflight(at rootURL: URL, reference: String) -> GitCheckoutPreflightPayload? { + func gitCheckoutPreflight(at rootURL: URL, reference: GitReference) -> GitCheckoutPreflightPayload? { execute( command: "git.checkoutPreflight", payload: GitCheckoutPreflightRequest( root: rootURL.standardizedFileURL.path, - reference: reference + gitReference: GitReferenceRequest(reference) ) ) } @@ -2688,7 +2715,8 @@ struct RustCoreBridge: Sendable { func gitIntegrationPreflight( at rootURL: URL, - reference: String, + reference: String? = nil, + gitReference: GitReference? = nil, operation: String ) -> GitIntegrationPreflightPayload? { execute( @@ -2696,6 +2724,7 @@ struct RustCoreBridge: Sendable { payload: GitIntegrationPreflightRequest( root: rootURL.standardizedFileURL.path, reference: reference, + gitReference: gitReference.map(GitReferenceRequest.init), operation: operation ) ) @@ -2706,6 +2735,7 @@ struct RustCoreBridge: Sendable { operation: String, paths: [String] = [], reference: String? = nil, + gitReference: GitReference? = nil, referenceKind: String? = nil, revision: String? = nil, name: String? = nil, @@ -2726,6 +2756,7 @@ struct RustCoreBridge: Sendable { operation: operation, paths: paths, reference: reference, + gitReference: gitReference.map(GitReferenceRequest.init), referenceKind: referenceKind, revision: revision, name: name, @@ -2746,6 +2777,8 @@ struct RustCoreBridge: Sendable { at rootURL: URL, pathspecs: [String], reference: String? = nil, + gitReference: GitReference? = nil, + targetGitReference: GitReference? = nil, commit: String? = nil, staged: Bool, untracked: Bool, @@ -2758,6 +2791,8 @@ struct RustCoreBridge: Sendable { root: rootURL.standardizedFileURL.path, pathspecs: pathspecs, reference: reference, + gitReference: gitReference.map(GitReferenceRequest.init), + targetGitReference: targetGitReference.map(GitReferenceRequest.init), commit: commit, staged: staged, untracked: untracked, @@ -2829,12 +2864,19 @@ struct RustCoreBridge: Sendable { ) } - func gitComparison(at rootURL: URL, reference: String) -> GitComparisonPayload? { + func gitComparison( + at rootURL: URL, + reference: String? = nil, + gitReference: GitReference? = nil, + targetGitReference: GitReference? = nil + ) -> GitComparisonPayload? { execute( command: "git.comparison", payload: GitComparisonRequest( root: rootURL.standardizedFileURL.path, - reference: reference + reference: reference, + gitReference: gitReference.map(GitReferenceRequest.init), + targetGitReference: targetGitReference.map(GitReferenceRequest.init) ) ) } diff --git a/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift b/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift index cbc5f34f..69cc7c35 100644 --- a/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift +++ b/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift @@ -30,7 +30,10 @@ struct RustGitOperations: GitOperations, Sendable { stashReference: $0.stashReference, conflictedPaths: $0.conflictedPaths ) - } + }, + warnings: response.warnings?.map { + GitOperationWarning(code: $0.code, message: $0.message, details: $0.details) + } ?? [] ) } @@ -60,6 +63,7 @@ struct RustGitOperations: GitOperations, Sendable { operation: String, paths: [String] = [], reference: String? = nil, + gitReference: GitReference? = nil, referenceKind: GitReferenceKind? = nil, revision: String? = nil, name: String? = nil, @@ -78,6 +82,7 @@ struct RustGitOperations: GitOperations, Sendable { operation: operation, paths: paths, reference: reference, + gitReference: gitReference, referenceKind: referenceKind?.rawValue, revision: revision, name: name, @@ -134,39 +139,30 @@ struct RustGitOperations: GitOperations, Sendable { write( at: rootURL, operation: "createBranch", - reference: reference.fullName, + gitReference: reference, name: name, checkout: checkout ) } func renameBranch(_ reference: GitReference, to name: String, at rootURL: URL) -> GitProcessResult? { - write(at: rootURL, operation: "renameBranch", reference: reference.fullName, name: name) + write(at: rootURL, operation: "renameBranch", gitReference: reference, name: name) } func deleteBranch(_ reference: GitReference, at rootURL: URL) -> GitProcessResult? { - write(at: rootURL, operation: "deleteBranch", reference: reference.fullName) + write(at: rootURL, operation: "deleteBranch", gitReference: reference) } func mergeBranch(_ reference: GitReference, at rootURL: URL) -> GitProcessResult? { - write(at: rootURL, operation: "merge", reference: reference.fullName) + write(at: rootURL, operation: "merge", gitReference: reference) } func rebaseCurrentBranch(onto reference: GitReference, at rootURL: URL) -> GitProcessResult? { - write(at: rootURL, operation: "rebase", reference: reference.fullName) + write(at: rootURL, operation: "rebase", gitReference: reference) } func checkoutAndRebase(_ reference: GitReference, at rootURL: URL) -> GitProcessResult? { - write( - at: rootURL, - operation: "checkoutAndRebase", - reference: reference.fullName, - referenceKind: reference.kind - ) - } - - func updateCurrentBranch(at rootURL: URL, strategy: GitPullStrategy = .ffOnly) -> GitProcessResult? { - write(at: rootURL, operation: "pull", mode: strategy.rawValue) + write(at: rootURL, operation: "checkoutAndRebase", gitReference: reference) } func pullRemoteReference( @@ -177,12 +173,15 @@ struct RustGitOperations: GitOperations, Sendable { write( at: rootURL, operation: "pull", - reference: reference.fullName, - referenceKind: reference.kind, + gitReference: reference, mode: strategy.rawValue ) } + func updateCurrentBranch(at rootURL: URL, strategy: GitPullStrategy = .ffOnly) -> GitProcessResult? { + write(at: rootURL, operation: "pull", mode: strategy.rawValue) + } + /// Staged files still containing conflict markers. func conflictMarkerPaths(at rootURL: URL) -> [String] { core.gitConflictMarkerPaths(at: rootURL)?.paths ?? [] @@ -194,11 +193,22 @@ struct RustGitOperations: GitOperations, Sendable { operation: GitIntegrationOperation, at rootURL: URL ) -> GitIntegrationPreflightState? { - guard let payload = core.gitIntegrationPreflight( - at: rootURL, - reference: target.revision, - operation: operation.rawValue - ) else { return nil } + let payload: RustCoreBridge.GitIntegrationPreflightPayload? + switch target { + case .reference(let reference): + payload = core.gitIntegrationPreflight( + at: rootURL, + gitReference: reference, + operation: operation.rawValue + ) + case .commit: + payload = core.gitIntegrationPreflight( + at: rootURL, + reference: target.revision, + operation: operation.rawValue + ) + } + guard let payload else { return nil } return GitIntegrationPreflightState( blockingPaths: payload.blockingPaths, blocksEntirely: payload.blocksEntirely @@ -230,8 +240,7 @@ struct RustGitOperations: GitOperations, Sendable { write( at: rootURL, operation: "checkout", - reference: reference.fullName, - referenceKind: reference.kind, + gitReference: reference, force: force, autoStash: autoStash ) @@ -239,7 +248,7 @@ struct RustGitOperations: GitOperations, Sendable { /// Returns the working-tree paths that would block checking out `reference`. func checkoutBlockingPaths(for reference: GitReference, at rootURL: URL) -> [String] { - core.gitCheckoutPreflight(at: rootURL, reference: reference.fullName)?.blockingPaths ?? [] + core.gitCheckoutPreflight(at: rootURL, reference: reference)?.blockingPaths ?? [] } func operationState(at rootURL: URL) -> GitOperationState? { @@ -273,7 +282,7 @@ struct RustGitOperations: GitOperations, Sendable { } func push(_ reference: GitReference, at rootURL: URL) -> GitProcessResult? { - write(at: rootURL, operation: "push", reference: reference.fullName) + write(at: rootURL, operation: "push", gitReference: reference) } func cloneRepository(from remote: String, to destination: URL) -> GitProcessResult? { @@ -382,6 +391,24 @@ struct RustGitOperations: GitOperations, Sendable { )?.makeDocument() } + func comparisonDiffDocument( + at rootURL: URL, + reference: GitReference, + targetReference: GitReference?, + pathspecs: [String], + whitespace: GitDiffWhitespaceMode = .doNotIgnore + ) -> DiffDocument? { + core.gitDiff( + at: rootURL, + pathspecs: pathspecs, + gitReference: reference, + targetGitReference: targetReference, + staged: false, + untracked: false, + ignoreAllWhitespace: whitespace == .ignoreAllWhitespace + )?.makeDocument() + } + func applyPatch( _ patch: String, at rootURL: URL, @@ -421,7 +448,7 @@ struct RustGitOperations: GitOperations, Sendable { for reference: GitReference, at rootURL: URL ) -> GitBranchComparison? { - guard let payload = core.gitComparison(at: rootURL, reference: reference.fullName) else { + guard let payload = core.gitComparison(at: rootURL, gitReference: reference) else { return nil } return GitBranchComparison( @@ -432,6 +459,25 @@ struct RustGitOperations: GitOperations, Sendable { ) } + func comparison( + from reference: GitReference, + to target: GitReference, + at rootURL: URL + ) -> GitBranchComparison? { + guard let payload = core.gitComparison( + at: rootURL, + gitReference: reference, + targetGitReference: target + ) else { return nil } + return GitBranchComparison( + reference: reference, + targetReference: target, + files: payload.files.map { file in + GitBranchComparisonFile(status: file.status, path: file.path) + } + ) + } + func stashes(at rootURL: URL) -> [GitStash]? { core.gitStashes(at: rootURL)?.stashes.map { stash in GitStash( diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+ExecutionModules.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+ExecutionModules.swift index 89483485..4168b0dd 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+ExecutionModules.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+ExecutionModules.swift @@ -22,6 +22,11 @@ extension AppModel { } func activateExecutionModule() async -> ExecutionFeatureAccess? { + // Run and Debug activate on demand, so they can arrive while the previous + // session's module graph is still being torn down. Activating first would + // hand back a run feature that teardown releases moments later, and the + // deferred action waiting on it would never be resumed. + await awaitModuleRuntimeShutdown() if let mavenFeature = mavenFeatureIfActive, let runFeature = runFeatureIfActive, let tests = languageTestServiceIfActive, @@ -50,6 +55,7 @@ extension AppModel { } func activateDebugModule() async -> DebugFeatureAccess? { + await awaitModuleRuntimeShutdown() if let genericFeature = genericDebugFeatureIfActive { configureDebugHostHandlers(genericFeature) if let workspaceURL { genericFeature.openWorkspace(at: workspaceURL) } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel.swift b/macos/Sources/Lithe/Models/AppModel/AppModel.swift index 6d442cd4..c63c61c3 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -485,7 +485,7 @@ final class AppModel: ObservableObject, Identifiable { }, reloadProjectServices: { [weak self] in guard let self, let workspaceURL = self.workspaceURL else { return } - await self.loadProjectServices(at: workspaceURL, files: self.projectFiles) + await self.loadProjectServicesForAppliedSnapshot(at: workspaceURL) }, refreshGit: { [weak self] in guard let feature = self?.gitFeatureIfActive else { return } @@ -496,9 +496,15 @@ final class AppModel: ObservableObject, Identifiable { await feature.updateVisibilityRules(rules.localHistoryRules) }, onSnapshotLoaded: { [weak self] snapshot, isInitialLoad in - guard let self, let workspaceURL = self.workspaceURL else { return } - // WorkspaceFeatureModel requests the single Git refresh after this callback. - await self.loadProjectServices(at: workspaceURL, files: snapshot.files) + guard let self else { return } + // The snapshot callback owns the transition from a provisional + // inventory to a ready run project and resumes any deferred action. + await self.loadProjectServices( + at: snapshot.root.url, + files: snapshot.files, + snapshotID: snapshot.id, + resumesDeferredRunAction: true + ) if isInitialLoad { self.projectHistoryFeatureIfActive?.seed(files: snapshot.files) } @@ -696,9 +702,6 @@ final class AppModel: ObservableObject, Identifiable { func shutdownProjectSession() async { shortcutDetector?.stop() - Task { [weak self] in - await self?.services.moduleRuntime.shutdownAll() - } cancelJavaTestWorkflows() languageToolingSessionsIfActive?.stopAll() languageTestServiceIfActive?.stop() @@ -711,22 +714,35 @@ final class AppModel: ObservableObject, Identifiable { await shutdownModuleRuntime() } + /// Records this session's module-graph teardown before it can yield, so an + /// on-demand activation that follows a project switch can join the same + /// operation instead of racing a capability release. + private func beginModuleRuntimeShutdown() { + guard moduleRuntimeShutdownTask == nil else { return } + let moduleRuntime = services.moduleRuntime + moduleRuntimeShutdownTask = Task { @MainActor [weak self] in + await moduleRuntime.shutdownAll() + guard let self else { return } + self.moduleRuntimeShutdownTask = nil + self.clearModuleBindings(for: .database) + } + } + /// Shuts down this session's module graph once, even when multiple /// lifecycle paths request cleanup at the same time. private func shutdownModuleRuntime() async { - if let moduleRuntimeShutdownTask { - await moduleRuntimeShutdownTask.value - return - } + beginModuleRuntimeShutdown() + await moduleRuntimeShutdownTask?.value + } - let moduleRuntime = services.moduleRuntime - let shutdownTask = Task { @MainActor in - await moduleRuntime.shutdownAll() + /// Lets an on-demand activation resume after a session teardown finishes. + /// + /// A shutdown that starts while this wait is suspended is joined as well, so + /// activation never returns a capability the runtime is about to release. + func awaitModuleRuntimeShutdown() async { + while let shutdownTask = moduleRuntimeShutdownTask { + await shutdownTask.value } - moduleRuntimeShutdownTask = shutdownTask - await shutdownTask.value - moduleRuntimeShutdownTask = nil - clearModuleBindings(for: .database) } private func reloadJavaRuntimeServices() { @@ -743,30 +759,11 @@ final class AppModel: ObservableObject, Identifiable { } Task { [weak self] in guard let self else { return } - await self.loadProjectServices(at: workspaceURL, files: self.projectFiles) + await self.loadProjectServicesForAppliedSnapshot(at: workspaceURL) } } } - /// Loads build-system and run state at the workspace boundary. The generic - /// run lifecycle is intentionally not owned by JavaFeatureModel. - func loadProjectServices(at workspaceURL: URL, files: [URL]) async { - prepareJavaLanguageServerForWorkspaceIfNeeded( - at: workspaceURL, - files: files - ) - await springFeature.load( - workspaceURL: workspaceURL, - files: files, - textOverrides: Dictionary(uniqueKeysWithValues: openDocuments.map { - ($0.url.standardizedFileURL, $0.text) - }) - ) - guard let execution = await activateExecutionModule() else { return } - execution.tests.discover(workspaceURL: workspaceURL, files: files) - await execution.projectDevelopment.loadProject(at: workspaceURL, files: files) - } - var projectName: String { workspaceURL?.lastPathComponent ?? "Lithe" } @@ -909,10 +906,7 @@ final class AppModel: ObservableObject, Identifiable { func openProjectDirectly(_ url: URL) { let normalizedURL = url.standardizedFileURL - Task { [weak self] in - guard let self else { return } - await self.shutdownModuleRuntime() - } + beginModuleRuntimeShutdown() if let previousWorkspaceURL = workspaceURL { workspaceFeature.persistWorkspaceSession(for: previousWorkspaceURL) } @@ -929,6 +923,8 @@ final class AppModel: ObservableObject, Identifiable { runtimeFeature.openProject(at: normalizedURL) mavenFeatureIfActive?.reset() runFeatureIfActive?.reset() + pendingRunAction = nil + scheduleObjectWillChangeRelay() genericDebugFeatureIfActive?.reset() debugBreakpointPresentation.reset() clearLanguageNavigationProjection() @@ -963,13 +959,21 @@ final class AppModel: ObservableObject, Identifiable { pendingProjectItemDeletion = nil recentProjects = recentProjectsStore.record(normalizedURL, in: recentProjects) + // The rebuild belongs to this opening. Reopening the same path advances + // the generation, so a rebuild left over from the previous opening + // cannot publish its snapshot into this one. + let generation = workspaceFeature.workspaceGeneration Task { await restoreDebugBreakpoints(for: normalizedURL) - guard workspaceURL == normalizedURL else { return } + guard workspaceURL == normalizedURL, + workspaceFeature.workspaceGeneration == generation else { return } _ = await workspaceFeature.rebuild( at: normalizedURL, rules: visibilityRules, - isCurrent: { [weak self] in self?.workspaceURL == normalizedURL } + isCurrent: { [weak self] in + self?.workspaceURL == normalizedURL + && self?.workspaceFeature.workspaceGeneration == generation + } ) } } @@ -996,10 +1000,7 @@ final class AppModel: ObservableObject, Identifiable { private func performCloseProject() { cancelJavaLanguageServerPreparation() - Task { [weak self] in - guard let self else { return } - await self.shutdownModuleRuntime() - } + beginModuleRuntimeShutdown() if let workspaceURL { workspaceFeature.persistWorkspaceSession(for: workspaceURL) } @@ -1039,6 +1040,8 @@ final class AppModel: ObservableObject, Identifiable { runtimeFeature.closeProject() mavenFeatureIfActive?.reset() runFeatureIfActive?.reset() + pendingRunAction = nil + scheduleObjectWillChangeRelay() genericDebugFeatureIfActive?.reset() debugBreakpointPresentation.reset() javaFeature.stop() diff --git a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift index 9e3365bb..97697c95 100644 --- a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift +++ b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift @@ -24,6 +24,46 @@ private enum WorkbenchWorkspaceMetrics { static let paneCornerRadius: CGFloat = 10 } +private enum WorkbenchPopoverLayoutMetrics { + static let leadingOverlap: CGFloat = 10 + static let viewportMargin: CGFloat = 8 + static let arrowWidth: CGFloat = 22 + static let arrowHeight: CGFloat = 12 +} + +private struct WorkbenchPopoverArrow: Shape { + func path(in rect: CGRect) -> Path { + var path = Path() + path.move(to: CGPoint(x: rect.minX, y: rect.maxY)) + path.addLine(to: CGPoint(x: rect.midX, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY)) + path.closeSubpath() + return path + } +} + +private struct ProjectSwitcherButtonBoundsPreferenceKey: PreferenceKey { + static var defaultValue: Anchor? + + static func reduce( + value: inout Anchor?, + nextValue: () -> Anchor? + ) { + value = nextValue() ?? value + } +} + +private struct BranchSwitcherButtonBoundsPreferenceKey: PreferenceKey { + static var defaultValue: Anchor? + + static func reduce( + value: inout Anchor?, + nextValue: () -> Anchor? + ) { + value = nextValue() ?? value + } +} + struct WorkbenchView: View { private let moduleUIRegistry = WorkbenchModuleUIComposition.builtIn @EnvironmentObject private var model: AppModel @@ -234,26 +274,34 @@ struct WorkbenchView: View { } message: { Text(model.pendingDiscardHunk?.change.path ?? "This action cannot be undone by Lithe.") } - .confirmationDialog( - "Push '\(pendingTopBarPushReference?.shortName ?? "")'?", - isPresented: Binding( - get: { pendingTopBarPushReference != nil }, - set: { if !$0 { pendingTopBarPushReference = nil } } - ), - titleVisibility: .visible - ) { - Button("Push") { - guard let reference = pendingTopBarPushReference else { return } - pendingTopBarPushReference = nil - Task { await model.pushBranch(reference) } + .sheet(item: $pendingTopBarPushReference) { reference in + GitPushDialog( + projectName: model.projectName, + reference: reference, + onPush: { + Task { await model.pushBranch(reference) } + } + ) + } + .overlayPreferenceValue(ProjectSwitcherButtonBoundsPreferenceKey.self) { bounds in + GeometryReader { geometry in + if isProjectSwitcherPresented, let bounds { + projectSwitcherOverlay( + buttonFrame: geometry[bounds], + viewportSize: geometry.size + ) + } } - .lithePointer() - Button("Cancel", role: .cancel) { - pendingTopBarPushReference = nil + } + .overlayPreferenceValue(BranchSwitcherButtonBoundsPreferenceKey.self) { bounds in + GeometryReader { geometry in + if isBranchSwitcherPresented, let bounds { + branchSwitcherOverlay( + buttonFrame: geometry[bounds], + viewportSize: geometry.size + ) + } } - .lithePointer() - } message: { - Text("This sends the current branch to its configured remote.") } .overlay(alignment: .bottom) { if let message = model.notificationMessage { @@ -406,7 +454,10 @@ struct WorkbenchView: View { private var topBar: some View { HStack(spacing: 9) { Button { - isProjectSwitcherPresented.toggle() + updateSwitcherPresentation( + project: !isProjectSwitcherPresented, + branch: false + ) } label: { HStack(spacing: 8) { LitheLogo(size: 24) @@ -431,28 +482,10 @@ struct WorkbenchView: View { .buttonStyle(.plain) .lithePointer() .accessibilityIdentifier("project-switcher-\(model.id.uuidString)") - .popover(isPresented: $isProjectSwitcherPresented, arrowEdge: .bottom) { - ProjectSwitcherPopover( - isPresented: $isProjectSwitcherPresented, - onNewProject: { - isProjectSwitcherPresented = false - model.chooseProject(title: "New Project", prompt: "Choose Folder") - }, - onOpenProject: { - isProjectSwitcherPresented = false - model.chooseProject() - }, - onCloneRepository: { - isProjectSwitcherPresented = false - model.showCloneRepository() - }, - onOpenRecentProject: { project in - isProjectSwitcherPresented = false - model.openProject(project.url) - } - ) - .environmentObject(model) - } + .anchorPreference( + key: ProjectSwitcherButtonBoundsPreferenceKey.self, + value: .bounds + ) { $0 } Rectangle() .fill(LitheTheme.divider) @@ -460,7 +493,10 @@ struct WorkbenchView: View { .padding(.horizontal, 5) Button { - isBranchSwitcherPresented.toggle() + updateSwitcherPresentation( + project: false, + branch: !isBranchSwitcherPresented + ) if isBranchSwitcherPresented { Task { await model.refreshGitHistory() } } @@ -490,27 +526,142 @@ struct WorkbenchView: View { } .buttonStyle(.plain) .lithePointer() - .popover(isPresented: $isBranchSwitcherPresented, arrowEdge: .bottom) { + .anchorPreference( + key: BranchSwitcherButtonBoundsPreferenceKey.self, + value: .bounds + ) { $0 } + + Spacer(minLength: 22) + + runConfigurationPicker + runLaunchButton + debugLaunchButton + if hasActiveExecution { + stopExecutionButton + } + + backgroundPickerButton + + } + .padding(.leading, 76) + .padding(.trailing, 10) + .frame(height: LitheTheme.Metrics.toolbarHeight) + .background { + (model.workbenchBackgroundFeature.hasImage ? Color.clear : LitheTheme.titlebar) + .contentShape(Rectangle()) + .onTapGesture(count: 2) { + (NSApplication.shared.keyWindow?.delegate as? LitheWindowCoordinator)? + .toggleWorkspaceZoom() + } + } + } + + private func projectSwitcherOverlay( + buttonFrame: CGRect, + viewportSize: CGSize + ) -> some View { + let popupMetrics = ProjectSwitcherLayoutMetrics.self + let chromeMetrics = WorkbenchPopoverLayoutMetrics.self + let placement = workbenchPopoverPlacement( + buttonFrame: buttonFrame, + viewportWidth: viewportSize.width, + popupWidth: popupMetrics.width + ) + + return ZStack(alignment: .topLeading) { + Color.clear + .contentShape(Rectangle()) + .onTapGesture { updateSwitcherPresentation(project: false) } + + ZStack(alignment: .topLeading) { + WorkbenchPopoverArrow() + .fill(LitheTheme.popupBackground) + .overlay { + WorkbenchPopoverArrow() + .stroke(LitheTheme.panelBorder, lineWidth: 1) + } + .frame(width: chromeMetrics.arrowWidth, height: chromeMetrics.arrowHeight) + .offset(x: placement.arrowCenterX - (chromeMetrics.arrowWidth / 2)) + + ProjectSwitcherPopover( + isPresented: instantProjectSwitcherPresentation, + onNewProject: { + updateSwitcherPresentation(project: false) + model.chooseProject(title: "New Project", prompt: "Choose Folder") + }, + onOpenProject: { + updateSwitcherPresentation(project: false) + model.chooseProject() + }, + onCloneRepository: { + updateSwitcherPresentation(project: false) + model.showCloneRepository() + }, + onOpenRecentProject: { project in + updateSwitcherPresentation(project: false) + model.openProject(project.url) + } + ) + .environmentObject(model) + .lithePopupChrome() + .padding(.top, chromeMetrics.arrowHeight - 1) + } + .offset(x: placement.popupX, y: buttonFrame.maxY) + } + .transaction { transaction in + transaction.animation = nil + transaction.disablesAnimations = true + } + .onExitCommand { updateSwitcherPresentation(project: false) } + } + + private func branchSwitcherOverlay( + buttonFrame: CGRect, + viewportSize: CGSize + ) -> some View { + let popupMetrics = BranchSwitcherPopover.Metrics.self + let chromeMetrics = WorkbenchPopoverLayoutMetrics.self + let placement = workbenchPopoverPlacement( + buttonFrame: buttonFrame, + viewportWidth: viewportSize.width, + popupWidth: popupMetrics.popupWidth + ) + + return ZStack(alignment: .topLeading) { + Color.clear + .contentShape(Rectangle()) + .onTapGesture { updateSwitcherPresentation(branch: false) } + + ZStack(alignment: .topLeading) { + WorkbenchPopoverArrow() + .fill(LitheTheme.popupBackground) + .overlay { + WorkbenchPopoverArrow() + .stroke(LitheTheme.panelBorder, lineWidth: 1) + } + .frame(width: chromeMetrics.arrowWidth, height: chromeMetrics.arrowHeight) + .offset(x: placement.arrowCenterX - (chromeMetrics.arrowWidth / 2)) + BranchSwitcherPopover( - isPresented: $isBranchSwitcherPresented, + isPresented: instantBranchSwitcherPresentation, onCommit: { - isBranchSwitcherPresented = false + updateSwitcherPresentation(branch: false) model.selectedSidebar = .changes }, onPush: { reference in - isBranchSwitcherPresented = false + updateSwitcherPresentation(branch: false) pendingTopBarPushReference = reference }, onNewBranch: { reference in - isBranchSwitcherPresented = false + updateSwitcherPresentation(branch: false) newBranchReference = reference }, onCheckoutRevision: { - isBranchSwitcherPresented = false + updateSwitcherPresentation(branch: false) isCheckoutRevisionPresented = true }, onManageBranches: { - isBranchSwitcherPresented = false + updateSwitcherPresentation(branch: false) if !model.isGitLogVisible { model.selectedSidebar = .changes Task { await model.toggleGitLog() } @@ -518,30 +669,63 @@ struct WorkbenchView: View { } ) .environmentObject(model) + .padding(.top, chromeMetrics.arrowHeight - 1) } + .offset(x: placement.popupX, y: buttonFrame.maxY) + } + .transaction { transaction in + transaction.animation = nil + transaction.disablesAnimations = true + } + .onExitCommand { updateSwitcherPresentation(branch: false) } + } - Spacer(minLength: 22) + private func workbenchPopoverPlacement( + buttonFrame: CGRect, + viewportWidth: CGFloat, + popupWidth: CGFloat + ) -> (popupX: CGFloat, arrowCenterX: CGFloat) { + let metrics = WorkbenchPopoverLayoutMetrics.self + let desiredX = buttonFrame.minX - metrics.leadingOverlap + let maximumX = max( + metrics.viewportMargin, + viewportWidth - popupWidth - metrics.viewportMargin + ) + let popupX = min(max(desiredX, metrics.viewportMargin), maximumX) + let arrowCenterX = min( + max(buttonFrame.midX - popupX, metrics.arrowWidth), + popupWidth - metrics.arrowWidth + ) + return (popupX, arrowCenterX) + } - runConfigurationPicker - runLaunchButton - debugLaunchButton - if hasActiveExecution { - stopExecutionButton - } + private var instantProjectSwitcherPresentation: Binding { + Binding( + get: { isProjectSwitcherPresented }, + set: { updateSwitcherPresentation(project: $0) } + ) + } - backgroundPickerButton + private var instantBranchSwitcherPresentation: Binding { + Binding( + get: { isBranchSwitcherPresented }, + set: { updateSwitcherPresentation(branch: $0) } + ) + } - } - .padding(.leading, 76) - .padding(.trailing, 10) - .frame(height: LitheTheme.Metrics.toolbarHeight) - .background { - (model.workbenchBackgroundFeature.hasImage ? Color.clear : LitheTheme.titlebar) - .contentShape(Rectangle()) - .onTapGesture(count: 2) { - (NSApplication.shared.keyWindow?.delegate as? LitheWindowCoordinator)? - .toggleWorkspaceZoom() - } + private func updateSwitcherPresentation( + project: Bool? = nil, + branch: Bool? = nil + ) { + var transaction = Transaction(animation: nil) + transaction.disablesAnimations = true + withTransaction(transaction) { + if let project { + isProjectSwitcherPresented = project + } + if let branch { + isBranchSwitcherPresented = branch + } } } diff --git a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift index bc4bd629..3067d887 100644 --- a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift +++ b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift @@ -1780,7 +1780,14 @@ package final class GitFeatureModel: ObservableObject { } if let stash = gitStashes.first(where: { $0.message.contains(message) }) { let restored = await service.popStash(stash, at: gitRepositoryRoot) - if !restored.succeeded { notify?("恢复本地改动失败:\(trimmedMessage(restored))") } + if let conflict = restored.stashRestoreConflict { + presentStashRestoreConflict(conflict, operationTitle: "pull") + return + } + guard restored.succeeded else { + notify?("恢复本地改动失败:\(trimmedMessage(restored))") + return + } } await reportBranchOperation(result, success: strategy == .rebase ? "从远程分支变基拉取完成" : "从远程分支合并拉取完成") return @@ -1803,6 +1810,7 @@ package final class GitFeatureModel: ObservableObject { } if !(await restoreShelf(shelf, at: gitRepositoryRoot)) { notify?("恢复搁置改动失败") + return } await reportBranchOperation(result, success: strategy == .rebase ? "从远程分支变基拉取完成" : "从远程分支合并拉取完成") return @@ -2017,7 +2025,7 @@ package final class GitFeatureModel: ObservableObject { if let state = gitOperationState, state.hasConflicts { notify?("\(state.kind.title) stopped with \(state.conflictedPaths.count) conflicted file(s)") } else { - notify?(result.succeeded ? success : trimmedMessage(result)) + notify?(result.succeeded ? successfulMessage(result, fallback: success) : trimmedMessage(result)) } } @@ -2268,7 +2276,11 @@ package final class GitFeatureModel: ObservableObject { isPerformingBranchOperation = true let result = await withGitOperation { await service.push(reference, at: gitRepositoryRoot) } isPerformingBranchOperation = false - notify?(result.succeeded ? "Pushed \(reference.shortName)" : trimmedMessage(result)) + notify?( + result.succeeded + ? successfulMessage(result, fallback: "Pushed \(reference.shortName)") + : trimmedMessage(result) + ) await refreshGit() } @@ -2297,7 +2309,12 @@ package final class GitFeatureModel: ObservableObject { } private func showResult(_ result: GitService.CommandResult, success: String) { - notify?(result.succeeded ? success : trimmedMessage(result)) + notify?(result.succeeded ? successfulMessage(result, fallback: success) : trimmedMessage(result)) + } + + private func successfulMessage(_ result: GitService.CommandResult, fallback: String) -> String { + guard let warning = result.warnings.first else { return fallback } + return "\(fallback): \(warning.message)" } private func trimmedMessage(_ result: GitService.CommandResult) -> String { diff --git a/macos/Sources/LitheGitModule/Ports/GitPorts.swift b/macos/Sources/LitheGitModule/Ports/GitPorts.swift index 6b07db2a..df478682 100644 --- a/macos/Sources/LitheGitModule/Ports/GitPorts.swift +++ b/macos/Sources/LitheGitModule/Ports/GitPorts.swift @@ -21,6 +21,18 @@ public struct GitProcessInvocation: Equatable, Sendable { public var output: String { standardOutput + standardError } } +public struct GitOperationWarning: Equatable, Sendable { + public let code: String + public let message: String + public let details: String? + + public init(code: String, message: String, details: String? = nil) { + self.code = code + self.message = message + self.details = details + } +} + public struct GitProcessResult: Sendable { public let arguments: [String] public let output: String @@ -30,6 +42,7 @@ public struct GitProcessResult: Sendable { public let invocations: [GitProcessInvocation] public let operationErrorMessage: String? public let stashRestoreConflict: GitStashRestoreConflict? + public let warnings: [GitOperationWarning] public init( arguments: [String] = [], output: String, @@ -38,7 +51,8 @@ public struct GitProcessResult: Sendable { exitCode: Int32, invocations: [GitProcessInvocation] = [], operationErrorMessage: String? = nil, - stashRestoreConflict: GitStashRestoreConflict? = nil + stashRestoreConflict: GitStashRestoreConflict? = nil, + warnings: [GitOperationWarning] = [] ) { self.arguments = arguments self.output = output @@ -48,6 +62,7 @@ public struct GitProcessResult: Sendable { self.invocations = invocations self.operationErrorMessage = operationErrorMessage self.stashRestoreConflict = stashRestoreConflict + self.warnings = warnings } } diff --git a/macos/Sources/LitheGitModule/Services/GitService.swift b/macos/Sources/LitheGitModule/Services/GitService.swift index 2c5d23bc..9c93abf2 100644 --- a/macos/Sources/LitheGitModule/Services/GitService.swift +++ b/macos/Sources/LitheGitModule/Services/GitService.swift @@ -41,6 +41,13 @@ package protocol GitOperations: Sendable { pathspecs: [String], whitespace: GitDiffWhitespaceMode ) -> DiffDocument? + func comparisonDiffDocument( + at rootURL: URL, + reference: GitReference, + targetReference: GitReference?, + pathspecs: [String], + whitespace: GitDiffWhitespaceMode + ) -> DiffDocument? func applyPatch( _ patch: String, @@ -57,6 +64,11 @@ package protocol GitOperations: Sendable { func files(in commit: GitCommit, at rootURL: URL) -> [GitCommitFile]? func commit(at rootURL: URL, hash: String) -> GitCommit? func comparison(for reference: GitReference, at rootURL: URL) -> GitBranchComparison? + func comparison( + from reference: GitReference, + to target: GitReference, + at rootURL: URL + ) -> GitBranchComparison? func stashes(at rootURL: URL) -> [GitStash]? func blame(at rootURL: URL, relativePath: String) -> [GitBlameLine]? @@ -130,6 +142,7 @@ package struct GitService: Sendable { package let invocations: [GitProcessInvocation] package let operationErrorMessage: String? package let stashRestoreConflict: GitStashRestoreConflict? + package let warnings: [GitOperationWarning] package init( workingDirectory: URL? = nil, @@ -140,7 +153,8 @@ package struct GitService: Sendable { exitCode: Int32, invocations: [GitProcessInvocation] = [], operationErrorMessage: String? = nil, - stashRestoreConflict: GitStashRestoreConflict? = nil + stashRestoreConflict: GitStashRestoreConflict? = nil, + warnings: [GitOperationWarning] = [] ) { self.workingDirectory = workingDirectory self.arguments = arguments @@ -151,6 +165,7 @@ package struct GitService: Sendable { self.invocations = invocations self.operationErrorMessage = operationErrorMessage self.stashRestoreConflict = stashRestoreConflict + self.warnings = warnings } package var succeeded: Bool { @@ -413,9 +428,8 @@ package struct GitService: Sendable { to target: GitReference, at repositoryRoot: URL ) async -> GitBranchComparison { - let range = comparisonRange(from: reference, to: target) let payload = await read(priority: .utility) { - $0.comparison(for: range, at: repositoryRoot) + $0.comparison(from: reference, to: target, at: repositoryRoot) } return GitBranchComparison( reference: reference, @@ -458,30 +472,17 @@ package struct GitService: Sendable { at repositoryRoot: URL, whitespace: GitDiffWhitespaceMode = .doNotIgnore ) async -> [DiffRow] { - let range = comparisonRange(from: reference, to: target) return await read { $0.comparisonDiffDocument( at: repositoryRoot, - reference: range.fullName, + reference: reference, + targetReference: target, pathspecs: [file.path], whitespace: whitespace ) }?.rows ?? [] } - private func comparisonRange( - from reference: GitReference, - to target: GitReference - ) -> GitReference { - GitReference( - fullName: "\(reference.fullName)..\(target.fullName)", - shortName: "\(reference.shortName)..\(target.shortName)", - kind: reference.kind, - isCurrent: false, - upstreamShortName: nil - ) - } - func createBranch( named name: String, from reference: GitReference, @@ -653,7 +654,8 @@ package struct GitService: Sendable { exitCode: result?.exitCode ?? 1, invocations: result?.invocations ?? [], operationErrorMessage: result?.operationErrorMessage, - stashRestoreConflict: result?.stashRestoreConflict + stashRestoreConflict: result?.stashRestoreConflict, + warnings: result?.warnings ?? [] ) }.value } diff --git a/macos/Tests/LitheGitModuleTests/GitModuleTests.swift b/macos/Tests/LitheGitModuleTests/GitModuleTests.swift index 0735266f..6ca0bbfc 100644 --- a/macos/Tests/LitheGitModuleTests/GitModuleTests.swift +++ b/macos/Tests/LitheGitModuleTests/GitModuleTests.swift @@ -455,7 +455,14 @@ struct GitModuleTests { stageResult: GitProcessResult( arguments: ["add", "--", "README.md"], output: "staged", - exitCode: 0 + exitCode: 0, + warnings: [ + GitOperationWarning( + code: "git_follow_up_failed", + message: "The main operation succeeded", + details: "follow-up diagnostic" + ) + ] ) )) @@ -465,6 +472,13 @@ struct GitModuleTests { #expect(result.arguments == ["add", "--", "README.md"]) #expect(result.output == "staged") #expect(result.succeeded) + #expect(result.warnings == [ + GitOperationWarning( + code: "git_follow_up_failed", + message: "The main operation succeeded", + details: "follow-up diagnostic" + ) + ]) } @Test @@ -1057,7 +1071,7 @@ struct GitModuleTests { ]) let service = GitService(operations: TestGitOperations( snapshotValue: snapshot, - comparisonValue: payload + typedComparisonValue: payload )) let comparison = await service.comparison(from: source, to: target, at: root) @@ -1445,9 +1459,11 @@ private func waitForGitWorkToBecomeIdle( private struct TestGitOperations: GitOperations { private let snapshotValue: GitSnapshot? private let comparisonValue: GitBranchComparison? + private let typedComparisonValue: GitBranchComparison? private let filesValue: [GitCommitFile]? private let untrackedDiffDocumentValue: DiffDocument? private let comparisonDiffDocumentValue: DiffDocument? + private let typedComparisonDiffDocumentValue: DiffDocument? private let historyValue: GitHistorySnapshot? private let stageResult: GitProcessResult? private let runGate: TestGitRunGate? @@ -1457,10 +1473,12 @@ private struct TestGitOperations: GitOperations { init( snapshotValue: GitSnapshot? = nil, comparisonValue: GitBranchComparison? = nil, + typedComparisonValue: GitBranchComparison? = nil, historyValue: GitHistorySnapshot? = nil, filesValue: [GitCommitFile]? = nil, untrackedDiffDocumentValue: DiffDocument? = nil, comparisonDiffDocumentValue: DiffDocument? = nil, + typedComparisonDiffDocumentValue: DiffDocument? = nil, stageResult: GitProcessResult? = nil, runGate: TestGitRunGate? = nil, filesRecorder: GitFilesCallRecorder? = nil, @@ -1468,10 +1486,12 @@ private struct TestGitOperations: GitOperations { ) { self.snapshotValue = snapshotValue self.comparisonValue = comparisonValue + self.typedComparisonValue = typedComparisonValue self.historyValue = historyValue self.filesValue = filesValue self.untrackedDiffDocumentValue = untrackedDiffDocumentValue self.comparisonDiffDocumentValue = comparisonDiffDocumentValue + self.typedComparisonDiffDocumentValue = typedComparisonDiffDocumentValue self.stageResult = stageResult self.runGate = runGate self.filesRecorder = filesRecorder @@ -1497,6 +1517,7 @@ private struct TestGitOperations: GitOperations { func diffPatch(at rootURL: URL, pathspecs: [String], staged: Bool, untracked: Bool, whitespace: GitDiffWhitespaceMode) -> String? { nil } func commitDiffDocument(at rootURL: URL, commit: String, pathspecs: [String], whitespace: GitDiffWhitespaceMode) -> DiffDocument? { nil } func comparisonDiffDocument(at rootURL: URL, reference: String, pathspecs: [String], whitespace: GitDiffWhitespaceMode) -> DiffDocument? { comparisonDiffDocumentValue } + func comparisonDiffDocument(at rootURL: URL, reference: GitReference, targetReference: GitReference?, pathspecs: [String], whitespace: GitDiffWhitespaceMode) -> DiffDocument? { typedComparisonDiffDocumentValue } func applyPatch(_ patch: String, at rootURL: URL, mode: String) -> GitProcessResult? { nil } func history(at rootURL: URL, reference: GitReference?, limit: Int) -> GitHistorySnapshot? { historyValue } func files(in commit: GitCommit, at rootURL: URL) -> [GitCommitFile]? { @@ -1508,6 +1529,7 @@ private struct TestGitOperations: GitOperations { } func commit(at rootURL: URL, hash: String) -> GitCommit? { nil } func comparison(for reference: GitReference, at rootURL: URL) -> GitBranchComparison? { comparisonValue } + 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 } diff --git a/macos/Tests/LitheTests/GitReferenceOperationsTests.swift b/macos/Tests/LitheTests/GitReferenceOperationsTests.swift new file mode 100644 index 00000000..f56a683f --- /dev/null +++ b/macos/Tests/LitheTests/GitReferenceOperationsTests.swift @@ -0,0 +1,102 @@ +import Foundation +import Testing +@testable import Lithe +@testable import LitheGitModule + +@Suite("Git reference operations", .serialized) +struct GitReferenceOperationsTests { + @Test + func remoteReferenceWorkflowsUseCompleteIdentityThroughRustCore() async throws { + let core = RustCoreBridge() + guard core.isAvailable else { return } + let fixture = try await GitReferenceFixture() + let repository = fixture.repository + let mainName = try await fixture.git(["branch", "--show-current"]) + let mainReference = GitReference( + fullName: "refs/heads/\(mainName)", + shortName: mainName, + kind: .local, + isCurrent: true, + upstreamShortName: nil + ) + + try await fixture.git(["switch", "-q", "-c", "feature"]) + try Data("feature\n".utf8).write(to: repository.appendingPathComponent("tracked.txt")) + try await fixture.git(["commit", "-qam", "feature"]) + try await fixture.git(["update-ref", "refs/remotes/origin/feature", "refs/heads/feature"]) + try await fixture.git(["switch", "-q", mainName]) + try await fixture.git(["branch", "-D", "feature"]) + + let remoteReference = GitReference( + fullName: "refs/remotes/origin/feature", + shortName: "origin/feature", + kind: .remote, + isCurrent: false, + upstreamShortName: nil + ) + let operations = RustGitOperations(core: core) + + let comparison = operations.comparison( + from: mainReference, + to: remoteReference, + at: repository + ) + #expect(comparison?.files.map(\.path) == ["tracked.txt"]) + + let checkoutAndRebase = operations.checkoutAndRebase(remoteReference, at: repository) + #expect(checkoutAndRebase?.exitCode == 0) + #expect(try await fixture.git(["branch", "--show-current"]) == "feature") + + try await fixture.git(["switch", "-q", mainName]) + let pull = operations.pullRemoteReference( + remoteReference, + strategy: .merge, + at: repository + ) + #expect(pull?.exitCode == 0) + #expect(try String(contentsOf: repository.appendingPathComponent("tracked.txt")) == "feature\n") + } +} + +private final class GitReferenceFixture { + let repository: URL + + init() async throws { + repository = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-git-reference-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: repository, withIntermediateDirectories: true) + try await git(["init", "-q"]) + try await git(["config", "user.email", "tests@lithe.local"]) + try await git(["config", "user.name", "Lithe Tests"]) + try await git(["config", "core.autocrlf", "false"]) + try await git(["remote", "add", "origin", "."]) + try Data("main\n".utf8).write(to: repository.appendingPathComponent("tracked.txt")) + try await git(["add", "tracked.txt"]) + try await git(["commit", "-qm", "initial"]) + } + + deinit { + try? FileManager.default.removeItem(at: repository) + } + + @discardableResult + func git(_ arguments: [String]) async throws -> String { + let result = try await TestProcess.run( + executableURL: URL(fileURLWithPath: "/usr/bin/git"), + arguments: arguments, + currentDirectoryURL: repository + ) + guard result.terminationStatus == 0 else { + throw GitReferenceFixtureError.commandFailed( + arguments, + String(decoding: result.output, as: UTF8.self) + ) + } + return String(decoding: result.output, as: UTF8.self) + .trimmingCharacters(in: .whitespacesAndNewlines) + } +} + +private enum GitReferenceFixtureError: Error { + case commandFailed([String], String) +} diff --git a/macos/Tests/LitheTests/WorkbenchRenderingSafetyTests.swift b/macos/Tests/LitheTests/WorkbenchRenderingSafetyTests.swift index 9039b7e5..4f29f9f5 100644 --- a/macos/Tests/LitheTests/WorkbenchRenderingSafetyTests.swift +++ b/macos/Tests/LitheTests/WorkbenchRenderingSafetyTests.swift @@ -29,4 +29,25 @@ struct WorkbenchRenderingSafetyTests { "WorkbenchView contains NSViewRepresentable content and must not be flattened with drawingGroup()." ) } + + @Test + func workbenchKeepsCustomSwitchersAlongsideExecutionControls() throws { + let repositoryRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let workbenchURL = repositoryRoot.appendingPathComponent( + "Sources/Lithe/Views/Workbench/WorkbenchView.swift" + ) + let source = try String(contentsOf: workbenchURL, encoding: .utf8) + + #expect(source.contains(".overlayPreferenceValue(ProjectSwitcherButtonBoundsPreferenceKey.self)")) + #expect(source.contains(".overlayPreferenceValue(BranchSwitcherButtonBoundsPreferenceKey.self)")) + #expect(source.contains(".sheet(item: $pendingTopBarPushReference)")) + #expect(source.contains("GitPushDialog(")) + #expect(source.contains("run-selected-run-configuration")) + #expect(source.contains("debug-selected-run-configuration")) + #expect(!source.contains(".popover(isPresented: $isProjectSwitcherPresented")) + #expect(!source.contains(".popover(isPresented: $isBranchSwitcherPresented")) + } } diff --git a/rust/lithe-core/src/execution/configuration.rs b/rust/lithe-core/src/execution/configuration.rs index 77a83413..a3786271 100644 --- a/rust/lithe-core/src/execution/configuration.rs +++ b/rust/lithe-core/src/execution/configuration.rs @@ -12,7 +12,7 @@ use std::path::{Component, Path, PathBuf}; const VERSION: u32 = 2; const LEGACY_VERSION: u32 = 1; -const GENERATOR_REVISION: &str = "2"; +const GENERATOR_REVISION: &str = "3"; /// Toolchain requirements and `project.json` are separate documents that happen /// to live under `.lithe`. Their schema did not change with run-config v2, so /// they keep their own version and must not be validated against `VERSION`. @@ -467,11 +467,6 @@ pub fn generate(request: GenerateRequest) -> Result { .filter(|value| value.is_spring_boot) .map(|value| (value.path.clone(), value.qualified_name.clone())) .collect::>(); - let main_class_sources = scanned - .main_classes - .iter() - .map(|value| (value.qualified_name.clone(), value.path.clone())) - .collect::>(); let mut maven_owners = BTreeMap::, Option<(PathBuf, String)>>::new(); let configurations = scanned .configurations @@ -508,11 +503,7 @@ pub fn generate(request: GenerateRequest) -> Result { if let Some(main_class) = value.main_class.as_ref() { maven.insert("mainClass".to_string(), json!(main_class)); } - let source_path = value - .main_class - .as_ref() - .and_then(|name| main_class_sources.get(name)) - .cloned(); + let source_path = value.source_path; // Maven ownership is per entry: one workspace can contain standalone // Java files or multiple independent reactors in the same request. let uses_maven_toolchain = maven_owner.is_some() && provider != "java.current-file"; @@ -523,9 +514,10 @@ pub fn generate(request: GenerateRequest) -> Result { } let mut extensions = BTreeMap::new(); extensions.insert("maven".to_string(), Value::Object(maven)); - if let Some(path) = source_path.as_ref() { - extensions.insert("java".to_string(), json!({ "source": path })); - } + extensions.insert( + "java".to_string(), + json!({ "source": source_path, "sourceSet": value.source_set }), + ); Ok(RunConfiguration { id, name: value.name, @@ -550,7 +542,7 @@ pub fn generate(request: GenerateRequest) -> Result { members: Vec::new(), extensions, disabled: false, - source: source_path, + source: Some(source_path), }) }) .collect::, _>>()?; @@ -1486,12 +1478,20 @@ pub fn create_launch_plan(request: LaunchPlanRequest) -> Result bool { || framework_goal(provider).is_some() } +fn uses_java_test_source_set(configuration: &Value) -> bool { + match configuration["extensions"]["java"]["sourceSet"].as_str() { + Some("test") => true, + Some(_) => false, + // Generated documents from older versions predate the explicit source + // set. Preserve their launch behavior until regeneration replaces them. + None => configuration["extensions"]["java"]["source"] + .as_str() + .is_some_and(|source| { + let normalized = source.to_ascii_lowercase(); + normalized.starts_with("src/test/") || normalized.contains("/src/test/") + }), + } +} + /// How a framework's Maven goal expects a debugger to be attached. #[derive(Clone, Copy, PartialEq)] enum FrameworkDebug { diff --git a/rust/lithe-core/src/git/mod.rs b/rust/lithe-core/src/git/mod.rs index 8132218d..4f5328cd 100644 --- a/rust/lithe-core/src/git/mod.rs +++ b/rust/lithe-core/src/git/mod.rs @@ -8,23 +8,28 @@ use crate::protocol::{ GitCommitLookupResponse, GitCommitResponse, GitComparisonResponse, GitConflictMarkerResponse, GitDiffHunkResponse, GitDiffResponse, GitDiffRowResponse, GitFileResponse, GitFilesResponse, GitHistoryResponse, GitIntegrationPreflightResponse, GitOperationStateResponse, - GitPullPreflightResponse, GitReferenceResponse, GitStashResponse, GitStashesResponse, - GitStatusResponse, GitWatchContextResponse, + GitPullPreflightResponse, GitPushPreviewResponse, GitPushTagResponse, GitReferenceResponse, + GitStashResponse, GitStashesResponse, GitStatusResponse, GitWatchContextResponse, }; use serde::{Deserialize, Serialize}; use std::cell::RefCell; +use std::collections::HashSet; use std::io::Read; use std::io::Write; #[cfg(target_os = "windows")] use std::os::windows::process::CommandExt; use std::path::{Path, PathBuf}; use std::process::Command; +use std::sync::atomic::{AtomicU64, Ordering}; use std::thread; use std::time::Duration; const RECENT_BRANCH_LIMIT: usize = 5; const RECENT_BRANCH_REFLOG_LIMIT: &str = "100"; const DEFAULT_BRANCH_FALLBACKS: [&str; 2] = ["main", "master"]; +const DEFAULT_PUSH_PREVIEW_LIMIT: usize = 500; +static TEMPORARY_INDEX_SEQUENCE: AtomicU64 = AtomicU64::new(0); +static AUTO_STASH_SEQUENCE: AtomicU64 = AtomicU64::new(0); #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -119,6 +124,32 @@ pub struct GitCommandResponse { /// output. #[serde(skip_serializing_if = "Option::is_none")] pub stash_restore: Option, + /// Non-fatal follow-up failures after the requested repository mutation succeeded. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub warnings: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Stable non-fatal outcome emitted after a Git mutation has already succeeded. +pub struct GitOperationWarning { + /// Machine-readable warning category interpreted by platform presentation. + pub code: String, + /// Safe English fallback for clients without a localized presentation. + pub message: String, + /// Optional Git diagnostic retained for troubleshooting. + #[serde(skip_serializing_if = "Option::is_none")] + pub details: Option, +} + +impl GitOperationWarning { + fn new(code: &str, message: &str, details: Option) -> Self { + Self { + code: code.to_string(), + message: message.to_string(), + details, + } + } } /// Raw Git process streams kept separate for machine-readable consumers. @@ -148,6 +179,7 @@ impl GitProcessOutput { invocations: vec![invocation], operation_error: None, stash_restore: None, + warnings: Vec::new(), } } } @@ -187,6 +219,12 @@ fn with_git_invocation_trace( } fn synchronize_final_invocation(response: &mut GitCommandResponse) { + if response.exit_code == 0 && !response.warnings.is_empty() { + // The mutation already succeeded and a later reconciliation step only + // produced a warning. Keep the authoritative success summary while the + // failed follow-up remains available in `invocations` and `warnings`. + return; + } let Some(final_invocation) = response.invocations.last() else { return; }; @@ -216,6 +254,48 @@ pub struct GitStashRestoreResponse { pub conflicted_paths: Vec, } +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Complete identity of a Git reference supplied by a platform client. +pub struct GitReferenceRequest { + /// Fully qualified reference, such as `refs/remotes/origin/main`. + pub full_name: String, + /// User-facing short name, such as `origin/main`. + pub short_name: String, + /// Reference namespace: `local`, `remote`, or `tag`. + pub kind: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Repository snapshot that a previously reviewed push preview was based on. +pub struct GitPushExpectationRequest { + /// Local branch resolved when the preview was created. + pub local_branch: String, + /// Commit at the tip of the local branch when the preview was created. + pub local_head: String, + /// Push remote resolved from the repository configuration. + pub remote: String, + /// Destination branch name on the resolved remote. + pub remote_branch: String, + /// Locally observed destination OID, or `None` when the remote branch was absent. + #[serde(default)] + pub remote_tracking_oid: Option, + /// Exact reviewed tags that may be sent with the branch. + #[serde(default)] + pub tags: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// One tag identity copied from a push preview into the mutation request. +pub struct GitPushTagExpectationRequest { + /// Fully qualified tag reference under `refs/tags/`. + pub full_name: String, + /// Tag object ID observed while the preview was created. + pub object_id: String, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] /// Typed mutation request translated into a controlled Git invocation. @@ -227,11 +307,18 @@ pub struct GitWriteRequest { pub paths: Vec, #[serde(default)] pub reference: Option, + /// Preferred typed reference. Legacy callers may still use `reference` and + /// `referenceKind`, but new cross-platform workflows send all identity fields. + #[serde(default)] + pub git_reference: Option, /// Reference category used by checkout: `local`, `remote`, or `tag`. #[serde(default)] pub reference_kind: Option, #[serde(default)] pub revision: Option, + /// Commit revisions selected by an operation that rewrites a contiguous range. + #[serde(default)] + pub revisions: Vec, #[serde(default)] pub name: Option, #[serde(default)] @@ -251,10 +338,132 @@ pub struct GitWriteRequest { pub amend: bool, #[serde(default)] pub force: bool, + /// Tag scope for push: `none`, `all`, or `reachable`. + #[serde(default)] + pub push_tags: Option, + /// Optional reviewed preview snapshot that must still match before pushing. + #[serde(default)] + pub expected_push: Option, #[serde(default)] pub auto_stash: bool, } +/// Isolated Git administration directory used to commit a reviewed snapshot. +struct TemporaryGitCommitContext { + directory: PathBuf, + index_path: PathBuf, + temporary_reference: Option, +} + +impl TemporaryGitCommitContext { + fn prepare(root: &str, head: Option<&str>) -> Result { + let common_directory = git_resolved_path( + root, + &["rev-parse", "--path-format=absolute", "--git-common-dir"], + "Git common directory", + )?; + let (directory, sequence) = loop { + let sequence = TEMPORARY_INDEX_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let candidate = + common_directory.join(format!("lithe-commit-{}-{sequence}", std::process::id())); + if !candidate.exists() { + break (candidate, sequence); + } + }; + std::fs::create_dir(&directory).map_err(|error| { + CoreError::new( + ErrorCode::Unknown, + "Could not create an isolated Git commit context", + ) + .with_details(error.to_string()) + })?; + let temporary_reference = head.is_none().then(|| { + format!( + "refs/lithe/selected-commit-{}-{sequence}", + std::process::id() + ) + }); + let head_contents = head.map(|head| format!("{head}\n")).unwrap_or_else(|| { + format!( + "ref: {}\n", + temporary_reference + .as_deref() + .expect("an unborn repository needs a temporary reference") + ) + }); + std::fs::write(directory.join("HEAD"), head_contents).map_err(|error| { + CoreError::new( + ErrorCode::Unknown, + "Could not initialize an isolated Git commit context", + ) + .with_details(error.to_string()) + })?; + let index_path = directory.join("index"); + Ok(Self { + directory, + index_path, + temporary_reference, + }) + } + + fn environment(&self, root: &str, common_directory: &Path) -> Vec<(String, String)> { + vec![ + ( + "GIT_DIR".to_string(), + self.directory.to_string_lossy().into_owned(), + ), + ( + "GIT_COMMON_DIR".to_string(), + common_directory.to_string_lossy().into_owned(), + ), + ("GIT_WORK_TREE".to_string(), root.to_string()), + ( + "GIT_INDEX_FILE".to_string(), + self.index_path.to_string_lossy().into_owned(), + ), + ] + } + + fn cleanup_reference(&self, root: &str) -> Result<(), CoreError> { + let Some(reference) = self.temporary_reference.as_ref() else { + return Ok(()); + }; + let arguments = vec!["update-ref".into(), "-d".into(), reference.clone()]; + let removed = capture_git_with_options(root, &arguments, None, false)?; + if removed.exit_code != 0 { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Could not remove temporary Git reference", + ) + .with_details(String::from_utf8_lossy(&removed.stderr))); + } + Ok(()) + } +} + +impl Drop for TemporaryGitCommitContext { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.directory); + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Request to resolve the exact destination and commits for a branch push. +pub struct GitPushPreviewRequest { + pub root: String, + #[serde(default)] + pub reference: Option, + /// Preferred complete local branch identity. + #[serde(default)] + pub git_reference: Option, + #[serde(default = "default_push_preview_limit")] + pub limit: usize, + /// Tag scope to resolve into an immutable preview: `none`, `all`, or `reachable`. + #[serde(default)] + pub push_tags: Option, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] /// Request for a structured diff suitable for side-by-side rendering. @@ -263,12 +472,26 @@ pub struct GitDiffRequest { pub pathspecs: Vec, #[serde(default)] pub reference: Option, + /// Compare the supplied legacy `reference` target against Git's empty tree. + /// This is used for cumulative review ranges that begin with a root commit. + #[serde(default)] + pub empty_tree_base: bool, + #[serde(default)] + pub git_reference: Option, + /// Optional typed comparison target. When present, `gitReference` is the + /// base and Core constructs the validated two-reference range. + #[serde(default)] + pub target_git_reference: Option, #[serde(default)] pub commit: Option, #[serde(default)] pub staged: bool, #[serde(default)] pub untracked: bool, + /// Compare HEAD with the complete worktree state that a selected-path + /// commit would stage, independently of the real index. + #[serde(default)] + pub worktree_snapshot: bool, #[serde(default = "default_review_context_lines")] pub context_lines: usize, #[serde(default)] @@ -317,7 +540,13 @@ pub struct GitCommitFilesRequest { /// Request to compare a reference with the current checkout. pub struct GitComparisonRequest { pub root: String, - pub reference: String, + #[serde(default)] + pub reference: Option, + #[serde(default)] + pub git_reference: Option, + /// Optional typed target for a comparison between two references. + #[serde(default)] + pub target_git_reference: Option, } #[derive(Debug, Deserialize)] @@ -332,7 +561,10 @@ pub struct GitStashesRequest { /// Request to identify local edits that would block switching references. pub struct GitCheckoutPreflightRequest { pub root: String, - pub reference: String, + #[serde(default)] + pub reference: Option, + #[serde(default)] + pub git_reference: Option, } #[derive(Debug, Deserialize)] @@ -347,7 +579,10 @@ pub struct GitConflictMarkerRequest { /// Request to determine whether a merge or rebase can start safely. pub struct GitIntegrationPreflightRequest { pub root: String, - pub reference: String, + #[serde(default)] + pub reference: Option, + #[serde(default)] + pub git_reference: Option, /// Either "merge" or "rebase"; the two have different tolerances for a dirty tree. pub operation: String, } @@ -382,6 +617,10 @@ fn default_history_limit() -> usize { 300 } +fn default_push_preview_limit() -> usize { + DEFAULT_PUSH_PREVIEW_LIMIT +} + /// Executes an argument-based Git command after validating the workspace root. pub fn command(request: GitCommandRequest) -> Result { with_git_invocation_trace(|| { @@ -407,28 +646,34 @@ fn write_with_trace(request: GitWriteRequest) -> Result { let paths = validate_paths(&request.paths)?; - arguments = ["add", "-A", "--"] - .into_iter() - .map(String::from) - .chain(paths) - .collect(); + arguments = vec![ + "add".into(), + "-A".into(), + "--pathspec-from-file=-".into(), + "--pathspec-file-nul".into(), + ]; + return execute_git(&root, &arguments, Some(nul_pathspec_input(&paths))); } "unstage" => { let paths = validate_paths(&request.paths)?; - let restore_arguments = ["restore", "--staged", "--"] - .into_iter() - .map(String::from) - .chain(paths.clone()) - .collect::>(); - let restore = execute_git(&root, &restore_arguments, None)?; + let pathspec_input = nul_pathspec_input(&paths); + let restore_arguments = vec![ + "restore".into(), + "--staged".into(), + "--pathspec-from-file=-".into(), + "--pathspec-file-nul".into(), + ]; + let restore = execute_git(&root, &restore_arguments, Some(pathspec_input.clone()))?; if restore.exit_code == 0 { return Ok(restore); } - arguments = ["reset", "HEAD", "--"] - .into_iter() - .map(String::from) - .chain(paths) - .collect(); + arguments = vec![ + "reset".into(), + "HEAD".into(), + "--pathspec-from-file=-".into(), + "--pathspec-file-nul".into(), + ]; + return execute_git(&root, &arguments, Some(pathspec_input)); } "discard" => { let paths = validate_paths(&request.paths)?; @@ -469,12 +714,22 @@ 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.paths.is_empty() { + let paths = validate_paths(&request.paths)?; + return commit_selected_paths(&root, paths, message, request.amend); + } arguments = vec!["commit".into()]; if request.amend { arguments.push("--amend".into()); } arguments.extend(["-m".into(), message]); } + "ignore" => { + return append_git_ignore_patterns(&root, &request.paths, GitIgnoreTarget::Repository) + } + "exclude" => { + return append_git_ignore_patterns(&root, &request.paths, GitIgnoreTarget::LocalExclude) + } "cherryPick" => { arguments = vec![ "cherry-pick".into(), @@ -502,9 +757,22 @@ fn write_with_trace(request: GitWriteRequest) -> Result { + let revision = validated_revision(request.revision.as_deref())?; + let message = required_text(request.message.as_deref(), "commit message")?; + return edit_commit_message(&root, &revision, &message); + } + "deleteCommit" => { + let revision = validated_revision(request.revision.as_deref())?; + return delete_commit(&root, &revision); + } + "squashCommits" => { + let message = required_text(request.message.as_deref(), "commit message")?; + return squash_commits(&root, &request.revisions, &message); + } "createBranch" => { let name = validated_branch_name(&root, request.name.as_deref())?; - let reference = validated_reference(request.reference.as_deref())?; + let reference = write_request_reference(&root, &request)?; arguments = if request.checkout { vec!["switch".into(), "-c".into(), name, reference] } else { @@ -516,19 +784,40 @@ fn write_with_trace(request: GitWriteRequest) -> Result { let name = validated_branch_name(&root, request.name.as_deref())?; - let reference = validated_reference(request.reference.as_deref())?; + let reference = write_request_reference(&root, &request)?; let current = current_branch(&root)?; let current_reference = format!("refs/heads/{current}"); - arguments = if request.reference.as_deref() == Some(current.as_str()) - || request.reference.as_deref() == Some(current_reference.as_str()) - { + arguments = if reference == current || reference == current_reference { vec!["branch".into(), "-m".into(), name] } else { vec!["branch".into(), "-m".into(), reference, name] }; } + "setUpstream" => { + let branch = validated_branch_name(&root, request.name.as_deref())?; + let upstream = request + .git_reference + .as_ref() + .ok_or_else(invalid_git_reference) + .and_then(|reference| validated_git_reference(&root, reference))?; + if upstream.kind != "remote" { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "A branch upstream must be a remote Git reference", + )); + } + arguments = vec![ + "branch".into(), + format!("--set-upstream-to={}", upstream.full_name), + branch, + ]; + } + "unsetUpstream" => { + let branch = validated_branch_name(&root, request.name.as_deref())?; + arguments = vec!["branch".into(), "--unset-upstream".into(), branch]; + } "deleteBranch" => { - let reference = validated_reference(request.reference.as_deref())?; + let reference = write_request_reference(&root, &request)?; let branch = local_branch_name(&reference)?; if current_branch(&root)?.as_str() == branch { return Err(CoreError::new( @@ -539,7 +828,7 @@ fn write_with_trace(request: GitWriteRequest) -> Result { - let reference = validated_reference(request.reference.as_deref())?; + let reference = write_request_reference(&root, &request)?; if is_current_reference(&root, &reference)? { return Err(CoreError::new( ErrorCode::InvalidRequest, @@ -549,7 +838,7 @@ fn write_with_trace(request: GitWriteRequest) -> Result { - let reference = validated_reference(request.reference.as_deref())?; + let reference = write_request_reference(&root, &request)?; if is_current_reference(&root, &reference)? { return Err(CoreError::new( ErrorCode::InvalidRequest, @@ -559,6 +848,7 @@ fn write_with_trace(request: GitWriteRequest) -> Result return mutations::checkout_and_rebase(&root, request), + "createWorktree" => return create_worktree(&root, &request), "fetch" => arguments = vec!["fetch".into(), "--all".into(), "--prune".into()], // Strategy comes from the caller because only the user can decide whether a // divergent history should be merged or replayed. Absent a choice we stay on @@ -575,19 +865,66 @@ fn write_with_trace(request: GitWriteRequest) -> Result { + let reference = request + .git_reference + .as_ref() + .ok_or_else(|| invalid_git_reference())?; + let reference = validated_git_reference(&root, reference)?; + if reference.kind != "remote" { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Remote branch deletion requires a remote Git reference", + )); + } + let (remote, branch) = + mutations::remote_branch_components(&root, &reference.full_name)?; + arguments = vec![ + "push".into(), + "--delete".into(), + "--".into(), + remote, + format!("refs/heads/{branch}"), + ]; + } + "push" => { + let reference = optional_write_request_reference(&root, &request)?; + return push( + &root, + reference.as_deref(), + request.force, + request.push_tags.as_deref(), + request.expected_push.as_ref(), + ); } - "push" => return push(&root, request.reference.as_deref()), "checkout" => return checkout(&root, request), "checkoutRevision" => { arguments = vec![ @@ -615,6 +952,14 @@ fn write_with_trace(request: GitWriteRequest) -> Result { return resolve_operation(&root, &request.operation) @@ -673,15 +1018,44 @@ fn execute_git_with_options( }) } +fn execute_git_with_environment( + root: &str, + arguments: &[String], + input: Option, + disable_optional_locks: bool, + environment: &[(String, String)], +) -> Result { + capture_git_with_environment(root, arguments, input, disable_optional_locks, environment).map( + |output| { + let response = output.into_command_response(arguments); + record_git_invocation(&response); + response + }, + ) +} + fn capture_git_with_options( root: &str, arguments: &[String], input: Option, disable_optional_locks: bool, +) -> Result { + capture_git_with_environment(root, arguments, input, disable_optional_locks, &[]) +} + +fn capture_git_with_environment( + root: &str, + arguments: &[String], + input: Option, + disable_optional_locks: bool, + environment: &[(String, String)], ) -> Result { crate::protocol::cancellation::check()?; let mut process = git_process(); - process.args(arguments).current_dir(root); + process + .args(arguments) + .current_dir(root) + .envs(environment.iter().map(|(key, value)| (key, value))); if disable_optional_locks { process.env("GIT_OPTIONAL_LOCKS", "0"); } @@ -783,14 +1157,57 @@ pub fn diff(request: GitDiffRequest) -> Result { )); } - if request.reference.is_some() && request.commit.is_some() { + let root = validate_root(&request.root)?; + if request.worktree_snapshot { + if request.reference.is_some() + || request.empty_tree_base + || request.git_reference.is_some() + || request.target_git_reference.is_some() + || request.commit.is_some() + || request.staged + || request.untracked + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Git worktree snapshot diff cannot combine other diff modes", + )); + } + return worktree_snapshot_diff(&root, &request); + } + let reference = if request.empty_tree_base { + if request.git_reference.is_some() + || request.target_git_reference.is_some() + || request.commit.is_some() + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Git empty-tree diff cannot combine reference forms", + )); + } + let target = request.reference.as_deref().ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "Git empty-tree diff requires a target reference", + ) + })?; + validate_revision(target)?; + Some(format!("{}..{target}", empty_tree_oid(&root)?)) + } else { + typed_reference_range( + &root, + request.git_reference.as_ref(), + request.target_git_reference.as_ref(), + request.reference.as_deref(), + )? + }; + if reference.is_some() && request.commit.is_some() { return Err(CoreError::new( ErrorCode::InvalidRequest, "Git diff cannot combine a reference and a commit", )); } - let include_untracked_with_reference = request.reference.is_some() && request.untracked; + let include_untracked_with_reference = reference.is_some() && request.untracked; let mut arguments = if let Some(commit) = request.commit { validate_revision(&commit)?; vec![ @@ -801,7 +1218,7 @@ pub fn diff(request: GitDiffRequest) -> Result { format!("--unified={}", request.context_lines), commit, ] - } else if let Some(reference) = request.reference { + } else if let Some(reference) = reference { validate_revision(&reference)?; vec![ "diff".to_string(), @@ -832,45 +1249,48 @@ pub fn diff(request: GitDiffRequest) -> Result { if request.untracked && !include_untracked_with_reference { arguments.push(null_device().to_string()); } - arguments.extend(request.pathspecs); + arguments.extend(request.pathspecs.clone()); let root = validate_root(&request.root)?; let mut output = capture_git_with_options(&root, &arguments, None, true)?; if include_untracked_with_reference { - let status = readonly_command(GitCommandRequest { + let untracked_paths = readonly_command(GitCommandRequest { root: root.clone(), arguments: vec![ - "status".into(), - "--porcelain".into(), - "--untracked-files=all".into(), - ], + "ls-files".into(), + "--others".into(), + "--exclude-standard".into(), + "-z".into(), + "--".into(), + ] + .into_iter() + .chain(request.pathspecs.clone()) + .collect(), input: None, })?; - if status.exit_code != 0 { - return Err( - CoreError::new(ErrorCode::ProcessFailed, "Git status failed") - .with_details(status.output), - ); + if untracked_paths.exit_code != 0 { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Git untracked file lookup failed", + ) + .with_details(untracked_paths.output)); } - for line in status.output.lines().filter(|line| line.starts_with("?? ")) { - let path = line[3..].trim(); + for path in untracked_paths.stdout.split('\0') { if path.is_empty() || !is_safe_pathspec(path) { continue; } - let untracked = capture_git_with_options( - &root, - &[ - "diff".into(), - "--no-ext-diff".into(), - "--binary".into(), - "--no-index".into(), - "--".into(), - null_device().into(), - path.into(), - ], - None, - true, - )?; + let mut untracked_arguments = vec![ + "diff".into(), + "--no-ext-diff".into(), + "--binary".into(), + "--no-index".into(), + format!("--unified={}", request.context_lines), + ]; + if request.ignore_all_whitespace { + untracked_arguments.push("--ignore-all-space".into()); + } + untracked_arguments.extend(["--".into(), null_device().into(), path.into()]); + let untracked = capture_git_with_options(&root, &untracked_arguments, None, true)?; output.stdout.extend(untracked.stdout); output.stderr.extend(untracked.stderr); } @@ -878,6 +1298,114 @@ pub fn diff(request: GitDiffRequest) -> Result { Ok(structured_diff_from_output(output)) } +fn worktree_snapshot_diff( + root: &str, + request: &GitDiffRequest, +) -> Result { + let head = execute_git_readonly( + root, + &[ + "rev-parse".into(), + "--verify".into(), + "--quiet".into(), + "HEAD".into(), + ], + None, + )?; + if head.exit_code > 1 { + return Err( + CoreError::new(ErrorCode::ProcessFailed, "Could not resolve Git HEAD") + .with_details(head.output), + ); + } + let head = (head.exit_code == 0).then(|| head.stdout.trim().to_string()); + let common_directory = git_resolved_path( + root, + &["rev-parse", "--path-format=absolute", "--git-common-dir"], + "Git common directory", + )?; + let temporary_context = TemporaryGitCommitContext::prepare(root, head.as_deref())?; + let environment = temporary_context.environment(root, &common_directory); + let initialize_arguments = if let Some(head) = head.as_ref() { + vec!["read-tree".to_string(), head.clone()] + } else { + vec!["read-tree".to_string(), "--empty".to_string()] + }; + let initialized = + execute_git_with_environment(root, &initialize_arguments, None, true, &environment)?; + if initialized.exit_code != 0 { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Could not prepare Git snapshot diff", + ) + .with_details(initialized.output)); + } + + let mut arguments = vec![ + "diff-files".to_string(), + "--no-ext-diff".to_string(), + "--binary".to_string(), + format!("--unified={}", request.context_lines), + ]; + if request.ignore_all_whitespace { + arguments.push("--ignore-all-space".to_string()); + } + arguments.push("--".to_string()); + arguments.extend(request.pathspecs.clone()); + let mut output = capture_git_with_environment(root, &arguments, None, true, &environment)?; + if output.exit_code != 0 { + return Err( + CoreError::new(ErrorCode::ProcessFailed, "Git snapshot diff failed") + .with_details(String::from_utf8_lossy(&output.stderr)), + ); + } + + let untracked_arguments = vec![ + "ls-files".into(), + "--others".into(), + "--exclude-standard".into(), + "-z".into(), + "--".into(), + ] + .into_iter() + .chain(request.pathspecs.clone()) + .collect::>(); + let untracked = + capture_git_with_environment(root, &untracked_arguments, None, true, &environment)?; + if untracked.exit_code != 0 { + return Err( + CoreError::new(ErrorCode::ProcessFailed, "Git snapshot file lookup failed") + .with_details(String::from_utf8_lossy(&untracked.stderr)), + ); + } + for path in untracked + .stdout + .split(|byte| *byte == 0) + .filter(|path| !path.is_empty()) + { + let path = String::from_utf8_lossy(path).to_string(); + if !is_safe_pathspec(&path) { + continue; + } + let mut untracked_arguments = vec![ + "diff".into(), + "--no-ext-diff".into(), + "--binary".into(), + "--no-index".into(), + format!("--unified={}", request.context_lines), + ]; + if request.ignore_all_whitespace { + untracked_arguments.push("--ignore-all-space".into()); + } + untracked_arguments.extend(["--".into(), null_device().into(), path]); + let added = + capture_git_with_environment(root, &untracked_arguments, None, true, &environment)?; + output.stdout.extend(added.stdout); + output.stderr.extend(added.stderr); + } + Ok(structured_diff_from_output(output)) +} + fn structured_diff_from_output(output: GitProcessOutput) -> GitDiffResponse { // Diff is a machine-readable stdout protocol. Git diagnostics on stderr // must never become synthetic file lines in the parsed patch. @@ -952,18 +1480,22 @@ pub fn history(request: GitHistoryRequest) -> Result Result>(); + let nonlocal_reference_output = readonly_command(GitCommandRequest { + root: root.clone(), + arguments: vec![ + "for-each-ref".to_string(), + "--sort=refname".to_string(), + "--format=%(refname)\t%(refname:short)\t%(HEAD)\t%(upstream:short)\t%(upstream)" + .to_string(), + "refs/remotes".to_string(), + "refs/tags".to_string(), + ], + input: None, + })?; + if nonlocal_reference_output.exit_code != 0 { + return Err( + CoreError::new(ErrorCode::ProcessFailed, "Git references failed") + .with_details(nonlocal_reference_output.output), + ); + } + references.extend( + nonlocal_reference_output + .output + .lines() + .filter_map(parse_reference), + ); let recent_references = recent_local_references(&root, &references, RECENT_BRANCH_LIMIT); - let mut arguments = vec!["log".to_string()]; - if let Some(reference) = request.reference { + let selectors = if let Some(reference) = request.reference { if reference.starts_with('-') || reference.contains('\0') { return Err(CoreError::new( ErrorCode::InvalidRequest, "Invalid Git reference", )); } - arguments.push(reference); + vec![reference] } else { - arguments.push("--all".to_string()); - } + vec!["--all".to_string()] + }; + let (commits, has_more) = read_commit_log(&root, selectors, limit, "Git history failed")?; + Ok(GitHistoryResponse { + references, + recent_references, + commits, + has_more, + user_name, + user_email, + }) +} + +fn read_commit_log( + root: &str, + selectors: Vec, + limit: usize, + failure_message: &str, +) -> Result<(Vec, bool), CoreError> { + let mut arguments = vec!["log".to_string()]; + arguments.extend(selectors); arguments.extend([ "--topo-order".to_string(), "--decorate=short".to_string(), @@ -999,15 +1573,13 @@ pub fn history(request: GitHistoryRequest) -> Result Result>(); let has_more = all_commits.len() > limit; - Ok(GitHistoryResponse { - references, - recent_references, - commits: all_commits.into_iter().take(limit).collect(), - has_more, - user_name, - user_email, - }) + Ok((all_commits.into_iter().take(limit).collect(), has_more)) } /// Builds a bounded MRU list from Git's own checkout history. @@ -1209,7 +1774,14 @@ pub fn commit_files(request: GitCommitFilesRequest) -> Result Result { let root = validate_root(&request.root)?; - validate_revision(&request.reference)?; + let reference = typed_reference_range( + &root, + request.git_reference.as_ref(), + request.target_git_reference.as_ref(), + request.reference.as_deref(), + )? + .ok_or_else(|| CoreError::new(ErrorCode::InvalidRequest, "Missing Git reference"))?; + validate_revision(&reference)?; let response = readonly_command(GitCommandRequest { root, arguments: vec![ @@ -1218,7 +1790,7 @@ pub fn comparison(request: GitComparisonRequest) -> Result Result { let root = validate_root(&request.root)?; - let reference = validated_reference(Some(&request.reference))?; + let reference = request_reference( + &root, + request.git_reference.as_ref(), + request.reference.as_deref(), + )?; let dirty = readonly_command(GitCommandRequest { root: root.clone(), @@ -1368,18 +1944,61 @@ pub fn conflict_marker_paths( request: GitConflictMarkerRequest, ) -> Result { let root = validate_root(&request.root)?; + let paths = staged_conflict_marker_paths(&root)?; + Ok(GitConflictMarkerResponse { paths }) +} - let found = readonly_command(GitCommandRequest { - root, - arguments: vec![ - "grep".to_string(), - "--cached".to_string(), - "-l".to_string(), - "-E".to_string(), - r"^(<<<<<<<|>>>>>>>|\|\|\|\|\|\|\|) ".to_string(), - ], - input: None, - })?; +fn staged_conflict_marker_paths(root: &str) -> Result, CoreError> { + staged_conflict_marker_paths_with_environment(root, &[], false) +} + +fn staged_conflict_marker_paths_with_environment( + root: &str, + environment: &[(String, String)], + changed_only: bool, +) -> Result, CoreError> { + // The alternate index starts at HEAD, so its staged diff is the authoritative + // expansion of directory and glob pathspecs without repeating a long path list. + let changed_paths = if changed_only { + let changed = execute_git_with_environment( + root, + &[ + "diff".to_string(), + "--cached".to_string(), + "--no-ext-diff".to_string(), + "--name-only".to_string(), + "-z".to_string(), + ], + None, + true, + environment, + )?; + if changed.exit_code != 0 { + return Err( + CoreError::new(ErrorCode::ProcessFailed, "Git staged diff failed") + .with_details(changed.output), + ); + } + Some( + changed + .stdout + .split('\0') + .filter(|path| !path.is_empty()) + .map(str::to_string) + .collect::>(), + ) + } else { + None + }; + let arguments = vec![ + "grep".to_string(), + "--cached".to_string(), + "-l".to_string(), + "-z".to_string(), + "-E".to_string(), + r"^(<<<<<<<|>>>>>>>|\|\|\|\|\|\|\|) ".to_string(), + ]; + let found = execute_git_with_environment(root, &arguments, None, true, environment)?; // `git grep` exits 1 when nothing matches, which is not a failure here. if found.exit_code > 1 { return Err( @@ -1388,15 +2007,298 @@ pub fn conflict_marker_paths( } let mut paths: Vec = found - .output - .lines() - .map(str::trim) - .filter(|line| !line.is_empty()) + .stdout + .split('\0') + .filter(|path| { + !path.is_empty() + && changed_paths + .as_ref() + .map_or(true, |changed| changed.contains(*path)) + }) .map(str::to_string) .collect(); paths.sort(); paths.dedup(); - Ok(GitConflictMarkerResponse { paths }) + Ok(paths) +} + +fn commit_selected_paths( + root: &str, + paths: Vec, + message: String, + amend: bool, +) -> Result { + let operation = operation_state(GitOperationStateRequest { + root: root.to_string(), + })?; + if !operation.kind.is_empty() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Finish or abort the current Git operation before committing selected files", + )); + } + + let branch = execute_git_readonly( + root, + &["symbolic-ref".into(), "--quiet".into(), "HEAD".into()], + None, + )?; + let branch_reference = branch.stdout.trim(); + if branch.exit_code != 0 || !branch_reference.starts_with("refs/heads/") { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Selected files can only be committed on a local branch", + )); + } + + let head = execute_git_readonly( + root, + &[ + "rev-parse".to_string(), + "--verify".to_string(), + "-q".to_string(), + "HEAD".to_string(), + ], + None, + )?; + if head.exit_code > 1 { + return Ok(head); + } + let original_head = (head.exit_code == 0).then(|| head.stdout.trim().to_string()); + if amend && original_head.is_none() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "An initial commit cannot be amended", + )); + } + + // Git's own commit command still runs hooks and signing, but its HEAD and + // index live in an isolated administration directory. The reviewed branch + // is updated only after the resulting snapshot passes validation. + let common_directory = git_resolved_path( + root, + &["rev-parse", "--path-format=absolute", "--git-common-dir"], + "Git common directory", + )?; + let temporary_context = TemporaryGitCommitContext::prepare(root, original_head.as_deref())?; + let environment = temporary_context.environment(root, &common_directory); + let pathspec_input = nul_pathspec_input(&paths); + let initialize_arguments = if let Some(head) = original_head.as_ref() { + vec!["read-tree".to_string(), head.clone()] + } else { + vec!["read-tree".to_string(), "--empty".to_string()] + }; + let initialized = + execute_git_with_environment(root, &initialize_arguments, None, false, &environment)?; + if initialized.exit_code != 0 { + return Ok(initialized); + } + let stage_arguments = vec![ + "add".to_string(), + "-A".to_string(), + "--pathspec-from-file=-".to_string(), + "--pathspec-file-nul".to_string(), + ]; + let staged = execute_git_with_environment( + root, + &stage_arguments, + Some(pathspec_input.clone()), + false, + &environment, + )?; + if staged.exit_code != 0 { + return Ok(staged); + } + + let allowed_paths = staged_changed_paths(root, &environment)?; + + let marker_paths = staged_conflict_marker_paths_with_environment(root, &environment, true)?; + if !marker_paths.is_empty() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Conflict markers remain in selected files", + ) + .with_details(marker_paths.join(", "))); + } + + let mut arguments = vec!["commit".into()]; + if amend { + arguments.push("--amend".into()); + } + arguments.extend(["-m".into(), message]); + let committed = execute_git_with_environment(root, &arguments, None, false, &environment)?; + if committed.exit_code != 0 { + return Ok(committed); + } + + let committed_head = execute_git_with_environment( + root, + &["rev-parse".into(), "--verify".into(), "HEAD".into()], + None, + true, + &environment, + )?; + if committed_head.exit_code != 0 { + let _ = temporary_context.cleanup_reference(root); + return Ok(committed_head); + } + let committed_head = committed_head.stdout.trim().to_string(); + let committed_paths = commit_changed_paths(root, original_head.as_deref(), &committed_head)?; + let unexpected_paths = committed_paths + .difference(&allowed_paths) + .cloned() + .collect::>(); + if !unexpected_paths.is_empty() { + let _ = temporary_context.cleanup_reference(root); + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "A Git hook added files outside the selected commit", + ) + .with_details(unexpected_paths.join(", "))); + } + + let expected_old = match original_head.as_ref() { + Some(head) => head.clone(), + None => null_object_id(root)?, + }; + let updated = execute_git( + root, + &[ + "update-ref".into(), + "-m".into(), + if amend { + "commit (amend): selected files".into() + } else { + "commit: selected files".into() + }, + branch_reference.to_string(), + committed_head, + expected_old, + ], + None, + )?; + if updated.exit_code != 0 { + let _ = temporary_context.cleanup_reference(root); + return Ok(updated); + } + + // Reconcile only committed paths in the real index. Unrelated staging that + // another Git process created while hooks ran remains intact. + let mut reconciled = execute_git( + root, + &[ + "reset".to_string(), + "-q".to_string(), + "HEAD".to_string(), + "--pathspec-from-file=-".to_string(), + "--pathspec-file-nul".to_string(), + ], + Some(pathspec_input), + )?; + if reconciled.exit_code != 0 { + reconciled.warnings.push(GitOperationWarning::new( + "git_index_reconcile_failed", + "The commit succeeded, but the Git index could not be reconciled", + Some(reconciled.output.clone()), + )); + reconciled.exit_code = 0; + } + if let Err(error) = temporary_context.cleanup_reference(root) { + reconciled.warnings.push(GitOperationWarning::new( + "git_temporary_reference_cleanup_failed", + "The commit succeeded, but its temporary Git reference could not be removed", + error.details, + )); + } + Ok(reconciled) +} + +fn staged_changed_paths( + root: &str, + environment: &[(String, String)], +) -> Result, CoreError> { + let changed = execute_git_with_environment( + root, + &[ + "diff".into(), + "--cached".into(), + "--name-only".into(), + "-z".into(), + ], + None, + true, + environment, + )?; + if changed.exit_code != 0 { + return Err( + CoreError::new(ErrorCode::ProcessFailed, "Git staged diff failed") + .with_details(changed.output), + ); + } + Ok(changed + .stdout + .split('\0') + .filter(|path| !path.is_empty()) + .map(str::to_string) + .collect()) +} + +fn commit_changed_paths( + root: &str, + original_head: Option<&str>, + committed_head: &str, +) -> Result, CoreError> { + let arguments = if let Some(original_head) = original_head { + vec![ + "diff".into(), + "--name-only".into(), + "-z".into(), + original_head.into(), + committed_head.into(), + ] + } else { + vec![ + "diff-tree".into(), + "--root".into(), + "--no-commit-id".into(), + "--name-only".into(), + "-z".into(), + committed_head.into(), + ] + }; + let changed = execute_git_readonly(root, &arguments, None)?; + if changed.exit_code != 0 { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Could not validate the selected commit", + ) + .with_details(changed.output)); + } + Ok(changed + .stdout + .split('\0') + .filter(|path| !path.is_empty()) + .map(str::to_string) + .collect()) +} + +fn null_object_id(root: &str) -> Result { + let format = execute_git_readonly( + root, + &["rev-parse".into(), "--show-object-format".into()], + None, + )?; + if format.exit_code != 0 { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Could not determine Git object format", + ) + .with_details(format.output)); + } + Ok(match format.stdout.trim() { + "sha256" => "0".repeat(64), + _ => "0".repeat(40), + }) } /// How an operation decides whether a dirty working tree is in its way. @@ -1419,7 +2321,11 @@ pub fn integration_preflight( request: GitIntegrationPreflightRequest, ) -> Result { let root = validate_root(&request.root)?; - let reference = validated_reference(Some(&request.reference))?; + let reference = request_reference( + &root, + request.git_reference.as_ref(), + request.reference.as_deref(), + )?; let shape = match request.operation.as_str() { "merge" => IntegrationShape::MergeBase, "rebase" => IntegrationShape::AnyDirty, @@ -1899,62 +2805,942 @@ fn required_text(value: Option<&str>, label: &str) -> Result } } -fn validate_paths(paths: &[String]) -> Result, CoreError> { - if paths.is_empty() || paths.iter().any(|path| !is_safe_pathspec(path)) { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Git operation contains an invalid path", - )); - } - Ok(paths.to_vec()) +#[derive(Clone)] +struct ValidatedGitReference { + full_name: String, + short_name: String, + kind: String, } -fn validated_revision(value: Option<&str>) -> Result { - let value = required_text(value, "revision")?; - validate_revision(&value)?; - Ok(value) +fn invalid_git_reference() -> CoreError { + CoreError::new(ErrorCode::InvalidRequest, "Invalid Git reference") } -pub(super) fn validated_reference(value: Option<&str>) -> Result { - let value = required_text(value, "reference")?; - if value.starts_with('-') || value.chars().any(char::is_whitespace) { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Invalid Git reference", - )); +fn validated_git_reference( + root: &str, + reference: &GitReferenceRequest, +) -> Result { + if reference.full_name.contains(['\0', '\n', '\r']) + || reference.short_name.contains(['\0', '\n', '\r']) + || reference.full_name.chars().any(char::is_whitespace) + || reference.short_name.trim() != reference.short_name + { + return Err(invalid_git_reference()); } - Ok(value) -} -fn validated_stash_reference(value: Option<&str>) -> Result { - let value = required_text(value, "stash reference")?; - if value.starts_with('-') || value.contains(char::is_whitespace) { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Invalid Git stash reference", - )); + let prefix = match reference.kind.as_str() { + "local" => "refs/heads/", + "remote" => "refs/remotes/", + "tag" => "refs/tags/", + _ => return Err(invalid_git_reference()), + }; + let expected_short_name = reference + .full_name + .strip_prefix(prefix) + .filter(|value| !value.is_empty()) + .ok_or_else(invalid_git_reference)?; + if expected_short_name != reference.short_name { + return Err(invalid_git_reference()); } - Ok(value) -} -fn validated_branch_name(root: &str, value: Option<&str>) -> Result { - let value = required_text(value, "branch name")?; - let validation = execute_git( + let checked = execute_git_readonly( root, - &["check-ref-format".into(), "--branch".into(), value.clone()], + &["check-ref-format".into(), reference.full_name.clone()], None, )?; - if validation.exit_code != 0 { - return Err( - CoreError::new(ErrorCode::InvalidRequest, "Invalid Git branch name") - .with_details(validation.output), - ); + if checked.exit_code != 0 { + return Err(invalid_git_reference()); } - Ok(value) + if reference.kind == "remote" { + let (_, branch) = mutations::remote_branch_components(root, &reference.full_name)?; + if branch == "HEAD" { + return Err(invalid_git_reference()); + } + } + + Ok(ValidatedGitReference { + full_name: reference.full_name.clone(), + short_name: reference.short_name.clone(), + kind: reference.kind.clone(), + }) } -fn local_branch_name(reference: &str) -> Result { - let branch = reference +fn legacy_checkout_reference( + root: &str, + reference: &str, + kind: &str, +) -> Result { + let (full_name, short_name) = match kind { + "local" => { + let short_name = reference.strip_prefix("refs/heads/").unwrap_or(reference); + (format!("refs/heads/{short_name}"), short_name.to_string()) + } + "remote" => { + let short_name = reference + .strip_prefix("refs/remotes/") + .ok_or_else(invalid_git_reference)?; + (reference.to_string(), short_name.to_string()) + } + "tag" => { + let short_name = reference.strip_prefix("refs/tags/").unwrap_or(reference); + (format!("refs/tags/{short_name}"), short_name.to_string()) + } + _ => return Err(invalid_git_reference()), + }; + validated_git_reference( + root, + &GitReferenceRequest { + full_name, + short_name, + kind: kind.to_string(), + }, + ) +} + +fn optional_write_request_reference( + root: &str, + request: &GitWriteRequest, +) -> Result, CoreError> { + if let Some(reference) = request.git_reference.as_ref() { + return validated_git_reference(root, reference).map(|value| Some(value.full_name)); + } + request + .reference + .as_deref() + .map(|reference| validated_reference(Some(reference))) + .transpose() +} + +pub(super) fn write_request_reference( + root: &str, + request: &GitWriteRequest, +) -> Result { + optional_write_request_reference(root, request)? + .ok_or_else(|| CoreError::new(ErrorCode::InvalidRequest, "Missing Git reference")) +} + +fn checkout_request_reference( + root: &str, + request: &GitWriteRequest, +) -> Result { + if let Some(reference) = request.git_reference.as_ref() { + return validated_git_reference(root, reference); + } + let reference = validated_reference(request.reference.as_deref())?; + let kind = required_text(request.reference_kind.as_deref(), "reference kind")?; + legacy_checkout_reference(root, &reference, &kind) +} + +fn request_reference( + root: &str, + typed: Option<&GitReferenceRequest>, + legacy: Option<&str>, +) -> Result { + if let Some(reference) = typed { + return validated_git_reference(root, reference).map(|value| value.full_name); + } + validated_reference(legacy) +} + +fn typed_reference_range( + root: &str, + base: Option<&GitReferenceRequest>, + target: Option<&GitReferenceRequest>, + legacy: Option<&str>, +) -> Result, CoreError> { + match (base, target) { + (Some(base), Some(target)) => { + if legacy.is_some() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Git comparison cannot combine typed and legacy references", + )); + } + let base = validated_git_reference(root, base)?; + let target = validated_git_reference(root, target)?; + Ok(Some(format!("{}..{}", base.full_name, target.full_name))) + } + (None, Some(_)) => Err(CoreError::new( + ErrorCode::InvalidRequest, + "Git comparison target requires a base reference", + )), + (Some(base), None) => { + if legacy.is_some() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Git comparison cannot combine typed and legacy references", + )); + } + Ok(Some(validated_git_reference(root, base)?.full_name)) + } + (None, None) => legacy + .map(|value| validated_reference(Some(value))) + .transpose(), + } +} + +fn validate_paths(paths: &[String]) -> Result, CoreError> { + if paths.is_empty() || paths.iter().any(|path| !is_safe_pathspec(path)) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Git operation contains an invalid path", + )); + } + Ok(paths.to_vec()) +} + +/// Chooses whether ignore patterns belong to the shared repository file or the +/// current checkout's local Git metadata. +enum GitIgnoreTarget { + Repository, + LocalExclude, +} + +fn append_git_ignore_patterns( + root: &str, + paths: &[String], + target: GitIgnoreTarget, +) -> Result { + let patterns = git_ignore_patterns(paths)?; + let target_path = match target { + GitIgnoreTarget::Repository => repository_root(root)?.join(".gitignore"), + GitIgnoreTarget::LocalExclude => git_path(root, "info/exclude")?, + }; + let existing = match std::fs::read(&target_path) { + Ok(content) => content, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(), + Err(error) => return Err(git_ignore_io_error("read", error)), + }; + let existing_text = String::from_utf8_lossy(&existing); + let additions = patterns + .into_iter() + .filter(|pattern| !existing_text.lines().any(|line| line == pattern)) + .collect::>(); + if additions.is_empty() { + return Ok(successful_git_result()); + } + + if let Some(parent) = target_path.parent() { + std::fs::create_dir_all(parent).map_err(|error| git_ignore_io_error("create", error))?; + } + let mut appended = String::new(); + if !existing.is_empty() && !existing.ends_with(b"\n") { + appended.push('\n'); + } + for pattern in additions { + appended.push_str(&pattern); + appended.push('\n'); + } + let mut file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(target_path) + .map_err(|error| git_ignore_io_error("open", error))?; + file.write_all(appended.as_bytes()) + .map_err(|error| git_ignore_io_error("write", error))?; + Ok(successful_git_result()) +} + +fn git_ignore_patterns(paths: &[String]) -> Result, CoreError> { + let mut patterns = Vec::with_capacity(paths.len()); + for path in paths { + if path.contains(['\0', '\n', '\r']) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Git ignore operation contains an invalid path", + )); + } + let normalized = path.replace('\\', "/"); + let is_directory = normalized.ends_with('/'); + let normalized = normalized.trim_end_matches('/'); + if !is_safe_pathspec(normalized) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Git ignore operation contains an invalid path", + )); + } + + let mut escaped = String::with_capacity(normalized.len() + 2); + escaped.push('/'); + for character in normalized.chars() { + if matches!(character, '*' | '?' | '[' | ']' | '#' | '!' | ' ') { + escaped.push('\\'); + } + escaped.push(character); + } + if is_directory { + escaped.push('/'); + } + patterns.push(escaped); + } + patterns.sort(); + patterns.dedup(); + if patterns.is_empty() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Git ignore operation contains an invalid path", + )); + } + Ok(patterns) +} + +fn repository_root(root: &str) -> Result { + git_resolved_path(root, &["rev-parse", "--show-toplevel"], "repository root") +} + +fn git_path(root: &str, path: &str) -> Result { + git_resolved_path( + root, + &["rev-parse", "--path-format=absolute", "--git-path", path], + "Git metadata path", + ) +} + +fn git_resolved_path(root: &str, arguments: &[&str], label: &str) -> Result { + let arguments = arguments + .iter() + .map(|value| value.to_string()) + .collect::>(); + let response = execute_git_readonly(root, &arguments, None)?; + if response.exit_code != 0 { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + format!("Could not resolve {label}"), + ) + .with_details(response.output)); + } + let path = response.output.trim(); + if path.is_empty() { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + format!("Could not resolve {label}"), + )); + } + Ok(PathBuf::from(path)) +} + +fn git_ignore_io_error(action: &str, error: std::io::Error) -> CoreError { + let code = if error.kind() == std::io::ErrorKind::PermissionDenied { + ErrorCode::PermissionDenied + } else { + ErrorCode::Unknown + }; + CoreError::new(code, format!("Could not {action} Git ignore file")) + .with_details(error.to_string()) +} + +fn successful_git_result() -> GitCommandResponse { + GitCommandResponse { + arguments: Vec::new(), + output: String::new(), + stdout: String::new(), + stderr: String::new(), + exit_code: 0, + invocations: Vec::new(), + operation_error: None, + stash_restore: None, + warnings: Vec::new(), + } +} + +/// Immutable commit fields needed to rebuild a linear history without invoking +/// an editor or losing author and committer attribution. +#[derive(Clone)] +struct RewriteCommit { + /// Original commit object used to derive the patch during history replay. + hash: String, + tree: String, + parents: Vec, + author_name: String, + author_email: String, + author_date: String, + committer_name: String, + committer_email: String, + committer_date: String, + message: String, +} + +/// Current local branch context and its first-parent chain, ordered from HEAD +/// toward the root commit. +struct HistoryRewriteContext { + branch_reference: String, + original_head: String, + first_parent_chain: Vec, + published_commits: HashSet, +} + +fn edit_commit_message( + root: &str, + revision: &str, + message: &str, +) -> Result { + let context = history_rewrite_context(root)?; + let target = resolve_commit_revision(root, revision)?; + let target_index = history_commit_index(&context, &target)?; + let commits = checked_rewrite_range(root, &context, target_index)?; + let mut parent = commits[0].parents.first().cloned(); + + for (index, commit) in commits.iter().enumerate() { + let commit_message = if index == 0 { message } else { &commit.message }; + parent = Some(write_commit_tree( + root, + commit, + parent.as_deref(), + commit_message, + )?); + } + + update_history_reference( + root, + &context, + parent.as_deref().expect("rewrite range contains a commit"), + "lithe: edit commit message", + ) +} + +fn delete_commit(root: &str, revision: &str) -> Result { + let context = history_rewrite_context(root)?; + let target = resolve_commit_revision(root, revision)?; + let target_index = history_commit_index(&context, &target)?; + let commits = checked_rewrite_range(root, &context, target_index)?; + let target_commit = &commits[0]; + let parent = target_commit.parents.first().ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "The root commit cannot be deleted", + ) + })?; + + let mut rewritten_head = parent.clone(); + for commit in commits.iter().skip(1) { + let mut replayed_commit = commit.clone(); + replayed_commit.tree = replay_commit_tree(root, commit, &rewritten_head)?; + rewritten_head = write_commit_tree( + root, + &replayed_commit, + Some(&rewritten_head), + &commit.message, + )?; + } + + let updated = + update_history_reference(root, &context, &rewritten_head, "lithe: delete commit")?; + if updated.exit_code != 0 { + return Ok(updated); + } + + // The branch move is CAS-protected above. A merge reset refreshes the clean + // index and worktree but refuses to overwrite edits created concurrently + // after the initial clean-tree check. + let mut refreshed = execute_git( + root, + &["reset".into(), "--merge".into(), "HEAD".into()], + None, + )?; + if refreshed.exit_code != 0 { + refreshed.warnings.push(GitOperationWarning::new( + "git_worktree_refresh_failed", + "The commit was deleted, but the working tree could not be refreshed", + Some(refreshed.output.clone()), + )); + refreshed.exit_code = 0; + } + Ok(refreshed) +} + +fn squash_commits( + root: &str, + revisions: &[String], + message: &str, +) -> Result { + if revisions.len() < 2 { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Select at least two commits to squash", + )); + } + + let context = history_rewrite_context(root)?; + let mut selected = Vec::with_capacity(revisions.len()); + for revision in revisions { + validate_revision(revision)?; + selected.push(resolve_commit_revision(root, revision)?); + } + selected.sort(); + selected.dedup(); + if selected.len() != revisions.len() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Select distinct commits to squash", + )); + } + + let mut selected_indices = selected + .iter() + .map(|commit| history_commit_index(&context, commit)) + .collect::, _>>()?; + selected_indices.sort_unstable(); + let newest_index = selected_indices[0]; + let oldest_index = *selected_indices + .last() + .expect("at least two commits were selected"); + if oldest_index - newest_index + 1 != selected_indices.len() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Only a contiguous range of commits can be squashed", + )); + } + + let commits = checked_rewrite_range(root, &context, oldest_index)?; + let selected_count = oldest_index - newest_index + 1; + let oldest = &commits[0]; + let newest = &commits[selected_count - 1]; + let mut squashed = oldest.clone(); + squashed.tree.clone_from(&newest.tree); + squashed.committer_name.clone_from(&newest.committer_name); + squashed.committer_email.clone_from(&newest.committer_email); + squashed.committer_date.clone_from(&newest.committer_date); + + let mut parent = Some(write_commit_tree( + root, + &squashed, + oldest.parents.first().map(String::as_str), + message, + )?); + for commit in commits.iter().skip(selected_count) { + parent = Some(write_commit_tree( + root, + commit, + parent.as_deref(), + &commit.message, + )?); + } + + update_history_reference( + root, + &context, + parent.as_deref().expect("squash produces a commit"), + "lithe: squash commits", + ) +} + +fn history_rewrite_context(root: &str) -> Result { + let operation = operation_state(GitOperationStateRequest { + root: root.to_string(), + })?; + if !operation.kind.is_empty() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Finish or abort the current Git operation before rewriting history", + )); + } + + let status = execute_git_readonly( + root, + &[ + "status".into(), + "--porcelain=v1".into(), + "--untracked-files=all".into(), + ], + None, + )?; + if status.exit_code != 0 { + return Err( + CoreError::new(ErrorCode::ProcessFailed, "Git status failed") + .with_details(status.output), + ); + } + if !status.output.trim().is_empty() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Commit history can only be rewritten with a clean working tree", + )); + } + + let branch = execute_git_readonly( + root, + &["symbolic-ref".into(), "--quiet".into(), "HEAD".into()], + None, + )?; + let branch_reference = branch.output.trim(); + if branch.exit_code != 0 || !branch_reference.starts_with("refs/heads/") { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Commit history can only be rewritten on a checked out local branch", + )); + } + + let chain = execute_git_readonly( + root, + &["rev-list".into(), "--first-parent".into(), "HEAD".into()], + None, + )?; + if chain.exit_code != 0 { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Could not read the current branch history", + ) + .with_details(chain.output)); + } + let first_parent_chain = chain + .output + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(String::from) + .collect::>(); + let original_head = first_parent_chain.first().cloned().ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "The current branch does not contain any commits", + ) + })?; + + let remote_commits = + execute_git_readonly(root, &["rev-list".into(), "--remotes".into()], None)?; + if remote_commits.exit_code != 0 { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Could not inspect remote Git history", + ) + .with_details(remote_commits.output)); + } + + Ok(HistoryRewriteContext { + branch_reference: branch_reference.to_string(), + original_head, + first_parent_chain, + published_commits: remote_commits + .output + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(String::from) + .collect(), + }) +} + +fn resolve_commit_revision(root: &str, revision: &str) -> Result { + let response = execute_git_readonly( + root, + &[ + "rev-parse".into(), + "--verify".into(), + "--quiet".into(), + "--end-of-options".into(), + format!("{revision}^{{commit}}"), + ], + None, + )?; + let commit = response.output.trim(); + if response.exit_code != 0 || commit.is_empty() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "The selected Git commit does not exist", + )); + } + Ok(commit.to_string()) +} + +fn history_commit_index(context: &HistoryRewriteContext, commit: &str) -> Result { + context + .first_parent_chain + .iter() + .position(|candidate| candidate == commit) + .ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "Only commits on the current branch first-parent history can be rewritten", + ) + }) +} + +fn checked_rewrite_range( + root: &str, + context: &HistoryRewriteContext, + oldest_index: usize, +) -> Result, CoreError> { + let hashes = &context.first_parent_chain[..=oldest_index]; + if hashes + .iter() + .any(|commit| context.published_commits.contains(commit)) + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Commits published to a remote cannot be rewritten", + )); + } + + let mut commits = hashes + .iter() + .rev() + .map(|hash| read_rewrite_commit(root, hash)) + .collect::, _>>()?; + if commits.iter().any(|commit| commit.parents.len() > 1) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "A history range containing merge commits cannot be rewritten", + )); + } + commits.shrink_to_fit(); + Ok(commits) +} + +fn read_rewrite_commit(root: &str, hash: &str) -> Result { + let format = "%T%x00%P%x00%an%x00%ae%x00%aI%x00%cn%x00%ce%x00%cI%x00%B%x00"; + let response = execute_git_readonly( + root, + &[ + "show".into(), + "--no-patch".into(), + "--no-show-signature".into(), + format!("--format={format}"), + hash.to_string(), + ], + None, + )?; + if response.exit_code != 0 { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Could not read a commit selected for history rewrite", + ) + .with_details(response.output)); + } + + let output = response + .output + .strip_suffix("\0\n") + .or_else(|| response.output.strip_suffix('\0')) + .unwrap_or(&response.output); + let fields = output.splitn(9, '\0').collect::>(); + if fields.len() != 9 { + return Err(CoreError::new( + ErrorCode::ParseFailed, + "Could not decode a commit selected for history rewrite", + )); + } + Ok(RewriteCommit { + hash: hash.to_string(), + tree: fields[0].to_string(), + parents: fields[1].split_whitespace().map(String::from).collect(), + author_name: fields[2].to_string(), + author_email: fields[3].to_string(), + author_date: fields[4].to_string(), + committer_name: fields[5].to_string(), + committer_email: fields[6].to_string(), + committer_date: fields[7].to_string(), + message: fields[8].to_string(), + }) +} + +fn replay_commit_tree( + root: &str, + commit: &RewriteCommit, + rewritten_parent: &str, +) -> Result { + let original_parent = commit.parents.first().ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "A root commit cannot be replayed after deleting its parent", + ) + })?; + let patch_arguments = vec![ + "diff-tree".to_string(), + "--binary".to_string(), + "--full-index".to_string(), + "--no-ext-diff".to_string(), + "--no-renames".to_string(), + "--no-commit-id".to_string(), + "-p".to_string(), + original_parent.clone(), + commit.hash.clone(), + ]; + let patch = execute_git_readonly(root, &patch_arguments, None)?; + if patch.exit_code != 0 { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Could not read changes after the deleted commit", + ) + .with_details(patch.output)); + } + + let common_directory = git_resolved_path( + root, + &["rev-parse", "--path-format=absolute", "--git-common-dir"], + "Git common directory", + )?; + let temporary_context = TemporaryGitCommitContext::prepare(root, Some(rewritten_parent))?; + let environment = temporary_context.environment(root, &common_directory); + let initialized = execute_git_with_environment( + root, + &["read-tree".to_string(), rewritten_parent.to_string()], + None, + false, + &environment, + )?; + if initialized.exit_code != 0 { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Could not initialize history replay", + ) + .with_details(initialized.output)); + } + + if !patch.stdout.is_empty() { + let applied = execute_git_with_environment( + root, + &[ + "apply".to_string(), + "--cached".to_string(), + "--whitespace=nowarn".to_string(), + "-".to_string(), + ], + Some(patch.stdout), + false, + &environment, + )?; + if applied.exit_code != 0 { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Could not replay changes after the deleted commit", + ) + .with_details(applied.output)); + } + } + + let tree = + execute_git_with_environment(root, &["write-tree".to_string()], None, false, &environment)?; + if tree.exit_code != 0 { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Could not write replayed Git tree", + ) + .with_details(tree.output)); + } + let tree_hash = tree.stdout.trim(); + if tree_hash.is_empty() { + return Err(CoreError::new( + ErrorCode::ParseFailed, + "Git did not return a replayed tree identifier", + )); + } + Ok(tree_hash.to_string()) +} + +fn write_commit_tree( + root: &str, + commit: &RewriteCommit, + parent: Option<&str>, + message: &str, +) -> Result { + let mut arguments = vec![ + "-c".into(), + "commit.gpgSign=false".into(), + "commit-tree".into(), + commit.tree.clone(), + ]; + if let Some(parent) = parent { + arguments.extend(["-p".into(), parent.to_string()]); + } + arguments.extend(["-F".into(), "-".into()]); + let environment = vec![ + ("GIT_AUTHOR_NAME".into(), commit.author_name.clone()), + ("GIT_AUTHOR_EMAIL".into(), commit.author_email.clone()), + ("GIT_AUTHOR_DATE".into(), commit.author_date.clone()), + ("GIT_COMMITTER_NAME".into(), commit.committer_name.clone()), + ("GIT_COMMITTER_EMAIL".into(), commit.committer_email.clone()), + ("GIT_COMMITTER_DATE".into(), commit.committer_date.clone()), + ]; + let output = capture_git_with_environment( + root, + &arguments, + Some(message.to_string()), + false, + &environment, + )?; + if output.exit_code != 0 { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Could not rebuild Git commit history", + ) + .with_details(output.into_command_response(&arguments).output)); + } + let hash = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if hash.is_empty() { + return Err(CoreError::new( + ErrorCode::ParseFailed, + "Git did not return a rebuilt commit identifier", + )); + } + Ok(hash) +} + +fn update_history_reference( + root: &str, + context: &HistoryRewriteContext, + new_head: &str, + reflog_message: &str, +) -> Result { + execute_git( + root, + &[ + "update-ref".into(), + "-m".into(), + reflog_message.into(), + context.branch_reference.clone(), + new_head.into(), + context.original_head.clone(), + ], + None, + ) +} + +fn validated_revision(value: Option<&str>) -> Result { + let value = required_text(value, "revision")?; + validate_revision(&value)?; + Ok(value) +} + +pub(super) fn validated_reference(value: Option<&str>) -> Result { + let value = required_text(value, "reference")?; + if value.starts_with('-') || value.chars().any(char::is_whitespace) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Invalid Git reference", + )); + } + Ok(value) +} + +fn validated_stash_reference(value: Option<&str>) -> Result { + let value = required_text(value, "stash reference")?; + if value.starts_with('-') || value.contains(char::is_whitespace) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Invalid Git stash reference", + )); + } + Ok(value) +} + +fn validated_branch_name(root: &str, value: Option<&str>) -> Result { + let value = required_text(value, "branch name")?; + let validation = execute_git( + root, + &["check-ref-format".into(), "--branch".into(), value.clone()], + None, + )?; + if validation.exit_code != 0 { + return Err( + CoreError::new(ErrorCode::InvalidRequest, "Invalid Git branch name") + .with_details(validation.output), + ); + } + Ok(value) +} + +fn local_branch_name(reference: &str) -> Result { + let branch = reference .strip_prefix("refs/heads/") .filter(|value| !value.is_empty()) .ok_or_else(|| { @@ -2021,32 +3807,33 @@ fn failed_git_result(error: CoreError) -> GitCommandResponse { invocations: Vec::new(), operation_error: Some(error), stash_restore: None, + warnings: Vec::new(), } } -/// Discards both index and working-tree content for a conflict-dialog rollback. +/// Discards both index and working-tree content for an explicitly confirmed rollback. /// The normal `discard` operation deliberately preserves staged content, while -/// this explicit operation is destructive for the whole path and therefore only -/// used after the UI's second confirmation. +/// this explicit operation is destructive for the whole path. fn discard_all(root: &str, paths: &[String]) -> Result { let mut tracked = Vec::new(); let mut untracked = Vec::new(); - let mut status_arguments = vec![ + let status_arguments = vec![ "status".to_string(), - "--porcelain".to_string(), + "--porcelain=v1".to_string(), + "-z".to_string(), "--untracked-files=all".to_string(), - "--".to_string(), ]; - status_arguments.extend(paths.iter().cloned()); let status = execute_git(root, &status_arguments, None)?; if status.exit_code != 0 { return Ok(status); } + let untracked_paths = status + .output + .split('\0') + .filter_map(|record| record.strip_prefix("?? ")) + .collect::>(); for path in paths { - let is_untracked = status.output.lines().any(|line| { - (line.starts_with("??") || line.starts_with("!!")) && line[3..].trim() == path - }); - if is_untracked { + if untracked_paths.contains(path.as_str()) { untracked.push(path.clone()); } else { tracked.push(path.clone()); @@ -2055,12 +3842,25 @@ fn discard_all(root: &str, paths: &[String]) -> Result(); + let restored = execute_git(root, &arguments, Some(pathspec_input))?; + if restored.exit_code != 0 { + return Ok(restored); } + final_response = restored; } if !untracked.is_empty() { let mut arguments = vec![ @@ -2106,7 +3906,134 @@ fn apply_stash(root: &str, reference: &str) -> Result Result { + let status = execute_git_readonly( + root, + &[ + "status".into(), + "--porcelain=v1".into(), + "-z".into(), + "--untracked-files=all".into(), + ], + None, + )?; + if status.exit_code != 0 { + return Ok(status); + } + if status.stdout.is_empty() { + return execute_git(root, pull_arguments, None); + } + + let stash_marker = format!( + "lithe: auto-stash before pull:{}:{}", + std::process::id(), + AUTO_STASH_SEQUENCE.fetch_add(1, Ordering::Relaxed) + ); + let stashed = execute_git( + root, + &[ + "stash".into(), + "push".into(), + "--include-untracked".into(), + "--message".into(), + stash_marker.clone(), + ], + None, + )?; + if stashed.exit_code != 0 { + return Ok(stashed); + } + let stash_oid = find_stash_oid_by_message(root, &stash_marker)?.ok_or_else(|| { + CoreError::new( + ErrorCode::ProcessFailed, + "Pull created a temporary stash but could not identify it", + ) + })?; + + let pulled = execute_git(root, pull_arguments, None)?; + if pulled.exit_code != 0 { + return Ok(pulled); + } + + let mut restored = execute_git( + root, + &["stash".into(), "apply".into(), stash_oid.clone()], + None, + )?; + let conflicted_paths = conflicted_paths(root)?; + let stash_reference = stash_reference_for_oid(root, &stash_oid)?; + if restored.exit_code != 0 || !conflicted_paths.is_empty() { + restored.exit_code = 1; + restored.stash_restore = Some(GitStashRestoreResponse { + stash_reference: stash_reference.unwrap_or(stash_oid), + conflicted_paths, + }); + return Ok(restored); + } + + let Some(stash_reference) = stash_reference else { + restored.warnings.push(GitOperationWarning::new( + "git_stash_drop_failed", + "The pull and stash restore succeeded, but the saved stash could not be located for removal", + None, + )); + return Ok(restored); + }; + let dropped = execute_git( + root, + &["stash".into(), "drop".into(), stash_reference], + None, + )?; + if dropped.exit_code != 0 { + restored.warnings.push(GitOperationWarning::new( + "git_stash_drop_failed", + "The pull and stash restore succeeded, but the saved stash could not be removed", + Some(dropped.output), + )); + } + Ok(restored) +} + +fn find_stash_oid_by_message(root: &str, message: &str) -> Result, CoreError> { + let list = execute_git_readonly( + root, + &["stash".into(), "list".into(), "--format=%H%x09%gs".into()], + None, + )?; + if list.exit_code != 0 { + return Err( + CoreError::new(ErrorCode::ProcessFailed, "Could not inspect Git stashes") + .with_details(list.output), + ); + } + Ok(list.stdout.lines().find_map(|line| { + let (oid, subject) = line.split_once('\t')?; + subject.contains(message).then(|| oid.to_string()) + })) +} + +fn stash_reference_for_oid(root: &str, oid: &str) -> Result, CoreError> { + let list = execute_git_readonly( + root, + &["stash".into(), "list".into(), "--format=%gd%x09%H".into()], + None, + )?; + if list.exit_code != 0 { + return Err( + CoreError::new(ErrorCode::ProcessFailed, "Could not inspect Git stashes") + .with_details(list.output), + ); + } + Ok(list.stdout.lines().find_map(|line| { + let (reference, commit) = line.split_once('\t')?; + (commit == oid).then(|| reference.to_string()) + })) } fn conflicted_paths(root: &str) -> Result, CoreError> { @@ -2167,40 +4094,482 @@ fn find_stash_reference(root: &str, message: &str) -> Result, Cor })) } -fn push(root: &str, reference: Option<&str>) -> Result { - let current = current_branch(root)?; - let branch = match reference { - Some(reference) => local_branch_name(&validated_reference(Some(reference))?)?, - None => current.clone(), +#[derive(Clone)] +struct PushTarget { + local_branch: String, + remote: String, + remote_branch: String, + upstream: Option, + comparison_reference: Option, +} + +/// Resolves the remote destination and commits that a subsequent push will use. +pub fn push_preview(request: GitPushPreviewRequest) -> Result { + let root = validate_root(&request.root)?; + let reference = if let Some(reference) = request.git_reference.as_ref() { + let reference = validated_git_reference(&root, reference)?; + if reference.kind != "local" { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Only local branches can be pushed", + )); + } + Some(reference.full_name) + } else { + request + .reference + .as_deref() + .map(|reference| validated_reference(Some(reference))) + .transpose()? + }; + let target = resolve_push_target(&root, reference.as_deref())?; + let limit = request.limit.clamp(1, 5_000); + let local_reference = format!("refs/heads/{}", target.local_branch); + let local_head = resolve_commit_revision(&root, &local_reference)?; + let tags = resolve_push_tags( + &root, + request.push_tags.as_deref().unwrap_or("none"), + &local_head, + )?; + let remote_tracking_oid = target + .comparison_reference + .as_deref() + .map(|reference| resolve_commit_revision(&root, reference)) + .transpose()?; + let selectors = if let Some(comparison_reference) = target.comparison_reference.as_ref() { + vec![format!("{comparison_reference}..{local_reference}")] + } else { + // A branch without an upstream may still be based on another remote branch. + // Excluding every commit reachable from the selected remote keeps the preview + // focused on commits that publication would introduce. + vec![ + local_reference, + "--not".to_string(), + format!("--remotes={}", target.remote), + ] + }; + let (commits, has_more) = read_commit_log(&root, selectors, limit, "Git push preview failed")?; + + Ok(GitPushPreviewResponse { + local_branch: target.local_branch, + local_head, + remote: target.remote, + remote_branch: target.remote_branch, + remote_tracking_oid, + upstream: target.upstream, + tags, + commits, + has_more, + }) +} + +fn push( + root: &str, + reference: Option<&str>, + force: bool, + push_tags: Option<&str>, + expected_push: Option<&GitPushExpectationRequest>, +) -> Result { + let tag_scope = push_tags.unwrap_or("none"); + let tag_argument = match tag_scope { + "none" => None, + "all" => Some("--tags"), + "reachable" => Some("--follow-tags"), + _ => { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Unsupported Git push tag scope", + )) + } }; - let upstream = execute_git( + let target = resolve_push_target(root, reference)?; + let should_set_upstream = target.upstream.is_none(); + if let Some(expected_push) = expected_push { + validate_push_expectation(root, &target, tag_scope, expected_push)?; + } + let mut arguments = vec!["push".to_string()]; + if force { + // Bind reviewed pushes to the observed remote OID so an unseen remote update is rejected. + arguments.push(match expected_push { + Some(expected) => format!( + "--force-with-lease=refs/heads/{}:{}", + target.remote_branch, + expected.remote_tracking_oid.as_deref().unwrap_or_default() + ), + None => "--force-with-lease".into(), + }); + } + if expected_push.is_none() { + if let Some(tag_argument) = tag_argument { + arguments.push(tag_argument.into()); + } + } + if should_set_upstream && expected_push.is_none() { + arguments.push("--set-upstream".into()); + } + let source = expected_push + .map(|expected| expected.local_head.clone()) + .unwrap_or_else(|| format!("refs/heads/{}", target.local_branch)); + let remote = target.remote; + let remote_branch = target.remote_branch; + let local_branch = target.local_branch; + arguments.extend([ + remote.clone(), + format!("{source}:refs/heads/{remote_branch}"), + ]); + if let Some(expected) = expected_push { + arguments.extend( + expected + .tags + .iter() + .map(|tag| format!("{}:{}", tag.object_id, tag.full_name)), + ); + } + let pushed = execute_git(root, &arguments, None)?; + if pushed.exit_code != 0 || !should_set_upstream || expected_push.is_none() { + return Ok(pushed); + } + + if let Some(configuration) = + configure_branch_upstream(root, &local_branch, &remote, &remote_branch)? + { + return Ok(push_with_upstream_warning(pushed, configuration)); + } + Ok(pushed) +} + +fn push_with_upstream_warning( + mut pushed: GitCommandResponse, + configuration: GitCommandResponse, +) -> GitCommandResponse { + pushed.warnings.push(GitOperationWarning::new( + "git_upstream_configuration_failed", + "The push succeeded, but the branch upstream could not be configured", + Some(configuration.output), + )); + pushed +} + +fn create_worktree(root: &str, request: &GitWriteRequest) -> Result { + let branch = validated_branch_name(root, request.name.as_deref())?; + let destination = required_text(request.destination.as_deref(), "worktree destination")?; + if destination.starts_with('-') || destination.contains(['\0', '\n', '\r']) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Invalid Git worktree destination", + )); + } + let reference = request + .git_reference + .as_ref() + .ok_or_else(invalid_git_reference) + .and_then(|reference| validated_git_reference(root, reference))?; + let mut arguments = vec!["worktree".into(), "add".into()]; + if reference.kind == "remote" { + // Let Git create the branch and its tracking configuration in one + // mutation. The complete ref keeps a same-named local branch from + // making the selected remote-tracking branch ambiguous. + arguments.push("--track".into()); + } + arguments.extend([ + "-b".into(), + branch, + "--".into(), + destination, + reference.full_name, + ]); + execute_git(root, &arguments, None) +} + +fn configure_branch_upstream( + root: &str, + local_branch: &str, + remote: &str, + remote_branch: &str, +) -> Result, CoreError> { + let remote_config = execute_git( root, &[ - "rev-parse".into(), - "--abbrev-ref".into(), - format!("{branch}@{{upstream}}"), + "config".to_string(), + "--local".to_string(), + "--replace-all".to_string(), + format!("branch.{local_branch}.remote"), + remote.to_string(), + ], + None, + )?; + if remote_config.exit_code != 0 { + return Ok(Some(remote_config)); + } + let merge_config = execute_git( + root, + &[ + "config".to_string(), + "--local".to_string(), + "--replace-all".to_string(), + format!("branch.{local_branch}.merge"), + format!("refs/heads/{remote_branch}"), + ], + None, + )?; + Ok((merge_config.exit_code != 0).then_some(merge_config)) +} + +fn nul_pathspec_input(paths: &[String]) -> String { + paths + .iter() + .flat_map(|path| [path.as_str(), "\0"]) + .collect() +} + +fn empty_tree_oid(root: &str) -> Result { + let hashed = execute_git_readonly( + root, + &[ + "hash-object".to_string(), + "-t".to_string(), + "tree".to_string(), + "--stdin".to_string(), + ], + Some(String::new()), + )?; + if hashed.exit_code != 0 { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Could not resolve Git's empty tree", + ) + .with_details(hashed.output)); + } + let oid = hashed.stdout.trim(); + validate_revision(oid)?; + Ok(oid.to_string()) +} + +fn validate_push_expectation( + root: &str, + target: &PushTarget, + tag_scope: &str, + expected: &GitPushExpectationRequest, +) -> Result<(), CoreError> { + let local_reference = format!("refs/heads/{}", target.local_branch); + let local_head = resolve_commit_revision(root, &local_reference)?; + let remote_tracking_oid = target + .comparison_reference + .as_deref() + .map(|reference| resolve_commit_revision(root, reference)) + .transpose()?; + let tags = resolve_push_tags(root, tag_scope, &local_head)?; + let expected_tags = expected + .tags + .iter() + .map(|tag| (&tag.full_name, &tag.object_id)) + .collect::>(); + let actual_tags = tags + .iter() + .map(|tag| (&tag.full_name, &tag.object_id)) + .collect::>(); + if expected.local_branch != target.local_branch + || expected.local_head != local_head + || expected.remote != target.remote + || expected.remote_branch != target.remote_branch + || expected.remote_tracking_oid != remote_tracking_oid + || expected_tags != actual_tags + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Git push preview is stale; refresh and try again.", + )); + } + Ok(()) +} + +fn resolve_push_tags( + root: &str, + scope: &str, + local_head: &str, +) -> Result, CoreError> { + if scope == "none" { + return Ok(Vec::new()); + } + if !matches!(scope, "all" | "reachable") { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Unsupported Git push tag scope", + )); + } + let listed = execute_git_readonly( + root, + &[ + "for-each-ref".into(), + "--sort=refname".into(), + "--format=%(refname)%09%(objectname)%09%(objecttype)%09%(*objectname)".into(), + "refs/tags".into(), ], None, )?; - if upstream.exit_code == 0 { - let tracking_name = upstream.output.trim(); - if branch == current { - return execute_git(root, &["push".into()], None); + if listed.exit_code != 0 { + return Err( + CoreError::new(ErrorCode::ProcessFailed, "Could not inspect Git tags") + .with_details(listed.output), + ); + } + + let mut tags = Vec::new(); + for line in listed.stdout.lines() { + let columns = line.split('\t').collect::>(); + if columns.len() != 4 || !columns[0].starts_with("refs/tags/") || columns[1].is_empty() { + return Err(CoreError::new( + ErrorCode::ParseFailed, + "Could not decode Git tag metadata", + )); } - if let Some((remote, remote_branch)) = tracking_name.split_once('/') { - return execute_git( + if scope == "reachable" { + if columns[2] != "tag" || columns[3].is_empty() { + continue; + } + let reachable = execute_git_readonly( root, &[ - "push".into(), - remote.to_string(), - format!("{branch}:{remote_branch}"), + "merge-base".into(), + "--is-ancestor".into(), + columns[3].into(), + local_head.into(), ], None, - ); + )?; + if reachable.exit_code == 1 { + continue; + } + if reachable.exit_code != 0 { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Could not inspect Git tag reachability", + ) + .with_details(reachable.output)); + } } + tags.push(GitPushTagResponse { + full_name: columns[0].to_string(), + object_id: columns[1].to_string(), + }); + } + Ok(tags) +} + +fn resolve_push_target(root: &str, reference: Option<&str>) -> Result { + let local_branch = match reference { + Some(reference) => local_branch_name(&validated_reference(Some(reference))?)?, + None => current_branch(root)?, + }; + let upstream_lookup = execute_git_readonly( + root, + &[ + "rev-parse".into(), + "--abbrev-ref".into(), + format!("{local_branch}@{{upstream}}"), + ], + None, + )?; + let (upstream, upstream_components) = if upstream_lookup.exit_code == 0 { + let upstream = upstream_lookup.output.trim().to_string(); + let components = + mutations::remote_branch_components(root, &format!("refs/remotes/{upstream}"))?; + (Some(upstream), Some(components)) + } else { + (None, None) + }; + + let branch_push_remote = + read_git_config_value(root, &format!("branch.{local_branch}.pushRemote"))?; + let default_push_remote = read_git_config_value(root, "remote.pushDefault")?; + let branch_remote = read_git_config_value(root, &format!("branch.{local_branch}.remote"))? + .filter(|remote| remote != "."); + let remote = branch_push_remote + .or(default_push_remote) + .or_else(|| { + upstream_components + .as_ref() + .map(|(remote, _)| remote.clone()) + }) + .or(branch_remote) + .map(Ok) + .unwrap_or_else(|| default_push_remote_name(root))?; + validate_push_component(&remote)?; + + let remote_branch = upstream_components + .as_ref() + .filter(|(upstream_remote, _)| upstream_remote == &remote) + .map(|(_, branch)| branch.clone()) + .unwrap_or_else(|| local_branch.clone()); + let target_reference = format!("refs/remotes/{remote}/{remote_branch}"); + let comparison_reference = if reference_exists(root, &target_reference)? { + Some(target_reference) + } else { + None + }; + + Ok(PushTarget { + local_branch, + remote, + remote_branch, + upstream, + comparison_reference, + }) +} + +fn read_git_config_value(root: &str, key: &str) -> Result, CoreError> { + let configured = execute_git_readonly( + root, + &["config".into(), "--get".into(), key.to_string()], + None, + )?; + if configured.exit_code == 1 { + return Ok(None); + } + if configured.exit_code != 0 { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Could not read Git push configuration", + ) + .with_details(configured.output)); + } + let value = configured.output.trim(); + if value.is_empty() { + return Ok(None); + } + validate_push_component(value)?; + Ok(Some(value.to_string())) +} + +fn reference_exists(root: &str, reference: &str) -> Result { + let result = execute_git_readonly( + root, + &[ + "show-ref".into(), + "--verify".into(), + "--quiet".into(), + reference.to_string(), + ], + None, + )?; + if result.exit_code > 1 { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Could not inspect Git push destination", + ) + .with_details(result.output)); } + Ok(result.exit_code == 0) +} - let remotes = execute_git(root, &["remote".into()], None)?; +fn default_push_remote_name(root: &str) -> Result { + let remotes = execute_git_readonly(root, &["remote".into()], None)?; + if remotes.exit_code != 0 { + return Err( + CoreError::new(ErrorCode::ProcessFailed, "Could not list Git remotes") + .with_details(remotes.output), + ); + } let remote = remotes .output .lines() @@ -2212,23 +4581,24 @@ fn push(root: &str, reference: Option<&str>) -> Result execute_git( - root, - &[ - "push".into(), - "--set-upstream".into(), - remote.to_string(), - branch, - ], - None, - ), - None => Err(CoreError::new( + }) + .ok_or_else(|| CoreError::new(ErrorCode::InvalidRequest, "No Git remote is configured"))?; + validate_push_component(remote)?; + Ok(remote.to_string()) +} + +fn validate_push_component(value: &str) -> Result<(), CoreError> { + if value.is_empty() + || value.starts_with('-') + || value.contains(['\0', '\n', '\r']) + || value.chars().any(char::is_whitespace) + { + return Err(CoreError::new( ErrorCode::InvalidRequest, - "No Git remote is configured", - )), + "Invalid Git push destination", + )); } + Ok(()) } fn publish_branch(root: &str, name: Option<&str>) -> Result { @@ -2350,32 +4720,39 @@ pub(super) fn switch_reference( root: &str, request: &GitWriteRequest, ) -> Result { - let reference = validated_reference(request.reference.as_deref())?; + let reference = checkout_request_reference(root, request)?; + switch_validated_reference(root, &reference, request.force) +} + +fn switch_validated_reference( + root: &str, + reference: &ValidatedGitReference, + force: bool, +) -> Result { let mut base: Vec = vec!["switch".into()]; - if request.force { + if force { base.push("--discard-changes".into()); } - match request.reference_kind.as_deref() { - Some("local") => { + match reference.kind.as_str() { + "local" => { + if current_branch(root)? == reference.short_name { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "The current branch is already checked out", + )); + } // `git switch` rejects fully qualified refs ("refs/heads/foo"), so pass the // short branch name. Tags still need the full ref for the --detach form. - let branch = local_branch_name(&reference)?; - base.push(branch); + base.push(reference.short_name.clone()); execute_git(root, &base, None) } - Some("tag") => { + "tag" => { base.push("--detach".into()); - base.push(reference); + base.push(reference.full_name.clone()); execute_git(root, &base, None) } - Some("remote") => { - let (_, local_name) = mutations::remote_branch_components(root, &reference)?; - if !is_safe_pathspec(&local_name) { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Invalid remote branch name", - )); - } + "remote" => { + let (_, local_name) = mutations::remote_branch_components(root, &reference.full_name)?; let local_ref = format!("refs/heads/{local_name}"); let existing = execute_git( root, @@ -2383,17 +4760,46 @@ pub(super) fn switch_reference( "show-ref".into(), "--verify".into(), "--quiet".into(), - local_ref, + local_ref.clone(), ], None, )?; if existing.exit_code == 0 { - base.push(local_name.to_string()); + let upstream = execute_git_readonly( + root, + &[ + "for-each-ref".into(), + "--format=%(upstream)".into(), + "--count=1".into(), + local_ref, + ], + None, + )?; + if upstream.exit_code != 0 { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Could not inspect the local branch upstream", + ) + .with_details(upstream.output)); + } + if upstream.stdout.trim() != reference.full_name { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "A same-named local branch tracks a different Git reference", + )); + } + if current_branch(root)? == local_name { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "The current branch is already checked out", + )); + } + base.push(local_name); } else { base.push("--track".into()); base.push("-c".into()); - base.push(local_name.to_string()); - base.push(reference); + base.push(local_name); + base.push(reference.full_name.clone()); } execute_git(root, &base, None) } @@ -2416,15 +4822,45 @@ fn parse_reference(line: &str) -> Option { } else { "tag" }; + let short_name = match kind { + "local" => columns[0].strip_prefix("refs/heads/"), + "remote" => columns[0].strip_prefix("refs/remotes/"), + "tag" => columns[0].strip_prefix("refs/tags/"), + _ => None, + }?; + let upstream_short_name = (!columns[3].is_empty()).then(|| columns[3].to_string()); + let (ahead, behind) = if kind == "local" && upstream_short_name.is_some() { + parse_tracking_counts(columns.get(5).copied().unwrap_or_default()) + } else { + (0, 0) + }; Some(GitReferenceResponse { full_name: columns[0].to_string(), - short_name: columns[1].to_string(), + // `%(refname:short)` deliberately adds `heads/` or `tags/` when + // namespaces collide. The typed contract keeps identity in + // `fullName` and always exposes the namespace-relative short name. + short_name: short_name.to_string(), kind: kind.to_string(), is_current: columns[2].trim() == "*", - upstream_short_name: (!columns[3].is_empty()).then(|| columns[3].to_string()), + upstream_short_name, + ahead, + behind, }) } +fn parse_tracking_counts(value: &str) -> (usize, usize) { + let mut ahead = 0; + let mut behind = 0; + for component in value.split(',').map(str::trim) { + if let Some(value) = component.strip_prefix("ahead ") { + ahead = value.parse().unwrap_or(0); + } else if let Some(value) = component.strip_prefix("behind ") { + behind = value.parse().unwrap_or(0); + } + } + (ahead, behind) +} + fn parse_commit(line: &str) -> Option { let columns = line.split('\u{1f}').collect::>(); if columns.len() < 8 { @@ -3118,11 +5554,16 @@ fn parse_status(output: &[u8]) -> Vec { } let x = bytes[0] as char; let y = bytes[1] as char; - let mut path = record[3..].to_string(); + // 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; + continue; + } + let path = record[3..].to_string(); let mut original_path = None; - if matches!(x, 'R' | 'C') && index + 1 < records.len() { - original_path = Some(path); - path = String::from_utf8_lossy(records[index + 1]).to_string(); + 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; } changes.push(GitChange { @@ -3159,7 +5600,8 @@ mod tests { GitCommandInvocation, GitCommandResponse, GitProcessOutput, MAX_ALIGNMENT_CELLS, }; use crate::protocol::{ - CoreError, ErrorCode, GitCommitResponse, GitHistoryResponse, GitReferenceResponse, + CoreError, ErrorCode, GitCommitResponse, GitHistoryResponse, GitPushPreviewResponse, + GitPushTagResponse, GitReferenceResponse, }; use serde_json::Value; @@ -3214,6 +5656,7 @@ mod tests { ], operation_error: None, stash_restore: None, + warnings: Vec::new(), }; super::synchronize_final_invocation(&mut response); @@ -3392,6 +5835,7 @@ mod tests { ], operation_error: None, stash_restore: None, + warnings: Vec::new(), }; assert_eq!( @@ -3413,6 +5857,8 @@ mod tests { kind: "local".into(), is_current: true, upstream_short_name: None, + ahead: 0, + behind: 0, }; let main = GitReferenceResponse { full_name: "refs/heads/main".into(), @@ -3420,6 +5866,8 @@ mod tests { kind: "local".into(), is_current: false, upstream_short_name: Some("origin/main".into()), + ahead: 2, + behind: 1, }; let response = GitHistoryResponse { references: vec![feature.clone(), main.clone()], @@ -3445,6 +5893,43 @@ mod tests { ); } + #[test] + fn push_preview_response_matches_shared_fixture() { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/fixtures/git/push-preview-v1.json" + ))) + .expect("Git push preview fixture should be valid JSON"); + let response = GitPushPreviewResponse { + local_branch: "feature/core".into(), + local_head: "2222222222222222222222222222222222222222".into(), + remote: "origin".into(), + remote_branch: "feature/core".into(), + remote_tracking_oid: Some("1111111111111111111111111111111111111111".into()), + upstream: Some("origin/feature/core".into()), + tags: vec![GitPushTagResponse { + full_name: "refs/tags/v1.0.0".into(), + object_id: "3333333333333333333333333333333333333333".into(), + }], + commits: vec![GitCommitResponse { + hash: "2222222222222222222222222222222222222222".into(), + short_hash: "2222222".into(), + parent_hashes: vec!["1111111111111111111111111111111111111111".into()], + author_name: "Lithe Developer".into(), + author_email: "developer@lithe.local".into(), + date: "2026/08/31 10:30".into(), + subject: "Add push preview".into(), + decorations: "HEAD -> feature/core".into(), + }], + has_more: false, + }; + + assert_eq!( + serde_json::to_value(response).expect("Git push preview should serialize"), + fixture + ); + } + #[test] fn command_error_response_matches_shared_fixture() { let fixture: Value = serde_json::from_str(include_str!(concat!( @@ -3481,6 +5966,7 @@ mod tests { "Invalid Git reference", )), stash_restore: None, + warnings: Vec::new(), }; assert_eq!( diff --git a/rust/lithe-core/src/git/mutations.rs b/rust/lithe-core/src/git/mutations.rs index f0d7e506..db047278 100644 --- a/rust/lithe-core/src/git/mutations.rs +++ b/rust/lithe-core/src/git/mutations.rs @@ -4,7 +4,7 @@ use super::{capture_git_with_options, is_safe_pathspec}; use crate::protocol::{CoreError, ErrorCode}; use super::{ - current_branch, execute_git, switch_reference, validated_reference, GitCommandResponse, + current_branch, execute_git, switch_reference, write_request_reference, GitCommandResponse, GitWriteRequest, }; @@ -13,14 +13,19 @@ pub(super) fn checkout_and_rebase( root: &str, request: GitWriteRequest, ) -> Result { - if !matches!(request.reference_kind.as_deref(), Some("local" | "remote")) { + let reference_kind = request + .git_reference + .as_ref() + .map(|reference| reference.kind.as_str()) + .or(request.reference_kind.as_deref()); + if !matches!(reference_kind, Some("local" | "remote")) { return Err(CoreError::new( ErrorCode::InvalidRequest, "Checkout and rebase requires a local or remote branch", )); } let original_branch = current_branch(root)?; - let reference = validated_reference(request.reference.as_deref())?; + let reference = write_request_reference(root, &request)?; if reference == original_branch || reference == format!("refs/heads/{original_branch}") { return Err(CoreError::new( ErrorCode::InvalidRequest, diff --git a/rust/lithe-core/src/languages/java.rs b/rust/lithe-core/src/languages/java.rs index d612fdf9..22505690 100644 --- a/rust/lithe-core/src/languages/java.rs +++ b/rust/lithe-core/src/languages/java.rs @@ -5,7 +5,7 @@ use crate::protocol::{ JavaClassNameResponse, JavaCodeVisionHintResponse, JavaCodeVisionResponse, JavaFoldRegionResponse, JavaInlayHintResponse, JavaMainClassResponse, JavaRunConfigurationResponse, JavaRunConfigurationsResponse, JavaServerPortResponse, - JavaStructureResponse, + JavaSourceSetResponse, JavaStructureResponse, }; use regex::Regex; use serde::Deserialize; @@ -110,6 +110,8 @@ pub fn run_configurations( }, module_path: module_path(&value.path, &request.module_paths), main_class: Some(value.qualified_name.clone()), + source_path: value.path.clone(), + source_set: java_source_set(&value.path), }) .collect::>(); configurations.sort_by(|left, right| { @@ -335,6 +337,17 @@ fn module_path(path: &str, modules: &[String]) -> Option { .cloned() } +fn java_source_set(path: &str) -> JavaSourceSetResponse { + let normalized = path.to_ascii_lowercase(); + if normalized.starts_with("src/test/") || normalized.contains("/src/test/") { + JavaSourceSetResponse::Test + } else if normalized.starts_with("src/main/") || normalized.contains("/src/main/") { + JavaSourceSetResponse::Main + } else { + JavaSourceSetResponse::Other + } +} + fn is_inside(path: &str, directory: &str) -> bool { path == directory || path.starts_with(&(directory.trim_end_matches('/').to_string() + "/")) } diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index fc5b6d44..624fa88d 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -207,6 +207,8 @@ pub enum CoreCommand { GitApply, /// Lists references and bounded commit history (`git.history`). GitHistory, + /// Resolves the destination and commits for a branch push (`git.pushPreview`). + GitPushPreview, /// Resolves metadata for one commit (`git.commit`). GitCommit, /// Lists paths changed by one commit (`git.commitFiles`). @@ -329,6 +331,7 @@ impl CoreCommand { "git.diff" => Some(Self::GitDiff), "git.apply" => Some(Self::GitApply), "git.history" => Some(Self::GitHistory), + "git.pushPreview" => Some(Self::GitPushPreview), "git.commit" => Some(Self::GitCommit), "git.commitFiles" => Some(Self::GitCommitFiles), "git.comparison" => Some(Self::GitComparison), @@ -414,4 +417,12 @@ mod tests { assert!(CoreCommand::parse(command).is_some(), "missing {command}"); } } + + #[test] + fn parses_git_push_preview_command() { + assert!(matches!( + CoreCommand::parse("git.pushPreview"), + Some(CoreCommand::GitPushPreview) + )); + } } diff --git a/rust/lithe-core/src/protocol/contracts.rs b/rust/lithe-core/src/protocol/contracts.rs index 2bf803be..eac88a01 100644 --- a/rust/lithe-core/src/protocol/contracts.rs +++ b/rust/lithe-core/src/protocol/contracts.rs @@ -237,6 +237,18 @@ pub struct JavaMainClassResponse { pub is_spring_boot: bool, } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Source-set classification attached to one discovered Java run entry. +pub enum JavaSourceSetResponse { + /// Production source compiled into the main project output. + Main, + /// Test source compiled into the test project output. + Test, + /// Java source outside the conventional Maven main and test layouts. + Other, +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] /// UI-facing Java or Spring Boot run entry. @@ -247,6 +259,10 @@ pub struct JavaRunConfigurationResponse { pub kind: String, pub module_path: Option, pub main_class: Option, + /// Workspace-relative source that produced this exact run entry. + pub source_path: String, + /// Build output that must be present on the Java launch classpath. + pub source_set: JavaSourceSetResponse, } #[derive(Debug, Clone, Serialize)] @@ -393,6 +409,10 @@ pub struct GitReferenceResponse { pub kind: String, pub is_current: bool, pub upstream_short_name: Option, + /// Commits present only on this local branch compared with its upstream. + pub ahead: usize, + /// Commits present only on this local branch's upstream. + pub behind: usize, } #[derive(Debug, Clone, Serialize)] @@ -422,6 +442,40 @@ pub struct GitHistoryResponse { pub user_email: Option, } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Resolved destination and bounded commits for one branch push. +pub struct GitPushPreviewResponse { + /// Local branch that will be sent to the remote. + pub local_branch: String, + /// Commit at the local branch tip when this preview was resolved. + pub local_head: String, + /// Remote selected from the branch upstream or repository defaults. + pub remote: String, + /// Branch name created or updated on the remote. + pub remote_branch: String, + /// Locally observed destination OID, or `None` before first publication. + pub remote_tracking_oid: Option, + /// Configured upstream short name, or `None` before first publication. + pub upstream: Option, + /// Exact tag references and object IDs included in this reviewed push. + pub tags: Vec, + /// Commits reachable from the local branch but not its resolved remote base. + pub commits: Vec, + /// Whether more commits exist beyond the bounded preview. + pub has_more: bool, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// One immutable tag snapshot included in a reviewed push. +pub struct GitPushTagResponse { + /// Fully qualified tag reference under `refs/tags/`. + pub full_name: String, + /// Tag object ID observed while the preview was created. + pub object_id: String, +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] /// Exact lookup result for one commit. diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index b87bd388..537d247c 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -9,8 +9,8 @@ use crate::git::{ self, GitApplyRequest, GitBlameRequest, GitCheckoutPreflightRequest, GitCommandRequest, GitCommitFilesRequest, GitCommitRequest, GitComparisonRequest, GitConflictMarkerRequest, GitDiffRequest, GitHistoryRequest, GitIntegrationPreflightRequest, GitOperationStateRequest, - GitPullPreflightRequest, GitPullRequestContextRequest, GitStashesRequest, GitStatusRequest, - GitWatchContextRequest, GitWriteRequest, + GitPullPreflightRequest, GitPullRequestContextRequest, GitPushPreviewRequest, + GitStashesRequest, GitStatusRequest, GitWatchContextRequest, GitWriteRequest, }; use crate::github::{NormalizeResponseRequest, ParseRemoteRequest, RequestPlanRequest}; use crate::languages::{ @@ -1519,6 +1519,24 @@ fn execute(request: &str) -> CoreResponse { Err(error) => CoreResponse::failure(id, error), } } + CoreCommand::GitPushPreview => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid Git push preview request", + ) + .with_details(error.to_string()) + }) + .and_then(git::push_preview) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Git push preview response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } CoreCommand::GitCommit => match serde_json::from_value::(parsed.payload) .map_err(|error| { CoreError::new(ErrorCode::InvalidRequest, "Invalid Git commit request") diff --git a/rust/lithe-core/src/tests/git.rs b/rust/lithe-core/src/tests/git.rs index e20d535f..f32fe04d 100644 --- a/rust/lithe-core/src/tests/git.rs +++ b/rust/lithe-core/src/tests/git.rs @@ -40,6 +40,118 @@ fn git_status_returns_contract_shape() { fs::remove_dir_all(root).expect("temporary repository should be removable"); } +#[test] +fn git_status_preserves_both_paths_of_a_staged_rename() { + let root = temporary_root("git-status-rename"); + fs::create_dir_all(&root).expect("temporary repository should be creatable"); + let run = |arguments: &[&str]| history_git(&root, arguments); + assert!(run(&["init", "-q", "-b", "main"]).status.success()); + assert!(run(&["config", "core.autocrlf", "false"]).status.success()); + assert!(run(&["config", "user.email", "test@example.com"]) + .status + .success()); + assert!(run(&["config", "user.name", "Lithe Test"]).status.success()); + fs::write(root.join("old-name.txt"), "content\n").expect("file should be writable"); + assert!(run(&["add", "old-name.txt"]).status.success()); + assert!(run(&["commit", "-qm", "initial"]).status.success()); + assert!(run(&["mv", "old-name.txt", "new-name.txt"]) + .status + .success()); + + let response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&serde_json::json!({ + "id": "git-status-rename", + "command": "git.status", + "payload": { "root": root } + })) + .expect("Git request should encode"), + )) + .expect("Git response should be JSON"); + + assert_eq!(response["ok"], true, "{response:?}"); + assert_eq!( + response["data"]["changes"].as_array().map(Vec::len), + Some(1) + ); + assert_eq!(response["data"]["changes"][0]["status"], "R "); + assert_eq!(response["data"]["changes"][0]["path"], "new-name.txt"); + assert_eq!( + response["data"]["changes"][0]["originalPath"], + "old-name.txt" + ); + + fs::remove_dir_all(root).expect("temporary repository should be removable"); +} + +#[test] +fn git_diff_worktree_snapshot_matches_selected_path_commit_semantics() { + let root = git_write_repository("git-diff-worktree-snapshot"); + let run = |arguments: &[&str]| history_git(&root, arguments); + fs::write(root.join("recreated.txt"), "base\n").expect("file should be writable"); + fs::write(root.join("partial.txt"), "one\nbase\n").expect("file should be writable"); + assert!(run(&["add", "--all"]).status.success()); + assert!(run(&["commit", "-qm", "initial"]).status.success()); + + assert!(run(&["rm", "--cached", "-q", "--", "recreated.txt"]) + .status + .success()); + fs::write(root.join("recreated.txt"), "changed\n").expect("file should be writable"); + fs::write(root.join("partial.txt"), "staged\nbase\n").expect("file should be writable"); + assert!(run(&["add", "--", "partial.txt"]).status.success()); + fs::write(root.join("partial.txt"), "staged\nworktree\n").expect("file should be writable"); + let cached_before = run(&["diff", "--cached", "--binary"]).stdout; + + let response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&serde_json::json!({ + "id": "git-diff-worktree-snapshot", + "command": "git.diff", + "payload": { + "root": root, + "pathspecs": ["recreated.txt", "partial.txt"], + "worktreeSnapshot": true + } + })) + .expect("Git snapshot request should encode"), + )) + .expect("Git snapshot response should be JSON"); + + assert_eq!(response["ok"], true, "{response:?}"); + let patch = response["data"]["patch"] + .as_str() + .expect("Git snapshot should return a patch"); + assert!(patch.contains("-one"), "{patch}"); + assert!(patch.contains("+staged"), "{patch}"); + assert!(patch.contains("-base"), "{patch}"); + assert!(patch.contains("+worktree"), "{patch}"); + assert!(patch.contains("+changed"), "{patch}"); + assert!(!patch.contains("deleted file mode"), "{patch}"); + assert_eq!(run(&["diff", "--cached", "--binary"]).stdout, cached_before); + + let reference_response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&serde_json::json!({ + "id": "git-diff-reference-with-untracked", + "command": "git.diff", + "payload": { + "root": root, + "pathspecs": ["recreated.txt"], + "reference": "HEAD", + "untracked": true + } + })) + .expect("Git reference diff request should encode"), + )) + .expect("Git reference diff response should be JSON"); + assert_eq!(reference_response["ok"], true, "{reference_response:?}"); + assert!( + reference_response["data"]["patch"] + .as_str() + .is_some_and(|patch| patch.contains("+changed")), + "{reference_response:?}" + ); + + fs::remove_dir_all(root).expect("temporary repository should be removable"); +} + #[test] fn git_status_does_not_refresh_the_index() { let root = temporary_root("git-status-index"); @@ -181,8 +293,8 @@ fn git_command_returns_separate_process_streams_and_combined_output() { } #[test] -fn git_write_validates_and_executes_shared_mutations() { - let root = temporary_root("git-write"); +fn git_write_commits_only_selected_paths_and_keeps_other_index_entries() { + let root = temporary_root("git-write-selected-commit"); fs::create_dir_all(&root).expect("temporary repository should be creatable"); let run = |arguments: &[&str]| { Command::new("git") @@ -197,42 +309,646 @@ fn git_write_validates_and_executes_shared_mutations() { .status .success()); assert!(run(&["config", "user.name", "Lithe Test"]).status.success()); - assert!(run(&["remote", "add", "origin", "."]).status.success()); - fs::write(root.join("example.txt"), "initial\n").expect("file should be writable"); + fs::write(root.join("selected.txt"), "initial\n").expect("file should be writable"); + fs::write(root.join("other.txt"), "initial\n").expect("file should be writable"); + assert!(run(&["add", "--all"]).status.success()); + assert!(run(&["commit", "-qm", "initial"]).status.success()); - let request = |operation: &str, payload: Value| -> Value { - let request = serde_json::json!({ - "id": operation, + fs::write(root.join("selected.txt"), "selected staged change\n") + .expect("file should be writable"); + assert!(run(&["add", "selected.txt"]).status.success()); + fs::write( + root.join("selected.txt"), + "selected staged and unstaged change\n", + ) + .expect("file should be writable"); + fs::write(root.join("other.txt"), "other staged change\n").expect("file should be writable"); + fs::write(root.join("new.txt"), "selected untracked\n").expect("file should be writable"); + assert!(run(&["add", "other.txt"]).status.success()); + + let response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&serde_json::json!({ + "id": "selected-commit", "command": "git.write", "payload": { "root": root, - "operation": operation, - "paths": [], - "reference": null, - "referenceKind": null, - "revision": null, - "name": null, - "message": null, - "remote": null, - "destination": null, - "mode": null, - "includeUntracked": false, - "checkout": false, - "amend": false + "operation": "commit", + "message": "selected paths", + "paths": ["selected.txt", "new.txt"] } - }); - let mut request = request; - if let Value::Object(overrides) = payload { - for (key, value) in overrides { - request["payload"][key.as_str()] = value; + })) + .expect("selected commit request should encode"), + )) + .expect("selected commit response should be JSON"); + assert_eq!(response["ok"], true, "{response:?}"); + + let show = run(&["show", "--pretty=format:", "--name-only", "HEAD"]); + let committed_paths = String::from_utf8_lossy(&show.stdout) + .lines() + .filter(|line| !line.is_empty()) + .map(str::to_string) + .collect::>(); + assert_eq!(committed_paths, vec!["new.txt", "selected.txt"]); + assert_eq!( + String::from_utf8_lossy(&run(&["diff", "--cached", "--name-only"]).stdout).trim(), + "other.txt" + ); + assert_eq!( + String::from_utf8_lossy(&run(&["show", "HEAD:selected.txt"]).stdout), + "selected staged and unstaged change\n" + ); + assert_eq!( + String::from_utf8_lossy(&run(&["show", "HEAD:new.txt"]).stdout), + "selected untracked\n" + ); + + fs::remove_dir_all(root).expect("temporary workspace should be removable"); +} + +#[test] +fn git_write_commits_a_selected_rename_with_both_paths() { + let root = temporary_root("git-write-selected-rename"); + fs::create_dir_all(&root).expect("temporary repository should be creatable"); + let run = |arguments: &[&str]| history_git(&root, arguments); + assert!(run(&["init", "-q", "-b", "main"]).status.success()); + assert!(run(&["config", "core.autocrlf", "false"]).status.success()); + assert!(run(&["config", "user.email", "test@example.com"]) + .status + .success()); + assert!(run(&["config", "user.name", "Lithe Test"]).status.success()); + fs::write(root.join("old-name.txt"), "content\n").expect("file should be writable"); + assert!(run(&["add", "old-name.txt"]).status.success()); + assert!(run(&["commit", "-qm", "initial"]).status.success()); + assert!(run(&["mv", "old-name.txt", "new-name.txt"]) + .status + .success()); + + let response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&serde_json::json!({ + "id": "selected-rename", + "command": "git.write", + "payload": { + "root": root, + "operation": "commit", + "message": "rename selected file", + "paths": ["new-name.txt", "old-name.txt"] } - } + })) + .expect("selected rename request should encode"), + )) + .expect("selected rename response should be JSON"); + + assert_eq!(response["ok"], true, "{response:?}"); + assert_eq!(response["data"]["exitCode"], 0, "{response:?}"); + let changed = git_text( + &root, + &["show", "--pretty=format:", "--name-status", "HEAD"], + ); + assert!( + changed.starts_with("R100\told-name.txt\tnew-name.txt"), + "{changed}" + ); + assert!(!root.join("old-name.txt").exists()); + assert!(root.join("new-name.txt").exists()); + + fs::remove_dir_all(root).expect("temporary repository should be removable"); +} + +#[test] +fn git_write_selected_commit_uses_stdin_for_a_large_path_set() { + let root = temporary_root("git-write-selected-many-paths"); + fs::create_dir_all(root.join("selected")).expect("temporary repository should be creatable"); + let run = |arguments: &[&str]| history_git(&root, arguments); + assert!(run(&["init", "-q", "-b", "main"]).status.success()); + assert!(run(&["config", "core.autocrlf", "false"]).status.success()); + assert!(run(&["config", "user.email", "test@example.com"]) + .status + .success()); + assert!(run(&["config", "user.name", "Lithe Test"]).status.success()); + assert!(run(&["commit", "-q", "--allow-empty", "-m", "initial"]) + .status + .success()); + + let paths = (0..384) + .map(|index| { + let path = format!( + "selected/{index:04}-{}.txt", + "long-path-component-used-to-cross-the-windows-command-line-limit" + ); + fs::write(root.join(&path), format!("{index}\n")).expect("file should be writable"); + path + }) + .collect::>(); + let response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&serde_json::json!({ + "id": "selected-many-paths", + "command": "git.write", + "payload": { + "root": root, + "operation": "commit", + "message": "commit many selected paths", + "paths": paths + } + })) + .expect("large selected commit request should encode"), + )) + .expect("large selected commit response should be JSON"); + + assert_eq!(response["ok"], true, "{response:?}"); + assert_eq!(response["data"]["exitCode"], 0, "{response:?}"); + let stage_invocation = response["data"]["invocations"] + .as_array() + .expect("invocations should be present") + .iter() + .find(|invocation| invocation["arguments"][0] == "add") + .expect("stage invocation should be recorded"); + assert!(stage_invocation["arguments"] + .as_array() + .expect("stage arguments should be present") + .iter() + .any(|argument| argument == "--pathspec-from-file=-")); + assert_eq!( + git_text(&root, &["show", "--pretty=format:", "--name-only", "HEAD"]) + .lines() + .filter(|line| !line.is_empty()) + .count(), + 384 + ); + + fs::remove_dir_all(root).expect("temporary repository should be removable"); +} + +#[test] +fn git_write_restores_the_index_when_a_selected_commit_hook_fails() { + let root = temporary_root("git-write-selected-hook-failure"); + fs::create_dir_all(&root).expect("temporary repository should be creatable"); + let run = |arguments: &[&str]| { + Command::new("git") + .args(arguments) + .current_dir(&root) + .output() + .expect("git should be available") + }; + assert!(run(&["init", "-q"]).status.success()); + assert!(run(&["config", "core.autocrlf", "false"]).status.success()); + assert!(run(&["config", "user.email", "test@example.com"]) + .status + .success()); + assert!(run(&["config", "user.name", "Lithe Test"]).status.success()); + fs::write(root.join("selected.txt"), "initial\n").expect("file should be writable"); + fs::write(root.join("other.txt"), "initial\n").expect("file should be writable"); + assert!(run(&["add", "--all"]).status.success()); + assert!(run(&["commit", "-qm", "initial"]).status.success()); + + fs::write(root.join("selected.txt"), "selected worktree change\n") + .expect("file should be writable"); + fs::write(root.join("other.txt"), "other staged change\n").expect("file should be writable"); + assert!(run(&["add", "other.txt"]).status.success()); + let cached_before = run(&["diff", "--cached", "--binary"]).stdout; + + let hook = root.join(".git/hooks/pre-commit"); + fs::write(&hook, "#!/bin/sh\nexit 1\n").expect("hook should be writable"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut permissions = fs::metadata(&hook) + .expect("hook metadata should be readable") + .permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&hook, permissions).expect("hook should be executable"); + } + + let response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&serde_json::json!({ + "id": "selected-commit-hook-failure", + "command": "git.write", + "payload": { + "root": root, + "operation": "commit", + "message": "must fail", + "paths": ["selected.txt"] + } + })) + .expect("selected commit request should encode"), + )) + .expect("selected commit response should be JSON"); + assert_eq!(response["ok"], true, "{response:?}"); + assert_ne!(response["data"]["exitCode"], 0, "{response:?}"); + assert_eq!(run(&["diff", "--cached", "--binary"]).stdout, cached_before); + assert_eq!( + String::from_utf8_lossy(&run(&["diff", "--name-only"]).stdout).trim(), + "selected.txt" + ); + + fs::remove_dir_all(root).expect("temporary workspace should be removable"); +} + +#[test] +fn git_write_preserves_real_index_changes_made_by_a_failing_hook() { + let root = temporary_root("git-write-selected-hook-real-index"); + fs::create_dir_all(&root).expect("temporary repository should be creatable"); + let run = |arguments: &[&str]| history_git(&root, arguments); + assert!(run(&["init", "-q", "-b", "main"]).status.success()); + assert!(run(&["config", "core.autocrlf", "false"]).status.success()); + assert!(run(&["config", "user.email", "test@example.com"]) + .status + .success()); + assert!(run(&["config", "user.name", "Lithe Test"]).status.success()); + fs::write(root.join("selected.txt"), "initial\n").expect("file should be writable"); + fs::write(root.join("hook-staged.txt"), "initial\n").expect("file should be writable"); + assert!(run(&["add", "--all"]).status.success()); + assert!(run(&["commit", "-qm", "initial"]).status.success()); + fs::write(root.join("selected.txt"), "selected change\n").expect("file should be writable"); + fs::write(root.join("hook-staged.txt"), "staged by hook\n").expect("file should be writable"); + + let hook = root.join(".git/hooks/pre-commit"); + fs::write( + &hook, + "#!/bin/sh\nunset GIT_DIR GIT_COMMON_DIR GIT_WORK_TREE GIT_INDEX_FILE\ngit add -- hook-staged.txt\nexit 1\n", + ) + .expect("hook should be writable"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut permissions = fs::metadata(&hook) + .expect("hook metadata should be readable") + .permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&hook, permissions).expect("hook should be executable"); + } + + let response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&serde_json::json!({ + "id": "selected-hook-real-index", + "command": "git.write", + "payload": { + "root": root, + "operation": "commit", + "message": "must fail", + "paths": ["selected.txt"] + } + })) + .expect("selected commit request should encode"), + )) + .expect("selected commit response should be JSON"); + + assert_eq!(response["ok"], true, "{response:?}"); + assert_ne!(response["data"]["exitCode"], 0, "{response:?}"); + assert_eq!( + git_text(&root, &["diff", "--cached", "--name-only"]), + "hook-staged.txt" + ); + assert_eq!(git_text(&root, &["diff", "--name-only"]), "selected.txt"); + + fs::remove_dir_all(root).expect("temporary repository should be removable"); +} + +#[test] +fn git_write_checks_selected_worktree_conflict_markers_before_committing() { + let root = temporary_root("git-write-selected-markers"); + fs::create_dir_all(root.join("selected")).expect("temporary repository should be creatable"); + let run = |arguments: &[&str]| { + Command::new("git") + .args(arguments) + .current_dir(&root) + .output() + .expect("git should be available") + }; + assert!(run(&["init", "-q"]).status.success()); + assert!(run(&["config", "core.autocrlf", "false"]).status.success()); + assert!(run(&["config", "user.email", "test@example.com"]) + .status + .success()); + assert!(run(&["config", "user.name", "Lithe Test"]).status.success()); + fs::write(root.join("selected/file.txt"), "initial\n").expect("file should be writable"); + assert!(run(&["add", "--all"]).status.success()); + assert!(run(&["commit", "-qm", "initial"]).status.success()); + let head_before = run(&["rev-parse", "HEAD"]).stdout; + + fs::write( + root.join("selected/file.txt"), + "<<<<<<< ours\nleft\n=======\nright\n>>>>>>> theirs\n", + ) + .expect("file should be writable"); + let response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&serde_json::json!({ + "id": "selected-commit-markers", + "command": "git.write", + "payload": { + "root": root, + "operation": "commit", + "message": "must not commit markers", + "paths": ["selected"] + } + })) + .expect("selected commit request should encode"), + )) + .expect("selected commit response should be JSON"); + + assert_eq!(response["ok"], true, "{response:?}"); + assert_eq!( + response["data"]["operationError"]["message"], + "Conflict markers remain in selected files" + ); + assert!(run(&["diff", "--cached", "--name-only"]).stdout.is_empty()); + assert_eq!(run(&["rev-parse", "HEAD"]).stdout, head_before); + + fs::remove_dir_all(root).expect("temporary workspace should be removable"); +} + +#[test] +fn git_write_appends_shared_and_local_ignore_patterns_without_duplicates() { + let root = temporary_root("git-write-ignore"); + fs::create_dir_all(&root).expect("temporary repository should be creatable"); + let run = |arguments: &[&str]| { + Command::new("git") + .args(arguments) + .current_dir(&root) + .output() + .expect("git should be available") + }; + assert!(run(&["init", "-q"]).status.success()); + fs::write(root.join(".gitignore"), "# existing").expect("gitignore should be writable"); + + let request = |operation: &str, paths: Value| -> Value { serde_json::from_str(&execute_json( - &serde_json::to_string(&request).expect("write request should encode"), + &serde_json::to_string(&serde_json::json!({ + "id": operation, + "command": "git.write", + "payload": {"root": root, "operation": operation, "paths": paths} + })) + .expect("ignore request should encode"), )) - .expect("write response should be JSON") + .expect("ignore response should be JSON") }; + let shared_paths = serde_json::json!(["build output/", "reports/file[1].txt"]); + let shared = request("ignore", shared_paths.clone()); + assert_eq!(shared["ok"], true, "{shared:?}"); + assert_eq!(request("ignore", shared_paths)["ok"], true); + assert_eq!( + fs::read_to_string(root.join(".gitignore")).expect("gitignore should be readable"), + "# existing\n/build\\ output/\n/reports/file\\[1\\].txt\n" + ); + + fs::create_dir_all(root.join("cache")).expect("excluded directory should be creatable"); + fs::create_dir_all(root.join("unselected")).expect("unselected directory should be creatable"); + fs::write(root.join("cache/data.txt"), "excluded\n").expect("excluded file should be writable"); + fs::write(root.join("secret#file.txt"), "excluded\n") + .expect("excluded file should be writable"); + fs::write(root.join("unselected/keep.txt"), "keep\n") + .expect("unselected file should be writable"); + + let local_paths = serde_json::json!(["cache/", "secret#file.txt"]); + let local = request("exclude", local_paths.clone()); + assert_eq!(local["ok"], true, "{local:?}"); + assert_eq!(request("exclude", local_paths)["ok"], true); + assert_eq!( + fs::read_to_string(root.join(".git/info/exclude")) + .expect("local exclude file should be readable") + .lines() + .filter(|line| line.starts_with('/')) + .collect::>(), + vec!["/cache/", "/secret\\#file.txt"] + ); + assert!(root.join("cache/data.txt").is_file()); + assert!(root.join("secret#file.txt").is_file()); + assert!(root.join("unselected/keep.txt").is_file()); + + fs::create_dir_all(root.join("build output")).expect("ignored directory should be creatable"); + fs::create_dir_all(root.join("reports")).expect("ignored directory should be creatable"); + fs::write(root.join("build output/generated.txt"), "ignored\n") + .expect("ignored file should be writable"); + fs::write(root.join("reports/file[1].txt"), "ignored\n") + .expect("ignored file should be writable"); + assert!(run(&["check-ignore", "-q", "build output/generated.txt"]) + .status + .success()); + assert!(run(&["check-ignore", "-q", "reports/file[1].txt"]) + .status + .success()); + assert!(run(&["check-ignore", "-q", "cache/data.txt"]) + .status + .success()); + assert!(run(&["check-ignore", "-q", "secret#file.txt"]) + .status + .success()); + + let invalid = request("ignore", serde_json::json!(["unsafe\npattern"])); + assert_eq!(invalid["ok"], false); + assert_eq!(invalid["error"]["code"], "invalid_request"); + + fs::remove_dir_all(root).expect("temporary workspace should be removable"); +} + +#[test] +fn git_write_edits_a_local_commit_message_and_rebuilds_descendants() { + let root = history_rewrite_repository("git-edit-commit-message"); + commit_history_file(&root, "story.txt", "one\n", "one"); + commit_history_file(&root, "story.txt", "two\n", "two"); + let target = git_text(&root, &["rev-parse", "HEAD"]); + commit_history_file(&root, "story.txt", "three\n", "three"); + let original_tree = git_text(&root, &["rev-parse", "HEAD^{tree}"]); + + let response = history_write( + &root, + serde_json::json!({ + "operation": "editCommitMessage", + "revision": target, + "message": "two edited" + }), + ); + assert_eq!(response["ok"], true, "{response:?}"); + assert_eq!(response["data"]["exitCode"], 0, "{response:?}"); + assert_eq!( + git_text(&root, &["log", "--format=%s"]), + "three\ntwo edited\none" + ); + assert_eq!( + git_text(&root, &["rev-parse", "HEAD^{tree}"]), + original_tree + ); + assert_eq!(git_text(&root, &["status", "--porcelain"]), ""); + + fs::remove_dir_all(root).expect("temporary repository should be removable"); +} + +#[test] +fn git_write_squashes_a_contiguous_local_commit_range() { + let root = history_rewrite_repository("git-squash-commits"); + commit_history_file(&root, "story.txt", "one\n", "one"); + commit_history_file(&root, "story.txt", "two\n", "two"); + let older = git_text(&root, &["rev-parse", "HEAD"]); + commit_history_file(&root, "story.txt", "three\n", "three"); + let newer = git_text(&root, &["rev-parse", "HEAD"]); + commit_history_file(&root, "tail.txt", "tail\n", "tail"); + let tail = git_text(&root, &["rev-parse", "HEAD"]); + let original_tree = git_text(&root, &["rev-parse", "HEAD^{tree}"]); + + let rejected = history_write( + &root, + serde_json::json!({ + "operation": "squashCommits", + "revisions": [tail, older], + "message": "must be rejected" + }), + ); + assert_eq!(rejected["ok"], true, "{rejected:?}"); + assert_eq!( + rejected["data"]["operationError"]["code"], + "invalid_request" + ); + assert!(rejected["data"]["operationError"]["message"] + .as_str() + .expect("error message should be text") + .contains("contiguous")); + + let response = history_write( + &root, + serde_json::json!({ + "operation": "squashCommits", + "revisions": [newer, older], + "message": "two and three" + }), + ); + assert_eq!(response["ok"], true, "{response:?}"); + assert_eq!(response["data"]["exitCode"], 0, "{response:?}"); + assert_eq!( + git_text(&root, &["log", "--format=%s"]), + "tail\ntwo and three\none" + ); + assert_eq!(git_text(&root, &["rev-list", "--count", "HEAD"]), "3"); + assert_eq!( + git_text(&root, &["rev-parse", "HEAD^{tree}"]), + original_tree + ); + assert_eq!(git_text(&root, &["status", "--porcelain"]), ""); + + fs::remove_dir_all(root).expect("temporary repository should be removable"); +} + +#[test] +fn git_write_deletes_a_local_commit_and_replays_later_changes() { + let root = history_rewrite_repository("git-delete-commit"); + commit_history_file(&root, "base.txt", "base\n", "base"); + commit_history_file(&root, "dropped.txt", "drop\n", "drop this commit"); + let target = git_text(&root, &["rev-parse", "HEAD"]); + commit_history_file(&root, "kept.txt", "keep\n", "keep this commit"); + + let response = history_write( + &root, + serde_json::json!({"operation": "deleteCommit", "revision": target}), + ); + assert_eq!(response["ok"], true, "{response:?}"); + assert_eq!(response["data"]["exitCode"], 0, "{response:?}"); + assert_eq!( + git_text(&root, &["log", "--format=%s"]), + "keep this commit\nbase" + ); + assert!(!root.join("dropped.txt").exists()); + assert_eq!( + fs::read_to_string(root.join("kept.txt")).expect("kept file should remain"), + "keep\n" + ); + assert_eq!(git_text(&root, &["status", "--porcelain"]), ""); + + fs::remove_dir_all(root).expect("temporary repository should be removable"); +} + +#[test] +fn git_write_deletes_a_local_commit_and_preserves_a_later_empty_commit() { + let root = history_rewrite_repository("git-delete-before-empty-commit"); + commit_history_file(&root, "base.txt", "base\n", "base"); + commit_history_file(&root, "dropped.txt", "drop\n", "drop this commit"); + let target = git_text(&root, &["rev-parse", "HEAD"]); + assert!( + history_git(&root, &["commit", "--allow-empty", "-qm", "keep empty"]) + .status + .success() + ); + + let response = history_write( + &root, + serde_json::json!({"operation": "deleteCommit", "revision": target}), + ); + assert_eq!(response["ok"], true, "{response:?}"); + assert_eq!(response["data"]["exitCode"], 0, "{response:?}"); + assert_eq!(git_text(&root, &["log", "--format=%s"]), "keep empty\nbase"); + assert!(!root.join("dropped.txt").exists()); + assert_eq!(git_text(&root, &["status", "--porcelain"]), ""); + + fs::remove_dir_all(root).expect("temporary repository should be removable"); +} + +#[test] +fn git_history_rewrite_rejects_commits_reachable_from_remote_refs() { + let root = history_rewrite_repository("git-rewrite-published"); + commit_history_file(&root, "story.txt", "published\n", "published"); + let target = git_text(&root, &["rev-parse", "HEAD"]); + assert!( + history_git(&root, &["update-ref", "refs/remotes/origin/main", "HEAD"]) + .status + .success() + ); + + let response = history_write( + &root, + serde_json::json!({ + "operation": "editCommitMessage", + "revision": target, + "message": "must be rejected" + }), + ); + assert_eq!(response["ok"], true, "{response:?}"); + assert_eq!( + response["data"]["operationError"]["code"], + "invalid_request" + ); + assert!(response["data"]["operationError"]["message"] + .as_str() + .expect("error message should be text") + .contains("remote")); + assert_eq!(git_text(&root, &["log", "-1", "--format=%s"]), "published"); + + fs::remove_dir_all(root).expect("temporary repository should be removable"); +} + +#[test] +fn git_history_rewrite_rejects_a_dirty_working_tree() { + let root = history_rewrite_repository("git-rewrite-dirty"); + commit_history_file(&root, "story.txt", "clean\n", "clean"); + let target = git_text(&root, &["rev-parse", "HEAD"]); + fs::write(root.join("story.txt"), "dirty\n").expect("test file should be writable"); + + let response = history_write( + &root, + serde_json::json!({ + "operation": "editCommitMessage", + "revision": target, + "message": "must be rejected" + }), + ); + assert_eq!(response["ok"], true, "{response:?}"); + assert_eq!( + response["data"]["operationError"]["code"], + "invalid_request" + ); + assert!(response["data"]["operationError"]["message"] + .as_str() + .expect("error message should be text") + .contains("clean working tree")); + assert_eq!(git_text(&root, &["log", "-1", "--format=%s"]), "clean"); + + 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"); + let run = |arguments: &[&str]| history_git(&root, arguments); + fs::write(root.join("example.txt"), "initial\n").expect("file should be writable"); + let request = |operation: &str, payload: Value| git_write_request(&root, operation, payload); + let stage = request("stage", serde_json::json!({"paths": ["example.txt"]})); assert_eq!(stage["ok"], true); let commit = request( @@ -263,8 +979,8 @@ fn git_write_validates_and_executes_shared_mutations() { "initial\n" ); - // Conflict-dialog rollback must discard both sides of a file, including - // a staged edit followed by a working-tree edit. + // A confirmed rollback must discard both sides of a file, including a + // staged edit followed by a working-tree edit. fs::write(root.join("example.txt"), "staged\n").expect("file should be writable"); assert!(run(&["add", "example.txt"]).status.success()); fs::write(root.join("example.txt"), "working\n").expect("file should be writable"); @@ -272,7 +988,14 @@ fn git_write_validates_and_executes_shared_mutations() { assert_eq!(discard_all["ok"], true, "{discard_all:?}"); assert_eq!( discard_all["data"]["arguments"], - serde_json::json!(["checkout", "HEAD", "--", "example.txt"]) + serde_json::json!([ + "restore", + "--source=HEAD", + "--staged", + "--worktree", + "--pathspec-from-file=-", + "--pathspec-file-nul" + ]) ); assert_eq!( discard_all["data"]["invocations"] @@ -281,7 +1004,7 @@ fn git_write_validates_and_executes_shared_mutations() { .iter() .map(|invocation| invocation["arguments"][0].as_str().unwrap_or_default()) .collect::>(), - vec!["status", "checkout"] + vec!["status", "restore"] ); assert_eq!( fs::read_to_string(root.join("example.txt")).expect("file should be readable"), @@ -292,6 +1015,23 @@ fn git_write_validates_and_executes_shared_mutations() { "" ); + fs::write(root.join("newly-added.txt"), "staged addition\n") + .expect("new file should be writable"); + assert!(run(&["add", "newly-added.txt"]).status.success()); + fs::write(root.join("untracked-all.txt"), "untracked\n") + .expect("untracked file should be writable"); + let discard_added = request( + "discardAll", + serde_json::json!({"paths": ["newly-added.txt", "untracked-all.txt"]}), + ); + assert_eq!(discard_added["ok"], true, "{discard_added:?}"); + assert!(!root.join("newly-added.txt").exists()); + assert!(!root.join("untracked-all.txt").exists()); + assert_eq!( + String::from_utf8_lossy(&run(&["status", "--porcelain"]).stdout), + "" + ); + // An invalid checkout reference is discovered after smart checkout has // already started; the executed stash command must remain visible. let partial_failure = request( @@ -339,6 +1079,16 @@ fn git_write_validates_and_executes_shared_mutations() { ); assert!(!root.join("untracked.txt").exists()); + fs::remove_dir_all(root).expect("temporary repository should be removable"); +} + +#[test] +fn git_write_executes_branch_pull_and_stash_mutations() { + let root = git_write_repository("git-write-branch-workflows"); + let run = |arguments: &[&str]| history_git(&root, arguments); + commit_history_file(&root, "example.txt", "initial\n", "initial"); + let request = |operation: &str, payload: Value| git_write_request(&root, operation, payload); + let current = String::from_utf8_lossy(&run(&["branch", "--show-current"]).stdout) .trim() .to_string(); @@ -473,6 +1223,155 @@ fn git_write_validates_and_executes_shared_mutations() { "working tree\n" ); + fs::remove_dir_all(root).expect("temporary repository should be removable"); +} + +#[test] +fn git_write_sets_upstream_from_the_complete_remote_reference() { + let root = git_write_repository("git-write-complete-upstream"); + commit_history_file(&root, "base.txt", "base\n", "initial"); + let current = git_text(&root, &["branch", "--show-current"]); + assert!(history_git( + &root, + &[ + "remote", + "add", + "team/origin", + "https://example.invalid/team/repository.git" + ] + ) + .status + .success()); + assert!(history_git( + &root, + &["update-ref", "refs/remotes/team/origin/feature/foo", "HEAD"] + ) + .status + .success()); + assert!(history_git(&root, &["branch", "team/origin/feature/foo"]) + .status + .success()); + + let response = git_write_request( + &root, + "setUpstream", + serde_json::json!({ + "name": current, + "gitReference": { + "fullName": "refs/remotes/team/origin/feature/foo", + "shortName": "team/origin/feature/foo", + "kind": "remote" + } + }), + ); + assert_eq!(response["ok"], true, "{response:?}"); + assert_eq!(response["data"]["exitCode"], 0, "{response:?}"); + assert_eq!( + git_text(&root, &["config", &format!("branch.{current}.remote")]), + "team/origin" + ); + assert_eq!( + git_text(&root, &["config", &format!("branch.{current}.merge")]), + "refs/heads/feature/foo" + ); + + fs::remove_dir_all(root).expect("temporary repository should be removable"); +} + +#[test] +fn git_write_creates_a_tracked_worktree_from_an_unambiguous_complete_remote_reference() { + struct RemovePathsOnDrop(Vec); + + impl Drop for RemovePathsOnDrop { + fn drop(&mut self) { + for path in self.0.iter().rev() { + let _ = fs::remove_dir_all(path); + } + } + } + + let root = git_write_repository("git-write-complete-worktree-reference"); + let destination = root.with_extension("tracked-worktree"); + let _cleanup = RemovePathsOnDrop(vec![root.clone(), destination.clone()]); + commit_history_file(&root, "base.txt", "base\n", "initial"); + assert!( + history_git(&root, &["update-ref", "refs/remotes/origin/main", "HEAD"]) + .status + .success() + ); + assert!(history_git(&root, &["branch", "origin/main", "HEAD"]) + .status + .success()); + + let response = git_write_request( + &root, + "createWorktree", + serde_json::json!({ + "destination": destination, + "name": "feature/tracked-worktree", + "gitReference": { + "fullName": "refs/remotes/origin/main", + "shortName": "origin/main", + "kind": "remote" + } + }), + ); + + assert_eq!(response["ok"], true, "{response:?}"); + assert_eq!(response["data"]["exitCode"], 0, "{response:?}"); + let invocations = response["data"]["invocations"] + .as_array() + .expect("worktree invocations should be an array"); + assert_eq!( + invocations + .iter() + .filter(|invocation| invocation["arguments"][0] == "worktree") + .count(), + 1, + "{response:?}" + ); + assert!( + invocations + .iter() + .all(|invocation| invocation["arguments"][0] != "config"), + "{response:?}" + ); + assert_eq!( + response["data"]["arguments"], + serde_json::json!([ + "worktree", + "add", + "--track", + "-b", + "feature/tracked-worktree", + "--", + destination, + "refs/remotes/origin/main" + ]) + ); + assert_eq!( + git_text(&root, &["config", "branch.feature/tracked-worktree.remote"]), + "origin" + ); + assert_eq!( + git_text(&root, &["config", "branch.feature/tracked-worktree.merge"]), + "refs/heads/main" + ); + assert_eq!( + git_text(&destination, &["branch", "--show-current"]), + "feature/tracked-worktree" + ); +} + +#[test] +fn git_write_executes_checkout_preflight_clone_and_validation() { + let root = git_write_repository("git-write-checkout-workflows"); + let run = |arguments: &[&str]| history_git(&root, arguments); + commit_history_file(&root, "example.txt", "initial\n", "initial"); + let request = |operation: &str, payload: Value| git_write_request(&root, operation, payload); + let current = git_text(&root, &["branch", "--show-current"]); + assert!(run(&["branch", "feature/core"]).status.success()); + // Checkout conflict handling. `feature/core` and the current branch hold different // content for conflict.txt, so a dirty working copy of it blocks a plain switch. fs::write(root.join("conflict.txt"), "on main\n").expect("file should be writable"); @@ -613,6 +1512,62 @@ fn git_write_validates_and_executes_shared_mutations() { fs::remove_dir_all(root).expect("temporary repository should be removable"); } +#[test] +fn git_write_rolls_back_large_selected_path_set_without_command_line_overflow() { + let root = temporary_root("git-write-large-rollback"); + fs::create_dir_all(root.join("bulk")).expect("temporary repository should be creatable"); + let run = |arguments: &[&str]| { + Command::new("git") + .args(arguments) + .current_dir(&root) + .output() + .expect("git should be available") + }; + assert!(run(&["init", "-q"]).status.success()); + assert!(run(&["config", "core.autocrlf", "false"]).status.success()); + assert!(run(&["config", "user.email", "test@example.com"]) + .status + .success()); + assert!(run(&["config", "user.name", "Lithe Test"]).status.success()); + + // This path set exceeds the Windows process command-line limit when every + // path is passed as a separate argument. + let paths = (0..240) + .map(|index| format!("bulk/{index:03}_{}.txt", "selected_path_segment_".repeat(6))) + .collect::>(); + for path in &paths { + fs::write(root.join(path), "initial\n").expect("tracked file should be writable"); + } + assert!(run(&["add", "--all"]).status.success()); + assert!(run(&["commit", "-qm", "initial"]).status.success()); + for path in &paths { + fs::write(root.join(path), "changed\n").expect("tracked file should be writable"); + } + assert!(run(&["add", "--all"]).status.success()); + + let request = serde_json::json!({ + "id": "large-rollback", + "command": "git.write", + "payload": { + "root": root, + "operation": "discardAll", + "paths": paths + } + }); + let response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&request).expect("rollback request should encode"), + )) + .expect("rollback response should be JSON"); + assert_eq!(response["ok"], true, "{response:?}"); + assert!(run(&["status", "--porcelain"]).stdout.is_empty()); + assert_eq!( + fs::read_to_string(root.join(&paths[0])).expect("tracked file should be readable"), + "initial\n" + ); + + fs::remove_dir_all(root).expect("temporary repository should be removable"); +} + #[test] fn detached_worktree_context_can_publish_a_pull_request_branch() { let repository = temporary_root("detached-pr-repository"); @@ -1237,6 +2192,110 @@ fn git_diff_and_apply_round_trip_a_patch() { fs::remove_dir_all(root).expect("temporary workspace should be removable"); } +#[test] +fn git_diff_resolves_the_empty_tree_for_a_sha256_repository() { + let root = temporary_root("git-diff-sha256-empty-tree"); + fs::create_dir_all(&root).expect("temporary workspace should be creatable"); + let run = |arguments: &[&str]| history_git(&root, arguments); + assert!(run(&["init", "-q", "--object-format=sha256", "-b", "main"]) + .status + .success()); + assert!(run(&["config", "core.autocrlf", "false"]).status.success()); + assert!(run(&["config", "user.email", "test@example.com"]) + .status + .success()); + assert!(run(&["config", "user.name", "Lithe Test"]).status.success()); + fs::write(root.join("root.txt"), "root\n").expect("root file should be writable"); + assert!(run(&["add", "root.txt"]).status.success()); + assert!(run(&["commit", "-qm", "root"]).status.success()); + fs::write(root.join("later.txt"), "later\n").expect("later file should be writable"); + assert!(run(&["add", "later.txt"]).status.success()); + assert!(run(&["commit", "-qm", "later"]).status.success()); + + let response: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "sha256-root-range", + "command": "git.diff", + "payload": { + "root": root, + "pathspecs": ["."], + "reference": "HEAD", + "emptyTreeBase": true + } + }) + .to_string(), + )) + .expect("Git diff response should be JSON"); + + assert_eq!(response["ok"], true, "{response:?}"); + let patch = response["data"]["patch"] + .as_str() + .expect("root range should return a patch"); + assert!(patch.contains("root.txt"), "{patch}"); + assert!(patch.contains("later.txt"), "{patch}"); + + fs::remove_dir_all(root).expect("temporary repository should be removable"); +} + +#[test] +fn git_typed_two_reference_comparison_preserves_both_identities() { + let root = temporary_root("git-typed-comparison"); + fs::create_dir_all(&root).expect("temporary workspace should be creatable"); + let run = |arguments: &[&str]| { + Command::new("git") + .args(arguments) + .current_dir(&root) + .output() + .expect("git should be available") + }; + assert!(run(&["init", "-q"]).status.success()); + assert!(run(&["config", "core.autocrlf", "false"]).status.success()); + assert!(run(&["config", "user.email", "test@example.com"]) + .status + .success()); + assert!(run(&["config", "user.name", "Lithe Test"]).status.success()); + let main = String::from_utf8_lossy(&run(&["branch", "--show-current"]).stdout) + .trim() + .to_string(); + fs::write(root.join("example.txt"), "main\n").expect("file should be writable"); + assert!(run(&["add", "example.txt"]).status.success()); + assert!(run(&["commit", "-qm", "initial"]).status.success()); + assert!(run(&["switch", "-qc", "feature"]).status.success()); + fs::write(root.join("example.txt"), "feature\n").expect("file should be writable"); + assert!(run(&["commit", "-qam", "feature"]).status.success()); + assert!(run(&["switch", "-q", &main]).status.success()); + + let typed_references = serde_json::json!({ + "gitReference": { + "fullName": format!("refs/heads/{main}"), + "shortName": main, + "kind": "local" + }, + "targetGitReference": { + "fullName": "refs/heads/feature", + "shortName": "feature", + "kind": "local" + } + }); + for (command, id) in [ + ("git.diff", "typed-diff"), + ("git.comparison", "typed-files"), + ] { + let mut payload = typed_references.clone(); + payload["root"] = serde_json::json!(root); + if command == "git.diff" { + payload["pathspecs"] = serde_json::json!(["."]); + } + let request = serde_json::json!({ "id": id, "command": command, "payload": payload }); + let response: Value = serde_json::from_str(&execute_json(&request.to_string())) + .expect("comparison response should be JSON"); + assert_eq!(response["ok"], true, "{response:?}"); + assert!(response["data"].to_string().contains("example.txt")); + } + + fs::remove_dir_all(root).expect("temporary repository should be removable"); +} + #[test] fn git_history_returns_references_and_commit_graph_fields() { let root = temporary_root("git-history"); @@ -1317,47 +2376,137 @@ fn git_history_returns_references_and_commit_graph_fields() { comparison_response["data"]["files"][0]["path"], "example.txt" ); - - assert!(run(&["stash", "push", "-qm", "saved"]).status.success()); - let stashes_request = serde_json::json!({ - "id": "stashes", - "command": "git.stashes", - "payload": {"root": root} - }); - let stashes_response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&stashes_request).expect("stashes request should encode"), - )) - .expect("stashes response should be JSON"); - assert_eq!(stashes_response["ok"], true); - assert_eq!(stashes_response["data"]["stashes"][0]["message"], "saved"); + + assert!(run(&["stash", "push", "-qm", "saved"]).status.success()); + let stashes_request = serde_json::json!({ + "id": "stashes", + "command": "git.stashes", + "payload": {"root": root} + }); + let stashes_response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&stashes_request).expect("stashes request should encode"), + )) + .expect("stashes response should be JSON"); + assert_eq!(stashes_response["ok"], true); + assert_eq!(stashes_response["data"]["stashes"][0]["message"], "saved"); + + let request = serde_json::json!({ + "id": "history", + "command": "git.history", + "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); + assert_eq!(response["data"]["commits"][0]["subject"], "initial"); + assert_eq!(response["data"]["userName"], "Lithe Test"); + assert_eq!(response["data"]["userEmail"], "test@example.com"); + assert!( + response["data"]["commits"][0]["hash"] + .as_str() + .expect("commit hash should be text") + .len() + >= 7 + ); + assert!(response["data"]["references"] + .as_array() + .expect("references should be an array") + .iter() + .any(|reference| reference["kind"] == "local")); + + fs::remove_dir_all(root).expect("temporary workspace should be removable"); +} + +#[test] +fn git_history_reports_tracking_counts_for_a_noncurrent_local_branch() { + 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-tracking-counts"); + let _cleanup = RemoveOnDrop(root.clone()); + fs::create_dir_all(&root).expect("temporary repository should be creatable"); + let run = |arguments: &[&str]| { + Command::new("git") + .args(arguments) + .current_dir(&root) + .output() + .expect("git should be available") + }; + assert!(run(&["init", "-q", "-b", "main"]).status.success()); + assert!(run(&[ + "remote", + "add", + "origin", + "https://example.invalid/repository.git", + ]) + .status + .success()); + assert!(run(&["config", "user.email", "test@example.com"]) + .status + .success()); + assert!(run(&["config", "user.name", "Lithe Test"]).status.success()); + fs::write(root.join("story.txt"), "base\n").expect("test file should be writable"); + assert!(run(&["add", "story.txt"]).status.success()); + assert!(run(&["commit", "-qm", "base"]).status.success()); + assert!(run(&["branch", "feature"]).status.success()); + + assert!(run(&["checkout", "-q", "feature"]).status.success()); + fs::write(root.join("story.txt"), "local\n").expect("test file should be writable"); + assert!(run(&["commit", "-qam", "local feature"]).status.success()); + + assert!(run(&["checkout", "-q", "main"]).status.success()); + assert!(run(&["checkout", "-qb", "remote-feature"]).status.success()); + fs::write(root.join("story.txt"), "remote\n").expect("test file should be writable"); + assert!(run(&["commit", "-qam", "remote feature"]).status.success()); + let remote_commit = git_text(&root, &["rev-parse", "HEAD"]); + assert!(run(&["checkout", "-q", "main"]).status.success()); + assert!( + run(&["update-ref", "refs/remotes/origin/feature", &remote_commit,]) + .status + .success() + ); + assert!(run(&["branch", "-D", "remote-feature"]).status.success()); + assert!( + run(&["update-ref", "refs/remotes/origin/main", "refs/heads/main"]) + .status + .success() + ); + assert!(run(&["branch", "--set-upstream-to=origin/main", "main"]) + .status + .success()); + assert!( + run(&["branch", "--set-upstream-to=origin/feature", "feature",]) + .status + .success() + ); let request = serde_json::json!({ - "id": "history", + "id": "history-tracking-counts", "command": "git.history", - "payload": {"root": root, "reference": "HEAD", "limit": 10} + "payload": {"root": root, "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); - assert_eq!(response["data"]["commits"][0]["subject"], "initial"); - assert_eq!(response["data"]["userName"], "Lithe Test"); - assert_eq!(response["data"]["userEmail"], "test@example.com"); - assert!( - response["data"]["commits"][0]["hash"] - .as_str() - .expect("commit hash should be text") - .len() - >= 7 - ); - assert!(response["data"]["references"] + assert_eq!(response["ok"], true, "{response:?}"); + let feature = response["data"]["references"] .as_array() .expect("references should be an array") .iter() - .any(|reference| reference["kind"] == "local")); - - fs::remove_dir_all(root).expect("temporary workspace should be removable"); + .find(|reference| reference["shortName"] == "feature") + .expect("feature reference should be returned"); + assert_eq!(feature["isCurrent"], false); + assert_eq!(feature["upstreamShortName"], "origin/feature"); + assert_eq!(feature["ahead"], 1); + assert_eq!(feature["behind"], 1); } #[test] @@ -1924,3 +3073,407 @@ fn explicit_pull_resolves_nested_remote_and_branch_names_against_bare_remote() { ); fs::remove_dir_all(root).expect("fixture should be removable"); } + +#[test] +fn git_typed_remote_checkout_rebase_blocks_dirty_tree_before_switching() { + let root = temporary_root("git-checkout-rebase-remote"); + let upstream = root.join("upstream"); + let work = root.join("work"); + fs::create_dir_all(&upstream).expect("temporary workspace should be creatable"); + let git = |directory: &Path, arguments: &[&str]| history_git(directory, arguments); + let identify = |directory: &Path| { + assert!(git(directory, &["config", "core.autocrlf", "false"]) + .status + .success()); + assert!( + git(directory, &["config", "user.email", "test@example.com"]) + .status + .success() + ); + assert!(git(directory, &["config", "user.name", "Lithe Test"]) + .status + .success()); + }; + assert!(git(&upstream, &["init", "-q", "-b", "main"]) + .status + .success()); + identify(&upstream); + fs::write(upstream.join("base.txt"), "base\n").expect("base file should be writable"); + assert!(git(&upstream, &["add", "."]).status.success()); + assert!(git(&upstream, &["commit", "-qm", "base"]).status.success()); + assert!(git(&upstream, &["switch", "-qc", "feature"]) + .status + .success()); + fs::write(upstream.join("feature.txt"), "feature\n").expect("feature file should be writable"); + assert!(git(&upstream, &["add", "."]).status.success()); + assert!(git(&upstream, &["commit", "-qm", "feature"]) + .status + .success()); + assert!(git(&upstream, &["switch", "-q", "main"]).status.success()); + fs::write(upstream.join("main.txt"), "main\n").expect("main file should be writable"); + assert!(git(&upstream, &["add", "."]).status.success()); + assert!(git(&upstream, &["commit", "-qm", "main"]).status.success()); + assert!(git( + &root, + &[ + "clone", + "-q", + "-c", + "core.autocrlf=false", + "-b", + "main", + upstream.to_str().expect("path should be UTF-8"), + "work" + ] + ) + .status + .success()); + identify(&work); + + let write = |operation: &str, extra: Value| -> Value { + let mut payload = serde_json::json!({ + "root": work, + "operation": operation, + "gitReference": { + "fullName": "refs/remotes/origin/feature", + "shortName": "origin/feature", + "kind": "remote" + } + }); + if let Value::Object(fields) = extra { + for (key, value) in fields { + payload[key] = value; + } + } + serde_json::from_str(&execute_json( + &serde_json::to_string(&serde_json::json!({ + "id": operation, + "command": "git.write", + "payload": payload + })) + .expect("request should encode"), + )) + .expect("response should decode") + }; + + fs::write(work.join("untracked.txt"), "dirty\n").expect("dirty file should be writable"); + let blocked = write("checkoutAndRebase", serde_json::json!({})); + assert_eq!(blocked["ok"], true, "{blocked}"); + assert_eq!(blocked["data"]["operationError"]["code"], "invalid_request"); + assert_eq!(git_text(&work, &["branch", "--show-current"]), "main"); + + fs::remove_file(work.join("untracked.txt")).expect("dirty file should be removable"); + let completed = write("checkoutAndRebase", serde_json::json!({})); + assert_eq!(completed["ok"], true, "{completed}"); + assert_eq!(completed["data"]["exitCode"], 0, "{completed}"); + assert_eq!(git_text(&work, &["branch", "--show-current"]), "feature"); + assert!( + git(&work, &["merge-base", "--is-ancestor", "main", "feature"]) + .status + .success() + ); + + fs::remove_dir_all(root).expect("Git fixture should be removable"); +} + +#[test] +fn git_remote_checkout_rejects_a_same_named_local_branch_with_another_upstream() { + let root = temporary_root("git-checkout-remote-upstream-identity"); + fs::create_dir_all(&root).expect("temporary repository should be creatable"); + let run = |arguments: &[&str]| history_git(&root, arguments); + assert!(run(&["init", "-q", "-b", "main"]).status.success()); + assert!(run(&["config", "core.autocrlf", "false"]).status.success()); + assert!(run(&["config", "user.email", "test@example.com"]) + .status + .success()); + assert!(run(&["config", "user.name", "Lithe Test"]).status.success()); + fs::write(root.join("base.txt"), "base\n").expect("file should be writable"); + assert!(run(&["add", "base.txt"]).status.success()); + assert!(run(&["commit", "-qm", "base"]).status.success()); + assert!(run(&[ + "remote", + "add", + "origin", + "https://example.invalid/origin.git" + ]) + .status + .success()); + assert!(run(&[ + "remote", + "add", + "upstream", + "https://example.invalid/upstream.git" + ]) + .status + .success()); + assert!(run(&["update-ref", "refs/remotes/origin/feature", "HEAD"]) + .status + .success()); + assert!( + run(&["update-ref", "refs/remotes/upstream/feature", "HEAD"]) + .status + .success() + ); + assert!(run(&["branch", "feature", "refs/remotes/origin/feature"]) + .status + .success()); + assert!( + run(&["branch", "--set-upstream-to=origin/feature", "feature"]) + .status + .success() + ); + + let response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&serde_json::json!({ + "id": "checkout-upstream-feature", + "command": "git.write", + "payload": { + "root": root, + "operation": "checkout", + "gitReference": { + "fullName": "refs/remotes/upstream/feature", + "shortName": "upstream/feature", + "kind": "remote" + } + } + })) + .expect("remote checkout request should encode"), + )) + .expect("remote checkout response should be JSON"); + + assert_eq!(response["ok"], true, "{response:?}"); + assert_eq!( + response["data"]["operationError"]["message"], + "A same-named local branch tracks a different Git reference" + ); + assert_eq!(git_text(&root, &["branch", "--show-current"]), "main"); + + fs::remove_dir_all(root).expect("temporary repository should be removable"); +} + +#[test] +fn git_explicit_remote_pull_validates_identity_and_strategy() { + let root = temporary_root("git-pull-remote-reference"); + let upstream = root.join("upstream"); + let work = root.join("work"); + fs::create_dir_all(&upstream).expect("temporary workspace should be creatable"); + assert!(history_git(&upstream, &["init", "-q", "-b", "main"]) + .status + .success()); + assert!( + history_git(&upstream, &["config", "user.email", "test@example.com"]) + .status + .success() + ); + assert!( + history_git(&upstream, &["config", "user.name", "Lithe Test"]) + .status + .success() + ); + fs::write(upstream.join("base.txt"), "base\n").expect("base file should be writable"); + assert!(history_git(&upstream, &["add", "."]).status.success()); + assert!(history_git(&upstream, &["commit", "-qm", "base"]) + .status + .success()); + assert!(history_git(&upstream, &["switch", "-qc", "feature"]) + .status + .success()); + fs::write(upstream.join("feature.txt"), "feature\n").expect("feature file should be writable"); + assert!(history_git(&upstream, &["add", "."]).status.success()); + assert!(history_git(&upstream, &["commit", "-qm", "feature"]) + .status + .success()); + assert!(history_git(&upstream, &["switch", "-q", "main"]) + .status + .success()); + fs::write(upstream.join("main.txt"), "main\n").expect("main file should be writable"); + assert!(history_git(&upstream, &["add", "."]).status.success()); + assert!(history_git(&upstream, &["commit", "-qm", "main"]) + .status + .success()); + assert!(history_git( + &root, + &[ + "clone", + "-q", + "-c", + "core.autocrlf=false", + "-b", + "main", + upstream.to_str().expect("path should be UTF-8"), + "work" + ] + ) + .status + .success()); + assert!( + history_git(&work, &["config", "user.email", "test@example.com"]) + .status + .success() + ); + assert!(history_git(&work, &["config", "user.name", "Lithe Test"]) + .status + .success()); + + let pull = |reference: Value, mode: &str| -> Value { + serde_json::from_str(&execute_json( + &serde_json::to_string(&serde_json::json!({ + "id": "pull-remote", + "command": "git.write", + "payload": { + "root": work, + "operation": "pull", + "gitReference": reference, + "mode": mode + } + })) + .expect("request should encode"), + )) + .expect("response should decode") + }; + let remote_reference = serde_json::json!({ + "fullName": "refs/remotes/origin/feature", + "shortName": "origin/feature", + "kind": "remote" + }); + let merged = pull(remote_reference.clone(), "merge"); + assert_eq!(merged["ok"], true, "{merged}"); + assert_eq!(merged["data"]["exitCode"], 0, "{merged}"); + assert_eq!( + git_text(&work, &["rev-list", "--parents", "-n", "1", "HEAD"]) + .split_whitespace() + .count(), + 3 + ); + + let mismatched = pull( + serde_json::json!({ + "fullName": "refs/remotes/origin/feature", + "shortName": "feature", + "kind": "local" + }), + "rebase", + ); + assert_eq!(mismatched["ok"], false, "{mismatched}"); + assert_eq!(mismatched["error"]["code"], "invalid_request"); + + fs::remove_dir_all(root).expect("Git fixture should be removable"); +} + +fn git_write_repository(label: &str) -> std::path::PathBuf { + let root = temporary_root(label); + fs::create_dir_all(&root).expect("temporary repository should be creatable"); + assert!(history_git(&root, &["init", "-q"]).status.success()); + assert!(history_git(&root, &["config", "core.autocrlf", "false"]) + .status + .success()); + assert!( + history_git(&root, &["config", "user.email", "test@example.com"]) + .status + .success() + ); + assert!(history_git(&root, &["config", "user.name", "Lithe Test"]) + .status + .success()); + assert!(history_git(&root, &["remote", "add", "origin", "."]) + .status + .success()); + root +} + +fn git_write_request(root: &Path, operation: &str, overrides: Value) -> Value { + let mut request = serde_json::json!({ + "id": operation, + "command": "git.write", + "payload": { + "root": root, + "operation": operation, + "paths": [], + "reference": null, + "referenceKind": null, + "revision": null, + "name": null, + "message": null, + "remote": null, + "destination": null, + "mode": null, + "includeUntracked": false, + "checkout": false, + "amend": false + } + }); + if let Value::Object(overrides) = overrides { + for (key, value) in overrides { + request["payload"][key.as_str()] = value; + } + } + serde_json::from_str(&execute_json( + &serde_json::to_string(&request).expect("write request should encode"), + )) + .expect("write response should be JSON") +} + +fn history_rewrite_repository(label: &str) -> std::path::PathBuf { + let root = temporary_root(label); + fs::create_dir_all(&root).expect("temporary repository should be creatable"); + assert!(history_git(&root, &["init", "-q", "-b", "main"]) + .status + .success()); + assert!(history_git(&root, &["config", "core.autocrlf", "false"]) + .status + .success()); + assert!( + history_git(&root, &["config", "user.email", "test@example.com"]) + .status + .success() + ); + assert!(history_git(&root, &["config", "user.name", "Lithe Test"]) + .status + .success()); + root +} + +fn commit_history_file(root: &Path, path: &str, contents: &str, message: &str) { + fs::write(root.join(path), contents).expect("history fixture file should be writable"); + assert!(history_git(root, &["add", "--", path]).status.success()); + assert!(history_git(root, &["commit", "-qm", message]) + .status + .success()); +} + +fn history_write(root: &Path, overrides: Value) -> Value { + let mut payload = serde_json::json!({"root": root}); + if let Value::Object(overrides) = overrides { + for (key, value) in overrides { + payload[key.as_str()] = value; + } + } + serde_json::from_str(&execute_json( + &serde_json::to_string(&serde_json::json!({ + "id": "history-write", + "command": "git.write", + "payload": payload + })) + .expect("history rewrite request should encode"), + )) + .expect("history rewrite response should be JSON") +} + +fn history_git(root: &Path, arguments: &[&str]) -> std::process::Output { + Command::new("git") + .args(arguments) + .current_dir(root) + .output() + .expect("git should be available") +} + +fn git_text(root: &Path, arguments: &[&str]) -> String { + let output = history_git(root, arguments); + assert!( + output.status.success(), + "git {:?} failed: {}", + arguments, + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8_lossy(&output.stdout).trim().to_string() +} diff --git a/rust/lithe-core/src/tests/languages.rs b/rust/lithe-core/src/tests/languages.rs index 3407bef8..8ad971f4 100644 --- a/rust/lithe-core/src/tests/languages.rs +++ b/rust/lithe-core/src/tests/languages.rs @@ -424,6 +424,11 @@ fn java_core_commands_return_shared_runtime_and_structure_data() { ); assert_eq!(response["data"]["configurations"][0]["kind"], "springBoot"); assert_eq!(response["data"]["configurations"][0]["modulePath"], "src"); + assert_eq!( + response["data"]["configurations"][0]["sourcePath"], + "src/main/java/com/example/App.java" + ); + assert_eq!(response["data"]["configurations"][0]["sourceSet"], "main"); let structure = serde_json::json!({ "id": "java-structure", diff --git a/rust/lithe-core/src/tests/run_configuration.rs b/rust/lithe-core/src/tests/run_configuration.rs index b274f11a..eea109dc 100644 --- a/rust/lithe-core/src/tests/run_configuration.rs +++ b/rust/lithe-core/src/tests/run_configuration.rs @@ -566,17 +566,28 @@ fn run_configuration_generation_deduplicates_nested_checkout_sources() { #[test] fn run_configuration_generation_disambiguates_same_main_class_across_modules() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/execution/maven-java-main-source-sets-v1.json" + )) + .expect("Maven Java main source-set fixture should be valid JSON"); let root = temporary_root("run-config-duplicate-main-classes"); - let module_a = "module-a/src/main/java/com/example/App.java"; - let module_b = "module-b/src/main/java/com/example/App.java"; - fs::create_dir_all(root.join("module-a/src/main/java/com/example")).unwrap(); - fs::create_dir_all(root.join("module-b/src/main/java/com/example")).unwrap(); + fs::create_dir_all(&root).unwrap(); fs::write(root.join("pom.xml"), "").unwrap(); - fs::write(root.join("module-a/pom.xml"), "").unwrap(); - fs::write(root.join("module-b/pom.xml"), "").unwrap(); let java = "package com.example; class App { public static void main(String[] args) {} }"; - fs::write(root.join(module_a), java).unwrap(); - fs::write(root.join(module_b), java).unwrap(); + let cases = fixture["cases"] + .as_array() + .expect("source-set fixture should contain cases"); + let mut paths = Vec::new(); + let mut modules = Vec::new(); + for case in cases { + let module = case["module"].as_str().expect("case should name a module"); + let source = case["source"].as_str().expect("case should name a source"); + fs::create_dir_all(root.join(source).parent().unwrap()).unwrap(); + fs::write(root.join(module).join("pom.xml"), "").unwrap(); + fs::write(root.join(source), java).unwrap(); + paths.push(source); + modules.push(module); + } let generate = |paths: Vec<&str>| -> Value { serde_json::from_str(&execute_json( @@ -586,15 +597,15 @@ fn run_configuration_generation_disambiguates_same_main_class_across_modules() { "payload": { "root": root, "paths": paths, - "modulePaths": ["module-a", "module-b"] + "modulePaths": modules } }) .to_string(), )) .unwrap() }; - let response = generate(vec![module_a, module_b]); - let reversed = generate(vec![module_b, module_a]); + let response = generate(paths.clone()); + let reversed = generate(paths.into_iter().rev().collect()); assert_eq!(response["ok"], true, "{response}"); assert_eq!( @@ -609,14 +620,44 @@ fn run_configuration_generation_disambiguates_same_main_class_across_modules() { .filter(|value| value["provider"] == "java.main") .collect::>(); assert_eq!(module_configurations.len(), 2, "{configurations:?}"); - assert!(module_configurations.iter().any(|value| { - value["id"] == "java-main:com.example.App:module-a" - && value["extensions"]["maven"]["module"] == "module-a" - })); - assert!(module_configurations.iter().any(|value| { - value["id"] == "java-main:com.example.App:module-b" - && value["extensions"]["maven"]["module"] == "module-b" - })); + fs::create_dir_all(root.join(".lithe/run")).unwrap(); + fs::write( + root.join(".lithe/run/generated.json"), + serde_json::to_string(&response["data"]["generated"]).unwrap(), + ) + .unwrap(); + for case in cases { + let configuration_id = case["configurationId"] + .as_str() + .expect("case should name a configuration"); + let configuration = module_configurations + .iter() + .find(|value| value["id"] == configuration_id) + .expect("generated configuration should match the fixture"); + assert_eq!( + configuration["extensions"]["java"]["source"], + case["source"] + ); + assert_eq!( + configuration["extensions"]["java"]["sourceSet"], + case["sourceSet"] + ); + let plan: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": format!("plan-{configuration_id}"), + "command": "runConfig.createLaunchPlan", + "payload": {"root": root, "configurationId": configuration_id} + }) + .to_string(), + )) + .unwrap(); + assert_eq!(plan["ok"], true, "case {}: {plan}", case["name"]); + assert_eq!( + plan["data"]["arguments"], case["expectedArguments"], + "case {}", + case["name"] + ); + } assert_eq!(response["data"]["entryCount"], 2); assert_eq!(reversed["data"]["entryCount"], 2); @@ -680,6 +721,11 @@ fn ordinary_java_main_uses_an_application_launch_plan() { .unwrap() .iter() .any(|value| value == "-Dexec.mainClass=com.example.WorkerMain")); + assert!(!plan["data"]["arguments"] + .as_array() + .unwrap() + .iter() + .any(|value| value == "-Dexec.classpathScope=test" || value == "test-compile")); assert_eq!( plan["data"]["arguments"] .as_array() @@ -692,6 +738,73 @@ fn ordinary_java_main_uses_an_application_launch_plan() { fs::remove_dir_all(root).unwrap(); } +#[test] +fn maven_test_source_main_uses_the_test_classpath() { + let root = temporary_root("run-config-maven-test-main"); + let source = "src/test/java/com/example/MainTests.java"; + fs::create_dir_all(root.join("src/test/java/com/example")).unwrap(); + fs::write(root.join("pom.xml"), "").unwrap(); + fs::write( + root.join(source), + "package com.example; class MainTests { public static void main(String[] args) {} }", + ) + .unwrap(); + + let generated_response: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "generate-maven-test-main", + "command": "runConfig.generate", + "payload": {"root": root, "paths": [source], "modulePaths": []} + }) + .to_string(), + )) + .unwrap(); + let generated = &generated_response["data"]["generated"]; + let java_main = generated["configurations"] + .as_array() + .unwrap() + .iter() + .find(|value| value["id"] == "java-main:com.example.MainTests") + .unwrap(); + assert_eq!(java_main["extensions"]["java"]["source"], source); + assert_eq!(java_main["extensions"]["java"]["sourceSet"], "test"); + + fs::create_dir_all(root.join(".lithe/run")).unwrap(); + fs::write( + root.join(".lithe/run/generated.json"), + serde_json::to_string(generated).unwrap(), + ) + .unwrap(); + let plan: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "plan-maven-test-main", + "command": "runConfig.createLaunchPlan", + "payload": { + "root": root, + "configurationId": "java-main:com.example.MainTests" + } + }) + .to_string(), + )) + .unwrap(); + assert_eq!(plan["ok"], true, "{plan}"); + let arguments = plan["data"]["arguments"].as_array().unwrap(); + assert!(arguments + .iter() + .any(|value| value == "-Dexec.classpathScope=test")); + let test_compile = arguments + .iter() + .position(|value| value == "test-compile") + .expect("test sources should be compiled before launch"); + let exec_java = arguments + .iter() + .position(|value| value == "org.codehaus.mojo:exec-maven-plugin:3.5.0:java") + .expect("the Maven Exec goal should be present"); + assert!(test_compile < exec_java); + + fs::remove_dir_all(root).unwrap(); +} + #[test] fn plain_java_main_uses_the_jdk_without_maven() { let root = temporary_root("run-config-plain-java-main"); @@ -1497,24 +1610,50 @@ fn run_configuration_generation_detects_maven_compiler_target() { #[test] fn run_configuration_inspection_invalidates_an_older_generator_revision() { let root = temporary_root("run-config-generator-revision"); - fs::create_dir_all(root.join("src")).unwrap(); - fs::write(root.join("src/App.java"), "class App {}").unwrap(); + let test_source = "module-a/src/test/java/com/example/App.java"; + let main_source = "module-b/src/main/java/com/example/App.java"; + fs::create_dir_all(root.join("module-a/src/test/java/com/example")).unwrap(); + fs::create_dir_all(root.join("module-b/src/main/java/com/example")).unwrap(); + fs::write(root.join("pom.xml"), "").unwrap(); + fs::write(root.join("module-a/pom.xml"), "").unwrap(); + fs::write(root.join("module-b/pom.xml"), "").unwrap(); + let java = "package com.example; class App { public static void main(String[] args) {} }"; + fs::write(root.join(test_source), java).unwrap(); + fs::write(root.join(main_source), java).unwrap(); let generated: Value = serde_json::from_str(&execute_json( &serde_json::json!({ "id": "generate-revision", "command": "runConfig.generate", - "payload": {"root": root, "paths": ["src/App.java"], "modulePaths": []} + "payload": { + "root": root, + "paths": [test_source, main_source], + "modulePaths": ["module-a", "module-b"] + } }) .to_string(), )) .unwrap(); let mut document = generated["data"]["generated"].clone(); - let legacy_fingerprint = legacy_generator_fingerprint(&document["generator"]["inputs"]); + let revision_two_fingerprint = + generator_fingerprint_for_revision(&document["generator"]["inputs"], "2"); assert_ne!( document["generator"]["fingerprint"], - serde_json::json!(legacy_fingerprint) + serde_json::json!(revision_two_fingerprint) ); - document["generator"]["fingerprint"] = serde_json::json!(legacy_fingerprint); + document["generator"]["fingerprint"] = serde_json::json!(revision_two_fingerprint); + let configurations = document["configurations"] + .as_array_mut() + .expect("generated document should contain configurations"); + for configuration in configurations.iter_mut() { + if let Some(java) = configuration["extensions"]["java"].as_object_mut() { + java.remove("sourceSet"); + } + } + let stale_test_configuration = configurations + .iter_mut() + .find(|configuration| configuration["id"] == "java-main:com.example.App:module-a") + .expect("test module configuration should exist"); + stale_test_configuration["extensions"]["java"]["source"] = serde_json::json!(main_source); fs::create_dir_all(root.join(".lithe/run")).unwrap(); fs::write( root.join(".lithe/run/generated.json"), @@ -1532,6 +1671,10 @@ fn run_configuration_inspection_invalidates_an_older_generator_revision() { )) .unwrap(); assert_eq!(inspected["ok"], true, "{inspected}"); + assert_eq!( + inspected["data"]["diagnostics"][0]["code"], + "staleFingerprint" + ); assert_eq!( inspected["data"]["diagnostics"][0]["message"], "Run configuration generator changed; regenerate configurations" @@ -1540,9 +1683,11 @@ fn run_configuration_inspection_invalidates_an_older_generator_revision() { fs::remove_dir_all(root).unwrap(); } -fn legacy_generator_fingerprint(inputs: &Value) -> String { +fn generator_fingerprint_for_revision(inputs: &Value, revision: &str) -> String { let inputs = serde_json::from_value::>(inputs.clone()).unwrap(); let mut digest = Sha256::new(); + digest.update(revision.as_bytes()); + digest.update([0]); for (relative, content_hash) in inputs { digest.update(relative.as_bytes()); digest.update([0]); diff --git a/rust/lithe-core/tests/git_push.rs b/rust/lithe-core/tests/git_push.rs new file mode 100644 index 00000000..ea2c794b --- /dev/null +++ b/rust/lithe-core/tests/git_push.rs @@ -0,0 +1,390 @@ +use lithe_core::execute_json; +use serde_json::{json, Value}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +static NEXT_FIXTURE_ID: AtomicU64 = AtomicU64::new(0); + +struct GitFixture { + root: PathBuf, +} + +impl GitFixture { + fn new() -> Self { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be valid") + .as_nanos(); + let fixture_id = NEXT_FIXTURE_ID.fetch_add(1, Ordering::Relaxed); + let root = std::env::temp_dir().join(format!( + "lithe-git-push-{}-{nonce}-{fixture_id}", + std::process::id() + )); + fs::create_dir_all(&root).expect("Git fixture root should be creatable"); + Self { root } + } +} + +impl Drop for GitFixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.root); + } +} + +fn git(directory: &Path, arguments: &[&str]) -> Output { + Command::new("git") + .args(arguments) + .current_dir(directory) + .output() + .expect("git should be available") +} + +fn require_git(directory: &Path, arguments: &[&str]) { + let output = git(directory, arguments); + assert!( + output.status.success(), + "git {} failed: {}", + arguments.join(" "), + String::from_utf8_lossy(&output.stderr) + ); +} + +fn core(command: &str, root: &Path, payload: Value) -> Value { + let mut payload = payload.as_object().cloned().unwrap_or_default(); + payload.insert("root".into(), json!(root)); + let request = json!({ + "id": format!("test-{command}"), + "command": command, + "payload": payload + }); + serde_json::from_str(&execute_json( + &serde_json::to_string(&request).expect("Core request should encode"), + )) + .expect("Core response should be JSON") +} + +fn initialize_repository(fixture: &GitFixture) -> (PathBuf, PathBuf) { + let remote = fixture.root.join("remote.git"); + let repository = fixture.root.join("repository"); + fs::create_dir_all(&remote).expect("remote should be creatable"); + fs::create_dir_all(&repository).expect("repository should be creatable"); + require_git(&remote, &["init", "--bare", "-q"]); + require_git(&repository, &["init", "-q"]); + require_git(&repository, &["config", "user.email", "tests@lithe.local"]); + require_git(&repository, &["config", "user.name", "Lithe Tests"]); + fs::write(repository.join("tracked.txt"), "initial\n") + .expect("tracked fixture should be writable"); + require_git(&repository, &["add", "tracked.txt"]); + require_git(&repository, &["commit", "-q", "-m", "initial"]); + require_git(&repository, &["branch", "-M", "main"]); + let remote_path = remote.to_string_lossy().into_owned(); + require_git(&repository, &["remote", "add", "origin", &remote_path]); + require_git(&repository, &["push", "-q", "-u", "origin", "main"]); + (repository, remote) +} + +fn add_bare_remote(fixture: &GitFixture, repository: &Path, name: &str) -> PathBuf { + let remote = fixture.root.join(format!("{}.git", name.replace('/', "-"))); + fs::create_dir_all(&remote).expect("additional remote should be creatable"); + require_git(&remote, &["init", "--bare", "-q"]); + let remote_path = remote.to_string_lossy().into_owned(); + require_git(repository, &["remote", "add", name, &remote_path]); + remote +} + +fn push_expectation(preview: &Value) -> Value { + json!({ + "localBranch": preview["data"]["localBranch"], + "localHead": preview["data"]["localHead"], + "remote": preview["data"]["remote"], + "remoteBranch": preview["data"]["remoteBranch"], + "remoteTrackingOid": preview["data"]["remoteTrackingOid"], + "tags": preview["data"]["tags"], + }) +} + +#[test] +fn push_preview_and_write_share_destination_and_safe_options() { + let fixture = GitFixture::new(); + let (repository, remote) = initialize_repository(&fixture); + fs::write(repository.join("tracked.txt"), "initial\nsecond\n") + .expect("tracked fixture should be writable"); + require_git(&repository, &["commit", "-q", "-am", "second"]); + require_git(&repository, &["tag", "-a", "v1", "-m", "version one"]); + + let preview = core( + "git.pushPreview", + &repository, + json!({ "pushTags": "reachable" }), + ); + assert_eq!(preview["ok"], true, "response: {preview}"); + assert_eq!(preview["data"]["localBranch"], "main"); + assert_eq!(preview["data"]["remote"], "origin"); + assert_eq!(preview["data"]["remoteBranch"], "main"); + assert_eq!(preview["data"]["upstream"], "origin/main"); + assert_eq!( + preview["data"]["localHead"], + String::from_utf8_lossy(&git(&repository, &["rev-parse", "HEAD"]).stdout).trim() + ); + assert_eq!( + preview["data"]["remoteTrackingOid"], + String::from_utf8_lossy(&git(&repository, &["rev-parse", "origin/main"]).stdout).trim() + ); + assert_eq!(preview["data"]["commits"].as_array().map(Vec::len), Some(1)); + assert_eq!(preview["data"]["commits"][0]["subject"], "second"); + + let pushed = core( + "git.write", + &repository, + json!({ + "operation": "push", + "expectedPush": push_expectation(&preview), + "force": true, + "pushTags": "reachable" + }), + ); + assert_eq!(pushed["ok"], true, "response: {pushed}"); + let arguments = pushed["data"]["arguments"] + .as_array() + .expect("push arguments should be present") + .iter() + .filter_map(Value::as_str) + .collect::>(); + assert!(arguments + .iter() + .any(|argument| argument.starts_with("--force-with-lease=refs/heads/main:"))); + assert!(!arguments.contains(&"--force")); + assert!(!arguments.contains(&"--follow-tags")); + let reviewed_refspec = format!( + "{}:refs/heads/main", + preview["data"]["localHead"] + .as_str() + .expect("preview should contain the local head") + ); + assert!(arguments.contains(&reviewed_refspec.as_str())); + let reviewed_tag_refspec = format!( + "{}:{}", + preview["data"]["tags"][0]["objectId"] + .as_str() + .expect("preview should contain the tag object"), + preview["data"]["tags"][0]["fullName"] + .as_str() + .expect("preview should contain the tag reference") + ); + assert!(arguments.contains(&reviewed_tag_refspec.as_str())); + + let local_head = git(&repository, &["rev-parse", "HEAD"]); + let remote_head = git(&remote, &["rev-parse", "refs/heads/main"]); + assert_eq!(local_head.stdout, remote_head.stdout); + require_git(&remote, &["show-ref", "--verify", "refs/tags/v1"]); +} + +#[test] +fn push_rejects_a_preview_after_the_local_head_changes() { + let fixture = GitFixture::new(); + let (repository, remote) = initialize_repository(&fixture); + let preview = core("git.pushPreview", &repository, json!({})); + assert_eq!(preview["ok"], true, "response: {preview}"); + let remote_before = git(&remote, &["rev-parse", "refs/heads/main"]).stdout; + + fs::write(repository.join("after-preview.txt"), "changed\n") + .expect("fixture should be writable"); + require_git(&repository, &["add", "after-preview.txt"]); + require_git(&repository, &["commit", "-q", "-m", "after preview"]); + + let pushed = core( + "git.write", + &repository, + json!({ + "operation": "push", + "expectedPush": push_expectation(&preview) + }), + ); + assert_eq!(pushed["ok"], true, "response: {pushed}"); + assert_eq!( + pushed["data"]["operationError"]["message"], + "Git push preview is stale; refresh and try again." + ); + assert!(!pushed["data"]["invocations"] + .as_array() + .expect("invocations should be present") + .iter() + .any(|invocation| invocation["arguments"][0] == "push")); + assert_eq!( + git(&remote, &["rev-parse", "refs/heads/main"]).stdout, + remote_before + ); +} + +#[test] +fn push_rejects_a_preview_after_the_configured_destination_changes() { + let fixture = GitFixture::new(); + let (repository, origin) = initialize_repository(&fixture); + let fork = add_bare_remote(&fixture, &repository, "fork"); + fs::write(repository.join("destination.txt"), "changed\n").expect("fixture should be writable"); + require_git(&repository, &["add", "destination.txt"]); + require_git(&repository, &["commit", "-q", "-m", "destination change"]); + let preview = core("git.pushPreview", &repository, json!({})); + assert_eq!(preview["data"]["remote"], "origin"); + + require_git(&repository, &["config", "branch.main.pushRemote", "fork"]); + let pushed = core( + "git.write", + &repository, + json!({ + "operation": "push", + "expectedPush": push_expectation(&preview) + }), + ); + + assert_eq!(pushed["ok"], true, "response: {pushed}"); + assert_eq!( + pushed["data"]["operationError"]["message"], + "Git push preview is stale; refresh and try again." + ); + assert!(!pushed["data"]["invocations"] + .as_array() + .expect("invocations should be present") + .iter() + .any(|invocation| invocation["arguments"][0] == "push")); + assert_ne!( + git(&origin, &["rev-parse", "refs/heads/main"]).stdout, + git(&repository, &["rev-parse", "HEAD"]).stdout + ); + assert!(!git(&fork, &["show-ref", "--verify", "refs/heads/main"]) + .status + .success()); +} + +#[test] +fn reviewed_push_uses_default_remote_and_sets_upstream_when_missing() { + let fixture = GitFixture::new(); + let (repository, _) = initialize_repository(&fixture); + require_git(&repository, &["switch", "-q", "-c", "feature"]); + fs::write(repository.join("local.txt"), "local\n").expect("local fixture should be writable"); + require_git(&repository, &["add", "local.txt"]); + require_git(&repository, &["commit", "-q", "-m", "local only"]); + + let preview = core("git.pushPreview", &repository, json!({})); + assert_eq!(preview["ok"], true, "response: {preview}"); + assert_eq!(preview["data"]["remote"], "origin"); + assert_eq!(preview["data"]["remoteBranch"], "feature"); + assert!(preview["data"]["upstream"].is_null()); + assert_eq!(preview["data"]["commits"].as_array().map(Vec::len), Some(1)); + assert_eq!(preview["data"]["commits"][0]["subject"], "local only"); + + let pushed = core( + "git.write", + &repository, + json!({ + "operation": "push", + "expectedPush": push_expectation(&preview) + }), + ); + assert_eq!(pushed["ok"], true, "response: {pushed}"); + assert_eq!(pushed["data"]["exitCode"], 0, "response: {pushed}"); + assert_eq!( + String::from_utf8_lossy( + &git(&repository, &["rev-parse", "--abbrev-ref", "@{upstream}"]).stdout + ) + .trim(), + "origin/feature" + ); +} + +#[test] +fn push_preview_preserves_a_configured_remote_name_with_slashes() { + let fixture = GitFixture::new(); + let (repository, _) = initialize_repository(&fixture); + require_git(&repository, &["remote", "rename", "origin", "team/origin"]); + fs::write(repository.join("nested-remote.txt"), "local\n") + .expect("nested remote fixture should be writable"); + require_git(&repository, &["add", "nested-remote.txt"]); + require_git(&repository, &["commit", "-q", "-m", "nested remote"]); + + let preview = core("git.pushPreview", &repository, json!({})); + assert_eq!(preview["ok"], true, "response: {preview}"); + assert_eq!(preview["data"]["remote"], "team/origin"); + assert_eq!(preview["data"]["remoteBranch"], "main"); + assert_eq!(preview["data"]["upstream"], "team/origin/main"); + assert_eq!(preview["data"]["commits"][0]["subject"], "nested remote"); +} + +#[test] +fn branch_push_remote_overrides_the_tracking_remote_for_preview_and_push() { + let fixture = GitFixture::new(); + let (repository, _) = initialize_repository(&fixture); + let fork = add_bare_remote(&fixture, &repository, "fork"); + require_git(&repository, &["config", "branch.main.pushRemote", "fork"]); + fs::write(repository.join("fork.txt"), "fork\n").expect("fixture should be writable"); + require_git(&repository, &["add", "fork.txt"]); + require_git(&repository, &["commit", "-q", "-m", "fork change"]); + + let preview = core("git.pushPreview", &repository, json!({})); + assert_eq!(preview["ok"], true, "response: {preview}"); + assert_eq!(preview["data"]["remote"], "fork"); + assert_eq!(preview["data"]["remoteBranch"], "main"); + assert_eq!(preview["data"]["upstream"], "origin/main"); + + let pushed = core("git.write", &repository, json!({ "operation": "push" })); + assert_eq!(pushed["ok"], true, "response: {pushed}"); + assert_eq!(pushed["data"]["exitCode"], 0, "response: {pushed}"); + assert_eq!( + git(&repository, &["rev-parse", "HEAD"]).stdout, + git(&fork, &["rev-parse", "refs/heads/main"]).stdout + ); +} + +#[test] +fn remote_push_default_overrides_the_tracking_remote() { + let fixture = GitFixture::new(); + let (repository, _) = initialize_repository(&fixture); + add_bare_remote(&fixture, &repository, "fork"); + require_git(&repository, &["config", "remote.pushDefault", "fork"]); + + let preview = core("git.pushPreview", &repository, json!({})); + assert_eq!(preview["ok"], true, "response: {preview}"); + assert_eq!(preview["data"]["remote"], "fork"); + assert_eq!(preview["data"]["remoteBranch"], "main"); + assert_eq!(preview["data"]["upstream"], "origin/main"); +} + +#[test] +fn typed_remote_deletion_preserves_remote_names_with_slashes() { + let fixture = GitFixture::new(); + let (repository, remote) = initialize_repository(&fixture); + require_git(&repository, &["remote", "rename", "origin", "team/origin"]); + require_git( + &repository, + &[ + "push", + "-q", + "team/origin", + "refs/heads/main:refs/heads/feature/orders", + ], + ); + + let deleted = core( + "git.write", + &repository, + json!({ + "operation": "deleteRemoteBranch", + "gitReference": { + "fullName": "refs/remotes/team/origin/feature/orders", + "shortName": "team/origin/feature/orders", + "kind": "remote" + } + }), + ); + assert_eq!(deleted["ok"], true, "response: {deleted}"); + assert_eq!(deleted["data"]["exitCode"], 0, "response: {deleted}"); + assert!(!git( + &remote, + &["show-ref", "--verify", "refs/heads/feature/orders"] + ) + .status + .success()); +} diff --git a/scripts/test-lithe-issue-claim.mjs b/scripts/test-lithe-issue-claim.mjs new file mode 100644 index 00000000..a6b01d82 --- /dev/null +++ b/scripts/test-lithe-issue-claim.mjs @@ -0,0 +1,60 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + claimMarker, claimStatus, hasProgressSince, isActiveClaim, isMaintainer, isPullRequestIssue, latestProgressDate, needsReleaseRecovery, parseCommand, parseMarker, releaseMarker, releaseMatchesClaim, +} from '../.github/lithe-issue-claim/logic.mjs'; + +const now = new Date('2026-09-01T00:00:00Z'); + +test('only exact claim commands are accepted', () => { + assert.equal(parseCommand('/assign'), '/assign'); + assert.equal(parseCommand(' /unassign\n'), '/unassign'); + assert.equal(parseCommand('/assign please'), null); + assert.equal(parseCommand('progress update'), null); +}); + +test('pull requests are excluded and maintainer associations are explicit', () => { + assert.equal(isPullRequestIssue({ pull_request: { url: 'x' } }), true); + assert.equal(isPullRequestIssue({}), false); + assert.equal(isMaintainer('OWNER'), true); + assert.equal(isMaintainer('MEMBER'), true); + assert.equal(isMaintainer('CONTRIBUTOR'), false); +}); + +test('claim lifecycle warns after 30 days and releases after seven more', () => { + const claim = parseMarker(claimMarker('alice', '2026-08-01T00:00:00Z'), '