diff --git a/.gitignore b/.gitignore index 09ff835..033e252 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,8 @@ dist-ssr *.local package-lock.json +.pnpm-store/ +.tauri-updater.key # Editor directories and files .vscode/* diff --git a/README.md b/README.md index 0e2470e..c51ba79 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ HTTP / WebSocket / SSE / gRPC 请求客户端,类似 IntelliJ HTTP Client / VS - WebSocket 双向通信,支持多消息发送与 idle 超时 - gRPC(Unary + Server Streaming),支持 server reflection / proto 文件加载 - 工作区文件树管理、环境变量、历史记录、cURL 互转 +- Git 日常开发闭环:克隆/初始化、状态与 diff、暂存、提交、Pull/Push、分支和提交记录 - 跨平台:Windows / macOS / Linux ## 环境依赖 @@ -95,7 +96,7 @@ ARM64 设备下载 `linux-arm64` 版本。 ## 文档 -详细文档说明见 [docs](./docs/)。 +详细文档说明见 [docs](./docs/),Git 使用说明见 [Git 同步](./docs/git.md)。 ## 更新日志 diff --git a/docs/git.md b/docs/git.md new file mode 100644 index 0000000..5225f11 --- /dev/null +++ b/docs/git.md @@ -0,0 +1,47 @@ +# Git 同步 + +apisender 通过电脑上已安装的 Git 管理当前工作区,复用现有 SSH Key、Credential Manager、用户名和邮箱配置。应用不会保存 Git 密码或访问令牌。 + +## 环境要求 + +- Git 2.28 或更高版本。 +- SSH 或 HTTPS 认证需要先在系统 Git 中配置完成。 +- 远端仓库由用户在 GitHub、Gitee、GitLab 等平台提前创建。 + +## 接入仓库 + +### 克隆仓库 + +在欢迎页或工作区菜单选择“从 Git 克隆”,填写远端地址,选择父目录并确认文件夹名称。目标目录必须不存在或为空;克隆成功后会自动打开为工作区。 + +### 初始化当前文件夹 + +普通工作区打开 Git 页签后,可填写空远端地址和默认分支进行初始化。远端不能包含任何 Git 引用。初始化只执行以下操作: + +1. 创建本地仓库。 +2. 添加 `origin`。 +3. 向 `.gitignore` 追加缺失的 `env.private.json`、`.apisender/` 和 `.DS_Store`。 + +初始化不会自动暂存、提交或推送,仍需在“更改”页核对文件。 + +如果打开的是大型仓库的子目录,Git 面板管理整个父级仓库,并明确显示真实仓库根目录。 + +## 日常操作 + +- “更改”按冲突、已暂存、未暂存和未跟踪分组。部分暂存文件会同时出现在已暂存和未暂存组。 +- 点击文件查看统一 diff;二进制文件仅显示元数据。文本文件可从 diff 窗口进入编辑器。 +- 提交只包含已暂存文件。若仓库没有提交身份,可设置仅对当前仓库生效的姓名和邮箱。 +- 首次 Push 会要求选择已配置远端和要推送的本地分支,然后设置 upstream;后续沿用 tracking branch。 +- Pull 固定使用 fast-forward only。本地与远端分叉时停止,不自动 merge、rebase 或 stash。 +- 分支菜单支持搜索、切换本地分支,以及从当前 HEAD 创建新分支。 +- 提交记录每次加载 50 条,支持继续分页查看提交文件与 diff。 + +提交、Pull、创建或切换分支前,如编辑器仍有未保存标签页,操作会被阻止。请先保存或放弃编辑器内的修改。 + +## 冲突和错误 + +Git 页会识别冲突文件,但首版不提供图形化冲突合并。可直接打开含冲突标记的文本文件,编辑保存后暂存并提交。 + +认证失败时,请在系统终端验证相同远端可正常访问。错误详情会移除 HTTPS URL 中的用户名、密码或令牌后再显示。 + +首版不包含远端建仓、凭据存储、stash、merge、rebase、cherry-pick、标签、submodule 或丢弃修改。 diff --git a/docs/superpowers/plans/2026-08-12-git-refresh-and-setup-layout.md b/docs/superpowers/plans/2026-08-12-git-refresh-and-setup-layout.md new file mode 100644 index 0000000..563e4ae --- /dev/null +++ b/docs/superpowers/plans/2026-08-12-git-refresh-and-setup-layout.md @@ -0,0 +1,392 @@ +# Git 刷新与初始化布局修复实施计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 阻断 Git 状态读取触发的文件监听刷新循环,并让无仓库初始化向导在窄侧栏和自定义字体下完整显示。 + +**Architecture:** Rust Git 命令执行器显式区分只读与写入访问;只读访问设置 `GIT_OPTIONAL_LOCKS=0`,写入访问保留 Git 默认锁语义。前端把初始化向导的占位文案和忽略规则定义为可测试数据,组件使用宽度约束和自动换行标签渲染。 + +**Tech Stack:** Rust 2024、系统 Git 2.28+、Tauri 2、React 19、TypeScript 5.8、Tailwind CSS 4、Vite 7。 + +## Global Constraints + +- 所有 Git 命令继续通过参数数组启动,禁止 shell 拼接。 +- 只读命令设置 `GIT_OPTIONAL_LOCKS=0`;写命令不得改变 Git 默认锁策略。 +- 不过滤全部 `.git` 事件,真实 Git 和工作区变化仍需自动刷新。 +- 不缩小或覆盖用户配置的全局 UI 字体。 +- 所有新增或修改的方法添加中文注释,说明入参、出参、作用和处理流程。 +- Commit message 必须使用中文。 +- 不默认执行桌面应用 UI 冒烟测试。 + +--- + +### Task 1: 只读 Git 命令禁用 optional locks + +**Files:** +- Modify: `src-tauri/src/git.rs` +- Test: `src-tauri/tests/git.rs` + +**Interfaces:** +- Consumes: 现有 `execute_git(cwd, args, allow_exit_one)`、`run_git`、`run_git_diff_output` 和公开 `status(workspace_root)`。 +- Produces: 私有枚举 `GitCommandAccess::{ReadOnly, Write}`、私有方法 `run_git_read(cwd, args, allow_exit_one)`;公开 Git 服务接口不变。 + +- [ ] **Step 1: 写入会失败的目录修改时间回归测试** + +在 `src-tauri/tests/git.rs` 的标准库 import 中加入: + +```rust +use std::thread; +use std::time::Duration; +``` + +在文件末尾加入: + +```rust +#[test] +/// 校验 Git 状态读取不会触碰管理目录。 +/// 入参/出参:无;断言读取状态前后的 `.git` 目录修改时间一致。 +/// 作用与流程:创建含提交的临时仓库,跨过低精度文件时间窗口后调用服务层 status,防止 optional lock 再次触发 watcher。 +fn status_read_does_not_touch_git_directory() { + let root = temp_dir("status-no-optional-lock"); + run_git(&root, &["init", "-b", "main"]); + set_identity(&root, "Test", "test@example.com").unwrap(); + fs::write(root.join("request.http"), "GET https://example.com\n").unwrap(); + stage(&root, &["request.http".to_string()]).unwrap(); + apisender_lib::git::commit(&root, "初始化测试仓库").unwrap(); + + let git_dir = root.join(".git"); + thread::sleep(Duration::from_millis(1_100)); + let before = fs::metadata(&git_dir).unwrap().modified().unwrap(); + status(&root).unwrap(); + let after = fs::metadata(&git_dir).unwrap().modified().unwrap(); + + assert_eq!(before, after, "只读状态查询不应创建 optional lock"); +} +``` + +- [ ] **Step 2: 运行测试确认旧实现失败** + +Run: + +```bash +cd src-tauri +cargo test --test git status_read_does_not_touch_git_directory -- --exact +``` + +Expected: FAIL,`before` 与 `after` 不相等,证明 `git status` 修改了 `.git` 目录时间。 + +- [ ] **Step 3: 在命令执行器中区分只读与写入访问** + +在 `src-tauri/src/git.rs` 的命令执行函数前加入: + +```rust +#[derive(Clone, Copy)] +enum GitCommandAccess { + ReadOnly, + Write, +} +``` + +把 `execute_git` 改为接收 `access: GitCommandAccess`,并在启动命令前按访问类型设置环境变量: + +```rust +/// 执行系统 Git 命令并验证退出状态。 +/// 入参:工作目录、参数列表、是否允许退出码 1 和命令访问类型。 +/// 出参:完整进程输出。 +/// 作用与流程:只读命令禁用 optional locks;所有命令均禁用终端提示并把失败转换为脱敏结构化错误。 +fn execute_git( + cwd: &Path, + args: &[OsString], + allow_exit_one: bool, + access: GitCommandAccess, +) -> GitResult { + ensure_git_available()?; + let mut command = Command::new("git"); + command + .current_dir(cwd) + .env("GIT_TERMINAL_PROMPT", "0") + .env("LC_ALL", "C") + .env("LANG", "C") + .args(args); + if matches!(access, GitCommandAccess::ReadOnly) { + command.env("GIT_OPTIONAL_LOCKS", "0"); + } + let output = command + .output() + .map_err(|cause| error(GitErrorCode::Io, "无法启动 Git", Some(cause.to_string())))?; + + if output.status.success() || (allow_exit_one && output.status.code() == Some(1)) { + return Ok(output); + } + + let stderr = redact_secrets(&String::from_utf8_lossy(&output.stderr)); + Err(error( + classify_command_error(&stderr), + "Git 操作失败", + Some(stderr.trim().to_string()), + )) +} +``` + +让现有普通写入路径显式使用 `Write`,并新增受 10 MiB 限制的只读路径: + +```rust +/// 执行受 10 MiB 限制的普通 Git 写命令。 +/// 入参:工作目录、参数列表和是否允许退出码 1。 +/// 出参:不超过普通命令上限的完整进程输出。 +/// 作用与流程:保留 Git 默认锁语义,复用统一错误分类后限制输出大小。 +fn run_git(cwd: &Path, args: &[OsString], allow_exit_one: bool) -> GitResult { + let output = execute_git(cwd, args, allow_exit_one, GitCommandAccess::Write)?; + validate_default_output_size(output) +} + +/// 执行受 10 MiB 限制的只读 Git 命令。 +/// 入参:工作目录、参数列表和是否允许退出码 1。 +/// 出参:不超过普通命令上限的完整进程输出。 +/// 作用与流程:禁用 optional locks 后执行查询,再复用普通输出大小限制。 +fn run_git_read(cwd: &Path, args: &[OsString], allow_exit_one: bool) -> GitResult { + let output = execute_git(cwd, args, allow_exit_one, GitCommandAccess::ReadOnly)?; + validate_default_output_size(output) +} + +/// 校验普通 Git 命令输出大小。 +/// 入参:Git 完整进程输出。 +/// 出参:未超过 10 MiB 时返回原输出,否则返回 output_too_large。 +/// 作用与流程:集中计算 stdout 与 stderr 总长度,供只读和写入命令复用。 +fn validate_default_output_size(output: Output) -> GitResult { + if output.stdout.len().saturating_add(output.stderr.len()) > DEFAULT_OUTPUT_LIMIT { + return Err(error( + GitErrorCode::OutputTooLarge, + "Git 命令输出超过 10 MiB 限制", + None, + )); + } + Ok(output) +} +``` + +把 `run_git_diff_output` 改为: + +```rust +fn run_git_diff_output(cwd: &Path, args: &[OsString], allow_exit_one: bool) -> GitResult { + execute_git(cwd, args, allow_exit_one, GitCommandAccess::ReadOnly) +} +``` + +- [ ] **Step 4: 将所有查询调用切换到只读路径** + +在 `probe` 直接执行 `git --version` 时同样设置 `.env("GIT_OPTIONAL_LOCKS", "0")`;该命令不经过 `execute_git`,但仍显式遵循只读命令约定。 + +在以下位置把 `run_git(...)` 替换为 `run_git_read(...)`,参数保持原样: + +- `resolve_repository_root` 的 `rev-parse --show-toplevel` +- `resolve_git_dir` 的 `rev-parse --absolute-git-dir` +- `status` 的 `status --porcelain=v2` 和 `remote` +- `unstage` 的 `rev-parse --verify HEAD` 探测 +- `get_identity` 的两次 `config --get` +- `show_commit` 的 metadata、files 和提交 diff 查询 +- `list_branches` 的 `for-each-ref` +- `validate_branch` 的 `check-ref-format` +- `push` 中读取远端名称的 `remote` +- `ensure_remote_empty` 的 `ls-remote` +- `connect_origin` 中读取远端名称的 `remote` + +保留以下写入调用为 `run_git(...)`:`add`、`restore/rm --cached`、`config user.*`、`commit`、`switch`、`pull`、`push`、`init`、`remote add` 和 `clone`。 + +- [ ] **Step 5: 运行聚焦测试确认通过** + +Run: + +```bash +cd src-tauri +cargo test --test git status_read_does_not_touch_git_directory -- --exact +cargo test --test git +``` + +Expected: 新回归测试 PASS,Git 集成测试 15 项全部通过。 + +- [ ] **Step 6: 提交后端修复** + +```bash +git add src-tauri/src/git.rs src-tauri/tests/git.rs +git diff --cached --check +git commit -m "修复 Git 状态刷新循环" +``` + +### Task 2: 初始化向导适配窄侧栏 + +**Files:** +- Modify: `src/lib/git-state.ts` +- Test: `src/lib/git-state.test.ts` +- Modify: `src/components/git/GitPanel.tsx` + +**Interfaces:** +- Consumes: `RepositorySetup` 组件、现有 `Input` 和 Tailwind 工具类。 +- Produces: `GIT_REMOTE_PLACEHOLDER: string`、`GIT_DEFAULT_IGNORE_RULES: readonly string[]`,由测试和初始化向导共同使用。 + +- [ ] **Step 1: 为展示数据写入失败测试** + +在 `src/lib/git-state.test.ts` import 中加入: + +```ts +GIT_DEFAULT_IGNORE_RULES, +GIT_REMOTE_PLACEHOLDER, +``` + +在提交校验测试之前加入: + +```ts +expectEqual( + GIT_REMOTE_PLACEHOLDER, + "请输入远端仓库地址", + "repository setup should use a short remote placeholder", +); +expectEqual( + GIT_DEFAULT_IGNORE_RULES.join(","), + "env.private.json,.apisender/,.DS_Store", + "repository setup should expose ignore rules as independent items", +); +``` + +- [ ] **Step 2: 运行前端测试确认缺少导出** + +Run: + +```bash +pnpm test:git +``` + +Expected: FAIL,Vite 报告 `GIT_REMOTE_PLACEHOLDER` 或 `GIT_DEFAULT_IGNORE_RULES` 未从 `git-state.ts` 导出。 + +- [ ] **Step 3: 添加可复用的初始化向导展示数据** + +在 `src/lib/git-state.ts` 的错误码集合后加入: + +```ts +/** Git 初始化向导使用的短远端地址占位文案,避免窄侧栏中长示例被裁切。 */ +export const GIT_REMOTE_PLACEHOLDER = "请输入远端仓库地址"; + +/** Git 初始化向导逐项展示的默认忽略规则,与后端 DEFAULT_IGNORE_RULES 保持一致。 */ +export const GIT_DEFAULT_IGNORE_RULES = ["env.private.json", ".apisender/", ".DS_Store"] as const; +``` + +- [ ] **Step 4: 让 RepositorySetup 在窄侧栏内约束宽度并换行** + +在 `src/components/git/GitPanel.tsx` 从 `@/lib/git-state` 导入两个新常量,并把 `RepositorySetup` 返回内容调整为: + +```tsx +
+
+

初始化 Git 仓库

+

+ 当前工作区尚未加入 Git。远端必须已创建且不包含任何 Git 引用。 +

+
+
+ + setRemoteUrl(event.target.value)} + placeholder={GIT_REMOTE_PLACEHOLDER} + /> +
+
+ + setBranch(event.target.value)} + /> +
+
+ 将补充忽略: +
+ {GIT_DEFAULT_IGNORE_RULES.map((rule) => ( + + {rule} + + ))} +
+
+ +
+``` + +- [ ] **Step 5: 运行前端测试和类型检查确认通过** + +Run: + +```bash +pnpm test:git +pnpm exec tsc --noEmit +``` + +Expected: Git/环境轻量测试全部 PASS,TypeScript 退出码为 0。 + +- [ ] **Step 6: 提交前端布局修复** + +```bash +git add src/lib/git-state.ts src/lib/git-state.test.ts src/components/git/GitPanel.tsx +git diff --cached --check +git commit -m "修复 Git 初始化向导文本溢出" +``` + +### Task 3: 完整验证与交付检查 + +**Files:** +- Verify: `src-tauri/src/git.rs` +- Verify: `src-tauri/tests/git.rs` +- Verify: `src/components/git/GitPanel.tsx` +- Verify: `src/lib/git-state.ts` +- Verify: `src/lib/git-state.test.ts` + +**Interfaces:** +- Consumes: Task 1 的只读 Git 命令路径和 Task 2 的响应式初始化向导。 +- Produces: 干净的 `codex/git-sync` 分支及两项中文修复提交。 + +- [ ] **Step 1: 检查目标 Rust 文件格式** + +```bash +cd src-tauri +rustfmt --edition 2024 --check src/git.rs tests/git.rs +``` + +Expected: 退出码为 0,不运行会格式化无关模块的 `cargo fmt --all`。 + +- [ ] **Step 2: 执行完整 Rust 测试** + +```bash +cd src-tauri +cargo test +``` + +Expected: 所有测试通过,0 failed。 + +- [ ] **Step 3: 执行前端测试、类型检查和生产构建** + +```bash +pnpm test:git +pnpm exec tsc --noEmit +pnpm build +``` + +Expected: 测试和类型检查退出码为 0;Vite 构建成功,仅允许既有的 500 kB chunk size 警告。 + +- [ ] **Step 4: 检查差异和工作区** + +```bash +git diff --check +git status --short +git log -4 --oneline +``` + +Expected: `git diff --check` 无输出,`git status --short` 无输出,最近提交包含两个中文修复提交。 + +- [ ] **Step 5: 报告验证边界** + +交付说明必须包含:刷新循环的根因与修复、初始化向导的响应式处理、自动化验证结果,以及“未执行桌面 UI 冒烟测试”的明确边界。 diff --git a/docs/superpowers/specs/2026-08-12-git-refresh-loop-design.md b/docs/superpowers/specs/2026-08-12-git-refresh-loop-design.md new file mode 100644 index 0000000..b881d2c --- /dev/null +++ b/docs/superpowers/specs/2026-08-12-git-refresh-loop-design.md @@ -0,0 +1,63 @@ +# Git 页面刷新与初始化布局修复设计 + +## 背景与目标 + +Git 页面读取状态时会执行 `git status`。系统 Git 默认可能为刷新索引创建并删除 `.git/index.lock`,文件监听器把这类内部变化发送为 `git-changed`,前端随后再次刷新 Git 状态,形成“读取状态 → Git 管理目录变化 → 再次读取状态”的循环。刷新按钮根据 `loading` 状态显示旋转动画,因此表现为持续闪烁。 + +在没有 Git 仓库的工作区中,初始化向导还存在窄侧栏适配问题:长远端地址占位文字接近或超出输入框可视范围,三个默认忽略项拼成一行后会被容器裁切。该问题在较大的自定义 UI 字体下更明显。 + +本次修复包含两个目标: + +- 阻止只读 Git 操作产生可选锁,同时保留真实工作区文件变化和外部 Git 操作触发的自动刷新。 +- 让初始化向导在窄侧栏和自定义字体下保持内容完整,不依赖缩小字体或横向滚动。 + +## 方案 + +后端 Git 服务把命令分为两类: + +- 只读命令:设置 `GIT_OPTIONAL_LOCKS=0` 后启动系统 Git。状态、分支、提交、diff、身份读取和仓库探测等查询使用该路径。 +- 写入命令:保持 Git 默认锁行为。暂存、提交、推拉、分支切换、初始化、连接远端和克隆继续使用正常命令路径。 + +文件监听器和前端 `git-changed` 防抖逻辑保持不变。这样真实的 `.git/HEAD`、index、refs 或工作区文件变化仍会刷新 Git 页面,而页面自己的查询不会再次触发监听。 + +初始化向导采用响应式内容约束: + +- 表单区域、输入框和提示区域使用 `min-w-0`、`max-w-full` 与溢出约束,始终限制在侧栏内。 +- 远端地址输入框使用简短占位文字“请输入远端仓库地址”,实际 URL 仍由用户自由输入。 +- `env.private.json`、`.apisender/` 和 `.DS_Store` 作为三个独立标签渲染,通过 `flex-wrap` 自动换行。 +- 保留全局 UI 字体设置,不在 Git 面板内单独缩小用户字体。 + +## 数据流 + +1. 工作区或 Git 管理目录发生真实变化。 +2. Rust 文件监听器发送 `git-changed`。 +3. 前端防抖后调用 Git store 的完整刷新。 +4. 后端通过禁用 optional locks 的只读命令读取状态。 +5. 查询不写入 `.git`,监听器不会因本次刷新再次发出事件。 +6. Git store 结束 `loading`,刷新图标停止旋转。 + +初始化向导的数据流不变:用户输入远端地址和默认分支,前端提交原始值给现有 Git store;默认忽略项仅改变展示结构,后端实际追加规则保持不变。 + +## 错误与兼容性 + +- 不过滤全部 `.git` 事件,避免遗漏用户在终端或其他工具执行的提交、切分支和暂存操作。 +- 不改变写命令的锁策略,继续依赖 Git 原生互斥与完整性保护。 +- Git 2.28 及以上支持 `GIT_OPTIONAL_LOCKS`;Windows、macOS 和 Linux 使用同一环境变量。 +- 现有结构化错误、输出大小限制和凭据脱敏行为不变。 +- 初始化向导在窄侧栏时自动换行;不会截断规则名称,也不会引入横向滚动条。 + +## 测试与验收 + +- 新增 Rust 临时仓库集成测试:调用状态读取前后,`.git` 目录修改时间保持不变。 +- 测试先在旧实现上失败,再在只读命令禁用 optional locks 后通过。 +- 前端轻量测试验证远端地址使用短占位文案,三个忽略规则以独立数据项提供给自动换行布局。 +- 重新执行 `cargo test --test git`、完整 `cargo test`、前端 Git 测试、TypeScript 检查、生产构建和 `git diff --check`。 +- 不默认启动桌面应用做 UI 冒烟测试;本地自动化验证不会宣称真实桌面交互已经回放。 + +## 非目标 + +- 不关闭 Git 文件监听。 +- 不隐藏或取消刷新按钮的加载动画。 +- 不通过延长防抖时间掩盖后台循环。 +- 不调整用户的全局 UI 字体。 +- 不改动初始化向导以外的 Git 页面交互与布局。 diff --git a/package.json b/package.json index 5b87f6c..65e0c5c 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "dev": "vite", "build": "tsc && vite build", "preview": "vite preview", + "test:git": "node scripts/run-git-test.mjs", "tauri": "tauri" }, "dependencies": { diff --git a/scripts/run-git-test.mjs b/scripts/run-git-test.mjs new file mode 100644 index 0000000..61b0270 --- /dev/null +++ b/scripts/run-git-test.mjs @@ -0,0 +1,37 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { build } from "vite"; + +const outDir = await mkdtemp(path.join(tmpdir(), "apisender-git-test-")); +const entryName = "git-state-test.mjs"; + +try { + await build({ + configFile: false, + root: process.cwd(), + logLevel: "error", + resolve: { + alias: { + "@": path.resolve(process.cwd(), "src"), + }, + }, + build: { + emptyOutDir: true, + outDir, + ssr: "src/lib/git-test-suite.ts", + rollupOptions: { + output: { + entryFileNames: entryName, + }, + }, + }, + ssr: { + noExternal: true, + }, + }); + await import(pathToFileURL(path.join(outDir, entryName)).href); +} finally { + await rm(outDir, { force: true, recursive: true }); +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 64cc05f..fadec86 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -3,9 +3,10 @@ pub mod config; pub mod curl; pub mod env; pub mod font; +pub mod git; pub mod grpc; pub mod history; pub mod http; pub mod sse; pub mod websocket; -pub mod workspace; \ No newline at end of file +pub mod workspace; diff --git a/src-tauri/src/commands/git.rs b/src-tauri/src/commands/git.rs new file mode 100644 index 0000000..50db93e --- /dev/null +++ b/src-tauri/src/commands/git.rs @@ -0,0 +1,342 @@ +use std::path::PathBuf; + +use tauri::{AppHandle, State}; + +use crate::git::{ + self, GitAvailability, GitBranch, GitCommit, GitCommitDetail, GitDiff, GitErrorCode, + GitErrorPayload, GitIdentity, GitOperationState, GitRepositoryState, GitResult, +}; +use crate::workspace; + +/// 构造命令层错误。 +/// 入参:错误码、用户消息和可选详情。 +/// 出参:可通过 Tauri 返回的 Git 错误载荷。 +/// 作用与流程:供命令层补充“无工作区”和异步任务失败等服务层之外的错误。 +fn command_error( + code: GitErrorCode, + message: impl Into, + details: Option, +) -> GitErrorPayload { + GitErrorPayload { + code, + message: message.into(), + details: details.map(|value| git::redact_secrets(&value)), + } +} + +/// 获取当前 apisender 工作区路径。 +/// 入参:Tauri AppHandle。 +/// 出参:当前工作区目录。 +/// 作用与流程:从 WorkspaceState 读取路径;未打开工作区时返回 not_repository。 +fn current_workspace(app: &AppHandle) -> GitResult { + workspace::get_workspace_root(app) + .map(PathBuf::from) + .ok_or_else(|| command_error(GitErrorCode::NotRepository, "请先打开一个工作区", None)) +} + +/// 在线程池中执行 Git 读取操作。 +/// 入参:可在线程池执行并返回 GitResult 的闭包。 +/// 出参:闭包结果或异步任务错误。 +/// 作用与流程:把可能访问磁盘的 Git 命令移出 Tauri 异步调度线程。 +async fn run_read(operation: F) -> GitResult +where + T: Send + 'static, + F: FnOnce() -> GitResult + Send + 'static, +{ + tokio::task::spawn_blocking(operation) + .await + .map_err(|cause| { + command_error( + GitErrorCode::CommandFailed, + "Git 后台任务异常结束", + Some(cause.to_string()), + ) + })? +} + +/// 在线程池中执行互斥 Git 写操作。 +/// 入参:全局写操作状态和实际操作闭包。 +/// 出参:闭包结果、占用错误或异步任务错误。 +/// 作用与流程:先取得原子 guard,再在阻塞线程中持有 guard 直到写操作结束。 +async fn run_write(state: &GitOperationState, operation: F) -> GitResult +where + T: Send + 'static, + F: FnOnce() -> GitResult + Send + 'static, +{ + let guard = state.try_begin()?; + run_read(move || { + let _guard = guard; + operation() + }) + .await +} + +/// 探测系统 Git。 +/// 入参:无。 +/// 出参:安装和版本可用性。 +/// 作用与流程:调用 Git 服务探测,不要求当前存在工作区。 +#[tauri::command] +pub async fn git_probe() -> GitAvailability { + git::probe() +} + +/// 读取当前工作区的 Git 状态。 +/// 入参:Tauri AppHandle。 +/// 出参:真实仓库根目录、分支和文件状态。 +/// 作用与流程:获取工作区后在线程池定位父级仓库并解析 porcelain v2。 +#[tauri::command] +pub async fn git_status(app: AppHandle) -> GitResult { + let root = current_workspace(&app)?; + run_read(move || git::status(&root)).await +} + +/// 读取文件差异。 +/// 入参:工作区、仓库相对路径、暂存标记和可选提交 SHA。 +/// 出参:最多 1 MiB 的差异载荷。 +/// 作用与流程:把参数交给 Git 服务选择工作区、index 或提交差异。 +#[tauri::command] +pub async fn git_diff( + app: AppHandle, + path: String, + staged: bool, + commit_sha: Option, +) -> GitResult { + let root = current_workspace(&app)?; + run_read(move || git::diff(&root, &path, staged, commit_sha.as_deref())).await +} + +/// 列出当前仓库本地与远端分支。 +/// 入参:Tauri AppHandle。 +/// 出参:分支列表。 +/// 作用与流程:在线程池调用服务层稳定分支解析。 +#[tauri::command] +pub async fn git_list_branches(app: AppHandle) -> GitResult> { + let root = current_workspace(&app)?; + run_read(move || git::list_branches(&root)).await +} + +/// 分页读取当前分支提交。 +/// 入参:Tauri AppHandle、偏移和每页数量。 +/// 出参:最多 100 条提交摘要。 +/// 作用与流程:把分页参数传给服务层 git log 读取。 +#[tauri::command] +pub async fn git_list_commits( + app: AppHandle, + skip: usize, + limit: usize, +) -> GitResult> { + let root = current_workspace(&app)?; + run_read(move || git::list_commits(&root, skip, limit)).await +} + +/// 读取指定提交详情。 +/// 入参:Tauri AppHandle 和提交 SHA。 +/// 出参:提交摘要、文件列表和差异。 +/// 作用与流程:在线程池组合提交元数据与受限差异输出。 +#[tauri::command] +pub async fn git_show_commit(app: AppHandle, sha: String) -> GitResult { + let root = current_workspace(&app)?; + run_read(move || git::show_commit(&root, &sha)).await +} + +/// 读取当前仓库提交身份。 +/// 入参:Tauri AppHandle。 +/// 出参:Git 用户名和邮箱。 +/// 作用与流程:读取仓库及其继承的 Git 配置,不修改全局设置。 +#[tauri::command] +pub async fn git_get_identity(app: AppHandle) -> GitResult { + let root = current_workspace(&app)?; + run_read(move || git::get_identity(&root)).await +} + +/// 暂存选定文件。 +/// 入参:AppHandle、写操作状态和仓库相对路径列表。 +/// 出参:成功时为空。 +/// 作用与流程:取得写锁后校验并执行 git add。 +#[tauri::command] +pub async fn git_stage( + app: AppHandle, + state: State<'_, GitOperationState>, + paths: Vec, +) -> GitResult<()> { + let root = current_workspace(&app)?; + run_write(&state, move || git::stage(&root, &paths)).await +} + +/// 取消暂存选定文件。 +/// 入参:AppHandle、写操作状态和仓库相对路径列表。 +/// 出参:成功时为空。 +/// 作用与流程:取得写锁后根据仓库是否有 HEAD 选择 restore 或 rm --cached。 +#[tauri::command] +pub async fn git_unstage( + app: AppHandle, + state: State<'_, GitOperationState>, + paths: Vec, +) -> GitResult<()> { + let root = current_workspace(&app)?; + run_write(&state, move || git::unstage(&root, &paths)).await +} + +/// 提交已暂存改动。 +/// 入参:AppHandle、写操作状态和提交说明。 +/// 出参:新提交摘要。 +/// 作用与流程:取得写锁后校验身份和说明,创建提交并读取最新日志。 +#[tauri::command] +pub async fn git_commit( + app: AppHandle, + state: State<'_, GitOperationState>, + message: String, +) -> GitResult { + let root = current_workspace(&app)?; + run_write(&state, move || git::commit(&root, &message)).await +} + +/// 设置当前仓库 Git 身份。 +/// 入参:AppHandle、写操作状态、姓名和邮箱。 +/// 出参:写入后的身份。 +/// 作用与流程:取得写锁并仅修改当前仓库 config。 +#[tauri::command] +pub async fn git_set_identity( + app: AppHandle, + state: State<'_, GitOperationState>, + name: String, + email: String, +) -> GitResult { + let root = current_workspace(&app)?; + run_write(&state, move || git::set_identity(&root, &name, &email)).await +} + +/// 仅快进拉取当前 tracking branch。 +/// 入参:AppHandle 和写操作状态。 +/// 出参:更新后的仓库状态。 +/// 作用与流程:取得写锁执行 pull --ff-only,完成后重新读取状态。 +#[tauri::command] +pub async fn git_pull( + app: AppHandle, + state: State<'_, GitOperationState>, +) -> GitResult { + let root = current_workspace(&app)?; + run_write(&state, move || { + git::pull(&root)?; + git::status(&root) + }) + .await +} + +/// 推送当前分支。 +/// 入参:AppHandle、写状态及首次推送可选远端和分支。 +/// 出参:更新后的仓库状态。 +/// 作用与流程:首次推送设置 upstream,其余使用 tracking branch,完成后刷新状态。 +#[tauri::command] +pub async fn git_push( + app: AppHandle, + state: State<'_, GitOperationState>, + remote: Option, + branch: Option, +) -> GitResult { + let root = current_workspace(&app)?; + run_write(&state, move || { + git::push(&root, remote.as_deref(), branch.as_deref())?; + git::status(&root) + }) + .await +} + +/// 创建并切换到新分支。 +/// 入参:AppHandle、写状态和新分支名。 +/// 出参:更新后的仓库状态。 +/// 作用与流程:取得写锁后让 Git 校验、创建并切换分支,再刷新状态。 +#[tauri::command] +pub async fn git_create_branch( + app: AppHandle, + state: State<'_, GitOperationState>, + name: String, +) -> GitResult { + let root = current_workspace(&app)?; + run_write(&state, move || { + git::create_branch(&root, &name)?; + git::status(&root) + }) + .await +} + +/// 切换到已有本地分支。 +/// 入参:AppHandle、写状态和分支名。 +/// 出参:更新后的仓库状态。 +/// 作用与流程:取得写锁后按 Git 原生规则切换分支,再刷新状态。 +#[tauri::command] +pub async fn git_switch_branch( + app: AppHandle, + state: State<'_, GitOperationState>, + name: String, +) -> GitResult { + let root = current_workspace(&app)?; + run_write(&state, move || { + git::switch_branch(&root, &name)?; + git::status(&root) + }) + .await +} + +/// 初始化当前普通工作区并连接空远端。 +/// 入参:AppHandle、写状态、远端 URL 和默认分支。 +/// 出参:初始化后的仓库状态。 +/// 作用与流程:取得写锁后验证空远端、初始化、补忽略规则并添加 origin。 +#[tauri::command] +pub async fn git_init_workspace( + app: AppHandle, + state: State<'_, GitOperationState>, + remote_url: String, + default_branch: String, +) -> GitResult { + let root = current_workspace(&app)?; + run_write(&state, move || { + git::init_workspace(&root, &remote_url, &default_branch) + }) + .await +} + +/// 为已有本地仓库连接 origin。 +/// 入参:AppHandle、写状态和远端 URL。 +/// 出参:更新后的仓库状态。 +/// 作用与流程:取得写锁后拒绝覆盖已有 origin,添加远端并刷新状态。 +#[tauri::command] +pub async fn git_connect_origin( + app: AppHandle, + state: State<'_, GitOperationState>, + remote_url: String, +) -> GitResult { + let root = current_workspace(&app)?; + run_write(&state, move || { + git::connect_origin(&root, &remote_url)?; + git::status(&root) + }) + .await +} + +/// 克隆远端仓库并打开为 apisender 工作区。 +/// 入参:AppHandle、写状态、父目录、目标文件夹名和远端 URL。 +/// 出参:新工作区绝对路径。 +/// 作用与流程:互斥执行克隆,成功后启动现有工作区 watcher 并保存最近工作区。 +#[tauri::command] +pub async fn git_clone_workspace( + app: AppHandle, + state: State<'_, GitOperationState>, + parent: String, + folder_name: String, + remote_url: String, +) -> GitResult { + let path = run_write(&state, move || { + git::clone_repository(&PathBuf::from(parent), &folder_name, &remote_url) + }) + .await?; + let path_text = path.to_string_lossy().into_owned(); + workspace::open_workspace(&app, &path_text).map_err(|cause| { + command_error( + GitErrorCode::Io, + "仓库已克隆,但无法自动打开工作区", + Some(cause.to_string()), + ) + })?; + Ok(path_text) +} diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs new file mode 100644 index 0000000..2120ccf --- /dev/null +++ b/src-tauri/src/git.rs @@ -0,0 +1,1384 @@ +use std::ffi::OsString; +use std::fs; +use std::path::{Component, Path, PathBuf}; +use std::process::{Command, Output}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +use serde::{Deserialize, Serialize}; + +const MINIMUM_GIT_VERSION: (u32, u32) = (2, 28); +const DIFF_OUTPUT_LIMIT: usize = 1024 * 1024; +const DEFAULT_OUTPUT_LIMIT: usize = 10 * 1024 * 1024; +const DEFAULT_IGNORE_RULES: [&str; 3] = ["env.private.json", ".apisender/", ".DS_Store"]; + +pub type GitResult = Result; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum GitErrorCode { + GitNotInstalled, + GitVersionTooOld, + NotRepository, + TargetNotEmpty, + RemoteMissing, + RemoteNotEmpty, + RemoteAlreadyExists, + UpstreamMissing, + IdentityMissing, + AuthenticationFailed, + NonFastForward, + Conflict, + OperationBusy, + OutputTooLarge, + InvalidPath, + InvalidBranch, + CommandFailed, + Io, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GitErrorPayload { + pub code: GitErrorCode, + pub message: String, + pub details: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GitAvailability { + pub available: bool, + pub supported: bool, + pub version: Option, + pub executable: Option, + pub minimum_version: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GitRepositoryState { + pub workspace_root: String, + pub repository_root: String, + pub branch: Option, + pub detached: bool, + pub upstream: Option, + pub ahead: u32, + pub behind: u32, + pub remotes: Vec, + pub files: Vec, + pub has_conflicts: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GitFileStatus { + pub path: String, + pub original_path: Option, + pub index_status: Option, + pub worktree_status: Option, + pub conflict: bool, + pub untracked: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GitBranch { + pub name: String, + pub current: bool, + pub remote: bool, + pub upstream: Option, + pub ahead: u32, + pub behind: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GitCommit { + pub sha: String, + pub short_sha: String, + pub author_name: String, + pub author_email: String, + pub authored_at: String, + pub subject: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GitCommitDetail { + pub commit: GitCommit, + pub files: Vec, + pub diff: GitDiff, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GitDiff { + pub content: String, + pub binary: bool, + pub truncated: bool, + pub output_too_large: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GitIdentity { + pub name: Option, + pub email: Option, +} + +#[derive(Clone, Default)] +pub struct GitOperationState { + busy: Arc, +} + +#[derive(Debug)] +pub struct GitOperationGuard { + busy: Arc, +} + +impl GitOperationState { + /// 尝试取得 Git 写操作互斥权。 + /// 入参:无。 + /// 出参:成功时返回负责释放状态的 guard,占用时返回 operation_busy。 + /// 作用与流程:通过原子 compare_exchange 保证提交、推拉和切分支不会并发执行。 + pub fn try_begin(&self) -> GitResult { + self.busy + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .map_err(|_| { + error( + GitErrorCode::OperationBusy, + "另一个 Git 写操作正在进行,请稍后再试", + None, + ) + })?; + Ok(GitOperationGuard { + busy: Arc::clone(&self.busy), + }) + } +} + +impl Drop for GitOperationGuard { + /// 释放 Git 写操作互斥权。 + /// 入参:当前 guard 的可变引用。 + /// 出参:无。 + /// 作用与流程:guard 离开作用域时清空 busy 标记,让下一个写操作继续。 + fn drop(&mut self) { + self.busy.store(false, Ordering::Release); + } +} + +/// 创建结构化 Git 错误。 +/// 入参:错误码、用户可读消息和可选详情。 +/// 出参:可由 Tauri 序列化的错误载荷。 +/// 作用与流程:统一构造错误对象并对详情执行凭据脱敏。 +fn error( + code: GitErrorCode, + message: impl Into, + details: Option, +) -> GitErrorPayload { + GitErrorPayload { + code, + message: message.into(), + details: details.map(|value| redact_secrets(&value)), + } +} + +/// 对命令输出中的 URL 凭据做脱敏。 +/// 入参:可能包含 HTTP 或 HTTPS 凭据的文本。 +/// 出参:用户名、密码或令牌被替换后的文本。 +/// 作用与流程:扫描 URL authority 和常见令牌查询参数,以 *** 代替 userinfo 或令牌值。 +pub fn redact_secrets(input: &str) -> String { + let mut result = input.to_string(); + for scheme in ["https://", "http://"] { + let mut search_from = 0; + loop { + let Some(relative_start) = result[search_from..].find(scheme) else { + break; + }; + let authority_start = search_from + relative_start + scheme.len(); + let authority_end = result[authority_start..] + .find(['/', ' ', '\n', '\r', '\t']) + .map(|offset| authority_start + offset) + .unwrap_or(result.len()); + let authority = &result[authority_start..authority_end]; + if let Some(at_offset) = authority.rfind('@') { + let credentials_end = authority_start + at_offset + 1; + result.replace_range(authority_start..credentials_end, "***@"); + search_from = authority_start + 4; + } else { + search_from = authority_end; + } + } + } + + for key in [ + "access_token=", + "private_token=", + "oauth_token=", + "auth_token=", + "api_key=", + "apikey=", + "token=", + ] { + let mut search_from = 0; + loop { + let lower = result.to_ascii_lowercase(); + let Some(relative_start) = lower[search_from..].find(key) else { + break; + }; + let key_start = search_from + relative_start; + let valid_separator = + key_start > 0 && matches!(result.as_bytes()[key_start - 1], b'?' | b'&'); + if !valid_separator { + search_from = key_start + key.len(); + continue; + } + let value_start = key_start + key.len(); + let value_end = result[value_start..] + .find(['&', '#', ' ', '\n', '\r', '\t', '\'', '"']) + .map(|offset| value_start + offset) + .unwrap_or(result.len()); + result.replace_range(value_start..value_end, "***"); + search_from = value_start + 3; + } + } + result +} + +/// 解析 Git 版本号。 +/// 入参:`git --version` 的标准输出。 +/// 出参:主版本、次版本和完整版本字符串。 +/// 作用与流程:提取第一个数字版本片段,并兼容 Apple Git 等带后缀格式。 +fn parse_version(text: &str) -> Option<(u32, u32, String)> { + let version = text.split_whitespace().find(|part| { + part.chars() + .next() + .is_some_and(|value| value.is_ascii_digit()) + })?; + let mut parts = version.split('.'); + let major = parts.next()?.parse().ok()?; + let minor = parts.next()?.parse().ok()?; + Some((major, minor, version.to_string())) +} + +/// 探测系统 Git 是否可用且满足最低版本。 +/// 入参:无。 +/// 出参:Git 可用性、版本、可执行文件和最低版本信息。 +/// 作用与流程:以只读锁策略执行 `git --version`,区分未安装、版本过低和可用三种状态。 +pub fn probe() -> GitAvailability { + let minimum_version = format!("{}.{}", MINIMUM_GIT_VERSION.0, MINIMUM_GIT_VERSION.1); + match Command::new("git") + .arg("--version") + .env("GIT_OPTIONAL_LOCKS", "0") + .output() + { + Ok(output) if output.status.success() => { + let text = String::from_utf8_lossy(&output.stdout); + let parsed = parse_version(&text); + let supported = parsed + .as_ref() + .is_some_and(|(major, minor, _)| (*major, *minor) >= MINIMUM_GIT_VERSION); + GitAvailability { + available: true, + supported, + version: parsed.map(|(_, _, version)| version), + executable: Some("git".to_string()), + minimum_version, + } + } + _ => GitAvailability { + available: false, + supported: false, + version: None, + executable: None, + minimum_version, + }, + } +} + +/// 校验 Git 可用性。 +/// 入参:无。 +/// 出参:可用时返回空值,否则返回结构化版本或安装错误。 +/// 作用与流程:复用探测结果,在每次仓库操作前建立统一的运行环境边界。 +fn ensure_git_available() -> GitResult<()> { + let availability = probe(); + if !availability.available { + return Err(error( + GitErrorCode::GitNotInstalled, + "未检测到系统 Git,请先安装 Git", + None, + )); + } + if !availability.supported { + return Err(error( + GitErrorCode::GitVersionTooOld, + format!( + "Git 版本过低,需要 {} 或更高版本", + availability.minimum_version + ), + availability.version, + )); + } + Ok(()) +} + +/// 根据 stderr 分类 Git 命令错误。 +/// 入参:Git stderr 文本。 +/// 出参:稳定错误码。 +/// 作用与流程:按身份、认证、upstream、非快进、冲突等已知特征映射错误,其余归入命令失败。 +fn classify_command_error(stderr: &str) -> GitErrorCode { + let lower = stderr.to_ascii_lowercase(); + if lower.contains("please tell me who you are") + || lower.contains("unable to auto-detect email address") + { + GitErrorCode::IdentityMissing + } else if lower.contains("authentication failed") + || lower.contains("permission denied") + || lower.contains("could not read username") + || lower.contains("repository not found") + { + GitErrorCode::AuthenticationFailed + } else if lower.contains("no tracking information") + || lower.contains("has no upstream branch") + || lower.contains("no configured push destination") + { + GitErrorCode::UpstreamMissing + } else if lower.contains("does not appear to be a git repository") + || lower.contains("no such remote") + || (lower.contains("repository") && lower.contains("does not exist")) + { + GitErrorCode::RemoteMissing + } else if lower.contains("not possible to fast-forward") + || lower.contains("non-fast-forward") + || lower.contains("fetch first") + { + GitErrorCode::NonFastForward + } else if lower.contains("would be overwritten") + || lower.contains("you need to resolve your current index first") + || lower.contains("conflict") + { + GitErrorCode::Conflict + } else if lower.contains("not a git repository") { + GitErrorCode::NotRepository + } else { + GitErrorCode::CommandFailed + } +} + +/// 区分 Git 命令是否允许创建索引等可选锁文件。 +#[derive(Clone, Copy)] +enum GitCommandAccess { + ReadOnly, + Write, +} + +/// 执行系统 Git 命令并验证退出状态。 +/// 入参:工作目录、参数列表、是否允许退出码 1 和命令访问类型。 +/// 出参:完整进程输出。 +/// 作用与流程:只读命令禁用 optional locks;所有命令均禁用终端提示并把失败转换为脱敏结构化错误。 +fn execute_git( + cwd: &Path, + args: &[OsString], + allow_exit_one: bool, + access: GitCommandAccess, +) -> GitResult { + ensure_git_available()?; + let mut command = Command::new("git"); + command + .current_dir(cwd) + .env("GIT_TERMINAL_PROMPT", "0") + .env("LC_ALL", "C") + .env("LANG", "C") + .args(args); + if matches!(access, GitCommandAccess::ReadOnly) { + command.env("GIT_OPTIONAL_LOCKS", "0"); + } + let output = command + .output() + .map_err(|cause| error(GitErrorCode::Io, "无法启动 Git", Some(cause.to_string())))?; + + if output.status.success() || (allow_exit_one && output.status.code() == Some(1)) { + return Ok(output); + } + + let stderr = redact_secrets(&String::from_utf8_lossy(&output.stderr)); + Err(error( + classify_command_error(&stderr), + "Git 操作失败", + Some(stderr.trim().to_string()), + )) +} + +/// 执行受 10 MiB 限制的普通 Git 写命令。 +/// 入参:工作目录、参数列表和是否允许退出码 1。 +/// 出参:不超过普通命令上限的完整进程输出。 +/// 作用与流程:保留 Git 默认锁语义,复用统一错误分类后限制输出大小。 +fn run_git(cwd: &Path, args: &[OsString], allow_exit_one: bool) -> GitResult { + let output = execute_git(cwd, args, allow_exit_one, GitCommandAccess::Write)?; + validate_default_output_size(output) +} + +/// 执行受 10 MiB 限制的只读 Git 命令。 +/// 入参:工作目录、参数列表和是否允许退出码 1。 +/// 出参:不超过普通命令上限的完整进程输出。 +/// 作用与流程:禁用 optional locks 后执行查询,再复用普通输出大小限制。 +fn run_git_read(cwd: &Path, args: &[OsString], allow_exit_one: bool) -> GitResult { + let output = execute_git(cwd, args, allow_exit_one, GitCommandAccess::ReadOnly)?; + validate_default_output_size(output) +} + +/// 校验普通 Git 命令输出大小。 +/// 入参:Git 完整进程输出。 +/// 出参:未超过 10 MiB 时返回原输出,否则返回 output_too_large。 +/// 作用与流程:集中计算 stdout 与 stderr 总长度,供只读和写入命令复用。 +fn validate_default_output_size(output: Output) -> GitResult { + if output.stdout.len().saturating_add(output.stderr.len()) > DEFAULT_OUTPUT_LIMIT { + return Err(error( + GitErrorCode::OutputTooLarge, + "Git 命令输出超过 10 MiB 限制", + None, + )); + } + Ok(output) +} + +/// 执行由差异载荷自行截断的 Git 命令。 +/// 入参:工作目录、diff/show 参数和是否允许退出码 1。 +/// 出参:用于计算 truncated 与 outputTooLarge 状态的原始输出。 +/// 作用与流程:禁用 optional locks 并复用退出状态和脱敏错误处理,调用方必须在返回 IPC 前限制为 1 MiB。 +fn run_git_diff_output(cwd: &Path, args: &[OsString], allow_exit_one: bool) -> GitResult { + execute_git(cwd, args, allow_exit_one, GitCommandAccess::ReadOnly) +} + +/// 把字符串切片转换为 Git 参数列表。 +/// 入参:字符串参数切片。 +/// 出参:可安全传递给 Command 的 OsString 列表。 +/// 作用与流程:逐项转换参数,保证调用方不经过 shell 拼接。 +fn args(values: &[&str]) -> Vec { + values.iter().map(OsString::from).collect() +} + +/// 解析当前工作区对应的 Git 仓库根目录。 +/// 入参:当前 apisender 工作区路径。 +/// 出参:规范化后的父级或当前 Git 仓库根目录。 +/// 作用与流程:调用 `rev-parse --show-toplevel`,从而支持打开仓库子目录的场景。 +pub fn resolve_repository_root(workspace_root: &Path) -> GitResult { + let output = run_git_read( + workspace_root, + &args(&["rev-parse", "--show-toplevel"]), + false, + )?; + let raw = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if raw.is_empty() { + return Err(error( + GitErrorCode::NotRepository, + "当前工作区不在 Git 仓库中", + None, + )); + } + fs::canonicalize(raw).map_err(|cause| { + error( + GitErrorCode::Io, + "无法解析仓库根目录", + Some(cause.to_string()), + ) + }) +} + +/// 解析当前工作区对应的 Git 管理目录。 +/// 入参:当前 apisender 工作区路径。 +/// 出参:规范化后的实际 Git 管理目录。 +/// 作用与流程:调用 `rev-parse --absolute-git-dir`,兼容普通仓库和 linked worktree 的外部管理目录。 +pub fn resolve_git_dir(workspace_root: &Path) -> GitResult { + let output = run_git_read( + workspace_root, + &args(&["rev-parse", "--absolute-git-dir"]), + false, + )?; + let raw = String::from_utf8_lossy(&output.stdout).trim().to_string(); + fs::canonicalize(raw).map_err(|cause| { + error( + GitErrorCode::Io, + "无法解析 Git 管理目录", + Some(cause.to_string()), + ) + }) +} + +/// 将状态字符转换为可选字符串。 +/// 入参:Git XY 状态中的单个字符。 +/// 出参:点号和空格返回空值,其余返回字符文本。 +/// 作用与流程:统一 index 与 worktree 状态的空值表达。 +fn status_code(value: char) -> Option { + (!matches!(value, '.' | ' ')).then(|| value.to_string()) +} + +/// 解析 porcelain v2 的 XY 状态。 +/// 入参:两个字符的 XY 状态文本。 +/// 出参:index 状态和 worktree 状态。 +/// 作用与流程:分别读取前两个字符并转换为空值或状态码。 +fn parse_xy(value: &str) -> (Option, Option) { + let mut chars = value.chars(); + ( + chars.next().and_then(status_code), + chars.next().and_then(status_code), + ) +} + +/// 解析 Git porcelain v2 NUL 分隔状态。 +/// 入参:工作区根目录、仓库根目录和 Git 原始字节输出。 +/// 出参:包含分支、ahead/behind 和文件状态的仓库快照。 +/// 作用与流程:逐条读取 header、普通文件、重命名、未跟踪和冲突记录,并保留空格路径。 +pub fn parse_porcelain_v2( + workspace_root: &str, + repository_root: &str, + raw: &[u8], +) -> GitResult { + let records: Vec<&[u8]> = raw.split(|byte| *byte == 0).collect(); + let mut branch = None; + let mut upstream = None; + let mut ahead = 0; + let mut behind = 0; + let mut files = Vec::new(); + let mut index = 0; + + while index < records.len() { + let record = String::from_utf8_lossy(records[index]); + if record.is_empty() { + index += 1; + continue; + } + if let Some(value) = record.strip_prefix("# branch.head ") { + if value != "(detached)" { + branch = Some(value.to_string()); + } + } else if let Some(value) = record.strip_prefix("# branch.upstream ") { + upstream = Some(value.to_string()); + } else if let Some(value) = record.strip_prefix("# branch.ab ") { + for item in value.split_whitespace() { + if let Some(number) = item.strip_prefix('+') { + ahead = number.parse().unwrap_or_default(); + } else if let Some(number) = item.strip_prefix('-') { + behind = number.parse().unwrap_or_default(); + } + } + } else if record.starts_with("1 ") { + let fields: Vec<&str> = record.splitn(9, ' ').collect(); + if fields.len() == 9 { + let (index_status, worktree_status) = parse_xy(fields[1]); + files.push(GitFileStatus { + path: fields[8].to_string(), + original_path: None, + index_status, + worktree_status, + conflict: false, + untracked: false, + }); + } + } else if record.starts_with("2 ") { + let fields: Vec<&str> = record.splitn(10, ' ').collect(); + if fields.len() == 10 { + let (index_status, worktree_status) = parse_xy(fields[1]); + let original_path = records + .get(index + 1) + .map(|value| String::from_utf8_lossy(value).into_owned()); + files.push(GitFileStatus { + path: fields[9].to_string(), + original_path, + index_status, + worktree_status, + conflict: false, + untracked: false, + }); + index += 1; + } + } else if record.starts_with("u ") { + let fields: Vec<&str> = record.splitn(11, ' ').collect(); + if fields.len() == 11 { + let (index_status, worktree_status) = parse_xy(fields[1]); + files.push(GitFileStatus { + path: fields[10].to_string(), + original_path: None, + index_status, + worktree_status, + conflict: true, + untracked: false, + }); + } + } else if let Some(path) = record.strip_prefix("? ") { + files.push(GitFileStatus { + path: path.to_string(), + original_path: None, + index_status: None, + worktree_status: Some("?".to_string()), + conflict: false, + untracked: true, + }); + } + index += 1; + } + + let detached = branch.is_none(); + let has_conflicts = files.iter().any(|file| file.conflict); + Ok(GitRepositoryState { + workspace_root: workspace_root.to_string(), + repository_root: repository_root.to_string(), + branch, + detached, + upstream, + ahead, + behind, + remotes: Vec::new(), + files, + has_conflicts, + }) +} + +/// 读取仓库当前状态。 +/// 入参:当前 apisender 工作区路径,可为仓库子目录。 +/// 出参:父级真实仓库根目录下的结构化状态。 +/// 作用与流程:定位仓库根目录,执行 porcelain v2 分支状态命令并解析输出。 +pub fn status(workspace_root: &Path) -> GitResult { + let repository_root = resolve_repository_root(workspace_root)?; + let output = run_git_read( + &repository_root, + &args(&[ + "status", + "--porcelain=v2", + "-z", + "--branch", + "--untracked-files=all", + ]), + false, + )?; + let mut state = parse_porcelain_v2( + &workspace_root.to_string_lossy(), + &repository_root.to_string_lossy(), + &output.stdout, + )?; + let remote_output = run_git_read(&repository_root, &args(&["remote"]), false)?; + state.remotes = String::from_utf8_lossy(&remote_output.stdout) + .lines() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .collect(); + Ok(state) +} + +/// 返回当前平台的空设备路径。 +/// 入参:无。 +/// 出参:Windows 返回 `NUL`,其他平台返回 `/dev/null`。 +/// 作用与流程:为未跟踪文件生成 no-index 差异时提供跨平台空文件端点。 +pub fn null_device_path() -> &'static str { + #[cfg(target_os = "windows")] + { + "NUL" + } + #[cfg(not(target_os = "windows"))] + { + "/dev/null" + } +} + +/// 校验仓库相对路径列表。 +/// 入参:仓库根目录和前端提交的相对路径。 +/// 出参:可直接传给 Git 的安全路径参数。 +/// 作用与流程:拒绝绝对路径、父目录跳转和空路径,防止暂存仓库之外的文件。 +fn validate_paths(repository_root: &Path, paths: &[String]) -> GitResult> { + if paths.is_empty() { + return Err(error(GitErrorCode::InvalidPath, "至少选择一个文件", None)); + } + let mut result = Vec::with_capacity(paths.len()); + for path in paths { + let relative = Path::new(path); + if relative.is_absolute() + || relative.as_os_str().is_empty() + || relative.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }) + { + return Err(error( + GitErrorCode::InvalidPath, + "文件路径必须位于当前 Git 仓库中", + Some(path.clone()), + )); + } + let candidate = repository_root.join(relative); + if !candidate.starts_with(repository_root) { + return Err(error( + GitErrorCode::InvalidPath, + "文件路径超出当前 Git 仓库", + Some(path.clone()), + )); + } + result.push(relative.as_os_str().to_os_string()); + } + Ok(result) +} + +/// 暂存选定文件。 +/// 入参:工作区路径和仓库相对路径列表。 +/// 出参:成功时为空。 +/// 作用与流程:定位父级仓库、校验路径后以 `git add --` 暂存新增、修改或删除。 +pub fn stage(workspace_root: &Path, paths: &[String]) -> GitResult<()> { + let repository_root = resolve_repository_root(workspace_root)?; + let safe_paths = validate_paths(&repository_root, paths)?; + let mut command_args = args(&["add", "--"]); + command_args.extend(safe_paths); + run_git(&repository_root, &command_args, false)?; + Ok(()) +} + +/// 取消暂存选定文件。 +/// 入参:工作区路径和仓库相对路径列表。 +/// 出参:成功时为空。 +/// 作用与流程:有 HEAD 时使用 restore,无提交仓库则从 index 移除新增文件。 +pub fn unstage(workspace_root: &Path, paths: &[String]) -> GitResult<()> { + let repository_root = resolve_repository_root(workspace_root)?; + let safe_paths = validate_paths(&repository_root, paths)?; + let has_head = run_git_read( + &repository_root, + &args(&["rev-parse", "--verify", "HEAD"]), + false, + ) + .is_ok(); + let mut command_args = if has_head { + args(&["restore", "--staged", "--"]) + } else { + args(&["rm", "--cached", "-r", "--"]) + }; + command_args.extend(safe_paths); + run_git(&repository_root, &command_args, false)?; + Ok(()) +} + +/// 生成文件或提交差异。 +/// 入参:工作区、仓库相对路径、是否读取暂存区和可选提交 SHA。 +/// 出参:差异文本、二进制标记和截断状态。 +/// 作用与流程:按提交、暂存区或工作区选择 Git diff 命令;未跟踪文本文件回退为 no-index 差异。 +pub fn diff( + workspace_root: &Path, + path: &str, + staged: bool, + commit_sha: Option<&str>, +) -> GitResult { + let repository_root = resolve_repository_root(workspace_root)?; + let safe_path = validate_paths(&repository_root, &[path.to_string()])?.remove(0); + let mut command_args = if let Some(sha) = commit_sha { + vec![ + OsString::from("show"), + OsString::from("--format="), + OsString::from("--no-ext-diff"), + OsString::from("--end-of-options"), + OsString::from(sha), + OsString::from("--"), + ] + } else if staged { + args(&["diff", "--cached", "--no-ext-diff", "--"]) + } else { + args(&["diff", "--no-ext-diff", "--"]) + }; + command_args.push(safe_path.clone()); + let mut output = run_git_diff_output(&repository_root, &command_args, false)?; + + if output.stdout.is_empty() && !staged && commit_sha.is_none() { + let absolute_path = repository_root.join(Path::new(&safe_path)); + if absolute_path.is_file() { + output = run_git_diff_output( + &repository_root, + &[ + OsString::from("diff"), + OsString::from("--no-index"), + OsString::from("--no-ext-diff"), + OsString::from("--"), + OsString::from(null_device_path()), + absolute_path.into_os_string(), + ], + true, + )?; + } + } + + let binary = output + .stdout + .windows(12) + .any(|window| window == b"Binary files") + || output + .stdout + .windows(16) + .any(|window| window == b"GIT binary patch"); + let truncated = output.stdout.len() > DIFF_OUTPUT_LIMIT; + let output_too_large = + output.stdout.len().saturating_add(output.stderr.len()) > DEFAULT_OUTPUT_LIMIT; + let visible = &output.stdout[..output.stdout.len().min(DIFF_OUTPUT_LIMIT)]; + Ok(GitDiff { + content: String::from_utf8_lossy(visible).into_owned(), + binary, + truncated, + output_too_large, + }) +} + +/// 读取当前仓库提交身份。 +/// 入参:工作区路径。 +/// 出参:仓库配置继承后的姓名和邮箱。 +/// 作用与流程:分别读取 user.name 与 user.email,缺失时返回空值而不是命令错误。 +pub fn get_identity(workspace_root: &Path) -> GitResult { + let repository_root = resolve_repository_root(workspace_root)?; + let read = |key: &str| -> Option { + run_git_read(&repository_root, &args(&["config", "--get", key]), true) + .ok() + .filter(|output| output.status.success()) + .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string()) + .filter(|value| !value.is_empty()) + }; + Ok(GitIdentity { + name: read("user.name"), + email: read("user.email"), + }) +} + +/// 设置当前仓库提交身份。 +/// 入参:工作区路径、姓名和邮箱。 +/// 出参:写入后的身份对象。 +/// 作用与流程:校验非空值后仅写当前仓库 config,不修改全局 Git 配置。 +pub fn set_identity(workspace_root: &Path, name: &str, email: &str) -> GitResult { + let repository_root = resolve_repository_root(workspace_root)?; + let name = name.trim(); + let email = email.trim(); + if name.is_empty() || email.is_empty() || !email.contains('@') { + return Err(error( + GitErrorCode::IdentityMissing, + "请填写有效的 Git 用户名和邮箱", + None, + )); + } + run_git( + &repository_root, + &args(&["config", "user.name", name]), + false, + )?; + run_git( + &repository_root, + &args(&["config", "user.email", email]), + false, + )?; + get_identity(&repository_root) +} + +/// 提交已暂存改动。 +/// 入参:工作区路径和提交说明。 +/// 出参:最新提交摘要。 +/// 作用与流程:校验提交说明和身份,执行 commit 后读取最新一条日志。 +pub fn commit(workspace_root: &Path, message: &str) -> GitResult { + let repository_root = resolve_repository_root(workspace_root)?; + let message = message.trim(); + if message.is_empty() { + return Err(error(GitErrorCode::CommandFailed, "提交说明不能为空", None)); + } + let identity = get_identity(&repository_root)?; + if identity.name.is_none() || identity.email.is_none() { + return Err(error( + GitErrorCode::IdentityMissing, + "当前仓库尚未配置 Git 用户名和邮箱", + None, + )); + } + run_git(&repository_root, &args(&["commit", "-m", message]), false)?; + list_commits(&repository_root, 0, 1)? + .into_iter() + .next() + .ok_or_else(|| error(GitErrorCode::CommandFailed, "无法读取新提交", None)) +} + +/// 解析日志记录。 +/// 入参:使用 NUL 分隔记录、单元分隔字段的原始字节。 +/// 出参:结构化提交列表。 +/// 作用与流程:逐条验证六个字段并保留包含空格或中文的提交主题。 +fn parse_commits(raw: &[u8]) -> Vec { + String::from_utf8_lossy(raw) + .split('\0') + .filter_map(|record| { + let fields: Vec<&str> = record.trim_start_matches('\n').split('\u{1f}').collect(); + (fields.len() == 6).then(|| GitCommit { + sha: fields[0].to_string(), + short_sha: fields[1].to_string(), + author_name: fields[2].to_string(), + author_email: fields[3].to_string(), + authored_at: fields[4].to_string(), + subject: fields[5].to_string(), + }) + }) + .collect() +} + +/// 分页读取提交日志。 +/// 入参:工作区路径、偏移量和每页数量。 +/// 出参:当前分支最多 100 条提交摘要。 +/// 作用与流程:限制分页参数并使用稳定分隔符执行 git log;空仓库返回空列表。 +pub fn list_commits(workspace_root: &Path, skip: usize, limit: usize) -> GitResult> { + let repository_root = resolve_repository_root(workspace_root)?; + let count = limit.clamp(1, 100).to_string(); + let skip = skip.to_string(); + let output = run_git_diff_output( + &repository_root, + &[ + OsString::from("log"), + OsString::from("-z"), + OsString::from("--date=iso-strict"), + OsString::from("--pretty=format:%H%x1f%h%x1f%an%x1f%ae%x1f%aI%x1f%s"), + OsString::from("--skip"), + OsString::from(skip), + OsString::from("-n"), + OsString::from(count), + ], + false, + ); + match output { + Ok(output) => Ok(parse_commits(&output.stdout)), + Err(cause) + if cause + .details + .as_deref() + .is_some_and(|details| details.contains("does not have any commits yet")) => + { + Ok(Vec::new()) + } + Err(cause) => Err(cause), + } +} + +/// 读取单个提交详情。 +/// 入参:工作区路径和完整或短 SHA。 +/// 出参:提交摘要、文件列表和最多 1 MiB 的差异。 +/// 作用与流程:读取指定提交元数据、name-only 文件清单和完整提交 diff 后统一截断。 +pub fn show_commit(workspace_root: &Path, sha: &str) -> GitResult { + let repository_root = resolve_repository_root(workspace_root)?; + let metadata = run_git_read( + &repository_root, + &[ + OsString::from("show"), + OsString::from("-s"), + OsString::from("--date=iso-strict"), + OsString::from("--format=%H%x1f%h%x1f%an%x1f%ae%x1f%aI%x1f%s%x00"), + OsString::from("--end-of-options"), + OsString::from(sha), + ], + false, + )?; + let commit = parse_commits(&metadata.stdout) + .into_iter() + .next() + .ok_or_else(|| error(GitErrorCode::CommandFailed, "无法解析提交信息", None))?; + let files_output = run_git_read( + &repository_root, + &[ + OsString::from("diff-tree"), + OsString::from("--root"), + OsString::from("--no-commit-id"), + OsString::from("--name-only"), + OsString::from("-r"), + OsString::from("-z"), + OsString::from("--end-of-options"), + OsString::from(sha), + ], + false, + )?; + let files = files_output + .stdout + .split(|byte| *byte == 0) + .filter(|value| !value.is_empty()) + .map(|value| String::from_utf8_lossy(value).into_owned()) + .collect(); + let output = run_git_read( + &repository_root, + &[ + OsString::from("show"), + OsString::from("--format="), + OsString::from("--no-ext-diff"), + OsString::from("--end-of-options"), + OsString::from(sha), + ], + false, + )?; + let binary = output + .stdout + .windows(12) + .any(|window| window == b"Binary files"); + let truncated = output.stdout.len() > DIFF_OUTPUT_LIMIT; + let output_too_large = + output.stdout.len().saturating_add(output.stderr.len()) > DEFAULT_OUTPUT_LIMIT; + let visible = &output.stdout[..output.stdout.len().min(DIFF_OUTPUT_LIMIT)]; + Ok(GitCommitDetail { + commit, + files, + diff: GitDiff { + content: String::from_utf8_lossy(visible).into_owned(), + binary, + truncated, + output_too_large, + }, + }) +} + +/// 列出本地与远端分支。 +/// 入参:工作区路径。 +/// 出参:分支名称、当前状态、upstream 和 ahead/behind 信息。 +/// 作用与流程:使用 for-each-ref 读取稳定字段,并过滤远端 HEAD 占位引用。 +pub fn list_branches(workspace_root: &Path) -> GitResult> { + let repository_root = resolve_repository_root(workspace_root)?; + let output = run_git_read( + &repository_root, + &args(&[ + "for-each-ref", + "--format=%(refname)%1f%(refname:short)%1f%(HEAD)%1f%(upstream:short)%1f%(upstream:track,nobracket)", + "refs/heads", + "refs/remotes", + ]), + false, + )?; + let mut branches = Vec::new(); + for line in String::from_utf8_lossy(&output.stdout).lines() { + let fields: Vec<&str> = line.split('\u{1f}').collect(); + if fields.len() != 5 || fields[1].ends_with("/HEAD") { + continue; + } + let mut ahead = 0; + let mut behind = 0; + for item in fields[4].split(',').map(str::trim) { + if let Some(value) = item.strip_prefix("ahead ") { + ahead = value.parse().unwrap_or_default(); + } else if let Some(value) = item.strip_prefix("behind ") { + behind = value.parse().unwrap_or_default(); + } + } + branches.push(GitBranch { + name: fields[1].to_string(), + current: fields[2] == "*", + remote: fields[0].starts_with("refs/remotes/"), + upstream: (!fields[3].is_empty()).then(|| fields[3].to_string()), + ahead, + behind, + }); + } + Ok(branches) +} + +/// 校验分支名称。 +/// 入参:仓库根目录和候选分支名。 +/// 出参:合法时为空。 +/// 作用与流程:调用 Git 自身 check-ref-format,避免在前端复制不完整规则。 +fn validate_branch(repository_root: &Path, name: &str) -> GitResult<()> { + let result = run_git_read( + repository_root, + &args(&["check-ref-format", "--branch", name]), + false, + ); + result + .map(|_| ()) + .map_err(|cause| error(GitErrorCode::InvalidBranch, "分支名称不合法", cause.details)) +} + +/// 从当前 HEAD 创建并切换分支。 +/// 入参:工作区路径和新分支名。 +/// 出参:成功时为空。 +/// 作用与流程:校验名称后执行 switch -c,保留 Git 对工作区改动的原生保护行为。 +pub fn create_branch(workspace_root: &Path, name: &str) -> GitResult<()> { + let repository_root = resolve_repository_root(workspace_root)?; + validate_branch(&repository_root, name)?; + run_git(&repository_root, &args(&["switch", "-c", name]), false)?; + Ok(()) +} + +/// 切换到已有本地分支。 +/// 入参:工作区路径和分支名。 +/// 出参:成功时为空。 +/// 作用与流程:校验名称后执行 switch,由 Git 判断已保存改动能否安全保留。 +pub fn switch_branch(workspace_root: &Path, name: &str) -> GitResult<()> { + let repository_root = resolve_repository_root(workspace_root)?; + validate_branch(&repository_root, name)?; + run_git(&repository_root, &args(&["switch", name]), false)?; + Ok(()) +} + +/// 以仅快进策略拉取 tracking branch。 +/// 入参:工作区路径。 +/// 出参:成功时为空。 +/// 作用与流程:要求现有 upstream 并执行 pull --ff-only,不创建 merge commit 或 rebase。 +pub fn pull(workspace_root: &Path) -> GitResult<()> { + let repository_root = resolve_repository_root(workspace_root)?; + run_git(&repository_root, &args(&["pull", "--ff-only"]), false)?; + Ok(()) +} + +/// 推送当前分支。 +/// 入参:工作区路径、可选远端和可选远端分支。 +/// 出参:成功时为空。 +/// 作用与流程:提供远端和分支时首次设置 upstream,否则使用现有 tracking branch。 +pub fn push(workspace_root: &Path, remote: Option<&str>, branch: Option<&str>) -> GitResult<()> { + let repository_root = resolve_repository_root(workspace_root)?; + match (remote, branch) { + (Some(remote), Some(branch)) => { + let remotes = run_git_read(&repository_root, &args(&["remote"]), false)?; + if !String::from_utf8_lossy(&remotes.stdout) + .lines() + .any(|name| name.trim() == remote) + { + return Err(error( + GitErrorCode::RemoteMissing, + "选择的 Git 远端不存在", + Some(remote.to_string()), + )); + } + validate_branch(&repository_root, branch)?; + run_git( + &repository_root, + &args(&["push", "--set-upstream", "--", remote, branch]), + false, + )?; + } + (None, None) => { + run_git(&repository_root, &args(&["push"]), false)?; + } + _ => { + return Err(error( + GitErrorCode::UpstreamMissing, + "首次推送必须同时指定远端和分支", + None, + )); + } + } + Ok(()) +} + +/// 校验并规范化远端定位符。 +/// 入参:用户提供的远端 URL 或本地仓库路径。 +/// 出参:去除首尾空白后的非空定位符。 +/// 作用与流程:拒绝空值,后续始终把定位符放在 `--` 后作为普通参数传给 Git。 +fn validate_remote_locator(remote_url: &str) -> GitResult<&str> { + let remote_url = remote_url.trim(); + if remote_url.is_empty() { + return Err(error( + GitErrorCode::RemoteMissing, + "Git 远端地址不能为空", + None, + )); + } + Ok(remote_url) +} + +/// 检查远端仓库是否为空。 +/// 入参:可作为 Git remote 的 URL 或本地路径以及执行目录。 +/// 出参:远端不存在任何 refs 时为空。 +/// 作用与流程:执行不带 ref 过滤的 ls-remote;存在任一引用则拒绝初始化绑定,避免覆盖远端历史。 +fn ensure_remote_empty(cwd: &Path, remote_url: &str) -> GitResult<()> { + let remote_url = validate_remote_locator(remote_url)?; + let output = run_git_read(cwd, &args(&["ls-remote", "--", remote_url]), false)?; + if !output.stdout.is_empty() { + return Err(error( + GitErrorCode::RemoteNotEmpty, + "远端仓库已有提交或标签,不能作为新工作区的空远端", + None, + )); + } + Ok(()) +} + +/// 向 .gitignore 追加安全规则。 +/// 入参:工作区根目录。 +/// 出参:成功时为空。 +/// 作用与流程:读取现有规则,只追加缺失的私有环境、应用目录和系统文件规则。 +fn append_default_ignore_rules(workspace_root: &Path) -> GitResult<()> { + let ignore_path = workspace_root.join(".gitignore"); + let existing = match fs::read_to_string(&ignore_path) { + Ok(existing) => existing, + Err(cause) if cause.kind() == std::io::ErrorKind::NotFound => String::new(), + Err(cause) => { + return Err(error( + GitErrorCode::Io, + "无法安全读取现有 .gitignore,已停止追加", + Some(cause.to_string()), + )); + } + }; + let lines: Vec<&str> = existing.lines().collect(); + let missing: Vec<&str> = DEFAULT_IGNORE_RULES + .iter() + .copied() + .filter(|rule| !lines.iter().any(|line| line.trim() == *rule)) + .collect(); + if missing.is_empty() { + return Ok(()); + } + let mut updated = existing; + if !updated.is_empty() && !updated.ends_with('\n') { + updated.push('\n'); + } + if !updated.is_empty() { + updated.push('\n'); + } + updated.push_str("# apisender 本地与敏感文件\n"); + for rule in missing { + updated.push_str(rule); + updated.push('\n'); + } + fs::write(ignore_path, updated).map_err(|cause| { + error( + GitErrorCode::Io, + "无法更新 .gitignore", + Some(cause.to_string()), + ) + }) +} + +/// 初始化当前普通工作区并连接空远端。 +/// 入参:工作区路径、远端 URL 和默认分支名。 +/// 出参:初始化后的仓库状态。 +/// 作用与流程:拒绝已有仓库,验证分支和空远端,初始化仓库、补安全忽略规则并添加 origin。 +pub fn init_workspace( + workspace_root: &Path, + remote_url: &str, + default_branch: &str, +) -> GitResult { + if resolve_repository_root(workspace_root).is_ok() { + return Err(error( + GitErrorCode::CommandFailed, + "当前工作区已经是 Git 仓库", + None, + )); + } + validate_branch(workspace_root, default_branch)?; + ensure_remote_empty(workspace_root, remote_url)?; + run_git( + workspace_root, + &args(&["init", "-b", default_branch]), + false, + )?; + append_default_ignore_rules(workspace_root)?; + run_git( + workspace_root, + &args(&[ + "remote", + "add", + "--", + "origin", + validate_remote_locator(remote_url)?, + ]), + false, + )?; + status(workspace_root) +} + +/// 为已有本地仓库连接 origin。 +/// 入参:工作区路径和远端 URL。 +/// 出参:成功时为空。 +/// 作用与流程:先确认 origin 不存在,再新增远端;首版不覆盖已有配置。 +pub fn connect_origin(workspace_root: &Path, remote_url: &str) -> GitResult<()> { + let repository_root = resolve_repository_root(workspace_root)?; + let remote_url = validate_remote_locator(remote_url)?; + let existing = run_git_read(&repository_root, &args(&["remote"]), false)?; + if String::from_utf8_lossy(&existing.stdout) + .lines() + .any(|name| name.trim() == "origin") + { + return Err(error( + GitErrorCode::RemoteAlreadyExists, + "当前仓库已经配置 origin,首版不会覆盖现有远端", + None, + )); + } + run_git( + &repository_root, + &args(&["remote", "add", "--", "origin", remote_url]), + false, + )?; + Ok(()) +} + +/// 克隆远端仓库到指定子目录。 +/// 入参:父目录、目标文件夹名和远端 URL。 +/// 出参:克隆后的规范化目录路径。 +/// 作用与流程:校验名称与目标空状态,执行 clone;失败时只清理本次新创建的目标目录。 +pub fn clone_repository(parent: &Path, folder_name: &str, remote_url: &str) -> GitResult { + let parent = fs::canonicalize(parent).map_err(|cause| { + error( + GitErrorCode::Io, + "无法读取克隆父目录", + Some(cause.to_string()), + ) + })?; + let relative = Path::new(folder_name); + if relative.as_os_str().is_empty() + || relative.is_absolute() + || relative + .components() + .any(|component| !matches!(component, Component::Normal(_))) + { + return Err(error( + GitErrorCode::InvalidPath, + "克隆文件夹名称不合法", + Some(folder_name.to_string()), + )); + } + let target = parent.join(relative); + let remote_url = validate_remote_locator(remote_url)?; + let existed = target.exists(); + if existed { + if !target.is_dir() { + return Err(error( + GitErrorCode::TargetNotEmpty, + "克隆目标必须是空目录或不存在", + Some(target.to_string_lossy().into_owned()), + )); + } + let mut entries = fs::read_dir(&target).map_err(|cause| { + error( + GitErrorCode::Io, + "无法读取克隆目标目录", + Some(cause.to_string()), + ) + })?; + if entries.next().is_some() { + return Err(error( + GitErrorCode::TargetNotEmpty, + "克隆目标目录必须为空或不存在", + Some(target.to_string_lossy().into_owned()), + )); + } + } + let result = run_git( + &parent, + &[ + OsString::from("clone"), + OsString::from("--"), + OsString::from(remote_url), + target.as_os_str().to_os_string(), + ], + false, + ); + if let Err(cause) = result { + if existed { + return Err(error( + cause.code, + format!("克隆失败,原有空目录已保留,请检查:{}", target.display()), + cause.details, + )); + } + if target.exists() { + let _ = fs::remove_dir_all(&target); + } + return Err(cause); + } + fs::canonicalize(target).map_err(|cause| { + error( + GitErrorCode::Io, + "无法解析克隆目录", + Some(cause.to_string()), + ) + }) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 316f264..5b0f567 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,6 +3,7 @@ pub mod commands; pub mod config; pub mod error; pub mod executor; +pub mod git; pub mod parser; pub mod sse; pub mod storage; @@ -13,17 +14,25 @@ pub mod workspace; use std::sync::Mutex; -use tauri::Manager; -use workspace::watcher::WatcherState; -use workspace::WorkspaceState; +use commands::grpc::GrpcState; use commands::http::HttpState; use commands::sse::SseState; use commands::websocket::WsState; -use commands::grpc::GrpcState; +use git::GitOperationState; +use tauri::Manager; +use workspace::WorkspaceState; +use workspace::watcher::WatcherState; +/// 启动 apisender Tauri 应用。 +/// 入参:无。 +/// 出参:无;运行失败时终止进程并输出启动错误。 +/// 作用与流程:注册插件、全局状态和 IPC 命令,随后进入桌面应用事件循环。 #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - if rustls::crypto::ring::default_provider().install_default().is_err() { + if rustls::crypto::ring::default_provider() + .install_default() + .is_err() + { log::debug!("rustls ring crypto provider already installed"); } @@ -52,6 +61,7 @@ pub fn run() { app.manage(SseState::default()); app.manage(WsState::default()); app.manage(GrpcState::default()); + app.manage(GitOperationState::default()); Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -96,7 +106,25 @@ pub fn run() { commands::clipboard::clipboard_paste_files, commands::grpc::execute_grpc, commands::grpc::stop_grpc, + commands::git::git_probe, + commands::git::git_status, + commands::git::git_diff, + commands::git::git_list_branches, + commands::git::git_list_commits, + commands::git::git_show_commit, + commands::git::git_get_identity, + commands::git::git_stage, + commands::git::git_unstage, + commands::git::git_commit, + commands::git::git_set_identity, + commands::git::git_pull, + commands::git::git_push, + commands::git::git_create_branch, + commands::git::git_switch_branch, + commands::git::git_init_workspace, + commands::git::git_connect_origin, + commands::git::git_clone_workspace, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); -} \ No newline at end of file +} diff --git a/src-tauri/src/workspace/watcher.rs b/src-tauri/src/workspace/watcher.rs index a56544e..1f8b9bd 100644 --- a/src-tauri/src/workspace/watcher.rs +++ b/src-tauri/src/workspace/watcher.rs @@ -1,4 +1,3 @@ - use std::path::PathBuf; use std::sync::Mutex; use std::time::Duration; @@ -7,14 +6,30 @@ use notify::{Config, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; use tauri::{AppHandle, Emitter, Manager}; use crate::error::{AppError, AppResult}; +use crate::git; pub struct WatcherState { pub _watcher: Option, } +/// 判断文件事件是否来自 Git 管理目录。 +/// 入参:事件文件路径和实际 Git 管理目录。 +/// 出参:事件位于 Git 内部时返回 true。 +/// 作用与流程:使用路径层级判断,避免把 `.gitignore` 等普通工作区文件误判为 Git 内部事件。 +pub fn is_git_internal_path(path: &std::path::Path, git_dir: &std::path::Path) -> bool { + path.starts_with(git_dir) +} + +/// 启动当前工作区文件监听器。 +/// 入参:Tauri AppHandle 和 apisender 工作区路径。 +/// 出参:成功时为空,创建或监听失败时返回工作区错误。 +/// 作用与流程:Git 工作区监听真实父级仓库及外部管理目录;Git 内部变化仅发 git-changed,普通文件同时发 workspace-changed 与 git-changed。 pub fn start_watcher(app: &AppHandle, root: &str) -> AppResult<()> { - let root_path = PathBuf::from(root); + let workspace_path = PathBuf::from(root); + let root_path = git::resolve_repository_root(&workspace_path).unwrap_or(workspace_path); + let git_dir = git::resolve_git_dir(&root_path).ok(); let app_handle = app.clone(); + let event_git_dir = git_dir.clone(); let watcher = notify::recommended_watcher(move |res: Result| { if let Ok(event) = res { @@ -25,21 +40,50 @@ pub fn start_watcher(app: &AppHandle, root: &str) -> AppResult<()> { _ => return, }; - let paths: Vec = event + let git_paths: Vec = event + .paths + .iter() + .filter(|path| { + event_git_dir + .as_deref() + .is_some_and(|git_dir| is_git_internal_path(path, git_dir)) + }) + .map(|p| p.to_string_lossy().to_string()) + .collect(); + let workspace_paths: Vec = event .paths .iter() + .filter(|path| { + !event_git_dir + .as_deref() + .is_some_and(|git_dir| is_git_internal_path(path, git_dir)) + }) .map(|p| p.to_string_lossy().to_string()) .collect(); - if paths.is_empty() { + if git_paths.is_empty() && workspace_paths.is_empty() { return; } + if !workspace_paths.is_empty() { + let _ = app_handle.emit( + "workspace-changed", + serde_json::json!({ + "eventType": event_kind, + "paths": workspace_paths, + }), + ); + } + let git_event_paths = if git_paths.is_empty() { + workspace_paths + } else { + git_paths + }; let _ = app_handle.emit( - "workspace-changed", + "git-changed", serde_json::json!({ "eventType": event_kind, - "paths": paths, + "paths": git_event_paths, }), ); } @@ -51,6 +95,14 @@ pub fn start_watcher(app: &AppHandle, root: &str) -> AppResult<()> { .watch(&root_path, RecursiveMode::Recursive) .map_err(|e| AppError::Workspace(e.to_string()))?; + if let Some(git_dir) = git_dir + && !git_dir.starts_with(&root_path) + { + watcher + .watch(&git_dir, RecursiveMode::Recursive) + .map_err(|e| AppError::Workspace(e.to_string()))?; + } + let config = Config::default().with_poll_interval(Duration::from_secs(1)); watcher.configure(config).ok(); diff --git a/src-tauri/tests/git.rs b/src-tauri/tests/git.rs new file mode 100644 index 0000000..bcf6e99 --- /dev/null +++ b/src-tauri/tests/git.rs @@ -0,0 +1,484 @@ +use std::fs; +use std::ops::Deref; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::thread; +use std::time::Duration; + +use apisender_lib::git::{ + GitErrorCode, GitOperationState, clone_repository, connect_origin, create_branch, diff, + init_workspace, list_branches, list_commits, null_device_path, parse_porcelain_v2, pull, push, + redact_secrets, set_identity, show_commit, stage, status, switch_branch, unstage, +}; +use apisender_lib::workspace::watcher::is_git_internal_path; + +static TEMP_COUNTER: AtomicUsize = AtomicUsize::new(0); + +struct TestDir(PathBuf); + +impl Deref for TestDir { + type Target = Path; + + /// 返回临时目录路径引用。 + /// 入参:当前 TestDir 引用。 + /// 出参:内部 Path 引用。 + /// 作用与流程:让测试目录可直接调用 Path 方法并传给 Git 服务函数。 + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl AsRef for TestDir { + /// 将测试目录转换为 Path 引用。 + /// 入参:当前 TestDir 引用。 + /// 出参:内部 Path 引用。 + /// 作用与流程:支持 std::fs 等接受 AsRef 的清理和读写接口。 + fn as_ref(&self) -> &Path { + &self.0 + } +} + +impl Drop for TestDir { + /// 自动清理测试临时目录。 + /// 入参:当前 TestDir 可变引用。 + /// 出参:无。 + /// 作用与流程:无论测试正常结束还是 panic 展开,都尝试递归删除本次目录。 + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +/// 创建临时测试目录。 +/// 入参:测试名称。 +/// 出参:当前测试独占的临时目录路径。 +/// 作用与流程:组合进程号和递增序号生成目录,先清理同名残留再创建目录。 +fn temp_dir(name: &str) -> TestDir { + let sequence = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "apisender-git-{name}-{}-{sequence}", + std::process::id() + )); + let _ = fs::remove_dir_all(&path); + fs::create_dir_all(&path).unwrap(); + TestDir(path) +} + +/// 在指定目录执行 Git 测试命令。 +/// 入参:命令工作目录和参数列表。 +/// 出参:标准输出文本。 +/// 作用与流程:调用系统 Git,断言退出成功并返回 UTF-8 输出。 +fn run_git(cwd: &Path, args: &[&str]) -> String { + let output = Command::new("git") + .current_dir(cwd) + .args(args) + .output() + .unwrap(); + assert!( + output.status.success(), + "git {:?} failed: {}", + args, + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8_lossy(&output.stdout).into_owned() +} + +#[test] +/// 校验 porcelain v2 状态解析。 +/// 入参/出参:无;断言分支、ahead/behind、重命名、未跟踪与冲突字段。 +/// 作用与流程:构造含空格和 NUL 分隔路径的原始输出并验证结构化结果。 +fn parses_porcelain_v2_status_with_branch_and_rename() { + let raw = concat!( + "# branch.oid abcdef\0", + "# branch.head feature/test\0", + "# branch.upstream origin/feature/test\0", + "# branch.ab +2 -3\0", + "1 M. N... 100644 100644 100644 aaaaaaa bbbbbbb staged file.http\0", + "1 .M N... 100644 100644 100644 aaaaaaa aaaaaaa worktree.json\0", + "2 R. N... 100644 100644 100644 aaaaaaa bbbbbbb R100 renamed.http\0old name.http\0", + "? new file.proto\0", + "u UU N... 100644 100644 100644 100644 aaaaaaa bbbbbbb ccccccc conflict.http\0" + ); + + let state = parse_porcelain_v2("/workspace/subdir", "/workspace", raw.as_bytes()).unwrap(); + + assert_eq!(state.branch.as_deref(), Some("feature/test")); + assert_eq!(state.upstream.as_deref(), Some("origin/feature/test")); + assert_eq!((state.ahead, state.behind), (2, 3)); + assert_eq!(state.files.len(), 5); + assert!(state.remotes.is_empty()); + assert_eq!(state.files[2].path, "renamed.http"); + assert_eq!( + state.files[2].original_path.as_deref(), + Some("old name.http") + ); + assert!(state.files[3].untracked); + assert!(state.files[4].conflict); +} + +#[test] +/// 校验 HTTPS 远端凭据脱敏。 +/// 入参/出参:无;断言用户名与令牌不会出现在结果中。 +/// 作用与流程:传入带 userinfo 的失败文本并比较脱敏输出。 +fn redacts_credentials_from_https_urls() { + let input = "fatal: https://alice:secret-token@example.com/org/repo.git denied"; + assert_eq!( + redact_secrets(input), + "fatal: https://***@example.com/org/repo.git denied" + ); + assert_eq!( + redact_secrets( + "fatal: https://example.com/org/repo.git?access_token=secret-token&ref=main denied" + ), + "fatal: https://example.com/org/repo.git?access_token=***&ref=main denied" + ); +} + +#[test] +/// 校验本地仓库日常操作闭环。 +/// 入参/出参:无;断言状态、暂存、diff、提交、日志和分支操作。 +/// 作用与流程:在自动清理临时仓库中依次执行用户常用操作。 +fn repository_status_stage_diff_commit_and_branches_work() { + let root = temp_dir("lifecycle"); + run_git(&root, &["init", "-b", "main"]); + set_identity(&root, "API Sender Test", "test@example.com").unwrap(); + fs::write(root.join("request.http"), "GET https://example.com\n").unwrap(); + + let before = status(&root).unwrap(); + assert!( + before + .files + .iter() + .any(|file| file.path == "request.http" && file.untracked) + ); + + stage(&root, &["request.http".to_string()]).unwrap(); + let staged = status(&root).unwrap(); + assert_eq!(staged.files[0].index_status.as_deref(), Some("A")); + assert!( + !diff(&root, "request.http", true, None) + .unwrap() + .content + .is_empty() + ); + + let first_commit = apisender_lib::git::commit(&root, "新增请求示例").unwrap(); + assert_eq!(list_commits(&root, 0, 50).unwrap().len(), 1); + assert_eq!( + show_commit(&root, &first_commit.sha).unwrap().files, + vec!["request.http"] + ); + + create_branch(&root, "feature/git-sync").unwrap(); + assert_eq!( + status(&root).unwrap().branch.as_deref(), + Some("feature/git-sync") + ); + switch_branch(&root, "main").unwrap(); + assert_eq!(status(&root).unwrap().branch.as_deref(), Some("main")); + assert!( + list_branches(&root) + .unwrap() + .iter() + .any(|branch| branch.name == "feature/git-sync") + ); + + fs::write(root.join("request.http"), "GET https://example.org\n").unwrap(); + stage(&root, &["request.http".to_string()]).unwrap(); + unstage(&root, &["request.http".to_string()]).unwrap(); + assert_eq!(status(&root).unwrap().files[0].index_status, None); + + fs::remove_dir_all(root).unwrap(); +} + +#[test] +/// 校验初始化会拒绝非空远端。 +/// 入参/出参:无;断言返回 remote_not_empty。 +/// 作用与流程:创建仅含自定义 ref 的 bare 远端,再尝试把普通工作区连接为新仓库。 +fn init_rejects_non_empty_remote() { + let remote_parent = temp_dir("remote-non-empty"); + let remote = remote_parent.join("origin.git"); + run_git( + &remote_parent, + &["init", "--bare", remote.to_string_lossy().as_ref()], + ); + let source = temp_dir("remote-non-empty-source"); + run_git(&source, &["init", "-b", "main"]); + run_git(&source, &["config", "user.name", "Test"]); + run_git(&source, &["config", "user.email", "test@example.com"]); + fs::write(source.join("README.md"), "existing").unwrap(); + run_git(&source, &["add", "README.md"]); + run_git(&source, &["commit", "-m", "initial"]); + run_git( + &source, + &[ + "push", + remote.to_string_lossy().as_ref(), + "HEAD:refs/custom/existing", + ], + ); + + let workspace = temp_dir("init-workspace"); + let error = init_workspace(&workspace, remote.to_string_lossy().as_ref(), "main").unwrap_err(); + assert_eq!(error.code, GitErrorCode::RemoteNotEmpty); +} + +#[test] +/// 校验初始化不会覆盖无法安全读取的现有 .gitignore。 +/// 入参/出参:无;断言返回 io 错误且原始字节保持不变。 +/// 作用与流程:准备空 bare 远端和非 UTF-8 忽略文件,验证追加规则在读取失败时停止。 +fn init_preserves_unreadable_gitignore_bytes() { + let remote_parent = temp_dir("invalid-ignore-remote"); + let remote = remote_parent.join("origin.git"); + run_git( + &remote_parent, + &["init", "--bare", remote.to_string_lossy().as_ref()], + ); + let workspace = temp_dir("invalid-ignore-workspace"); + fs::write(workspace.join(".gitignore"), [0xff, 0xfe]).unwrap(); + + let error = init_workspace(&workspace, remote.to_string_lossy().as_ref(), "main").unwrap_err(); + assert_eq!(error.code, GitErrorCode::Io); + assert_eq!( + fs::read(workspace.join(".gitignore")).unwrap(), + [0xff, 0xfe] + ); +} + +#[test] +/// 校验 Git 写操作互斥状态。 +/// 入参/出参:无;断言第二个并发 guard 被拒绝且释放后可重试。 +/// 作用与流程:连续申请两次写锁,再释放首个 guard 验证状态恢复。 +fn write_operation_state_rejects_concurrent_writes() { + let state = GitOperationState::default(); + let first = state.try_begin().unwrap(); + let error = state.try_begin().unwrap_err(); + assert_eq!(error.code, GitErrorCode::OperationBusy); + drop(first); + assert!(state.try_begin().is_ok()); +} + +#[test] +/// 校验本地 bare remote 的完整同步行为。 +/// 入参/出参:无;断言首次 push、clone、快进 pull 和分叉拒绝。 +/// 作用与流程:用源仓库、bare remote 和克隆仓库模拟双端开发且不访问网络。 +fn local_bare_remote_supports_push_clone_fast_forward_pull_and_rejects_divergence() { + let remote_parent = temp_dir("remote-flow"); + let remote = remote_parent.join("origin.git"); + run_git( + &remote_parent, + &["init", "--bare", remote.to_string_lossy().as_ref()], + ); + + let source = temp_dir("remote-source"); + init_workspace(&source, remote.to_string_lossy().as_ref(), "main").unwrap(); + set_identity(&source, "Source User", "source@example.com").unwrap(); + fs::write(source.join("request.http"), "GET https://v1.example.com\n").unwrap(); + stage(&source, &["request.http".to_string()]).unwrap(); + apisender_lib::git::commit(&source, "初始化远端").unwrap(); + push(&source, Some("origin"), Some("main")).unwrap(); + assert_eq!(status(&source).unwrap().remotes, vec!["origin"]); + + let clones = temp_dir("remote-clones"); + let clone = + clone_repository(&clones, "working-copy", remote.to_string_lossy().as_ref()).unwrap(); + set_identity(&clone, "Clone User", "clone@example.com").unwrap(); + assert_eq!( + status(&clone).unwrap().upstream.as_deref(), + Some("origin/main") + ); + + fs::write(source.join("request.http"), "GET https://v2.example.com\n").unwrap(); + stage(&source, &["request.http".to_string()]).unwrap(); + apisender_lib::git::commit(&source, "更新远端请求").unwrap(); + push(&source, None, None).unwrap(); + pull(&clone).unwrap(); + assert_eq!( + fs::read_to_string(clone.join("request.http")).unwrap(), + "GET https://v2.example.com\n" + ); + + fs::write(clone.join("clone.http"), "GET https://clone.example.com\n").unwrap(); + stage(&clone, &["clone.http".to_string()]).unwrap(); + apisender_lib::git::commit(&clone, "本地分叉").unwrap(); + fs::write( + source.join("source.http"), + "GET https://source.example.com\n", + ) + .unwrap(); + stage(&source, &["source.http".to_string()]).unwrap(); + apisender_lib::git::commit(&source, "远端分叉").unwrap(); + push(&source, None, None).unwrap(); + + let error = pull(&clone).unwrap_err(); + assert_eq!(error.code, GitErrorCode::NonFastForward); + + fs::remove_dir_all(remote_parent).unwrap(); + fs::remove_dir_all(source).unwrap(); + fs::remove_dir_all(clones).unwrap(); +} + +#[test] +/// 校验跨平台空设备名称。 +/// 入参/出参:无;按编译目标断言 Windows 与类 Unix 路径。 +/// 作用与流程:防止未跟踪文件 no-index diff 硬编码类 Unix 路径。 +fn null_device_matches_the_current_platform() { + #[cfg(target_os = "windows")] + assert_eq!(null_device_path(), "NUL"); + + #[cfg(not(target_os = "windows"))] + assert_eq!(null_device_path(), "/dev/null"); +} + +#[test] +/// 校验 watcher 的 Git 内部路径分类。 +/// 入参/出参:无;断言 `.git` 内部与工作区 `.gitignore` 被正确区分。 +/// 作用与流程:构造典型路径并验证层级判断。 +fn watcher_distinguishes_git_internal_files_from_worktree_files() { + let repository = Path::new("/repo"); + let git_dir = repository.join(".git"); + + assert!(is_git_internal_path(&git_dir.join("index"), &git_dir)); + assert!(is_git_internal_path( + &git_dir.join("refs/heads/main"), + &git_dir + )); + assert!(!is_git_internal_path( + &repository.join("request.http"), + &git_dir + )); + assert!(!is_git_internal_path( + &repository.join(".gitignore"), + &git_dir + )); +} + +#[test] +/// 校验克隆不会覆盖非空目录。 +/// 入参/出参:无;断言 target_not_empty 且原文件仍存在。 +/// 作用与流程:预建包含文件的目标目录,再调用克隆入口验证早期拒绝。 +fn clone_rejects_non_empty_target_directory() { + let parent = temp_dir("clone-target"); + let target = parent.join("existing"); + fs::create_dir_all(&target).unwrap(); + fs::write(target.join("keep.txt"), "do not delete").unwrap(); + + let error = clone_repository(&parent, "existing", "/tmp/missing.git").unwrap_err(); + assert_eq!(error.code, GitErrorCode::TargetNotEmpty); + assert!(target.join("keep.txt").exists()); + + let file_target = parent.join("file-target"); + fs::write(&file_target, "keep file").unwrap(); + let file_error = clone_repository(&parent, "file-target", "/tmp/missing.git").unwrap_err(); + assert_eq!(file_error.code, GitErrorCode::TargetNotEmpty); + assert_eq!(fs::read_to_string(file_target).unwrap(), "keep file"); + + fs::remove_dir_all(parent).unwrap(); +} + +#[test] +/// 校验克隆失败时只清理本次新建的目录。 +/// 入参/出参:无;断言新目标被移除,而原本存在的空目录被保留。 +/// 作用与流程:分别以不存在和预建空目录为目标克隆缺失远端,验证失败清理边界。 +fn clone_failure_preserves_preexisting_empty_directory() { + let parent = temp_dir("clone-failure-cleanup"); + let missing_remote = parent.join("missing.git"); + + let new_error = clone_repository( + &parent, + "new-target", + missing_remote.to_string_lossy().as_ref(), + ) + .unwrap_err(); + assert_eq!(new_error.code, GitErrorCode::RemoteMissing); + assert!(!parent.join("new-target").exists()); + + let existing = parent.join("existing-empty"); + fs::create_dir(&existing).unwrap(); + let existing_error = clone_repository( + &parent, + "existing-empty", + missing_remote.to_string_lossy().as_ref(), + ) + .unwrap_err(); + assert_eq!(existing_error.code, GitErrorCode::RemoteMissing); + assert!(existing_error.message.contains("原有空目录已保留")); + assert!(existing.is_dir()); + assert_eq!(fs::read_dir(existing).unwrap().count(), 0); +} + +#[test] +/// 校验缺少推送目标时返回稳定错误码。 +/// 入参/出参:无;断言没有 upstream 返回 upstream_missing,未知远端返回 remote_missing。 +/// 作用与流程:创建只有本地提交的仓库,验证默认推送错误分类及首次推送远端白名单。 +fn push_without_remote_reports_upstream_missing() { + let root = temp_dir("missing-upstream"); + run_git(&root, &["init", "-b", "main"]); + set_identity(&root, "Test", "test@example.com").unwrap(); + fs::write(root.join("request.http"), "GET https://example.com\n").unwrap(); + stage(&root, &["request.http".to_string()]).unwrap(); + apisender_lib::git::commit(&root, "本地提交").unwrap(); + + let error = push(&root, None, None).unwrap_err(); + assert_eq!(error.code, GitErrorCode::UpstreamMissing); + + let remote_error = push(&root, Some("--help"), Some("main")).unwrap_err(); + assert_eq!(remote_error.code, GitErrorCode::RemoteMissing); +} + +#[test] +/// 校验已有仓库可以连接缺失的 origin,且不会覆盖已有 origin。 +/// 入参/出参:无;断言首次连接成功、重复连接返回 remote_already_exists。 +/// 作用与流程:创建本地仓库和 bare 远端,通过服务入口连接两次以覆盖正常与保护路径。 +fn connect_origin_adds_missing_remote_and_refuses_overwrite() { + let root = temp_dir("connect-origin"); + run_git(&root, &["init", "-b", "main"]); + let remote_parent = temp_dir("connect-origin-remote"); + let remote = remote_parent.join("origin.git"); + run_git( + &remote_parent, + &["init", "--bare", remote.to_string_lossy().as_ref()], + ); + + connect_origin(&root, remote.to_string_lossy().as_ref()).unwrap(); + assert_eq!(status(&root).unwrap().remotes, vec!["origin"]); + + let error = connect_origin(&root, remote.to_string_lossy().as_ref()).unwrap_err(); + assert_eq!(error.code, GitErrorCode::RemoteAlreadyExists); +} + +#[test] +/// 校验超大文本差异只返回受限内容和过大状态。 +/// 入参/出参:无;断言展示内容不超过 1 MiB,且 truncated/output_too_large 均为 true。 +/// 作用与流程:在本地仓库创建超过 10 MiB 的未跟踪文本,通过 no-index diff 验证 IPC 载荷边界。 +fn oversized_diff_is_truncated_and_marked_too_large() { + let root = temp_dir("oversized-diff"); + run_git(&root, &["init", "-b", "main"]); + fs::write(root.join("large.txt"), vec![b'x'; 10 * 1024 * 1024 + 1024]).unwrap(); + + let result = diff(&root, "large.txt", false, None).unwrap(); + assert!(result.truncated); + assert!(result.output_too_large); + assert!(result.content.len() <= 1024 * 1024); +} + +#[test] +/// 校验 Git 状态读取不会触碰管理目录。 +/// 入参/出参:无;断言读取状态前后的 `.git` 目录修改时间一致。 +/// 作用与流程:创建含提交的临时仓库,跨过低精度文件时间窗口后调用服务层 status,防止 optional lock 再次触发 watcher。 +fn status_read_does_not_touch_git_directory() { + let root = temp_dir("status-no-optional-lock"); + run_git(&root, &["init", "-b", "main"]); + set_identity(&root, "Test", "test@example.com").unwrap(); + fs::write(root.join("request.http"), "GET https://example.com\n").unwrap(); + stage(&root, &["request.http".to_string()]).unwrap(); + apisender_lib::git::commit(&root, "初始化测试仓库").unwrap(); + + let git_dir = root.join(".git"); + thread::sleep(Duration::from_millis(1_100)); + let before = fs::metadata(&git_dir).unwrap().modified().unwrap(); + status(&root).unwrap(); + let after = fs::metadata(&git_dir).unwrap().modified().unwrap(); + + assert_eq!(before, after, "只读状态查询不应创建 optional lock"); +} diff --git a/src/App.tsx b/src/App.tsx index ca708cd..d13deba 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,6 +8,7 @@ import { useHistoryStore } from "@/stores/history"; import { useTabsStore } from "@/stores/tabs"; import { useThemeStore } from "@/stores/theme"; import { useFontStore } from "@/stores/font"; +import { useGitStore } from "@/stores/git"; import { useSystemThemeListener } from "@/hooks/useSystemTheme"; import type { WorkspaceChangedEvent } from "@/lib/types"; @@ -20,6 +21,12 @@ import { Toaster } from "@/components/ui/sonner"; import { TooltipProvider } from "@/components/ui/tooltip"; import { TITLE_BAR_HEIGHT } from "@/lib/platform"; +/** + * 渲染并协调 apisender 根应用。 + * 入参:无。 + * 出参:欢迎页或已打开工作区的完整应用界面。 + * 作用与流程:初始化各 store,监听普通工作区与 Git 管理目录变化,并协调文件树、环境、标签页和 Git 状态刷新。 + */ function App() { const root = useWorkspaceStore((s) => s.root); const isInitialized = useWorkspaceStore((s) => s.isInitialized); @@ -33,6 +40,8 @@ function App() { const reloadTab = useTabsStore((s) => s.reloadFromDisk); const initTheme = useThemeStore((s) => s.init); const initFont = useFontStore((s) => s.init); + const refreshGit = useGitStore((s) => s.refresh); + const clearGit = useGitStore((s) => s.clear); const [settingsOpen, setSettingsOpen] = useState(false); useSystemThemeListener(); @@ -48,8 +57,11 @@ function App() { if (root) { refreshHistory(); refreshEnv(); + refreshGit(); + } else { + clearGit(); } - }, [root, refreshHistory, refreshEnv]); + }, [root, refreshHistory, refreshEnv, refreshGit, clearGit]); useEffect(() => { const unlisten = listen( @@ -76,6 +88,20 @@ function App() { }; }, [refreshTree, refreshEnv, tabs, reloadTab]); + useEffect(() => { + let timer: ReturnType | null = null; + const unlisten = listen("git-changed", () => { + if (timer) clearTimeout(timer); + timer = setTimeout(() => { + void refreshGit(); + }, 250); + }); + return () => { + if (timer) clearTimeout(timer); + unlisten.then((fn) => fn()); + }; + }, [refreshGit]); + useEffect(() => { const handler = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && (e.key === "o" || e.key === "O")) { @@ -94,6 +120,7 @@ function App() { if (!isInitialized) { return ( <> +
+
s.openDialog); const openFolder = useWorkspaceStore((s) => s.openFolder); const recent = useWorkspaceStore((s) => s.recentWorkspaces); const removeRecent = useWorkspaceStore((s) => s.removeRecent); const [showRecent, setShowRecent] = useState(false); + const [cloneOpen, setCloneOpen] = useState(false); useEffect(() => { setShowRecent(recent.length > 0); @@ -45,14 +53,16 @@ export function Welcome() {

API 请求管理工具

- +
+ + +
{showRecent && (
@@ -100,6 +110,7 @@ export function Welcome() { 快捷键: Ctrl+O 打开 · Ctrl+S 保存
+ ); } diff --git a/src/components/git/CloneRepositoryDialog.tsx b/src/components/git/CloneRepositoryDialog.tsx new file mode 100644 index 0000000..312ccf5 --- /dev/null +++ b/src/components/git/CloneRepositoryDialog.tsx @@ -0,0 +1,144 @@ +import { useEffect, useState } from "react"; +import { FolderOpen, GitFork } from "lucide-react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { gitCloneWorkspace, openFileDialog } from "@/lib/tauri"; +import { normalizeGitError } from "@/lib/git-state"; +import { useWorkspaceStore } from "@/stores/workspace"; + +interface CloneRepositoryDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +/** + * 从远端 URL 推导默认文件夹名。 + * 入参:HTTPS、SSH 或本地 Git 远端文本。 + * 出参:移除 `.git` 后缀的末段名称,无法推导时为空字符串。 + * 作用与流程:统一斜杠后取末段,同时兼容 `git@host:org/repo.git` 格式。 + */ +function inferFolderName(remoteUrl: string): string { + const normalized = remoteUrl.trim().replace(/\\/g, "/").replace(/:([^/])/g, "/$1"); + return normalized.split("/").filter(Boolean).pop()?.replace(/\.git$/i, "") ?? ""; +} + +/** + * 渲染克隆远端仓库对话框。 + * 入参:打开状态及状态变更回调。 + * 出参:克隆表单 React 元素。 + * 作用与流程:选择父目录、填写远端和目标名,调用后端克隆后同步前端工作区状态。 + */ +export function CloneRepositoryDialog({ open, onOpenChange }: CloneRepositoryDialogProps) { + const openFolder = useWorkspaceStore((state) => state.openFolder); + const [remoteUrl, setRemoteUrl] = useState(""); + const [parent, setParent] = useState(""); + const [folderName, setFolderName] = useState(""); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + if (!folderName) { + setFolderName(inferFolderName(remoteUrl)); + } + }, [remoteUrl, folderName]); + + /** + * 选择克隆父目录。 + * 入参:无。 + * 出参:Promise。 + * 作用与流程:打开系统目录选择器,用户确认后写入父目录输入框。 + */ + const handleSelectParent = async (): Promise => { + const selected = await openFileDialog(); + if (selected) setParent(selected); + }; + + /** + * 提交克隆请求。 + * 入参:无。 + * 出参:Promise。 + * 作用与流程:校验表单、调用 Git 克隆命令,成功后让 workspace store 打开新目录并关闭对话框。 + */ + const handleClone = async (): Promise => { + const targetName = folderName.trim() || inferFolderName(remoteUrl); + if (!remoteUrl.trim() || !parent.trim() || !targetName) { + toast.error("请填写远端地址、父目录和文件夹名称"); + return; + } + setSubmitting(true); + try { + const path = await gitCloneWorkspace(parent.trim(), targetName, remoteUrl.trim()); + await openFolder(path); + toast.success("仓库克隆完成"); + onOpenChange(false); + setRemoteUrl(""); + setFolderName(""); + } catch (cause) { + const error = normalizeGitError(cause); + toast.error(error.message, { description: error.details ?? undefined }); + } finally { + setSubmitting(false); + } + }; + + return ( + + + + + + 从 Git 克隆 + + 使用系统 Git 和现有 SSH/凭据配置克隆仓库。 + +
+
+ + setRemoteUrl(event.target.value)} + placeholder="git@github.com:org/repo.git" + /> +
+
+ +
+ + +
+
+
+ + setFolderName(event.target.value)} + placeholder="repo" + /> +
+
+ + + + +
+
+ ); +} diff --git a/src/components/git/GitDiffDialog.tsx b/src/components/git/GitDiffDialog.tsx new file mode 100644 index 0000000..a19551b --- /dev/null +++ b/src/components/git/GitDiffDialog.tsx @@ -0,0 +1,68 @@ +import type { GitDiff } from "@/lib/types"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +interface GitDiffDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + title: string; + diff: GitDiff | null; + onOpenFile?: () => void; +} + +/** + * 渲染统一 Git 差异对话框。 + * 入参:打开状态、标题、差异载荷和可选打开文件回调。 + * 出参:可滚动展示文本差异或二进制元数据的 React 元素。 + * 作用与流程:根据 binary/truncated 状态展示说明,并为工作区文件提供进入编辑器入口。 + */ +export function GitDiffDialog({ + open, + onOpenChange, + title, + diff, + onOpenFile, +}: GitDiffDialogProps) { + return ( + + + + {title} + + {diff?.binary + ? "二进制文件不展示文本差异。" + : diff?.outputTooLarge + ? "差异超过 10 MiB,当前仅展示前 1 MiB。" + : diff?.truncated + ? "差异超过 1 MiB,当前仅展示前 1 MiB。" + : "Git 统一差异视图"} + + +
+ {diff?.binary ? ( +
该变更包含二进制内容。
+ ) : ( +
+              {diff?.content || "没有可展示的文本差异。"}
+            
+ )} +
+ + {onOpenFile && ( + + )} + + +
+
+ ); +} diff --git a/src/components/git/GitPanel.tsx b/src/components/git/GitPanel.tsx new file mode 100644 index 0000000..9232748 --- /dev/null +++ b/src/components/git/GitPanel.tsx @@ -0,0 +1,777 @@ +import { useEffect, useMemo, useState } from "react"; +import { + AlertTriangle, + ArrowDown, + ArrowUp, + ChevronDown, + CircleDot, + FileCode2, + GitBranch as GitBranchIcon, + GitCommitHorizontal, + Minus, + Plus, + RefreshCw, +} from "lucide-react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { GitDiffDialog } from "@/components/git/GitDiffDialog"; +import { + GIT_DEFAULT_IGNORE_RULES, + GIT_REMOTE_PLACEHOLDER, + getDirtyTabPaths, + getGitOperationPaths, + groupGitFiles, + normalizeGitError, + shouldShowRepositorySetup, + validateCommit, +} from "@/lib/git-state"; +import { gitDiff, gitShowCommit } from "@/lib/tauri"; +import type { GitDiff, GitErrorPayload, GitFileStatus } from "@/lib/types"; +import { useGitStore } from "@/stores/git"; +import { useTabsStore } from "@/stores/tabs"; +import { useWorkspaceStore } from "@/stores/workspace"; + +interface ChangeGroupProps { + title: string; + files: GitFileStatus[]; + mode: "stage" | "unstage"; + disabled: boolean; + onFile: (file: GitFileStatus) => void; + onAll: (paths: string[]) => void; + onOne: (paths: string[]) => void; +} + +/** + * 把仓库相对路径转换为当前平台风格的绝对路径。 + * 入参:真实仓库根目录和 Git 相对路径。 + * 出参:可交给文件读取命令的绝对路径。 + * 作用与流程:根据根目录是否使用反斜杠选择分隔符,并清理重复分隔符。 + */ +function absoluteRepositoryPath(repositoryRoot: string, relativePath: string): string { + const separator = repositoryRoot.includes("\\") ? "\\" : "/"; + return `${repositoryRoot.replace(/[\\/]+$/, "")}${separator}${relativePath.replace(/[\\/]/g, separator)}`; +} + +/** + * 返回文件状态的紧凑显示标签。 + * 入参:Git 文件状态。 + * 出参:冲突、未跟踪或 XY 状态文本。 + * 作用与流程:优先展示冲突和未跟踪语义,普通状态拼接 index/worktree 代码。 + */ +function statusLabel(file: GitFileStatus): string { + if (file.conflict) return "冲突"; + if (file.untracked) return "U"; + return `${file.indexStatus ?? "."}${file.worktreeStatus ?? "."}`; +} + +/** + * 渲染一组 Git 文件变化。 + * 入参:分组标题、文件、暂存模式和操作回调。 + * 出参:支持单项及全部暂存/取消暂存的文件列表。 + * 作用与流程:文件主体打开 diff,右侧按钮只更新 index;部分暂存文件可在不同组分别操作。 + */ +function ChangeGroup({ + title, + files, + mode, + disabled, + onFile, + onAll, + onOne, +}: ChangeGroupProps) { + if (files.length === 0) return null; + const ActionIcon = mode === "stage" ? Plus : Minus; + return ( +
+
+ {title} ({files.length}) + +
+ {files.map((file) => ( +
+ + +
+ ))} +
+ ); +} + +/** + * 渲染普通文件夹的 Git 初始化向导。 + * 入参:无,使用 Git store 当前工作区。 + * 出参:远端 URL、默认分支和安全忽略项表单。 + * 作用与流程:收集初始化信息,调用后端验证空远端并创建仓库,但不自动暂存或提交。 + */ +function RepositorySetup() { + const initWorkspace = useGitStore((state) => state.initWorkspace); + const writing = useGitStore((state) => state.writing); + const [remoteUrl, setRemoteUrl] = useState(""); + const [branch, setBranch] = useState("main"); + + /** + * 初始化当前工作区。 + * 入参:无。 + * 出参:Promise。 + * 作用与流程:校验表单后调用 store,成功提示用户在更改页核对并暂存内容。 + */ + const handleInit = async (): Promise => { + if (!remoteUrl.trim() || !branch.trim()) { + toast.error("请填写空远端地址和默认分支"); + return; + } + try { + await initWorkspace(remoteUrl.trim(), branch.trim()); + toast.success("Git 仓库已初始化,请核对并暂存需要提交的文件"); + } catch (cause) { + const error = normalizeGitError(cause); + toast.error(error.message, { description: error.details ?? undefined }); + } + }; + + return ( +
+
+

初始化 Git 仓库

+

+ 当前工作区尚未加入 Git。远端必须已创建且不包含任何 Git 引用。 +

+
+
+ + setRemoteUrl(event.target.value)} + placeholder={GIT_REMOTE_PLACEHOLDER} + /> +
+
+ + setBranch(event.target.value)} + /> +
+
+ 将补充忽略: +
+ {GIT_DEFAULT_IGNORE_RULES.map((rule) => ( + + {rule} + + ))} +
+
+ +
+ ); +} + +/** + * 渲染 Git 日常开发闭环面板。 + * 入参:无,从 Git、标签页和工作区 store 读取状态。 + * 出参:状态/差异、暂存/提交、推拉、分支与历史界面。 + * 作用与流程:统一执行未保存标签保护、结构化错误提示和 Git 写后文件树/编辑器刷新。 + */ +export function GitPanel() { + const availability = useGitStore((state) => state.availability); + const repository = useGitStore((state) => state.repository); + const branches = useGitStore((state) => state.branches); + const commits = useGitStore((state) => state.commits); + const identity = useGitStore((state) => state.identity); + const loading = useGitStore((state) => state.loading); + const writing = useGitStore((state) => state.writing); + const error = useGitStore((state) => state.error); + const hasMoreCommits = useGitStore((state) => state.hasMoreCommits); + const refresh = useGitStore((state) => state.refresh); + const loadMoreCommits = useGitStore((state) => state.loadMoreCommits); + const stage = useGitStore((state) => state.stage); + const unstage = useGitStore((state) => state.unstage); + const commit = useGitStore((state) => state.commit); + const setIdentity = useGitStore((state) => state.setIdentity); + const pull = useGitStore((state) => state.pull); + const push = useGitStore((state) => state.push); + const createBranch = useGitStore((state) => state.createBranch); + const switchBranch = useGitStore((state) => state.switchBranch); + const connectOrigin = useGitStore((state) => state.connectOrigin); + const refreshTree = useWorkspaceStore((state) => state.refreshTree); + const openFile = useTabsStore((state) => state.openFile); + + const [view, setView] = useState("changes"); + const [message, setMessage] = useState(""); + const [branchQuery, setBranchQuery] = useState(""); + const [newBranchOpen, setNewBranchOpen] = useState(false); + const [newBranchName, setNewBranchName] = useState(""); + const [identityOpen, setIdentityOpen] = useState(false); + const [identityName, setIdentityName] = useState(""); + const [identityEmail, setIdentityEmail] = useState(""); + const [remoteOpen, setRemoteOpen] = useState(false); + const [remoteUrl, setRemoteUrl] = useState(""); + const [pushOpen, setPushOpen] = useState(false); + const [pushRemote, setPushRemote] = useState(""); + const [pushBranch, setPushBranch] = useState(""); + const [diffOpen, setDiffOpen] = useState(false); + const [diffTitle, setDiffTitle] = useState(""); + const [diffValue, setDiffValue] = useState(null); + const [diffPath, setDiffPath] = useState(null); + + useEffect(() => { + void refresh(); + }, [refresh]); + + useEffect(() => { + setIdentityName(identity?.name ?? ""); + setIdentityEmail(identity?.email ?? ""); + }, [identity]); + + const groups = useMemo(() => groupGitFiles(repository?.files ?? []), [repository?.files]); + const visibleBranches = useMemo( + () => branches.filter((branch) => !branch.remote && branch.name.toLowerCase().includes(branchQuery.toLowerCase())), + [branches, branchQuery], + ); + + /** + * 在会影响磁盘或提交语义的操作前检查未保存标签页。 + * 入参:即将执行的操作名称。 + * 出参:可以继续时返回 true,否则显示文件列表并返回 false。 + * 作用与流程:读取 tabs store 的 isDirty 标记,避免 Git 操作忽略编辑器内存修改。 + */ + const ensureSavedTabs = (action: string): boolean => { + const dirtyPaths = getDirtyTabPaths(useTabsStore.getState().tabs); + if (dirtyPaths.length === 0) return true; + toast.error(`${action}前请先保存所有编辑器文件`, { + description: dirtyPaths.map((path) => path.split(/[\\/]/).pop()).join("、"), + }); + return false; + }; + + /** + * 刷新 Git 写操作影响的文件树和所有干净标签页。 + * 入参:无。 + * 出参:Promise。 + * 作用与流程:刷新当前工作区文件树,并逐个从磁盘重新加载未编辑标签页。 + */ + const syncWorkspaceFiles = async (): Promise => { + await refreshTree(); + const tabs = useTabsStore.getState().tabs.filter((tab) => !tab.isDirty); + await Promise.all(tabs.map((tab) => useTabsStore.getState().reloadFromDisk(tab.path))); + }; + + /** + * 展示 Git 操作错误。 + * 入参:未知错误值。 + * 出参:规范化后的 GitErrorPayload。 + * 作用与流程:身份缺失时打开仓库身份表单,其余错误通过 toast 展示摘要和脱敏详情。 + */ + const showError = (cause: unknown): GitErrorPayload => { + const normalized = normalizeGitError(cause); + if (normalized.code === "identity_missing") { + setIdentityOpen(true); + } else { + toast.error(normalized.message, { description: normalized.details ?? undefined }); + } + return normalized; + }; + + /** + * 打开文件工作区或暂存区差异。 + * 入参:文件状态和是否读取暂存区。 + * 出参:Promise。 + * 作用与流程:读取后端受限差异,记录路径供用户从对话框打开原文件。 + */ + const handleFileDiff = async (file: GitFileStatus, stagedDiff: boolean): Promise => { + try { + const value = await gitDiff(file.path, stagedDiff); + setDiffTitle(file.path); + setDiffValue(value); + setDiffPath(file.path); + setDiffOpen(true); + } catch (cause) { + showError(cause); + } + }; + + /** + * 暂存一组路径。 + * 入参:仓库相对路径列表。 + * 出参:Promise。 + * 作用与流程:调用 store 暂存并显示成功或结构化失败消息。 + */ + const handleStage = async (paths: string[]): Promise => { + try { + await stage(paths); + } catch (cause) { + showError(cause); + } + }; + + /** + * 取消暂存一组路径。 + * 入参:仓库相对路径列表。 + * 出参:Promise。 + * 作用与流程:调用 store 更新 index 并显示结构化失败消息。 + */ + const handleUnstage = async (paths: string[]): Promise => { + try { + await unstage(paths); + } catch (cause) { + showError(cause); + } + }; + + /** + * 提交当前已暂存改动。 + * 入参:无,读取提交输入框。 + * 出参:Promise。 + * 作用与流程:校验未保存标签、说明和暂存数量,确保身份后创建提交并清空输入。 + */ + const handleCommit = async (): Promise => { + if (!ensureSavedTabs("提交")) return; + const validation = validateCommit(message, groups.staged.length); + if (validation) { + toast.error(validation); + return; + } + if (!identity?.name || !identity.email) { + setIdentityOpen(true); + return; + } + try { + await commit(message.trim()); + setMessage(""); + toast.success("提交完成"); + } catch (cause) { + showError(cause); + } + }; + + /** + * 快进拉取当前分支。 + * 入参:无。 + * 出参:Promise。 + * 作用与流程:阻止未保存标签,执行 ff-only pull 后刷新文件树和干净标签页。 + */ + const handlePull = async (): Promise => { + if (!ensureSavedTabs("拉取")) return; + try { + await pull(); + await syncWorkspaceFiles(); + toast.success("拉取完成"); + } catch (cause) { + showError(cause); + } + }; + + /** + * 推送当前分支并在首次推送时设置 upstream。 + * 入参:无。 + * 出参:Promise。 + * 作用与流程:已有 upstream 直接推送;首次推送打开远端和分支选择,无远端时先打开连接表单。 + */ + const handlePush = async (): Promise => { + if (!repository?.branch) return; + if (!repository.upstream) { + if (repository.remotes.length === 0) { + setRemoteOpen(true); + return; + } + setPushRemote(repository.remotes.includes("origin") ? "origin" : repository.remotes[0]); + setPushBranch(repository.branch); + setPushOpen(true); + return; + } + try { + await push(); + toast.success("推送完成"); + } catch (cause) { + showError(cause); + } + }; + + /** + * 执行首次推送并设置 upstream。 + * 入参:无,读取首次推送对话框中的远端与分支。 + * 出参:Promise。 + * 作用与流程:校验选择后调用 push --set-upstream,成功时关闭对话框并刷新仓库状态。 + */ + const handleInitialPush = async (): Promise => { + if (!pushRemote || !pushBranch.trim()) { + toast.error("请选择远端并填写分支"); + return; + } + try { + await push(pushRemote, pushBranch.trim()); + setPushOpen(false); + toast.success("首次推送完成,已设置 upstream"); + } catch (cause) { + showError(cause); + } + }; + + /** + * 切换已有本地分支。 + * 入参:目标分支名。 + * 出参:Promise。 + * 作用与流程:阻止未保存标签,调用 Git 原生 switch 后同步文件树与标签页。 + */ + const handleSwitchBranch = async (name: string): Promise => { + if (!ensureSavedTabs("切换分支")) return; + try { + await switchBranch(name); + await syncWorkspaceFiles(); + toast.success(`已切换到 ${name}`); + } catch (cause) { + showError(cause); + } + }; + + /** + * 创建并切换新分支。 + * 入参:无,读取新分支输入框。 + * 出参:Promise。 + * 作用与流程:阻止未保存标签,后端校验分支名并从当前 HEAD 创建,成功后关闭表单。 + */ + const handleCreateBranch = async (): Promise => { + if (!newBranchName.trim() || !ensureSavedTabs("创建分支")) return; + try { + await createBranch(newBranchName.trim()); + await syncWorkspaceFiles(); + toast.success(`已创建并切换到 ${newBranchName.trim()}`); + setNewBranchName(""); + setNewBranchOpen(false); + } catch (cause) { + showError(cause); + } + }; + + /** + * 保存仓库级 Git 用户身份。 + * 入参:无,读取姓名和邮箱输入。 + * 出参:Promise。 + * 作用与流程:仅写当前仓库配置,成功后关闭身份对话框。 + */ + const handleIdentity = async (): Promise => { + try { + await setIdentity(identityName, identityEmail); + setIdentityOpen(false); + toast.success("当前仓库 Git 身份已保存"); + } catch (cause) { + showError(cause); + } + }; + + /** + * 连接当前仓库的 origin。 + * 入参:无,读取远端 URL 输入。 + * 出参:Promise。 + * 作用与流程:拒绝覆盖已有 origin,添加远端后关闭表单并刷新状态。 + */ + const handleConnectOrigin = async (): Promise => { + if (!remoteUrl.trim()) return; + try { + await connectOrigin(remoteUrl.trim()); + setRemoteOpen(false); + setRemoteUrl(""); + toast.success("origin 已连接"); + } catch (cause) { + showError(cause); + } + }; + + /** + * 打开指定提交详情。 + * 入参:提交 SHA 和显示主题。 + * 出参:Promise。 + * 作用与流程:读取提交文件及完整 diff,并复用统一差异对话框展示。 + */ + const handleCommitDiff = async (sha: string, subject: string): Promise => { + try { + const detail = await gitShowCommit(sha); + setDiffTitle(`${detail.commit.shortSha} ${subject}`); + setDiffValue(detail.diff); + setDiffPath(null); + setDiffOpen(true); + } catch (cause) { + showError(cause); + } + }; + + if (!availability && loading) { + return
正在检测 Git…
; + } + if (availability && (!availability.available || !availability.supported)) { + return ( +
+

系统 Git 不可用

+

+ {availability.available + ? `当前版本 ${availability.version ?? "未知"},需要 ${availability.minimumVersion} 或更高版本。` + : "请先安装 Git,再重新打开 Git 面板。"} +

+ +
+ ); + } + if (!repository) { + if (shouldShowRepositorySetup(error)) { + return ; + } + if (!error) { + return ; + } + return ( +
+

无法读取 Git 仓库

+

{error.message}

+ {error.details && ( +
+            {error.details}
+          
+ )} + +
+ ); + } + + return ( +
+
+
+ + + + + +
+ setBranchQuery(event.target.value)} + onKeyDown={(event) => event.stopPropagation()} + placeholder="搜索本地分支" + className="h-7 text-xs" + /> +
+ {visibleBranches.map((branch) => ( + void handleSwitchBranch(branch.name)} + > + + {branch.name} + {branch.current && } + + ))} + + setNewBranchOpen(true)}>新建分支… +
+
+ + + +
+
+ {repository.repositoryRoot} +
+
+ upstream: {repository.upstream ?? "未设置"} + ↑{repository.ahead} ↓{repository.behind} +
+ {!repository.remotes.includes("origin") && ( + + )} + {error && error.code !== "not_repository" && ( +
+ {error.message} + {error.details &&
{error.details}
} +
+ )} +
+ + + + 更改 ({repository.files.length}) + 提交记录 + + + + {repository.files.length === 0 ? ( +
工作区没有 Git 改动
+ ) : ( + <> + void handleFileDiff(file, false)} onAll={(paths) => void handleStage(paths)} onOne={(paths) => void handleStage(paths)} /> + void handleFileDiff(file, true)} onAll={(paths) => void handleUnstage(paths)} onOne={(paths) => void handleUnstage(paths)} /> + void handleFileDiff(file, false)} onAll={(paths) => void handleStage(paths)} onOne={(paths) => void handleStage(paths)} /> + void handleFileDiff(file, false)} onAll={(paths) => void handleStage(paths)} onOne={(paths) => void handleStage(paths)} /> + + )} +
+
+