diff --git a/.env.example b/.env.example
index 12fdd33..2be9b5c 100644
--- a/.env.example
+++ b/.env.example
@@ -23,3 +23,8 @@ LLM_API_KEY=
# OpenAI 兼容 Base URL,例如 https://api.openai.com/v1(可空)
LLM_BASE_URL=
+
+# 本地 Git 仓库根。未设则用进程 cwd(从 server/ 启动时 cwd 不是仓根)。
+# 撤回会改磁盘。本地试用先跑 scripts/git-sandbox.sh,再把这里指到 tmp/git-sandbox。
+# 要操作本仓时显式写成仓根。pnpm dev:api 在沙箱存在时默认用沙箱。
+GIT_REPO=
diff --git a/.gitignore b/.gitignore
index cfd9e26..796402f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
.env
.env.local
*.db
+tmp/
.DS_Store
node_modules
.pnpm-store
diff --git a/AGENTS.md b/AGENTS.md
index 9e37a0a..fa99a50 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -2,23 +2,24 @@
修改 CodeDock 代码前,先阅读 [`docs/architecture.md`](docs/architecture.md)。该文档是当前目录归属和模块边界的依据。
-Agent Loop 已闭环:用户发文本、装上下文、调模型、产出文字或 Tool、事件落库并由 SSE 消费。默认注册 `ping` 与记忆工具 `memory_read` / `memory_write` / `memory_search`,不实现文件 / Shell / Git。
+Agent Loop 已闭环:用户发文本、装上下文、调模型、产出文字或 Tool、事件落库并由 SSE 消费。默认注册 `ping` 与记忆工具 `memory_read` / `memory_write` / `memory_search`,不实现文件 / Shell / Git **工具**。Git 用户操作走 HTTP + `pkg/git`,不经过 Agent Tool。前端 Git 在 `packages/core/git`、`packages/views/git` 与 `apps/web` 的 `/git`,不扩 `AgentClient`。
## 目录放置规则
- 服务启动、配置读取、Router 和依赖装配放在 `server/cmd/server`。
-- 大部分 HTTP 逻辑放在 `server/internal/handler`:Session / Message / Usage / Approval 的 CRUD,SSE,Run 的 Start / Continue / Cancel,审批裁决,以及用户侧记忆查看/删除。
+- 大部分 HTTP 逻辑放在 `server/internal/handler`:Session / Message / Usage / Approval 的 CRUD,SSE,Run 的 Start / Continue / Cancel,审批裁决,用户侧记忆查看/删除,以及 Git(直接调 `pkg/git`)。
- Agent 运行时编排和 sqlc 持久化放在 `server/internal/agent`。
- Markdown 记忆(热层目录+专题)与 context message 索引(冷层按工作区 FTS)放在 `server/internal/agent/memory`;不放 `pkg/memory`。memory 不 import 父包 `internal/agent`,不定义 Tool。
- 具体工具定义放在 `server/internal/agent/tools`。工具名、入参/出参、schema、权限和编排都在本包;Execute 若要调外部能力,只通过 `Ports` 里的接口。Runtime `New` 时由 `cmd/server` 注入 `Ports` 的具体实现,再 `Register`。每个工具只定义入参/出参结构体,执行用 `encoding/json`,schema 从类型推断。`tools` 可 import `memory`,不 import 父包 `internal/agent`。
- Agent 通用无状态逻辑放在 `server/pkg/agent`:类型、token 统计、提示词、上下文、Tool 抽象(不含具体工具定义)、Agent 配置、模型调用。
+- Git CLI 操作放在 `server/pkg/git`:无状态,不写产品流程;Handler 直接调用。不进 `pkg/agent`。
- 进程内事件总线放在 `server/internal/events`。
- 数据库入口和 sqlc 生成代码放在 `server/pkg/db`。
- 数据库结构演进放在 `server/migrations`。
-- 无头业务放在 `packages/core`(`@codedock/core`):按业务域拆(现有 `chat/`,以后 `auth/`、`memory/`),文件直接在域目录下,不要 `src/`。不依赖 React、Next、DOM、`process.env`。`baseUrl` / `userId` 由调用方注入。
+- 无头业务放在 `packages/core`(`@codedock/core`):按业务域拆(现有 `chat/`、`git/`),文件直接在域目录下,不要 `src/`。不依赖 React、Next、DOM、`process.env`。`baseUrl` / `userId` 由调用方注入。Git 用独立 `GitClient`。
- 无业务 UI 放在 `packages/ui`(`@codedock/ui`):`components/`、`lib/`、`styles/`,不要 `src/`,不按业务域拆。不依赖 core,不知道 Session / Run / TimelineItem。
-- 组合层放在 `packages/views`(`@codedock/views`):按业务域拆,与 core 对齐(现有 `chat/`)。包根 `provider.tsx` 注入 client。不 import `next/*`;导航用回调。不要 `src/`,不预建空业务域。
-- Web 路由和平台装配放在 `apps/web`:读 `NEXT_PUBLIC_*`、创建 `AgentClient`、包 `AgentProvider`、`router.push`。不解析 SSE。
+- 组合层放在 `packages/views`(`@codedock/views`):按业务域拆,与 core 对齐(现有 `chat/`、`git/`)。包根 `provider.tsx` 注入 Agent client;Git 用 `views/git` 的 `GitProvider`。不 import `next/*`;导航用回调。不要 `src/`,不预建空业务域。
+- Web 路由和平台装配放在 `apps/web`:读 `NEXT_PUBLIC_*`、创建 `AgentClient` / `GitClient`、包对应 Provider、`router.push`。`/git` 放在 `(chat)` 组外。开发态切页顶栏只放 web。不解析 SSE。
- 依赖方向:`apps/web` → `packages/views` → `packages/core`;`packages/views` → `packages/ui`。`ui` 不依赖 `core`。未来 CLI 只依赖 `core`。
- 不要创建 `server/pkg/ai`。大模型调用属于 `pkg/agent`。
diff --git a/README.md b/README.md
index d3e5f9c..06108d7 100644
--- a/README.md
+++ b/README.md
@@ -14,14 +14,19 @@ cp apps/web/.env.example apps/web/.env.local
pnpm install
```
-API(默认 `http://localhost:8080`):
+一次起 API + Web。若已有 `tmp/git-sandbox`,API 默认指到沙箱,避免在本仓上试撤回:
```bash
-cd server
-go run ./cmd/server
+pnpm dev
```
-服务会从当前目录向上查找 `.env`,在 `server/` 下启动也能读到仓库根的 `.env`。
+也可以分开起。API(默认 `http://localhost:8080`):
+
+```bash
+pnpm dev:api
+```
+
+从 `server/` 直接 `go run` 时,未设 `GIT_REPO` 会用进程 cwd(`server/` 不是仓根)。服务会从当前目录向上查找 `.env`。
Web(默认 `http://localhost:3000`):
@@ -29,7 +34,7 @@ Web(默认 `http://localhost:3000`):
pnpm dev:web
```
-浏览器打开 [http://localhost:3000](http://localhost:3000)。完整环境变量见 [`.env.example`](.env.example),不要提交 `.env` 或密钥。
+浏览器打开 [http://localhost:3000](http://localhost:3000)。开发态顶栏可在对话和仓库之间切换。完整环境变量见 [`.env.example`](.env.example) 和 [`apps/web/.env.example`](apps/web/.env.example),不要提交 `.env` 或密钥。
## 测试
diff --git a/apps/web/app/(chat)/layout.tsx b/apps/web/app/(chat)/layout.tsx
index e75e31b..6aa6221 100644
--- a/apps/web/app/(chat)/layout.tsx
+++ b/apps/web/app/(chat)/layout.tsx
@@ -4,9 +4,9 @@ import { ChatHost } from "../chat-host";
export default function ChatLayout({ children }: { children: ReactNode }) {
return (
- <>
+
{children}
- >
+
);
}
diff --git a/apps/web/app/git-host.tsx b/apps/web/app/git-host.tsx
new file mode 100644
index 0000000..97d2947
--- /dev/null
+++ b/apps/web/app/git-host.tsx
@@ -0,0 +1,19 @@
+"use client";
+
+import { GitClient } from "@codedock/core/git";
+import { GitPage, GitProvider } from "@codedock/views/git";
+import { useMemo } from "react";
+
+import { apiBase } from "@/lib/env";
+
+export function GitHost() {
+ const client = useMemo(() => new GitClient({ baseUrl: apiBase }), []);
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/apps/web/app/git/page.tsx b/apps/web/app/git/page.tsx
new file mode 100644
index 0000000..53bb88e
--- /dev/null
+++ b/apps/web/app/git/page.tsx
@@ -0,0 +1,9 @@
+import { GitHost } from "../git-host";
+
+export default function GitRoutePage() {
+ return (
+
+
+
+ );
+}
diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx
index 72023f4..b9b72a9 100644
--- a/apps/web/app/layout.tsx
+++ b/apps/web/app/layout.tsx
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Providers } from "./providers";
+import { TestNav } from "./test-nav";
import "./globals.css";
const geistSans = Geist({
@@ -25,8 +26,11 @@ export default function RootLayout({ children }: LayoutProps<"/">) {
lang="zh-CN"
className={`${geistSans.variable} ${geistMono.variable} h-full dark antialiased`}
>
-
- {children}
+
+
+ {process.env.NODE_ENV === "development" ? : null}
+ {children}
+
);
diff --git a/apps/web/app/test-nav.tsx b/apps/web/app/test-nav.tsx
new file mode 100644
index 0000000..f9e5137
--- /dev/null
+++ b/apps/web/app/test-nav.tsx
@@ -0,0 +1,38 @@
+"use client";
+
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+
+const items = [
+ { href: "/", label: "对话", match: (path: string) => path === "/" || path.startsWith("/s/") },
+ { href: "/git", label: "仓库", match: (path: string) => path === "/git" || path.startsWith("/git/") },
+] as const;
+
+export function TestNav() {
+ const pathname = usePathname();
+
+ return (
+
+ );
+}
diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts
index 9e18fc7..6ebb7d3 100644
--- a/apps/web/next.config.ts
+++ b/apps/web/next.config.ts
@@ -5,6 +5,9 @@ const nextConfig: NextConfig = {
"@codedock/core",
"@codedock/ui",
"@codedock/views",
+ "@git-diff-view/react",
+ "@git-diff-view/core",
+ "@git-diff-view/utils",
"streamdown",
"@streamdown/cjk",
"@streamdown/code",
diff --git a/docs/architecture.md b/docs/architecture.md
index e8d0da1..1f10207 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -26,6 +26,7 @@ server/internal/handler
|-- CRUD / SSE / Start / Continue / Cancel / 审批 --> pkg/db/sqlite
|-- 领取 Run 后的 Loop --> internal/agent
|-- 用户记忆查看 / 删除 --> pkg/db/sqlite
+ |-- Git HTTP --> pkg/git(本机 CLI,无产品流程)
|
v
server/internal/agent
@@ -37,6 +38,7 @@ server/internal/agent
|
v
server/pkg/agent
+server/pkg/git
```
`pkg/ai` 已删除。大模型调用放在 `pkg/agent`,由 `ModelConfig` 在方法内创建,不由 Runtime 注入。
@@ -55,7 +57,7 @@ CodeDock/
├── server/
│ ├── cmd/server/ # 服务启动、配置、Router 和依赖装配
│ ├── internal/
-│ │ ├── handler/ # 大部分 HTTP:CRUD、SSE、Start / Continue / Cancel、记忆查看/删除
+│ │ ├── handler/ # 大部分 HTTP:CRUD、SSE、Start / Continue / Cancel、记忆查看/删除、Git
│ │ ├── agent/ # 运行时编排 + sqlc 持久化
│ │ │ ├── memory/ # 热层目录+专题,冷层工作区 FTS 索引
│ │ │ └── tools/ # 具体工具定义:ping、memory_*
@@ -66,6 +68,7 @@ CodeDock/
│ │ └── util/
│ ├── pkg/
│ │ ├── agent/ # 全部通用无状态逻辑,含模型调用与 Tool 抽象
+│ │ ├── git/ # 无状态 Git CLI 操作,供 Handler 直接调用
│ │ └── db/ # Client 与 sqlc 生成代码
│ ├── migrations/
│ ├── go.mod
@@ -85,6 +88,7 @@ cmd/server
internal/handler
-> pkg/db/sqlite.Queries
-> pkg/agent # 映射响应、token 统计、Profile 装配
+ -> pkg/git # 本机 Git CLI 操作
-> internal/agent # Worker 领取后的 Loop
-> internal/agent/memory # 用户侧记忆响应类型
@@ -112,9 +116,13 @@ pkg/agent
不持有包级状态,不查库
Tool 包只含接口、Registry、Dispatch,不含具体工具定义
+pkg/git
+ 不依赖 handler、internal、sqlc
+ 无状态,只 exec 本机 git;不写产品流程
+
packages/core
不依赖 React、Next、DOM、process.env、AI SDK
- 按业务域拆目录(chat、以后的 auth / memory),不要 src/
+ 按业务域拆目录(chat),不要 src/
文件直接落在 packages/core//
baseUrl / userId 由调用方注入
@@ -149,8 +157,9 @@ apps/web
- 事件 JSON 回放:`GET /sessions/{id}/event-log`,供前端一次 hydrate,不替代 SSE 直播
- Run 的 Start / Continue / Retry / Cancel 和审批裁决直接在 Handler 中处理,需要执行时再交给 Worker
- 同一 Session 只有一个 active Run:`interrupt` 先取消再开新 Run;`queue` 只落库,当前结束后自动领取
+- Git HTTP(`/git/*`):校验 checkout、组响应,直接调用 `pkg/git`。`GIT_REPO` 为空则用进程 cwd。`GET /git/status` 回 `SiteState` 整局(含 `is_repo`、跟踪、ahead/behind、integrating)
-Handler 直接依赖 `*sqlite.Queries`,不经过 Store 接口。
+Handler 直接依赖 `*sqlite.Queries`,不经过 Store 接口。Git 不查库。
### `internal/agent`
@@ -185,6 +194,10 @@ Handler 直接依赖 `*sqlite.Queries`,不经过 Store 接口。
每个工具只定义入参/出参结构体;执行用 `encoding/json`,给模型的 schema 由 `jsonschema.For` 从类型推断。Agent 通过 `Profile.Tools.Names` 绑定工具。运行模式提供 `read` / `write` / `memory` 能力,只有模式覆盖了工具声明的全部能力时该工具才对模型可见且可 Dispatch。记忆工具声明 `memory`。审批仍由工具声明 `RequiresApproval`,`ask_for_approval` 暂停、`auto_approve` / `yolo` 自动过。一批待批工具对应一条审批,一次提交审完再流转。不 import 父包 `internal/agent`。测试用 Tool 可留在测试文件。
+### `pkg/git`
+
+无状态 Git CLI:`Open` / `Status`(`SiteState` 整局)/ Diff / 图 / 暂存提交 / reset / revert / 推拉 / remote / 分支 / worktree / `stash create` 副本 / 冲突读写。不进 `pkg/agent`,不写 HTTP 或产品流程。Workspace / Branch / Undo / 说明 / Agent 快照的产品组合在 Handler。
+
### `pkg/agent`
全部 Agent 通用逻辑,方法无状态:
@@ -208,7 +221,7 @@ Handler 直接依赖 `*sqlite.Queries`,不经过 Store 接口。
### `packages/core`
-跨端无头业务,无 UI。按业务域拆目录,文件直接放在 `packages/core//`,不要 `src/`。现有 `chat/`:Session / Message / Run / 审批的 HTTP、SSE、Timeline reducer。有鉴权再加 `auth/`,有记忆再加 `memory/`,不预建空目录。`baseUrl` / `userId` 由调用方注入。不依赖 React。第一版 thinking 用 Run 状态(`queued` / `loading_context` / `running_llm`),不是模型 reasoning token。
+跨端无头业务,无 UI。按业务域拆目录,文件直接放在 `packages/core//`,不要 `src/`。现有 `chat/`:Session / Message / Run / 审批的 HTTP、SSE、Timeline reducer。有鉴权再加 `auth/`,有记忆再加 `memory/`,Git 前端在 `git/`(`GitClient`,不扩 `AgentClient`)。`baseUrl` / `userId` 由调用方注入。不依赖 React。第一版 thinking 用 Run 状态(`queued` / `loading_context` / `running_llm`),不是模型 reasoning token。
### `packages/ui`
@@ -222,11 +235,11 @@ Handler 直接依赖 `*sqlite.Queries`,不经过 Store 接口。
### `packages/views`
-组合 core + ui。按业务域拆,与 core 对齐,不要 `src/`。现有 `chat/`:`ChatPage`、侧栏、瀑布、审批、prompt。包根 `provider.tsx` 注入 `AgentClient` + `userId`。`ChatPage` 接 `sessionId` 与 `onOpenSession`。不 import `next/*`。新业务新建目录,不预建 Issue / Task / Review / Workspace。
+组合 core + ui。按业务域拆,与 core 对齐,不要 `src/`。现有 `chat/`:`ChatPage`、侧栏、瀑布、审批、prompt。包根 `provider.tsx` 注入 `AgentClient` + `userId`。`ChatPage` 接 `sessionId` 与 `onOpenSession`。Git 在 `git/`:`GitProvider` 只注入 `GitClient`,不进 `AgentContext`。不 import `next/*`。新业务新建目录,不预建 Issue / Task / Review / Workspace。
### `apps/web`
-路由、`NEXT_PUBLIC_API_BASE` / `NEXT_PUBLIC_USER_ID`、创建 `AgentClient`、包 `AgentProvider`、`router.push`。本机 Web 直连 `:8080`(CORS)。
+路由、`NEXT_PUBLIC_API_BASE` / `NEXT_PUBLIC_USER_ID`、创建 `AgentClient`、包 `AgentProvider`、`router.push`。本机 Web 直连 `:8080`(仅回环 Origin 的 CORS)。Git 页在 `(chat)` 组外的 `/git`,只装配 `GitClient`。开发态顶栏(对话 / 仓库)只放 web,views 不知道路径。
## 组装关系
@@ -248,8 +261,8 @@ Worker
## 配置
-`LLM_PROVIDER`(`openai` | `fake`,默认 `fake`)、`LLM_MODEL`、`LLM_API_KEY`、`LLM_BASE_URL`。Handler 创建 Run 时写入 `RunConfigSnapshot`,后续 Turn 只读快照。
+`LLM_PROVIDER`(`openai` | `fake`,默认 `fake`)、`LLM_MODEL`、`LLM_API_KEY`、`LLM_BASE_URL`。`GIT_REPO` 指向本地仓库根,未设则用进程 cwd(不向上找 `.git`)。Handler 创建 Run 时写入 `RunConfigSnapshot`,后续 Turn 只读快照。
-HTTP 出站领域对象使用 snake_case JSON。Router 对带 Origin 的请求回显 CORS,便于本机 Web 直连 `:8080`。Web 用 `NEXT_PUBLIC_API_BASE`(默认 `http://localhost:8080`)和 `NEXT_PUBLIC_USER_ID`(默认 `local`)。
+HTTP 出站领域对象使用 snake_case JSON。Router 只对本地回环 Origin 放行 CORS,便于本机 Web 直连 `:8080`。Web 用 `NEXT_PUBLIC_API_BASE`(默认 `http://localhost:8080`)和 `NEXT_PUBLIC_USER_ID`(默认 `local`)。
修改 Agent 能力或跨端协议时,需要检查契约、取消与终态、流式事件语义以及敏感信息处理。
diff --git a/package.json b/package.json
index 1bc9ce2..a2b32fd 100644
--- a/package.json
+++ b/package.json
@@ -2,6 +2,8 @@
"name": "codedock",
"private": true,
"scripts": {
+ "dev": "sh scripts/dev.sh",
+ "dev:api": "sh scripts/dev-api.sh",
"dev:web": "pnpm --filter web dev",
"build:web": "pnpm --filter web build",
"test:client": "pnpm --filter @codedock/core test",
diff --git a/packages/core/git/client.test.ts b/packages/core/git/client.test.ts
new file mode 100644
index 0000000..815b2ed
--- /dev/null
+++ b/packages/core/git/client.test.ts
@@ -0,0 +1,149 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+
+import { GitClient, GitClientError } from "./client.ts";
+
+test("GitClient status and mutations hit /git routes", async () => {
+ const calls: { url: string; method: string; body?: unknown }[] = [];
+ const client = new GitClient({
+ baseUrl: "http://api.test/",
+ fetch: async (input, init) => {
+ const url = String(input);
+ const method = init?.method ?? "GET";
+ const raw = typeof init?.body === "string" ? init.body : undefined;
+ calls.push({ url, method, body: raw ? JSON.parse(raw) : undefined });
+ if (url.endsWith("/git/status")) {
+ return json({
+ path: "/repo",
+ is_repo: true,
+ empty: false,
+ branch: "main",
+ head: "abc",
+ detached: false,
+ upstream: "",
+ ahead: 0,
+ behind: 0,
+ upstream_gone: false,
+ integrating: "",
+ default_branch: "",
+ files: [],
+ remotes: [],
+ });
+ }
+ if (url.endsWith("/git/commit")) {
+ return json({
+ commit: { id: "def", parents: ["abc"], title: "add a", body: "", author: "t", date: "" },
+ });
+ }
+ if (url.includes("/git/branches")) {
+ return json({
+ current: "main",
+ locals: [],
+ remotes: [],
+ graph: { nodes: [], edges: [] },
+ });
+ }
+ if (url.includes("/git/log")) {
+ return json({
+ commits: [{ id: "abc", parents: [], title: "first", body: "", author: "t", date: "2026-08-30T00:00:00Z" }],
+ });
+ }
+ if (url.includes("/git/diff")) {
+ return json({
+ files: [
+ {
+ path: "a.txt",
+ orig_path: "",
+ kind: "modified",
+ binary: false,
+ patch: "@@ -1 +1 @@\n-old\n+new\n",
+ },
+ ],
+ });
+ }
+ if (url.includes("/git/commit-message/prompt")) {
+ return json({
+ presets: [{ id: "conventional", name: "Conventional", system_prompt: "写说明" }],
+ selected: "conventional",
+ custom: "",
+ system_prompt: "写说明",
+ });
+ }
+ if (url.includes("/git/commit-message/generate")) {
+ return json({ title: "add a", body: "details" });
+ }
+ return json({ ok: true });
+ },
+ });
+
+ const state = await client.status();
+ assert.equal(state.branch, "main");
+ await client.stage(["a.txt"]);
+ await client.unstage(["a.txt"], "/wt");
+ await client.discard(["a.txt"]);
+ const commit = await client.commit({ message: "add a", paths: ["a.txt"] });
+ assert.equal(commit.id, "def");
+ await client.push();
+ const view = await client.listBranches("/wt");
+ assert.equal(view.current, "main");
+ await client.createBranch("feature", "origin/feature");
+ await client.switchBranch("feature", "/wt");
+ const diffs = await client.diff("worktree");
+ assert.equal(diffs[0]?.path, "a.txt");
+ const commits = await client.log(20, "/wt");
+ assert.equal(commits[0]?.title, "first");
+ const prompt = await client.messagePrompt("/wt");
+ assert.equal(prompt.selected, "conventional");
+ const saved = await client.saveMessagePrompt({ selected: "custom", custom: "写短标题" });
+ assert.equal(saved.selected, "conventional");
+ const draft = await client.generateMessage();
+ assert.equal(draft.title, "add a");
+ assert.equal(draft.body, "details");
+
+ assert.equal(calls[0]?.url, "http://api.test/git/status");
+ assert.equal(calls[1]?.url, "http://api.test/git/stage");
+ assert.deepEqual(calls[1]?.body, { paths: ["a.txt"], checkout: "" });
+ assert.equal(calls[2]?.url, "http://api.test/git/unstage");
+ assert.deepEqual(calls[2]?.body, { paths: ["a.txt"], checkout: "/wt" });
+ assert.equal(calls[3]?.url, "http://api.test/git/discard");
+ assert.deepEqual(calls[3]?.body, { paths: ["a.txt"], checkout: "" });
+ assert.equal(calls[5]?.url, "http://api.test/git/push");
+ assert.deepEqual(calls[5]?.body, { checkout: "" });
+ assert.equal(calls[6]?.url, "http://api.test/git/branches?checkout=%2Fwt");
+ assert.equal(calls[7]?.url, "http://api.test/git/branches");
+ assert.deepEqual(calls[7]?.body, { name: "feature", start: "origin/feature", checkout: "" });
+ assert.equal(calls[8]?.url, "http://api.test/git/branches/switch");
+ assert.deepEqual(calls[8]?.body, { name: "feature", checkout: "/wt" });
+ assert.equal(calls[9]?.url, "http://api.test/git/diff?scope=worktree");
+ assert.equal(calls[10]?.url, "http://api.test/git/log?limit=20&checkout=%2Fwt");
+ assert.equal(calls[11]?.url, "http://api.test/git/commit-message/prompt?checkout=%2Fwt");
+ assert.equal(calls[12]?.url, "http://api.test/git/commit-message/prompt");
+ assert.equal(calls[12]?.method, "PUT");
+ assert.deepEqual(calls[12]?.body, { selected: "custom", custom: "写短标题" });
+ assert.equal(calls[13]?.url, "http://api.test/git/commit-message/generate");
+ assert.deepEqual(calls[13]?.body, { checkout: "" });
+});
+
+test("GitClient maps error JSON", async () => {
+ const client = new GitClient({
+ baseUrl: "http://api.test",
+ fetch: async () =>
+ new Response(JSON.stringify({ error: "paths required" }), {
+ status: 400,
+ headers: { "Content-Type": "application/json" },
+ }),
+ });
+ await assert.rejects(() => client.stage([]), (err: unknown) => {
+ assert.ok(err instanceof GitClientError);
+ assert.equal(err.status, 400);
+ assert.equal(err.message, "paths required");
+ return true;
+ });
+});
+
+function json(body: unknown): Response {
+ return new Response(JSON.stringify(body), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+}
diff --git a/packages/core/git/client.ts b/packages/core/git/client.ts
new file mode 100644
index 0000000..a02fa3e
--- /dev/null
+++ b/packages/core/git/client.ts
@@ -0,0 +1,168 @@
+import type {
+ BranchView,
+ Commit,
+ CommitRequest,
+ DiffFile,
+ DiffScope,
+ MessageDraft,
+ PromptConfig,
+ PromptConfigUpdate,
+ SiteState,
+} from "./types.ts";
+
+export class GitClientError extends Error {
+ readonly status: number;
+
+ constructor(status: number, message: string) {
+ super(message);
+ this.name = "GitClientError";
+ this.status = status;
+ }
+}
+
+export type GitClientOptions = {
+ baseUrl: string;
+ fetch?: typeof fetch;
+};
+
+export class GitClient {
+ readonly baseUrl: string;
+ private readonly fetchImpl: typeof fetch;
+
+ constructor(options: GitClientOptions) {
+ this.baseUrl = options.baseUrl.replace(/\/$/, "");
+ this.fetchImpl = options.fetch ?? fetch.bind(globalThis);
+ }
+
+ async status(checkout?: string): Promise {
+ return this.request(`/git/status${query({ checkout })}`);
+ }
+
+ async stage(paths: string[], checkout?: string): Promise {
+ await this.request<{ ok: boolean }>("/git/stage", {
+ method: "POST",
+ json: { paths, checkout: checkout ?? "" },
+ });
+ }
+
+ async unstage(paths: string[], checkout?: string): Promise {
+ await this.request<{ ok: boolean }>("/git/unstage", {
+ method: "POST",
+ json: { paths, checkout: checkout ?? "" },
+ });
+ }
+
+ async discard(paths: string[], checkout?: string): Promise {
+ await this.request<{ ok: boolean }>("/git/discard", {
+ method: "POST",
+ json: { paths, checkout: checkout ?? "" },
+ });
+ }
+
+ async commit(req: CommitRequest): Promise {
+ const body = await this.request<{ commit: Commit }>("/git/commit", {
+ method: "POST",
+ json: {
+ message: req.message,
+ paths: req.paths ?? [],
+ checkout: req.checkout ?? "",
+ },
+ });
+ return body.commit;
+ }
+
+ async push(checkout?: string): Promise {
+ await this.request<{ ok: boolean }>("/git/push", {
+ method: "POST",
+ json: { checkout: checkout ?? "" },
+ });
+ }
+
+ async listBranches(checkout?: string): Promise {
+ return this.request(`/git/branches${query({ checkout })}`);
+ }
+
+ async createBranch(name: string, start?: string, checkout?: string): Promise {
+ await this.request<{ ok: boolean }>("/git/branches", {
+ method: "POST",
+ json: { name, start: start ?? "", checkout: checkout ?? "" },
+ });
+ }
+
+ async switchBranch(name: string, checkout?: string): Promise {
+ await this.request<{ ok: boolean }>("/git/branches/switch", {
+ method: "POST",
+ json: { name, checkout: checkout ?? "" },
+ });
+ }
+
+ async diff(scope: DiffScope = "staged", checkout?: string): Promise {
+ const body = await this.request<{ files?: DiffFile[] }>(`/git/diff${query({ scope, checkout })}`);
+ return body.files ?? [];
+ }
+
+ async log(limit = 50, checkout?: string): Promise {
+ const body = await this.request<{ commits?: Commit[] }>(
+ `/git/log${query({ limit: String(limit), checkout })}`,
+ );
+ return body.commits ?? [];
+ }
+
+ async messagePrompt(checkout?: string): Promise {
+ return this.request(`/git/commit-message/prompt${query({ checkout })}`);
+ }
+
+ async saveMessagePrompt(req: PromptConfigUpdate): Promise {
+ return this.request("/git/commit-message/prompt", {
+ method: "PUT",
+ json: { selected: req.selected, custom: req.custom },
+ });
+ }
+
+ async generateMessage(checkout?: string): Promise {
+ return this.request("/git/commit-message/generate", {
+ method: "POST",
+ json: { checkout: checkout ?? "" },
+ });
+ }
+
+ private async request(path: string, init: RequestInit & { json?: unknown } = {}): Promise {
+ const headers = new Headers(init.headers);
+ if (init.json !== undefined) {
+ headers.set("Content-Type", "application/json");
+ }
+ const res = await this.fetchImpl(`${this.baseUrl}${path}`, {
+ ...init,
+ headers,
+ body: init.json !== undefined ? JSON.stringify(init.json) : init.body,
+ });
+ const text = await res.text();
+ let parsed: unknown = undefined;
+ if (text) {
+ try {
+ parsed = JSON.parse(text);
+ } catch {
+ parsed = { error: text };
+ }
+ }
+ if (!res.ok) {
+ const message =
+ parsed && typeof parsed === "object" && "error" in parsed
+ ? String((parsed as { error: unknown }).error)
+ : res.statusText;
+ throw new GitClientError(res.status, message);
+ }
+ return parsed as T;
+ }
+}
+
+function query(params: Record): string {
+ const search = new URLSearchParams();
+ for (const [key, value] of Object.entries(params)) {
+ if (value) {
+ search.set(key, value);
+ }
+ }
+ const encoded = search.toString();
+ return encoded ? `?${encoded}` : "";
+}
diff --git a/packages/core/git/index.ts b/packages/core/git/index.ts
new file mode 100644
index 0000000..626fead
--- /dev/null
+++ b/packages/core/git/index.ts
@@ -0,0 +1,20 @@
+export { GitClient, GitClientError, type GitClientOptions } from "./client.ts";
+export type {
+ Branch,
+ BranchView,
+ Commit,
+ CommitRequest,
+ DiffFile,
+ DiffScope,
+ FileStatus,
+ Graph,
+ GraphEdge,
+ GraphNode,
+ MessageDraft,
+ PromptConfig,
+ PromptConfigUpdate,
+ PromptPreset,
+ Ref,
+ Remote,
+ SiteState,
+} from "./types.ts";
diff --git a/packages/core/git/types.ts b/packages/core/git/types.ts
new file mode 100644
index 0000000..f125c35
--- /dev/null
+++ b/packages/core/git/types.ts
@@ -0,0 +1,118 @@
+export type Remote = {
+ name: string;
+ fetch_url: string;
+ push_url: string;
+};
+
+export type FileStatus = {
+ path: string;
+ orig_path: string;
+ staged_status: string;
+ worktree_status: string;
+ unmerged: boolean;
+};
+
+export type SiteState = {
+ path: string;
+ is_repo: boolean;
+ empty: boolean;
+ branch: string;
+ head: string;
+ detached: boolean;
+ upstream: string;
+ ahead: number;
+ behind: number;
+ upstream_gone: boolean;
+ integrating: string;
+ default_branch: string;
+ files: FileStatus[];
+ remotes: Remote[];
+};
+
+export type Commit = {
+ id: string;
+ parents: string[];
+ title: string;
+ body: string;
+ author: string;
+ date: string;
+};
+
+export type Ref = {
+ name: string;
+ kind: "local" | "remote" | "tag" | "head" | string;
+};
+
+export type GraphNode = {
+ commit: Commit;
+ refs: Ref[];
+};
+
+export type GraphEdge = {
+ child: string;
+ parent: string;
+};
+
+export type Graph = {
+ nodes: GraphNode[];
+ edges: GraphEdge[];
+};
+
+export type Branch = {
+ name: string;
+ head: string;
+ is_current: boolean;
+ is_remote: boolean;
+ upstream: string;
+ ahead: number;
+ behind: number;
+ upstream_gone: boolean;
+ worktree_path: string;
+ title: string;
+};
+
+export type BranchView = {
+ current: string;
+ locals: Branch[];
+ remotes: Branch[];
+ graph: Graph;
+};
+
+export type CommitRequest = {
+ message: string;
+ paths?: string[];
+ checkout?: string;
+};
+
+export type DiffScope = "staged" | "worktree";
+
+export type DiffFile = {
+ path: string;
+ orig_path: string;
+ kind: string;
+ binary: boolean;
+ patch: string;
+};
+
+export type MessageDraft = {
+ title: string;
+ body: string;
+};
+
+export type PromptPreset = {
+ id: string;
+ name: string;
+ system_prompt: string;
+};
+
+export type PromptConfig = {
+ presets: PromptPreset[];
+ selected: string;
+ custom: string;
+ system_prompt: string;
+};
+
+export type PromptConfigUpdate = {
+ selected: string;
+ custom: string;
+};
diff --git a/packages/core/index.ts b/packages/core/index.ts
index f528574..5c4be59 100644
--- a/packages/core/index.ts
+++ b/packages/core/index.ts
@@ -19,6 +19,26 @@ export {
type AgentClientOptions,
type WatchEventsOptions,
} from "./chat/index.ts";
+export { GitClient, GitClientError, type GitClientOptions } from "./git/index.ts";
+export type {
+ Branch,
+ BranchView,
+ Commit,
+ CommitRequest,
+ DiffFile,
+ DiffScope,
+ FileStatus,
+ Graph,
+ GraphEdge,
+ GraphNode,
+ MessageDraft,
+ PromptConfig,
+ PromptConfigUpdate,
+ PromptPreset,
+ Ref,
+ Remote,
+ SiteState,
+} from "./git/index.ts";
export type {
AgentEvent,
AgentMode,
diff --git a/packages/core/package.json b/packages/core/package.json
index 57de2af..83a9523 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -5,10 +5,11 @@
"type": "module",
"exports": {
".": "./index.ts",
- "./chat": "./chat/index.ts"
+ "./chat": "./chat/index.ts",
+ "./git": "./git/index.ts"
},
"scripts": {
- "test": "node --test --experimental-strip-types chat/reducer.test.ts chat/sse.test.ts"
+ "test": "node --test --experimental-strip-types chat/reducer.test.ts chat/sse.test.ts git/client.test.ts"
},
"devDependencies": {
"@types/node": "^20",
diff --git a/packages/views/chat/chat-page.tsx b/packages/views/chat/chat-page.tsx
index fddf6bb..6c6e570 100644
--- a/packages/views/chat/chat-page.tsx
+++ b/packages/views/chat/chat-page.tsx
@@ -1,7 +1,7 @@
"use client";
import type { AgentMode, TimelineItem } from "@codedock/core/chat";
-import { useState } from "react";
+import { useState, type ReactNode } from "react";
import { useAgent } from "../provider.tsx";
import { ApprovalDock } from "./approval-dock.tsx";
@@ -16,6 +16,7 @@ export type ChatPageProps = {
onOpenSession: (id: string) => void;
onNewConversation: () => void;
brandSrc?: string;
+ headerActions?: ReactNode;
};
export function ChatPage({
@@ -23,6 +24,7 @@ export function ChatPage({
onOpenSession,
onNewConversation,
brandSrc,
+ headerActions,
}: ChatPageProps) {
const { client } = useAgent();
const list = useSessionList();
@@ -57,7 +59,7 @@ export function ChatPage({
};
return (
-
+
-
- {sessionId ? "对话" : "新对话"}
+
+ {sessionId ? "对话" : "新对话"}
+ {headerActions ? {headerActions}
: null}
{timeline.error || composerError ? (
diff --git a/packages/views/git/branch-switcher.tsx b/packages/views/git/branch-switcher.tsx
new file mode 100644
index 0000000..85183af
--- /dev/null
+++ b/packages/views/git/branch-switcher.tsx
@@ -0,0 +1,137 @@
+"use client";
+
+import type { Branch, BranchView } from "@codedock/core/git";
+import { cn } from "@codedock/ui";
+import { ChevronDownIcon } from "lucide-react";
+import { useEffect, useRef, useState } from "react";
+
+import { shortHead } from "./lib/status.ts";
+
+export function BranchSwitcher({
+ view,
+ busy,
+ onSelectLocal,
+ onSelectRemote,
+}: {
+ view: BranchView;
+ busy: boolean;
+ onSelectLocal: (name: string) => Promise
;
+ onSelectRemote: (name: string) => Promise;
+}) {
+ const [open, setOpen] = useState(false);
+ const root = useRef(null);
+ const current = view.current || "游离 HEAD";
+
+ useEffect(() => {
+ if (!open) {
+ return;
+ }
+ const onPointer = (event: PointerEvent) => {
+ if (root.current && !root.current.contains(event.target as Node)) {
+ setOpen(false);
+ }
+ };
+ document.addEventListener("pointerdown", onPointer);
+ return () => document.removeEventListener("pointerdown", onPointer);
+ }, [open]);
+
+ const pick = async (kind: "local" | "remote", name: string) => {
+ if (busy) {
+ return;
+ }
+ setOpen(false);
+ if (kind === "local") {
+ if (name === view.current) {
+ return;
+ }
+ await onSelectLocal(name);
+ return;
+ }
+ await onSelectRemote(name);
+ };
+
+ return (
+
+
+ {open ? (
+
+ void pick("local", name)}
+ />
+ void pick("remote", name)}
+ />
+
+ ) : null}
+
+ );
+}
+
+function BranchGroup({
+ title,
+ empty,
+ items,
+ current,
+ onPick,
+}: {
+ title: string;
+ empty: string;
+ items: Branch[];
+ current: string;
+ onPick: (name: string) => void;
+}) {
+ return (
+
+
{title}
+ {items.length === 0 ? (
+
{empty}
+ ) : (
+
+ {items.map((branch) => {
+ const active = !branch.is_remote && (branch.is_current || branch.name === current);
+ return (
+ -
+
+
+ );
+ })}
+
+ )}
+
+ );
+}
diff --git a/packages/views/git/commit-history.tsx b/packages/views/git/commit-history.tsx
new file mode 100644
index 0000000..6a4a893
--- /dev/null
+++ b/packages/views/git/commit-history.tsx
@@ -0,0 +1,88 @@
+"use client";
+
+import type { Commit } from "@codedock/core/git";
+import { cn } from "@codedock/ui";
+import { ChevronDownIcon } from "lucide-react";
+import { useEffect, useRef, useState } from "react";
+
+import { shortHead } from "./lib/status.ts";
+
+export function CommitHistory({ commits, busy }: { commits: Commit[]; busy: boolean }) {
+ const [open, setOpen] = useState(false);
+ const root = useRef(null);
+ const latest = commits[0];
+
+ useEffect(() => {
+ if (!open) {
+ return;
+ }
+ const onPointer = (event: PointerEvent) => {
+ if (root.current && !root.current.contains(event.target as Node)) {
+ setOpen(false);
+ }
+ };
+ const onKey = (event: KeyboardEvent) => {
+ if (event.key === "Escape") {
+ setOpen(false);
+ }
+ };
+ document.addEventListener("pointerdown", onPointer);
+ document.addEventListener("keydown", onKey);
+ return () => {
+ document.removeEventListener("pointerdown", onPointer);
+ document.removeEventListener("keydown", onKey);
+ };
+ }, [open]);
+
+ return (
+
+
+ {open ? (
+
+
提交历史
+ {commits.length === 0 ? (
+
还没有提交
+ ) : (
+
+ {commits.map((commit, index) => (
+ -
+
+ {commit.title}
+ {index === 0 ? (
+ HEAD
+ ) : null}
+
+
+ {shortHead(commit.id)}
+ {commit.author ? ` · ${commit.author}` : ""}
+ {commit.date ? ` · ${commit.date.slice(0, 10)}` : ""}
+
+
+ ))}
+
+ )}
+
+ ) : null}
+
+ );
+}
diff --git a/packages/views/git/diff-panel.tsx b/packages/views/git/diff-panel.tsx
new file mode 100644
index 0000000..58671ab
--- /dev/null
+++ b/packages/views/git/diff-panel.tsx
@@ -0,0 +1,75 @@
+"use client";
+
+import type { DiffFile as GitDiffFile } from "@codedock/core/git";
+import { DiffModeEnum, DiffView } from "@git-diff-view/react";
+import "@git-diff-view/react/styles/diff-view.css";
+
+import { scopeLabel, type PreviewTarget } from "./lib/preview.ts";
+
+export function DiffPanel({
+ target,
+ file,
+ ready,
+}: {
+ target: PreviewTarget | null;
+ file: GitDiffFile | null;
+ ready: boolean;
+}) {
+ return (
+
+
+ {target ? (
+ <>
+ {scopeLabel(target.scope)}
+ {target.path}
+ {file?.orig_path ? (
+ 来自 {file.orig_path}
+ ) : null}
+ >
+ ) : (
+ 差异
+ )}
+
+ {content(target, file, ready)}
+
+ );
+}
+
+function content(target: PreviewTarget | null, file: GitDiffFile | null, ready: boolean) {
+ if (!target) {
+ return 点击左侧文件查看差异;
+ }
+ if (!ready) {
+ return 正在读取差异…;
+ }
+ if (!file) {
+ return 该文件当前没有差异;
+ }
+ if (file.binary) {
+ return 二进制文件,无法展示文本差异;
+ }
+ const patch = file.patch.trim();
+ if (!patch) {
+ return 这个文件没有可展示的文本差异;
+ }
+ return (
+
+ );
+}
+
+function Empty({ children }: { children: string }) {
+ return {children}
;
+}
diff --git a/packages/views/git/discard-confirm.tsx b/packages/views/git/discard-confirm.tsx
new file mode 100644
index 0000000..c496ce3
--- /dev/null
+++ b/packages/views/git/discard-confirm.tsx
@@ -0,0 +1,80 @@
+"use client";
+
+import { Button } from "@codedock/ui";
+import { useEffect } from "react";
+
+export type DiscardRequest = {
+ name: string;
+ paths: string[];
+};
+
+export function DiscardConfirm({
+ request,
+ busy,
+ onCancel,
+ onConfirm,
+}: {
+ request: DiscardRequest | null;
+ busy: boolean;
+ onCancel: () => void;
+ onConfirm: () => void;
+}) {
+ useEffect(() => {
+ if (!request) {
+ return;
+ }
+ const onKey = (event: KeyboardEvent) => {
+ if (event.key === "Escape" && !busy) {
+ onCancel();
+ }
+ };
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, [busy, onCancel, request]);
+
+ if (!request) {
+ return null;
+ }
+
+ const count = request.paths.length;
+ const summary =
+ count > 1
+ ? `将丢掉「${request.name}」下 ${count} 个文件的工作区改动。`
+ : `将丢掉「${request.name}」的工作区改动。`;
+
+ return (
+ {
+ if (!busy) {
+ onCancel();
+ }
+ }}
+ >
+
event.stopPropagation()}
+ >
+
+ 撤回更改
+
+
+ {summary}
+ 未跟踪的文件会被删除,已暂存的内容不受影响。此操作无法撤销。
+
+
+
+
+
+
+
+ );
+}
diff --git a/packages/views/git/file-tree.tsx b/packages/views/git/file-tree.tsx
new file mode 100644
index 0000000..1214dd9
--- /dev/null
+++ b/packages/views/git/file-tree.tsx
@@ -0,0 +1,191 @@
+"use client";
+
+import type { FileStatus } from "@codedock/core/git";
+import { cn } from "@codedock/ui";
+import { ChevronDownIcon, ChevronRightIcon, File, Folder, FolderOpen, Minus, Plus, Undo2 } from "lucide-react";
+import { useMemo, useState } from "react";
+
+import { isPreviewablePath } from "./lib/preview.ts";
+import { buildFileTree, collectFilePaths, type FileTreeNode } from "./lib/tree.ts";
+
+const ROW_PAD = 10;
+const DEPTH_STEP = 20;
+
+export type TreeAction = {
+ kind: "stage" | "unstage";
+ onRun: (paths: string[]) => void;
+ onDiscard?: (info: { name: string; paths: string[] }) => void;
+};
+
+export function FileTree({
+ files,
+ labelOf,
+ empty,
+ activePath,
+ busy,
+ action,
+ onPreview,
+}: {
+ files: FileStatus[];
+ labelOf: (file: FileStatus) => string;
+ empty: string;
+ activePath?: string | null;
+ busy?: boolean;
+ action?: TreeAction;
+ onPreview?: (path: string) => void;
+}) {
+ const tree = useMemo(() => buildFileTree(files), [files]);
+ if (files.length === 0) {
+ return {empty}
;
+ }
+ return (
+
+ {tree.map((node) => (
+
+ ))}
+
+ );
+}
+
+function TreeNode({
+ node,
+ depth,
+ activePath,
+ busy,
+ labelOf,
+ action,
+ onPreview,
+}: {
+ node: FileTreeNode;
+ depth: number;
+ activePath?: string | null;
+ busy?: boolean;
+ labelOf: (file: FileStatus) => string;
+ action?: TreeAction;
+ onPreview?: (path: string) => void;
+}) {
+ const [open, setOpen] = useState(true);
+ const paths = collectFilePaths(node);
+ const file = node.file;
+ const expandable = !file || node.children.length > 0;
+ const previewable = Boolean(file && isPreviewablePath(file.path));
+ const active = Boolean(file && activePath === file.path);
+ const padding = ROW_PAD + depth * DEPTH_STEP;
+
+ return (
+
+ {
+ if (event.key !== "Enter" && event.key !== " ") {
+ return;
+ }
+ event.preventDefault();
+ if (expandable) {
+ setOpen((prev) => !prev);
+ return;
+ }
+ if (previewable && file) {
+ onPreview?.(file.path);
+ }
+ }}
+ onClick={() => {
+ if (expandable) {
+ setOpen((prev) => !prev);
+ return;
+ }
+ if (previewable && file) {
+ onPreview?.(file.path);
+ }
+ }}
+ >
+ {expandable ? (
+
+ {open ? : }
+
+ ) : (
+
+ )}
+ {previewable ? (
+
+ ) : open && expandable ? (
+
+ ) : (
+
+ )}
+
+ {node.name}
+ {file?.orig_path ? (
+ 来自 {file.orig_path}
+ ) : null}
+
+ {file ? (
+
{labelOf(file)}
+ ) : (
+
{paths.length}
+ )}
+ {action ? (
+
+ ) : null}
+ {action?.onDiscard ? (
+
+ ) : null}
+
+ {expandable && open ? (
+
+ {node.children.map((child) => (
+
+ ))}
+
+ ) : null}
+
+ );
+}
diff --git a/packages/views/git/git-page.tsx b/packages/views/git/git-page.tsx
new file mode 100644
index 0000000..c499695
--- /dev/null
+++ b/packages/views/git/git-page.tsx
@@ -0,0 +1,98 @@
+"use client";
+
+import { Button } from "@codedock/ui";
+import { useMemo, useState, type ReactNode } from "react";
+
+import { BranchSwitcher } from "./branch-switcher.tsx";
+import { CommitHistory } from "./commit-history.tsx";
+import { DiffPanel } from "./diff-panel.tsx";
+import { useGitSite } from "./hooks/use-git-site.ts";
+import type { PreviewTarget } from "./lib/preview.ts";
+import { shortHead, trackLabel } from "./lib/status.ts";
+import { WorkspacePanel } from "./workspace-panel.tsx";
+
+export type GitPageProps = {
+ onBack?: () => void;
+ headerActions?: ReactNode;
+};
+
+export function GitPage({ onBack, headerActions }: GitPageProps) {
+ const site = useGitSite();
+ const [preview, setPreview] = useState(null);
+ const previewFile = useMemo(() => {
+ if (!preview) {
+ return null;
+ }
+ const list = preview.scope === "staged" ? site.diffs.staged : site.diffs.worktree;
+ return list.find((file) => file.path === preview.path) ?? null;
+ }, [preview, site.diffs]);
+
+ return (
+
+
+ 仓库
+ {site.state.is_repo ? (
+ <>
+
+
+
+ {site.state.path || "—"}
+ {site.state.head ? ` · ${shortHead(site.state.head)}` : ""}
+ {site.state.upstream
+ ? ` · ${trackLabel(site.state.ahead, site.state.behind, site.state.upstream, site.state.upstream_gone)}`
+ : ""}
+ {site.state.integrating ? ` · 正在 ${site.state.integrating}` : ""}
+
+ >
+ ) : null}
+
+ {headerActions}
+ {onBack ? (
+
+ ) : null}
+
+
+ {site.error ? (
+
+ {site.error}
+
+ ) : null}
+
+ {site.loading ? (
+
正在读取仓库…
+ ) : !site.state.is_repo ? (
+
+ 当前文件夹还不是 Git 仓库。把 GIT_REPO 指到仓根后再打开。
+
+ ) : (
+ <>
+
+
+ >
+ )}
+
+
+ );
+}
diff --git a/packages/views/git/hooks/use-git-site.ts b/packages/views/git/hooks/use-git-site.ts
new file mode 100644
index 0000000..c53b6b6
--- /dev/null
+++ b/packages/views/git/hooks/use-git-site.ts
@@ -0,0 +1,216 @@
+"use client";
+
+import type { BranchView, Commit, DiffFile, MessageDraft, PromptConfig, SiteState } from "@codedock/core/git";
+import { useCallback, useEffect, useState } from "react";
+
+import { localNameFromRemote } from "../lib/status.ts";
+import { useGit } from "../provider.tsx";
+
+const emptyState = (): SiteState => ({
+ path: "",
+ is_repo: false,
+ empty: true,
+ branch: "",
+ head: "",
+ detached: false,
+ upstream: "",
+ ahead: 0,
+ behind: 0,
+ upstream_gone: false,
+ integrating: "",
+ default_branch: "",
+ files: [],
+ remotes: [],
+});
+
+const emptyBranches = (): BranchView => ({
+ current: "",
+ locals: [],
+ remotes: [],
+ graph: { nodes: [], edges: [] },
+});
+
+const emptyDiffs = (): { staged: DiffFile[]; worktree: DiffFile[] } => ({
+ staged: [],
+ worktree: [],
+});
+
+export function useGitSite() {
+ const { client } = useGit();
+ const [state, setState] = useState(emptyState);
+ const [branches, setBranches] = useState(emptyBranches);
+ const [diffs, setDiffs] = useState(emptyDiffs);
+ const [commits, setCommits] = useState([]);
+ const [prompt, setPrompt] = useState(null);
+ const [error, setError] = useState(null);
+ const [busy, setBusy] = useState(false);
+ const [generating, setGenerating] = useState(false);
+ const [loading, setLoading] = useState(true);
+
+ const refresh = useCallback(async () => {
+ try {
+ const nextState = await client.status();
+ setState(nextState);
+ const extras = await Promise.allSettled([
+ client.listBranches(),
+ client.diff("staged"),
+ client.diff("worktree"),
+ client.log(),
+ ]);
+ const [nextBranches, staged, worktree, nextCommits] = extras;
+ if (nextBranches.status === "fulfilled") {
+ setBranches({
+ ...nextBranches.value,
+ locals: nextBranches.value.locals ?? [],
+ remotes: nextBranches.value.remotes ?? [],
+ graph: nextBranches.value.graph ?? { nodes: [], edges: [] },
+ });
+ }
+ if (staged.status === "fulfilled" || worktree.status === "fulfilled") {
+ setDiffs((prev) => ({
+ staged: staged.status === "fulfilled" ? staged.value : prev.staged,
+ worktree: worktree.status === "fulfilled" ? worktree.value : prev.worktree,
+ }));
+ }
+ if (nextCommits.status === "fulfilled") {
+ setCommits(nextCommits.value);
+ }
+ const failed = extras.find((item) => item.status === "rejected");
+ if (failed && failed.status === "rejected") {
+ setError(failed.reason instanceof Error ? failed.reason.message : "无法读取仓库");
+ return;
+ }
+ setError(null);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "无法读取仓库");
+ } finally {
+ setLoading(false);
+ }
+ }, [client]);
+
+ useEffect(() => {
+ void refresh();
+ }, [refresh]);
+
+ useEffect(() => {
+ let cancelled = false;
+ void client
+ .messagePrompt()
+ .then((next) => {
+ if (!cancelled) {
+ setPrompt(next);
+ }
+ })
+ .catch((err: unknown) => {
+ if (!cancelled) {
+ setError(err instanceof Error ? err.message : "无法读取提示词");
+ }
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [client]);
+
+ const run = useCallback(
+ async (action: () => Promise) => {
+ setBusy(true);
+ try {
+ await action();
+ await refresh();
+ setError(null);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "操作失败");
+ throw err;
+ } finally {
+ setBusy(false);
+ }
+ },
+ [refresh],
+ );
+
+ const stage = useCallback((paths: string[]) => run(() => client.stage(paths)), [client, run]);
+ const unstage = useCallback((paths: string[]) => run(() => client.unstage(paths)), [client, run]);
+ const discard = useCallback((paths: string[]) => run(() => client.discard(paths)), [client, run]);
+ const commit = useCallback(
+ (message: string) => run(() => client.commit({ message, paths: [] })),
+ [client, run],
+ );
+ const push = useCallback(() => run(() => client.push()), [client, run]);
+ const switchBranch = useCallback(
+ (name: string) => run(() => client.switchBranch(name)),
+ [client, run],
+ );
+ const reload = useCallback(async () => {
+ setBusy(true);
+ try {
+ await refresh();
+ } finally {
+ setBusy(false);
+ }
+ }, [refresh]);
+
+ const generate = useCallback(async (): Promise => {
+ setGenerating(true);
+ try {
+ const draft = await client.generateMessage();
+ setError(null);
+ return draft;
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "生成失败");
+ throw err;
+ } finally {
+ setGenerating(false);
+ }
+ }, [client]);
+
+ const savePrompt = useCallback(
+ async (selected: string, custom: string) => {
+ try {
+ const next = await client.saveMessagePrompt({ selected, custom });
+ setPrompt(next);
+ setError(null);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "无法保存提示词");
+ throw err;
+ }
+ },
+ [client],
+ );
+
+ const checkoutRemote = useCallback(
+ (remoteName: string) =>
+ run(async () => {
+ const local = localNameFromRemote(remoteName);
+ const listed = await client.listBranches();
+ const exists = (listed.locals ?? []).some((branch) => branch.name === local);
+ if (!exists) {
+ await client.createBranch(local, remoteName);
+ }
+ await client.switchBranch(local);
+ }),
+ [client, run],
+ );
+
+ return {
+ state,
+ branches,
+ diffs,
+ commits,
+ prompt,
+ error,
+ busy,
+ generating,
+ loading,
+ refresh,
+ reload,
+ stage,
+ unstage,
+ discard,
+ commit,
+ generate,
+ savePrompt,
+ push,
+ switchBranch,
+ checkoutRemote,
+ };
+}
diff --git a/packages/views/git/index.ts b/packages/views/git/index.ts
new file mode 100644
index 0000000..a22b476
--- /dev/null
+++ b/packages/views/git/index.ts
@@ -0,0 +1,3 @@
+export { GitPage, type GitPageProps } from "./git-page.tsx";
+export { GitProvider, useGit } from "./provider.tsx";
+export { useGitSite } from "./hooks/use-git-site.ts";
diff --git a/packages/views/git/lib/preview.ts b/packages/views/git/lib/preview.ts
new file mode 100644
index 0000000..6adda4f
--- /dev/null
+++ b/packages/views/git/lib/preview.ts
@@ -0,0 +1,14 @@
+import type { DiffScope } from "@codedock/core/git";
+
+export type PreviewTarget = {
+ path: string;
+ scope: DiffScope;
+};
+
+export function isPreviewablePath(path: string): boolean {
+ return path !== "" && !path.endsWith("/");
+}
+
+export function scopeLabel(scope: DiffScope): string {
+ return scope === "staged" ? "已暂存" : "当前目录";
+}
diff --git a/packages/views/git/lib/status.ts b/packages/views/git/lib/status.ts
new file mode 100644
index 0000000..e112347
--- /dev/null
+++ b/packages/views/git/lib/status.ts
@@ -0,0 +1,80 @@
+import type { FileStatus } from "@codedock/core/git";
+
+export function letterDirty(letter: string): boolean {
+ return letter !== "" && letter !== " " && letter !== ".";
+}
+
+export function isStaged(file: FileStatus): boolean {
+ return letterDirty(file.staged_status);
+}
+
+export function isWorktreeDirty(file: FileStatus): boolean {
+ return letterDirty(file.worktree_status);
+}
+
+export function splitWorkspaceFiles(files: FileStatus[]): {
+ staged: FileStatus[];
+ worktree: FileStatus[];
+} {
+ return {
+ staged: files.filter((file) => isStaged(file) && !file.unmerged),
+ worktree: files.filter((file) => isWorktreeDirty(file) || file.unmerged),
+ };
+}
+
+export function stagedLabel(file: FileStatus): string {
+ if (file.unmerged) {
+ return "冲突";
+ }
+ switch (file.staged_status) {
+ case "A":
+ return "新增";
+ case "D":
+ return "删除";
+ case "R":
+ return "重命名";
+ case "C":
+ return "复制";
+ default:
+ return "已修改";
+ }
+}
+
+export function worktreeLabel(file: FileStatus): string {
+ if (file.unmerged) {
+ return "冲突";
+ }
+ if (file.worktree_status === "?") {
+ return "未跟踪";
+ }
+ if (file.worktree_status === "D") {
+ return "删除";
+ }
+ return "已修改";
+}
+
+export function localNameFromRemote(remoteName: string): string {
+ const slash = remoteName.indexOf("/");
+ return slash === -1 ? remoteName : remoteName.slice(slash + 1);
+}
+
+export function shortHead(head: string): string {
+ return head.slice(0, 8);
+}
+
+export function trackLabel(ahead: number, behind: number, upstream: string, gone: boolean): string {
+ if (!upstream) {
+ return "未跟踪远程";
+ }
+ if (gone) {
+ return `${upstream} 已删除`;
+ }
+ const bits: string[] = [];
+ if (ahead > 0) {
+ bits.push(`超前 ${ahead}`);
+ }
+ if (behind > 0) {
+ bits.push(`落后 ${behind}`);
+ }
+ return bits.length > 0 ? `${upstream} · ${bits.join(" · ")}` : upstream;
+}
diff --git a/packages/views/git/lib/tree.test.ts b/packages/views/git/lib/tree.test.ts
new file mode 100644
index 0000000..4562bf3
--- /dev/null
+++ b/packages/views/git/lib/tree.test.ts
@@ -0,0 +1,98 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+
+import { isPreviewablePath } from "./preview.ts";
+import { localNameFromRemote, splitWorkspaceFiles } from "./status.ts";
+import { buildFileTree, collectFilePaths } from "./tree.ts";
+
+function file(
+ path: string,
+ staged = " ",
+ worktree = "M",
+): {
+ path: string;
+ orig_path: string;
+ staged_status: string;
+ worktree_status: string;
+ unmerged: boolean;
+} {
+ return {
+ path,
+ orig_path: "",
+ staged_status: staged,
+ worktree_status: worktree,
+ unmerged: false,
+ };
+}
+
+test("buildFileTree groups files under directories", () => {
+ const tree = buildFileTree([
+ file("apps/web/app/page.tsx"),
+ file("apps/web/package.json"),
+ file("README.md"),
+ ]);
+ assert.equal(tree.length, 2);
+ assert.equal(tree[0]?.name, "apps");
+ assert.equal(tree[0]?.children[0]?.name, "web");
+ assert.deepEqual(
+ tree[0]?.children[0]?.children.map((node) => node.name),
+ ["app", "package.json"],
+ );
+ assert.equal(tree[1]?.name, "README.md");
+ assert.equal(tree[1]?.file?.path, "README.md");
+});
+
+test("collectFilePaths walks a folder", () => {
+ const tree = buildFileTree([file("a/b.txt"), file("a/c.txt")]);
+ assert.deepEqual(collectFilePaths(tree[0]!), ["a/b.txt", "a/c.txt"]);
+});
+
+test("splitWorkspaceFiles separates staged and worktree", () => {
+ const split = splitWorkspaceFiles([
+ file("staged.ts", "M", " "),
+ file("dirty.ts", " ", "M"),
+ file("both.ts", "M", "M"),
+ { ...file("conflict.ts", "U", "U"), unmerged: true },
+ ]);
+ assert.deepEqual(
+ split.staged.map((item) => item.path),
+ ["staged.ts", "both.ts"],
+ );
+ assert.deepEqual(
+ split.worktree.map((item) => item.path),
+ ["dirty.ts", "both.ts", "conflict.ts"],
+ );
+});
+
+test("buildFileTree treats trailing-slash paths as folders", () => {
+ const tree = buildFileTree([file("apps/web/app/git/")]);
+ const git = tree[0]?.children[0]?.children[0]?.children[0];
+ assert.equal(git?.name, "git");
+ assert.equal(git?.file, undefined);
+ assert.deepEqual(git?.children, []);
+});
+
+test("buildFileTree nests untracked files under folders", () => {
+ const tree = buildFileTree([
+ file("packages/views/git/file-tree.tsx", " ", "?"),
+ file("packages/views/git/git-page.tsx", " ", "?"),
+ ]);
+ const git = tree[0]?.children[0]?.children[0];
+ assert.equal(git?.name, "git");
+ assert.equal(git?.file, undefined);
+ assert.deepEqual(
+ git?.children.map((node) => node.name),
+ ["file-tree.tsx", "git-page.tsx"],
+ );
+});
+
+test("localNameFromRemote strips the remote prefix", () => {
+ assert.equal(localNameFromRemote("origin/feat/git"), "feat/git");
+ assert.equal(localNameFromRemote("main"), "main");
+});
+
+test("isPreviewablePath skips folders", () => {
+ assert.equal(isPreviewablePath("apps/web/app/page.tsx"), true);
+ assert.equal(isPreviewablePath("apps/web/app/git/"), false);
+ assert.equal(isPreviewablePath(""), false);
+});
diff --git a/packages/views/git/lib/tree.ts b/packages/views/git/lib/tree.ts
new file mode 100644
index 0000000..879e86e
--- /dev/null
+++ b/packages/views/git/lib/tree.ts
@@ -0,0 +1,68 @@
+import type { FileStatus } from "@codedock/core/git";
+
+export type FileTreeNode = {
+ name: string;
+ path: string;
+ file?: FileStatus;
+ children: FileTreeNode[];
+};
+
+export function buildFileTree(files: FileStatus[]): FileTreeNode[] {
+ const root: FileTreeNode[] = [];
+ const dirs = new Map();
+
+ const ensureDir = (dirPath: string): FileTreeNode[] => {
+ if (!dirPath) {
+ return root;
+ }
+ const existing = dirs.get(dirPath);
+ if (existing) {
+ return existing.children;
+ }
+ const parts = dirPath.split("/");
+ const name = parts[parts.length - 1] ?? dirPath;
+ const parentPath = parts.slice(0, -1).join("/");
+ const node: FileTreeNode = { name, path: dirPath, children: [] };
+ dirs.set(dirPath, node);
+ ensureDir(parentPath).push(node);
+ return node.children;
+ };
+
+ const sorted = [...files].sort((a, b) => a.path.localeCompare(b.path));
+ for (const file of sorted) {
+ const normalized = file.path.replace(/\/+$/, "");
+ if (!normalized) {
+ continue;
+ }
+ if (file.path.endsWith("/")) {
+ ensureDir(normalized);
+ continue;
+ }
+ const parts = normalized.split("/").filter(Boolean);
+ const name = parts[parts.length - 1] ?? normalized;
+ const dir = parts.slice(0, -1).join("/");
+ ensureDir(dir).push({ name, path: file.path, file, children: [] });
+ }
+
+ const sortNodes = (nodes: FileTreeNode[]) => {
+ nodes.sort((a, b) => {
+ const dirFirst = Number(Boolean(a.file)) - Number(Boolean(b.file));
+ if (dirFirst !== 0) {
+ return dirFirst;
+ }
+ return a.name.localeCompare(b.name);
+ });
+ for (const node of nodes) {
+ sortNodes(node.children);
+ }
+ };
+ sortNodes(root);
+ return root;
+}
+
+export function collectFilePaths(node: FileTreeNode): string[] {
+ if (node.file) {
+ return [node.file.path];
+ }
+ return node.children.flatMap(collectFilePaths);
+}
diff --git a/packages/views/git/prompt-picker.tsx b/packages/views/git/prompt-picker.tsx
new file mode 100644
index 0000000..4eaae7c
--- /dev/null
+++ b/packages/views/git/prompt-picker.tsx
@@ -0,0 +1,123 @@
+"use client";
+
+import type { PromptConfig } from "@codedock/core/git";
+import { cn } from "@codedock/ui";
+import { ChevronDownIcon } from "lucide-react";
+import { useEffect, useRef, useState } from "react";
+
+export function PromptPicker({
+ prompt,
+ disabled,
+ onSelect,
+ onSaveCustom,
+}: {
+ prompt: PromptConfig | null;
+ disabled: boolean;
+ onSelect: (selected: string) => Promise;
+ onSaveCustom: (custom: string) => Promise;
+}) {
+ const [open, setOpen] = useState(false);
+ const [custom, setCustom] = useState(prompt?.custom ?? "");
+ const root = useRef(null);
+ const selected = prompt?.selected ?? "conventional";
+ const label = prompt?.presets.find((item) => item.id === selected)?.name ?? "提示词";
+
+ const persistCustom = () => {
+ if (selected === "custom" && custom !== (prompt?.custom ?? "")) {
+ void onSaveCustom(custom);
+ }
+ };
+
+ const close = () => {
+ persistCustom();
+ setOpen(false);
+ };
+
+ useEffect(() => {
+ setCustom(prompt?.custom ?? "");
+ }, [prompt?.custom]);
+
+ useEffect(() => {
+ if (!open) {
+ return;
+ }
+ const onPointer = (event: PointerEvent) => {
+ if (root.current && !root.current.contains(event.target as Node)) {
+ close();
+ }
+ };
+ document.addEventListener("pointerdown", onPointer);
+ return () => document.removeEventListener("pointerdown", onPointer);
+ }, [open, custom, selected, prompt?.custom]);
+
+ return (
+
+
+ {open && prompt ? (
+
+
生成说明用的提示词
+
+ {prompt.presets.map((preset) => {
+ const active = preset.id === selected;
+ return (
+ -
+
+
+ );
+ })}
+
+ {selected === "custom" ? (
+
+
+ ) : null}
+
+ ) : null}
+
+ );
+}
diff --git a/packages/views/git/provider.tsx b/packages/views/git/provider.tsx
new file mode 100644
index 0000000..2243bd6
--- /dev/null
+++ b/packages/views/git/provider.tsx
@@ -0,0 +1,22 @@
+"use client";
+
+import type { GitClient } from "@codedock/core/git";
+import { createContext, useContext, type ReactNode } from "react";
+
+type GitContextValue = {
+ client: GitClient;
+};
+
+const GitContext = createContext(null);
+
+export function GitProvider({ client, children }: { client: GitClient; children: ReactNode }) {
+ return {children};
+}
+
+export function useGit(): GitContextValue {
+ const ctx = useContext(GitContext);
+ if (!ctx) {
+ throw new Error("useGit must be used within GitProvider");
+ }
+ return ctx;
+}
diff --git a/packages/views/git/publish-actions.tsx b/packages/views/git/publish-actions.tsx
new file mode 100644
index 0000000..d1b1c0b
--- /dev/null
+++ b/packages/views/git/publish-actions.tsx
@@ -0,0 +1,113 @@
+"use client";
+
+import { Button, cn } from "@codedock/ui";
+import { ChevronDownIcon } from "lucide-react";
+import { useEffect, useRef, useState } from "react";
+
+export function PublishActions({
+ canCommit,
+ canPush,
+ busy,
+ onPush,
+}: {
+ canCommit: boolean;
+ canPush: boolean;
+ busy: boolean;
+ onPush: () => void;
+}) {
+ const [open, setOpen] = useState(false);
+ const root = useRef(null);
+
+ useEffect(() => {
+ if (!open) {
+ return;
+ }
+ const onPointer = (event: PointerEvent) => {
+ if (root.current && !root.current.contains(event.target as Node)) {
+ setOpen(false);
+ }
+ };
+ const onKey = (event: KeyboardEvent) => {
+ if (event.key === "Escape") {
+ setOpen(false);
+ }
+ };
+ document.addEventListener("pointerdown", onPointer);
+ document.addEventListener("keydown", onKey);
+ return () => {
+ document.removeEventListener("pointerdown", onPointer);
+ document.removeEventListener("keydown", onKey);
+ };
+ }, [open]);
+
+ return (
+
+
+
+ {open ? (
+
+
+
+
+ ) : null}
+
+ );
+}
+
+function MenuItem({
+ disabled,
+ submit,
+ onClick,
+ children,
+}: {
+ disabled?: boolean;
+ submit?: boolean;
+ onClick: () => void;
+ children: string;
+}) {
+ return (
+
+ );
+}
diff --git a/packages/views/git/workspace-panel.tsx b/packages/views/git/workspace-panel.tsx
new file mode 100644
index 0000000..38b15b5
--- /dev/null
+++ b/packages/views/git/workspace-panel.tsx
@@ -0,0 +1,211 @@
+"use client";
+
+import type { MessageDraft, PromptConfig, SiteState } from "@codedock/core/git";
+import { Button } from "@codedock/ui";
+import { RefreshCw } from "lucide-react";
+import { useMemo, useState, type ReactNode } from "react";
+
+import { DiscardConfirm, type DiscardRequest } from "./discard-confirm.tsx";
+import { FileTree } from "./file-tree.tsx";
+import type { PreviewTarget } from "./lib/preview.ts";
+import { splitWorkspaceFiles, stagedLabel, worktreeLabel } from "./lib/status.ts";
+import { PromptPicker } from "./prompt-picker.tsx";
+import { PublishActions } from "./publish-actions.tsx";
+
+function draftText(draft: MessageDraft): string {
+ const title = draft.title.trim();
+ const body = draft.body.trim();
+ return body ? `${title}\n\n${body}` : title;
+}
+
+export function WorkspacePanel({
+ state,
+ busy,
+ generating,
+ prompt,
+ preview,
+ onPreview,
+ onReload,
+ onStage,
+ onUnstage,
+ onDiscard,
+ onCommit,
+ onGenerate,
+ onSavePrompt,
+ onPush,
+}: {
+ state: SiteState;
+ busy: boolean;
+ generating: boolean;
+ prompt: PromptConfig | null;
+ preview: PreviewTarget | null;
+ onPreview: (target: PreviewTarget) => void;
+ onReload: () => Promise;
+ onStage: (paths: string[]) => Promise;
+ onUnstage: (paths: string[]) => Promise;
+ onDiscard: (paths: string[]) => Promise;
+ onCommit: (message: string) => Promise;
+ onGenerate: () => Promise;
+ onSavePrompt: (selected: string, custom: string) => Promise;
+ onPush: () => Promise;
+}) {
+ const [message, setMessage] = useState("");
+ const [discardRequest, setDiscardRequest] = useState(null);
+ const files = state.files ?? [];
+ const { staged, worktree } = useMemo(() => splitWorkspaceFiles(files), [files]);
+
+ const stage = (paths: string[]) => {
+ void onStage(paths)
+ .then(() => {
+ if (preview && preview.scope === "worktree" && paths.includes(preview.path)) {
+ onPreview({ path: preview.path, scope: "staged" });
+ }
+ })
+ .catch(() => undefined);
+ };
+
+ const unstage = (paths: string[]) => {
+ void onUnstage(paths)
+ .then(() => {
+ if (preview && preview.scope === "staged" && paths.includes(preview.path)) {
+ onPreview({ path: preview.path, scope: "worktree" });
+ }
+ })
+ .catch(() => undefined);
+ };
+
+ const confirmDiscard = () => {
+ if (!discardRequest) {
+ return;
+ }
+ void onDiscard(discardRequest.paths)
+ .then(() => setDiscardRequest(null))
+ .catch(() => undefined);
+ };
+
+ return (
+
+
+
+ void onReload()}>
+
+ 刷新
+
+ }
+ >
+ onPreview({ path, scope: "staged" })}
+ />
+
+
+ onPreview({ path, scope: "worktree" })}
+ />
+
+
+ setDiscardRequest(null)}
+ onConfirm={confirmDiscard}
+ />
+
+ );
+}
+
+function FileSection({
+ title,
+ count,
+ action,
+ children,
+}: {
+ title: string;
+ count: number;
+ action?: ReactNode;
+ children: ReactNode;
+}) {
+ return (
+
+
+
+ {title}
+ {count}
+
+ {action}
+
+ {children}
+
+ );
+}
diff --git a/packages/views/index.ts b/packages/views/index.ts
index 648f302..dae2d25 100644
--- a/packages/views/index.ts
+++ b/packages/views/index.ts
@@ -1,4 +1,5 @@
export { AgentProvider, useAgent } from "./provider.tsx";
+export { GitPage, GitProvider, useGit, type GitPageProps } from "./git/index.ts";
export {
ChatPage,
ConversationTimeline,
diff --git a/packages/views/package.json b/packages/views/package.json
index 4582227..6732b6f 100644
--- a/packages/views/package.json
+++ b/packages/views/package.json
@@ -5,11 +5,13 @@
"type": "module",
"exports": {
".": "./index.ts",
- "./chat": "./chat/index.ts"
+ "./chat": "./chat/index.ts",
+ "./git": "./git/index.ts"
},
"dependencies": {
"@codedock/core": "workspace:*",
"@codedock/ui": "workspace:*",
+ "@git-diff-view/react": "^0.1.7",
"lucide-react": "^0.544.0"
},
"peerDependencies": {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 26a4be6..7fed1ab 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -138,6 +138,9 @@ importers:
'@codedock/ui':
specifier: workspace:*
version: link:../ui
+ '@git-diff-view/react':
+ specifier: ^0.1.7
+ version: 0.1.7(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
lucide-react:
specifier: ^0.544.0
version: 0.544.0(react@19.2.8)
@@ -296,6 +299,18 @@ packages:
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ '@git-diff-view/core@0.1.7':
+ resolution: {integrity: sha512-ZW/kumNoUQ8+DgawYhcrABa7TOALYzn3pe723uTHSy6/r35qAwgswJxF1bkdV9Zr+KHuhlFeRLiVhjij59qdIA==}
+
+ '@git-diff-view/lowlight@0.1.7':
+ resolution: {integrity: sha512-Rkv2ERr83xTSsjlrJxjYVWndREEARG11viTrJ7qyUU+lnPPmSNF1aemp1lKiOLs6sNCeJJdIMhxLFJtKfmRIZw==}
+
+ '@git-diff-view/react@0.1.7':
+ resolution: {integrity: sha512-EMBFgeSpP3nF8hJwy/3bz8sZpxmyuWV20925oTGhslWXpIgg6AZg+KMRmQkz/WjX0D/2LrC3gQZJLE/5x6mfnw==}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
'@humanfs/core@0.19.2':
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
engines: {node: '>=18.18.0'}
@@ -1054,6 +1069,12 @@ packages:
'@upsetjs/venn.js@2.0.0':
resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==}
+ '@vue/reactivity@3.5.42':
+ resolution: {integrity: sha512-TzNNfKpb7hDxbQltwAut8VDQA5YP+BuRlxntHUuRjyKwlMvmAPbs3unhCvieijifY6vFfVBwsS7wG/C7uq+bEQ==}
+
+ '@vue/shared@3.5.42':
+ resolution: {integrity: sha512-2rPxex1jQf4jvl9MOHl6YaXCPcrNqz/FstMOEh3QWY+/OME9nQTvl9WYeCwhW7AFjaR0SnngZGlp/wkR6rkI6g==}
+
acorn-jsx@5.3.2:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies:
@@ -1670,6 +1691,9 @@ packages:
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
+ fast-diff@1.3.0:
+ resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==}
+
fast-glob@3.3.1:
resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==}
engines: {node: '>=8.6.0'}
@@ -1860,6 +1884,14 @@ packages:
hermes-parser@0.25.1:
resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
+ highlight.js@11.11.2:
+ resolution: {integrity: sha512-oaXMACAU0kzOMXBjWpNcX+vlwSBCIAiZ9BHa7gA15NOTtT2L/l8OSZDuqS2XppOhZBPJ7hm4o8ep2kyuip2uEQ==}
+ engines: {node: '>=12.0.0'}
+
+ highlight.js@11.12.0:
+ resolution: {integrity: sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg==}
+ engines: {node: '>=12.0.0'}
+
html-url-attributes@3.0.1:
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
@@ -2192,6 +2224,9 @@ packages:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
+ lowlight@3.3.0:
+ resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==}
+
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
@@ -2607,6 +2642,11 @@ packages:
resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==}
engines: {node: '>=0.10.0'}
+ reactivity-store@0.4.0:
+ resolution: {integrity: sha512-uL9uoREOBg2o4zUa8vMU0AbvAOk0osPloizscmyZqMvJzcuuKX3ELFYYr1DX8gAcfvlhPduz4QuLZn1eChCu4Q==}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
reflect.getprototypeof@1.0.10:
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
engines: {node: '>= 0.4'}
@@ -2983,6 +3023,11 @@ packages:
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
+ use-sync-external-store@1.6.0:
+ resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
uuid@14.0.2:
resolution: {integrity: sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==}
hasBin: true
@@ -3228,6 +3273,31 @@ snapshots:
'@eslint/core': 0.17.0
levn: 0.4.1
+ '@git-diff-view/core@0.1.7':
+ dependencies:
+ '@git-diff-view/lowlight': 0.1.7
+ fast-diff: 1.3.0
+ highlight.js: 11.12.0
+ lowlight: 3.3.0
+
+ '@git-diff-view/lowlight@0.1.7':
+ dependencies:
+ '@types/hast': 3.0.5
+ highlight.js: 11.12.0
+ lowlight: 3.3.0
+
+ '@git-diff-view/react@0.1.7(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ dependencies:
+ '@git-diff-view/core': 0.1.7
+ '@types/hast': 3.0.5
+ fast-diff: 1.3.0
+ highlight.js: 11.12.0
+ lowlight: 3.3.0
+ react: 19.2.8
+ react-dom: 19.2.8(react@19.2.8)
+ reactivity-store: 0.4.0(react@19.2.8)
+ use-sync-external-store: 1.6.0(react@19.2.8)
+
'@humanfs/core@0.19.2':
dependencies:
'@humanfs/types': 0.15.0
@@ -3913,6 +3983,12 @@ snapshots:
d3-selection: 3.0.0
d3-transition: 3.0.1(d3-selection@3.0.0)
+ '@vue/reactivity@3.5.42':
+ dependencies:
+ '@vue/shared': 3.5.42
+
+ '@vue/shared@3.5.42': {}
+
acorn-jsx@5.3.2(acorn@8.18.0):
dependencies:
acorn: 8.18.0
@@ -4721,6 +4797,8 @@ snapshots:
fast-deep-equal@3.1.3: {}
+ fast-diff@1.3.0: {}
+
fast-glob@3.3.1:
dependencies:
'@nodelib/fs.stat': 2.0.5
@@ -4996,6 +5074,10 @@ snapshots:
dependencies:
hermes-estree: 0.25.1
+ highlight.js@11.11.2: {}
+
+ highlight.js@11.12.0: {}
+
html-url-attributes@3.0.1: {}
html-void-elements@3.0.0: {}
@@ -5288,6 +5370,12 @@ snapshots:
dependencies:
js-tokens: 4.0.0
+ lowlight@3.3.0:
+ dependencies:
+ '@types/hast': 3.0.5
+ devlop: 1.1.0
+ highlight.js: 11.11.2
+
lru-cache@5.1.1:
dependencies:
yallist: 3.1.1
@@ -5964,6 +6052,13 @@ snapshots:
react@19.2.8: {}
+ reactivity-store@0.4.0(react@19.2.8):
+ dependencies:
+ '@vue/reactivity': 3.5.42
+ '@vue/shared': 3.5.42
+ react: 19.2.8
+ use-sync-external-store: 1.6.0(react@19.2.8)
+
reflect.getprototypeof@1.0.10:
dependencies:
call-bind: 1.0.9
@@ -6538,6 +6633,10 @@ snapshots:
dependencies:
punycode: 2.3.1
+ use-sync-external-store@1.6.0(react@19.2.8):
+ dependencies:
+ react: 19.2.8
+
uuid@14.0.2: {}
vfile-location@5.0.3:
diff --git a/scripts/dev-api.sh b/scripts/dev-api.sh
new file mode 100755
index 0000000..31ada9f
--- /dev/null
+++ b/scripts/dev-api.sh
@@ -0,0 +1,15 @@
+#!/bin/sh
+# 从仓库根启动 API。未设 GIT_REPO 时只用 tmp/git-sandbox,没有就先初始化。
+# 撤回会改磁盘,不要默认对着 CodeDock 工作区。要操作本仓:GIT_REPO=$root pnpm dev:api
+set -e
+root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
+sandbox="$root/tmp/git-sandbox"
+if [ -z "$GIT_REPO" ]; then
+ if [ ! -d "$sandbox/.git" ]; then
+ sh "$root/scripts/git-sandbox.sh" >/dev/null
+ fi
+ GIT_REPO="$sandbox"
+fi
+export GIT_REPO
+cd "$root/server"
+exec go run ./cmd/server
diff --git a/scripts/dev.sh b/scripts/dev.sh
new file mode 100755
index 0000000..1e168b4
--- /dev/null
+++ b/scripts/dev.sh
@@ -0,0 +1,17 @@
+#!/bin/sh
+# 同时起 API 与 Web,供本机把前后端调通。
+set -e
+root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
+cd "$root"
+sandbox="$root/tmp/git-sandbox"
+if [ -z "$GIT_REPO" ]; then
+ if [ ! -d "$sandbox/.git" ]; then
+ sh "$root/scripts/git-sandbox.sh" >/dev/null
+ fi
+ GIT_REPO="$sandbox"
+fi
+export GIT_REPO
+(cd "$root/server" && go run ./cmd/server) &
+api=$!
+trap 'kill "$api" 2>/dev/null || true' EXIT INT TERM
+pnpm --filter web dev
diff --git a/scripts/git-sandbox.sh b/scripts/git-sandbox.sh
new file mode 100755
index 0000000..3c851c8
--- /dev/null
+++ b/scripts/git-sandbox.sh
@@ -0,0 +1,24 @@
+#!/bin/sh
+# 造一个可随意暂存 / 撤回的独立 Git 仓。不要对着 CodeDock 本仓试破坏性操作。
+set -e
+root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
+dir="$root/tmp/git-sandbox"
+rm -rf "$dir"
+mkdir -p "$dir/src" "$dir/notes"
+cd "$dir"
+git init -b main >/dev/null
+git config user.name tester
+git config user.email tester@example.com
+printf 'hello sandbox\n' > README.txt
+printf 'version 1\n' > src/app.txt
+git add README.txt src/app.txt
+git commit -m "init sandbox" >/dev/null
+origin="$root/tmp/git-sandbox-origin.git"
+rm -rf "$origin"
+git init --bare "$origin" >/dev/null
+git remote add origin "$origin"
+git push -u origin main >/dev/null
+printf 'version 2 (dirty)\n' > src/app.txt
+printf 'scratch\n' > notes/todo.txt
+printf 'untracked\n' > new.txt
+echo "$dir"
diff --git a/server/cmd/server/cors.go b/server/cmd/server/cors.go
index e31318d..c3fd22a 100644
--- a/server/cmd/server/cors.go
+++ b/server/cmd/server/cors.go
@@ -1,17 +1,22 @@
package main
-import "net/http"
+import (
+ "net/http"
+ "net/url"
+ "os"
+ "strings"
+)
-// cors 允许本机 Web 直连 REST 与 SSE。回显 Origin,并放行预检。
+// cors 只对本地回环 Origin 放行本机 Web。额外来源用 CORS_ORIGINS(逗号分隔)。
func cors(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
- if origin != "" {
+ if allowOrigin(origin) {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Last-Event-ID, Authorization")
- w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
+ w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
w.Header().Set("Access-Control-Expose-Headers", "Last-Event-ID")
}
if r.Method == http.MethodOptions {
@@ -21,3 +26,26 @@ func cors(next http.Handler) http.Handler {
next.ServeHTTP(w, r)
})
}
+
+func allowOrigin(origin string) bool {
+ if origin == "" {
+ return false
+ }
+ parsed, err := url.Parse(origin)
+ if err != nil || parsed.Scheme == "" || parsed.Host == "" {
+ return false
+ }
+ if parsed.Scheme != "http" && parsed.Scheme != "https" {
+ return false
+ }
+ host := strings.ToLower(parsed.Hostname())
+ if host == "localhost" || host == "127.0.0.1" || host == "::1" {
+ return true
+ }
+ for _, extra := range strings.Split(os.Getenv("CORS_ORIGINS"), ",") {
+ if strings.TrimSpace(extra) == origin {
+ return true
+ }
+ }
+ return false
+}
diff --git a/server/cmd/server/cors_test.go b/server/cmd/server/cors_test.go
new file mode 100644
index 0000000..c792128
--- /dev/null
+++ b/server/cmd/server/cors_test.go
@@ -0,0 +1,47 @@
+package main
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+func TestAllowOriginLoopbackOnly(t *testing.T) {
+ t.Setenv("CORS_ORIGINS", "")
+ if !allowOrigin("http://localhost:3000") || !allowOrigin("http://127.0.0.1:3001") {
+ t.Fatal("loopback should be allowed")
+ }
+ if allowOrigin("https://evil.example") || allowOrigin("") {
+ t.Fatal("foreign origin must be rejected")
+ }
+}
+
+func TestAllowOriginExtraEnv(t *testing.T) {
+ t.Setenv("CORS_ORIGINS", "https://app.example")
+ if !allowOrigin("https://app.example") {
+ t.Fatal("CORS_ORIGINS should allow the listed origin")
+ }
+}
+
+func TestCorsReflectsAllowedOrigin(t *testing.T) {
+ t.Setenv("CORS_ORIGINS", "")
+ handler := cors(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusNoContent)
+ }))
+
+ req := httptest.NewRequest(http.MethodGet, "/git/status", nil)
+ req.Header.Set("Origin", "http://localhost:3000")
+ rec := httptest.NewRecorder()
+ handler.ServeHTTP(rec, req)
+ if rec.Header().Get("Access-Control-Allow-Origin") != "http://localhost:3000" {
+ t.Fatalf("allowed origin: %q", rec.Header().Get("Access-Control-Allow-Origin"))
+ }
+
+ req = httptest.NewRequest(http.MethodGet, "/git/status", nil)
+ req.Header.Set("Origin", "https://evil.example")
+ rec = httptest.NewRecorder()
+ handler.ServeHTTP(rec, req)
+ if rec.Header().Get("Access-Control-Allow-Origin") != "" {
+ t.Fatalf("rejected origin leaked: %q", rec.Header().Get("Access-Control-Allow-Origin"))
+ }
+}
diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go
index 1a22730..4a3ef7f 100644
--- a/server/cmd/server/main.go
+++ b/server/cmd/server/main.go
@@ -60,7 +60,7 @@ func main() {
runtime.Start(ctx)
defaults := pkgagent.DefaultRunConfig(pkgagent.ModeAskForApproval, model)
- api := handler.New(client, queries, runtime, bus, defaults, logger.NewLogger("handler"))
+ api := handler.New(client, queries, runtime, bus, defaults, cfg, logger.NewLogger("handler"))
server := &http.Server{
Addr: cfg.HTTPAddr,
diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go
index ea5a845..3ee4f1b 100644
--- a/server/cmd/server/router.go
+++ b/server/cmd/server/router.go
@@ -10,7 +10,7 @@ import (
"github.com/go-chi/chi/v5/middleware"
)
-// newRouter 注册健康检查与 Session / Run / Approval / Memory 路由。
+// newRouter 注册健康检查与 Session / Run / Approval / Memory / Git 路由。
func newRouter(log *slog.Logger, api *handler.API) http.Handler {
router := chi.NewRouter()
router.Use(cors)
@@ -52,6 +52,39 @@ func newRouter(log *slog.Logger, api *handler.API) http.Handler {
r.Get("/{scope}/{scope_id}", api.GetTextMemory)
r.Delete("/{scope}/{scope_id}", api.DeleteTextMemory)
})
+ router.Route("/git", func(r chi.Router) {
+ r.Get("/status", api.GitStatus)
+ r.Get("/diff", api.GitDiff)
+ r.Get("/graph", api.GitGraph)
+ r.Get("/log", api.GitLog)
+ r.Post("/stage", api.GitStage)
+ r.Post("/unstage", api.GitUnstage)
+ r.Post("/discard", api.GitDiscard)
+ r.Post("/commit", api.GitCommit)
+ r.Post("/reset", api.GitReset)
+ r.Post("/revert", api.GitRevert)
+ r.Post("/push", api.GitPush)
+ r.Post("/pull", api.GitPull)
+ r.Get("/remotes", api.GitListRemotes)
+ r.Get("/worktrees", api.GitListWorktrees)
+ r.Post("/worktrees", api.GitAddWorktree)
+ r.Get("/branches", api.GitListBranches)
+ r.Post("/branches", api.GitCreateBranch)
+ r.Post("/branches/switch", api.GitSwitchBranch)
+ r.Delete("/branches", api.GitDeleteBranch)
+ r.Get("/conflict", api.GitGetConflict)
+ r.Post("/conflict/write", api.GitWriteConflict)
+ r.Post("/conflict/continue", api.GitContinueConflict)
+ r.Post("/conflict/abort", api.GitAbortConflict)
+ r.Get("/commit-message/prompt", api.GitGetPrompt)
+ r.Put("/commit-message/prompt", api.GitSetPrompt)
+ r.Post("/commit-message/generate", api.GitGenerateMessage)
+ r.Post("/stash", api.GitCreateSnapshot)
+ r.Get("/stash/latest", api.GitLatestSnapshot)
+ r.Post("/stash/restore", api.GitRestoreSnapshot)
+ r.Get("/undo", api.GitListUndo)
+ r.Post("/undo", api.GitClickUndo)
+ })
}
return router
diff --git a/server/internal/config/config.go b/server/internal/config/config.go
index 6bb0caa..ff477b8 100644
--- a/server/internal/config/config.go
+++ b/server/internal/config/config.go
@@ -12,6 +12,7 @@ type Config struct {
LLMModel string
LLMAPIKey string
LLMBaseURL string
+ GitRepo string
}
// Load 从环境变量读取配置,未设置时使用默认值。
@@ -25,6 +26,7 @@ func Load() Config {
LLMModel: env("LLM_MODEL", "fake"),
LLMAPIKey: env("LLM_API_KEY", ""),
LLMBaseURL: env("LLM_BASE_URL", ""),
+ GitRepo: env("GIT_REPO", ""),
}
}
diff --git a/server/internal/config/config_test.go b/server/internal/config/config_test.go
index 8f2cb68..a99c434 100644
--- a/server/internal/config/config_test.go
+++ b/server/internal/config/config_test.go
@@ -16,6 +16,7 @@ func TestLoadDefaults(t *testing.T) {
t.Setenv("LLM_MODEL", "")
t.Setenv("LLM_API_KEY", "")
t.Setenv("LLM_BASE_URL", "")
+ t.Setenv("GIT_REPO", "")
cfg := Load()
if cfg.HTTPAddr != ":8080" {
@@ -36,6 +37,9 @@ func TestLoadDefaults(t *testing.T) {
if cfg.LLMModel != "fake" {
t.Fatalf("LLMModel = %q, want fake", cfg.LLMModel)
}
+ if cfg.GitRepo != "" {
+ t.Fatalf("GitRepo = %q, want empty", cfg.GitRepo)
+ }
}
// TestLoadFromEnv 校验环境变量覆盖默认配置。
@@ -48,6 +52,7 @@ func TestLoadFromEnv(t *testing.T) {
t.Setenv("LLM_MODEL", "gpt-4o")
t.Setenv("LLM_API_KEY", "sk-test")
t.Setenv("LLM_BASE_URL", "https://api.example.com/v1")
+ t.Setenv("GIT_REPO", "/tmp/repo")
cfg := Load()
if cfg.HTTPAddr != ":9090" || cfg.LogLevel != "info" || cfg.DBEngine != "postgres" || cfg.DBDSN != "postgres://localhost" {
@@ -56,6 +61,9 @@ func TestLoadFromEnv(t *testing.T) {
if cfg.LLMProvider != "openai" || cfg.LLMModel != "gpt-4o" || cfg.LLMAPIKey != "sk-test" || cfg.LLMBaseURL != "https://api.example.com/v1" {
t.Fatalf("Load() LLM = %+v", cfg)
}
+ if cfg.GitRepo != "/tmp/repo" {
+ t.Fatalf("GitRepo = %q, want /tmp/repo", cfg.GitRepo)
+ }
}
// TestParseDotEnvFile 校验注释、引号、export 与行尾注释。
diff --git a/server/internal/handler/api.go b/server/internal/handler/api.go
index 15b39b2..49036f0 100644
--- a/server/internal/handler/api.go
+++ b/server/internal/handler/api.go
@@ -8,6 +8,7 @@ import (
"net/http"
"codedock/internal/agent"
+ "codedock/internal/config"
cderr "codedock/internal/errors"
"codedock/internal/events"
"codedock/internal/logger"
@@ -23,15 +24,16 @@ type API struct {
runtime *agent.Runtime
bus *events.Bus
defaults pkgagent.RunConfigSnapshot
+ cfg config.Config
log *slog.Logger
}
// New 创建 Handler 入口。log 为 nil 时回退到 slog.Default。
-func New(client db.Client, queries *sqlite.Queries, runtime *agent.Runtime, bus *events.Bus, defaults pkgagent.RunConfigSnapshot, log *slog.Logger) *API {
+func New(client db.Client, queries *sqlite.Queries, runtime *agent.Runtime, bus *events.Bus, defaults pkgagent.RunConfigSnapshot, cfg config.Config, log *slog.Logger) *API {
if log == nil {
log = slog.Default()
}
- return &API{db: client, queries: queries, runtime: runtime, bus: bus, defaults: defaults, log: log}
+ return &API{db: client, queries: queries, runtime: runtime, bus: bus, defaults: defaults, cfg: cfg, log: log}
}
// logger 返回 Handler 日志;API 或字段为空时回退到 slog.Default。
diff --git a/server/internal/handler/commit_message.go b/server/internal/handler/commit_message.go
new file mode 100644
index 0000000..d8862e8
--- /dev/null
+++ b/server/internal/handler/commit_message.go
@@ -0,0 +1,410 @@
+package handler
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "time"
+ "unicode/utf8"
+
+ cderr "codedock/internal/errors"
+ pkgagent "codedock/pkg/agent"
+ "codedock/pkg/git"
+)
+
+const conventionalCommitPrompt = `Write a GitHub Copilot-style commit message from the staged file list and diffs. Do not copy the example wording; write only from this staged change.
+
+First line only: Conventional Commit type(scope): subject.
+- type: feat, fix, docs, style, refactor, perf, test, chore, or ci
+- add a scope only when one area is obvious
+- subject: imperative, about 50 characters, no period, names the overall change
+
+Then a blank line and 3 to 7 bullets, one change each:
+- start every line with "- "
+- past tense like Copilot: Added, Updated, Refactored, Introduced, Improved
+- say what changed and why it matters, not every hunk
+- skip lockfiles and generated files
+- no fences, preamble, or signature
+
+Example:
+feat: enhance commit message generation and prompt management
+
+- Added a PromptPicker for selecting and saving custom prompts.
+- Updated the workspace panel to generate and apply drafts.
+- Refactored backend prompt load and save.
+- Introduced an API to generate messages from staged diffs.`
+const draftMaxOutputTokens int64 = 320
+const draftStreamTimeout = 8 * time.Second
+const draftRequestTimeout = 9 * time.Second
+
+const (
+ promptIDConventional = "conventional"
+ promptIDCustom = "custom"
+)
+
+const patchBudgetTokens int64 = 1500
+const perFilePatchTokens int64 = 300
+
+type messagePack struct {
+ Inventory string
+ Patches string
+}
+
+type promptStore struct {
+ Selected string `json:"selected"`
+ Custom string `json:"custom"`
+}
+
+type gitPromptRequest struct {
+ Selected string `json:"selected"`
+ Custom string `json:"custom"`
+}
+
+// PromptPreset 是产品预制的一份 system prompt。
+type PromptPreset struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ SystemPrompt string `json:"system_prompt"`
+}
+
+// PromptConfig 是本仓当前选中的生成说明提示词整局。
+type PromptConfig struct {
+ Presets []PromptPreset `json:"presets"`
+ Selected string `json:"selected"`
+ Custom string `json:"custom"`
+ SystemPrompt string `json:"system_prompt"`
+}
+
+func commitPromptPresets(custom string) []PromptPreset {
+ return []PromptPreset{
+ {ID: promptIDConventional, Name: "Conventional", SystemPrompt: conventionalCommitPrompt},
+ {ID: promptIDCustom, Name: "自定义", SystemPrompt: custom},
+ }
+}
+
+func validPromptID(id string) bool {
+ switch id {
+ case promptIDConventional, promptIDCustom:
+ return true
+ default:
+ return false
+ }
+}
+
+func resolvePrompt(store promptStore) string {
+ if store.Selected == promptIDCustom && strings.TrimSpace(store.Custom) != "" {
+ return store.Custom
+ }
+ return conventionalCommitPrompt
+}
+
+func promptConfigFrom(store promptStore) PromptConfig {
+ if !validPromptID(store.Selected) {
+ store.Selected = promptIDConventional
+ }
+ return PromptConfig{
+ Presets: commitPromptPresets(store.Custom),
+ Selected: store.Selected,
+ Custom: store.Custom,
+ SystemPrompt: resolvePrompt(store),
+ }
+}
+
+func (a *API) loadPromptStore(repo git.Repo) promptStore {
+ path, err := codedockFile(repo, "commit-message.json")
+ if err == nil {
+ body, err := os.ReadFile(path)
+ if err == nil && len(bytesTrimSpace(body)) > 0 {
+ var store promptStore
+ if json.Unmarshal(body, &store) == nil && (store.Selected != "" || store.Custom != "") {
+ if !validPromptID(store.Selected) {
+ store.Selected = promptIDConventional
+ }
+ return store
+ }
+ }
+ }
+ old, err := codedockFile(repo, "commit-message-prompt")
+ if err == nil {
+ body, err := os.ReadFile(old)
+ if err == nil && strings.TrimSpace(string(body)) != "" {
+ return promptStore{Selected: promptIDCustom, Custom: string(body)}
+ }
+ }
+ return promptStore{Selected: promptIDConventional}
+}
+
+func bytesTrimSpace(body []byte) []byte {
+ return []byte(strings.TrimSpace(string(body)))
+}
+
+func (a *API) savePromptStore(repo git.Repo, store promptStore) error {
+ path, err := codedockFile(repo, "commit-message.json")
+ if err != nil {
+ return err
+ }
+ body, err := json.Marshal(store)
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(path, body, 0o644)
+}
+
+func (a *API) resolvedPrompt(repo git.Repo) string {
+ return resolvePrompt(a.loadPromptStore(repo))
+}
+
+// GitGetPrompt 读本仓生成说明用的提示词整局。
+func (a *API) GitGetPrompt(w http.ResponseWriter, r *http.Request) {
+ repo, _, err := a.openSite(r.URL.Query().Get("checkout"))
+ if err != nil {
+ writeGitError(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, promptConfigFrom(a.loadPromptStore(repo)))
+}
+
+// GitSetPrompt 保存当前选中的预制或自定义提示词。
+func (a *API) GitSetPrompt(w http.ResponseWriter, r *http.Request) {
+ var req gitPromptRequest
+ if err := decodeJSON(r, &req); err != nil {
+ writeError(w, err)
+ return
+ }
+ if !validPromptID(req.Selected) {
+ writeError(w, cderr.Invalid("unknown prompt id"))
+ return
+ }
+ repo, _, err := a.openSite("")
+ if err != nil {
+ writeGitError(w, err)
+ return
+ }
+ store := promptStore{Selected: req.Selected, Custom: req.Custom}
+ if err := a.savePromptStore(repo, store); err != nil {
+ writeGitError(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, promptConfigFrom(store))
+}
+
+// GitGenerateMessage 读已暂存 diff,生成说明草稿。
+func (a *API) GitGenerateMessage(w http.ResponseWriter, r *http.Request) {
+ var req gitCheckoutRequest
+ if err := decodeJSON(r, &req); err != nil {
+ writeError(w, err)
+ return
+ }
+ repo, co, err := a.openSite(req.Checkout)
+ if err != nil {
+ writeGitError(w, err)
+ return
+ }
+ files, err := git.Diff(repo, co, "staged")
+ if err != nil {
+ writeGitError(w, err)
+ return
+ }
+ if len(files) == 0 {
+ writeError(w, cderr.Invalid("nothing staged"))
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), draftRequestTimeout)
+ defer cancel()
+ draft := a.generateDraft(ctx, repo, files)
+ draft.Title = strings.TrimSpace(draft.Title)
+ if draft.Title == "" {
+ draft = parseDraft(fallbackDraft(files))
+ }
+ writeJSON(w, http.StatusOK, draft)
+}
+
+func (a *API) generateDraft(ctx context.Context, repo git.Repo, files []git.DiffFile) MessageDraft {
+ prepared := make([]git.DiffFile, 0, len(files))
+ for _, file := range files {
+ class := classifyPatch(file) // 这份 diff 是 text / binary / generated / empty
+ if class != "text" || isGeneratedPath(file.Path) { // lockfile、go.sum 不当正文
+ file.Patch = ""
+ prepared = append(prepared, file)
+ continue
+ }
+ file.Patch = clipPatch(file.Patch, perFilePatchTokens) // 单文件超上限就截,标 truncated
+ prepared = append(prepared, file)
+ }
+
+ pack := messagePack{
+ Inventory: fileInventory(prepared), // 每个文件一行 kind+path,正文被砍也不丢
+ Patches: fillPatchBudget(prepared, patchBudgetTokens), // 只装 text,满预算停
+ }
+ text, ok := a.streamDraft(ctx, repo, pack) // 当前提示词 + 清单 + 正文,只打一次
+ if !ok {
+ return parseDraft(fallbackDraft(files)) // 模型失败:用文件列表凑一条
+ }
+ draft := parseDraft(text) // 第一行标题,其余正文
+ if strings.TrimSpace(draft.Title) == "" {
+ return parseDraft(fallbackDraft(files)) // 空标题同样回退
+ }
+ return draft
+}
+
+func classifyPatch(file git.DiffFile) string {
+ if file.Binary {
+ return "binary"
+ }
+ if isGeneratedPath(file.Path) {
+ return "generated"
+ }
+ if strings.TrimSpace(file.Patch) == "" {
+ return "empty"
+ }
+ return "text"
+}
+
+func isGeneratedPath(path string) bool {
+ switch strings.ToLower(filepath.Base(path)) {
+ case "pnpm-lock.yaml", "package-lock.json", "yarn.lock", "npm-shrinkwrap.json",
+ "go.sum", "cargo.lock", "gemfile.lock", "poetry.lock", "composer.lock":
+ return true
+ default:
+ return false
+ }
+}
+
+func fileInventory(files []git.DiffFile) string {
+ var b strings.Builder
+ for _, file := range files {
+ switch classifyPatch(file) {
+ case "binary":
+ b.WriteString("binary ")
+ case "generated":
+ b.WriteString("generated ")
+ }
+ b.WriteString(file.Kind)
+ b.WriteByte(' ')
+ b.WriteString(file.Path)
+ b.WriteByte('\n')
+ }
+ return strings.TrimSpace(b.String())
+}
+
+func fillPatchBudget(files []git.DiffFile, budget int64) string {
+ sorted := append([]git.DiffFile(nil), files...)
+ sort.Slice(sorted, func(i, j int) bool { return sorted[i].Path < sorted[j].Path })
+ var b strings.Builder
+ var used int64
+ for _, file := range sorted {
+ if classifyPatch(file) != "text" {
+ continue
+ }
+ n := pkgagent.CountTokens(file.Patch)
+ if used+n > budget {
+ break
+ }
+ b.WriteString(file.Patch)
+ b.WriteByte('\n')
+ used += n
+ }
+ return strings.TrimSpace(b.String())
+}
+
+func clipPatch(patch string, max int64) string {
+ if strings.TrimSpace(patch) == "" {
+ return patch
+ }
+ if max <= 0 {
+ return "... truncated"
+ }
+ if pkgagent.CountTokens(patch) <= max {
+ return patch
+ }
+ limit := int(max * 4)
+ if limit >= len(patch) {
+ return patch
+ }
+ cut := patch[:limit]
+ for !utf8.ValidString(cut) && len(cut) > 0 {
+ cut = cut[:len(cut)-1]
+ }
+ return strings.TrimRight(cut, "\n") + "\n... truncated"
+}
+
+func (a *API) streamDraft(ctx context.Context, repo git.Repo, pack messagePack) (string, bool) {
+ ctx, cancel := context.WithTimeout(ctx, draftStreamTimeout)
+ defer cancel()
+ var user strings.Builder
+ if pack.Inventory != "" {
+ user.WriteString(pack.Inventory)
+ user.WriteByte('\n')
+ }
+ if pack.Patches != "" {
+ user.WriteByte('\n')
+ user.WriteString(pack.Patches)
+ }
+ stream, err := pkgagent.Stream(ctx, pkgagent.Chat{
+ Model: a.gitModel(),
+ SystemPrompt: a.resolvedPrompt(repo),
+ MaxOutputTokens: draftMaxOutputTokens,
+ Messages: []pkgagent.Message{{
+ Role: pkgagent.RoleUser,
+ Content: pkgagent.EncodeText(user.String()),
+ }},
+ })
+ if err != nil {
+ return "", false
+ }
+ defer func() { _ = stream.Close() }()
+ text := readDraftText(stream)
+ if text == "" || text == "ok" {
+ return "", false
+ }
+ return text, true
+}
+
+func readDraftText(stream pkgagent.ModelStream) string {
+ var b strings.Builder
+ for ev := range stream.Events() {
+ if ev.Type == pkgagent.ModelStreamTextDelta {
+ b.WriteString(pkgagent.DecodeText(ev.Delta))
+ }
+ }
+ return strings.TrimSpace(b.String())
+}
+
+func parseDraft(text string) MessageDraft {
+ text = strings.TrimSpace(text)
+ title, body, ok := strings.Cut(text, "\n")
+ if !ok {
+ return MessageDraft{Title: text}
+ }
+ return MessageDraft{Title: strings.TrimSpace(title), Body: strings.TrimSpace(body)}
+}
+
+func fallbackDraft(files []git.DiffFile) string {
+ if len(files) == 0 {
+ return ""
+ }
+ title := "chore: update staged files"
+ if len(files) == 1 {
+ title = "chore: update " + files[0].Path
+ }
+ var body strings.Builder
+ for _, file := range files {
+ switch file.Kind {
+ case "added":
+ body.WriteString("- Added ")
+ case "deleted":
+ body.WriteString("- Removed ")
+ case "renamed":
+ body.WriteString("- Renamed ")
+ default:
+ body.WriteString("- Updated ")
+ }
+ body.WriteString(file.Path)
+ body.WriteByte('\n')
+ }
+ return title + "\n\n" + strings.TrimSpace(body.String())
+}
diff --git a/server/internal/handler/commit_message_test.go b/server/internal/handler/commit_message_test.go
new file mode 100644
index 0000000..38201e8
--- /dev/null
+++ b/server/internal/handler/commit_message_test.go
@@ -0,0 +1,152 @@
+package handler
+
+import (
+ "context"
+ "strings"
+ "testing"
+ "time"
+
+ "codedock/internal/config"
+ pkgagent "codedock/pkg/agent"
+ "codedock/pkg/git"
+)
+
+func TestClassifyPatchAndGeneratedPath(t *testing.T) {
+ if got := classifyPatch(git.DiffFile{Path: "a.png", Binary: true, Kind: "added"}); got != "binary" {
+ t.Fatalf("binary: %s", got)
+ }
+ if got := classifyPatch(git.DiffFile{Path: "pnpm-lock.yaml", Patch: "huge", Kind: "modified"}); got != "generated" {
+ t.Fatalf("generated: %s", got)
+ }
+ if got := classifyPatch(git.DiffFile{Path: "a.go", Kind: "modified"}); got != "empty" {
+ t.Fatalf("empty: %s", got)
+ }
+ if got := classifyPatch(git.DiffFile{Path: "a.go", Kind: "modified", Patch: "@@\n+x\n"}); got != "text" {
+ t.Fatalf("text: %s", got)
+ }
+ if !isGeneratedPath("vendor/go.sum") || !isGeneratedPath("Cargo.lock") {
+ t.Fatal("generated path")
+ }
+ if isGeneratedPath("server/pkg/git/repo.go") {
+ t.Fatal("source is not generated")
+ }
+}
+
+func TestFileInventoryKeepsEveryPath(t *testing.T) {
+ inv := fileInventory([]git.DiffFile{
+ {Path: "a.go", Kind: "modified", Patch: "@@\n+x\n"},
+ {Path: "logo.png", Kind: "added", Binary: true},
+ {Path: "go.sum", Kind: "modified", Patch: "+h1"},
+ })
+ if !strings.Contains(inv, "modified a.go") || !strings.Contains(inv, "binary added logo.png") || !strings.Contains(inv, "generated modified go.sum") {
+ t.Fatalf("inventory: %s", inv)
+ }
+}
+
+func TestClipPatchAndFillBudget(t *testing.T) {
+ small := git.DiffFile{Path: "a.go", Kind: "modified", Patch: "@@ -1 +1 @@\n-old\n+new\n"}
+ if got := fillPatchBudget([]git.DiffFile{small}, 8000); got != strings.TrimSpace(small.Patch) {
+ t.Fatalf("small patch should fit: %q", got)
+ }
+
+ long := strings.Repeat("x", 40)
+ clipped := clipPatch(long, 2)
+ if !strings.Contains(clipped, "... truncated") {
+ t.Fatalf("clip: %q", clipped)
+ }
+ if strings.Contains(clipPatch("abcd", 8), "truncated") {
+ t.Fatal("short patch should not clip")
+ }
+
+ files := []git.DiffFile{
+ {Path: "z.go", Kind: "modified", Patch: strings.Repeat("z", 20)},
+ {Path: "a.go", Kind: "modified", Patch: strings.Repeat("a", 20)},
+ {Path: "go.sum", Kind: "modified", Patch: strings.Repeat("s", 20)},
+ }
+ packed := fillPatchBudget(files, 5)
+ if strings.Contains(packed, "s") {
+ t.Fatalf("generated should not pack: %q", packed)
+ }
+ if !strings.Contains(packed, "a") {
+ t.Fatalf("path order should prefer a.go: %q", packed)
+ }
+ if strings.Count(packed, "z") > 0 && strings.Count(packed, "a") == 0 {
+ t.Fatalf("budget should fill a.go first: %q", packed)
+ }
+}
+
+func TestParseDraftAndFallback(t *testing.T) {
+ draft := parseDraft("feat(notes): add demo\n\n- Added the generate fixture.\n- Updated the large payload for budget tests.")
+ if draft.Title != "feat(notes): add demo" || !strings.Contains(draft.Body, "- Added the generate fixture.") {
+ t.Fatalf("draft: %+v", draft)
+ }
+ files := []git.DiffFile{{Path: "a.go", Kind: "modified"}, {Path: "b.go", Kind: "added"}}
+ got := fallbackDraft(files)
+ if !strings.HasPrefix(got, "chore: update staged files") || !strings.Contains(got, "- Added b.go") || !strings.Contains(got, "- Updated a.go") {
+ t.Fatalf("fallback: %q", got)
+ }
+}
+
+func TestGenerateDraftDeadlineFallsBackFast(t *testing.T) {
+ dir := t.TempDir()
+ t.Setenv("GIT_REPO", dir)
+ t.Setenv("LLM_PROVIDER", "fake")
+ t.Setenv("LLM_MODEL", "fake")
+ api := New(nil, nil, nil, nil, pkgagent.RunConfigSnapshot{}, config.Load(), nil)
+ repo, err := git.Open(dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ files := []git.DiffFile{{Path: "a.txt", Kind: "added", Patch: "@@\n+hello\n"}}
+ ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond)
+ defer cancel()
+ time.Sleep(2 * time.Millisecond)
+ start := time.Now()
+ draft := api.generateDraft(ctx, repo, files)
+ if time.Since(start) > 2*time.Second {
+ t.Fatalf("deadline fallback took %s", time.Since(start))
+ }
+ if draft.Title == "" {
+ t.Fatal("empty fallback title")
+ }
+}
+
+func TestDraftBudgetStaysSmall(t *testing.T) {
+ if draftStreamTimeout > 8*time.Second || draftRequestTimeout > 9*time.Second {
+ t.Fatalf("timeouts %s %s", draftStreamTimeout, draftRequestTimeout)
+ }
+ if patchBudgetTokens > 1500 || perFilePatchTokens > 300 || draftMaxOutputTokens > 320 {
+ t.Fatalf("budgets %d %d %d", patchBudgetTokens, perFilePatchTokens, draftMaxOutputTokens)
+ }
+}
+
+func TestReadDraftTextKeepsDeltas(t *testing.T) {
+ opts := `{"turns":[{"text":"feat(notes): add demo\n\nAdd the fixture.\nKeep tests green."}]}`
+ stream, err := pkgagent.Stream(context.Background(), pkgagent.Chat{
+ Model: pkgagent.ModelConfig{Provider: "fake", Options: []byte(opts)},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer stream.Close()
+ got := readDraftText(stream)
+ if !strings.HasPrefix(got, "feat(notes): add demo") || !strings.Contains(got, "Keep tests green.") {
+ t.Fatalf("text: %q", got)
+ }
+}
+
+func TestResolvePrompt(t *testing.T) {
+ if resolvePrompt(promptStore{Selected: promptIDCustom, Custom: "写短标题"}) != "写短标题" {
+ t.Fatal("custom")
+ }
+ if resolvePrompt(promptStore{Selected: promptIDCustom}) != conventionalCommitPrompt {
+ t.Fatal("empty custom falls back")
+ }
+ got := resolvePrompt(promptStore{Selected: promptIDConventional})
+ if !strings.Contains(got, "type(scope)") || !strings.Contains(got, `- "`) {
+ t.Fatal("conventional")
+ }
+ if resolvePrompt(promptStore{Selected: "standard"}) != conventionalCommitPrompt {
+ t.Fatal("old standard maps to conventional")
+ }
+}
diff --git a/server/internal/handler/git.go b/server/internal/handler/git.go
new file mode 100644
index 0000000..897e847
--- /dev/null
+++ b/server/internal/handler/git.go
@@ -0,0 +1,1067 @@
+package handler
+
+import (
+ "encoding/json"
+ "errors"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+
+ cderr "codedock/internal/errors"
+ pkgagent "codedock/pkg/agent"
+ "codedock/pkg/git"
+
+ "github.com/google/uuid"
+)
+
+type gitCheckoutRequest struct {
+ Checkout string `json:"checkout"`
+}
+
+type gitPathsRequest struct {
+ Paths []string `json:"paths"`
+ Checkout string `json:"checkout"`
+}
+
+type gitCommitRequest struct {
+ Message string `json:"message"`
+ Paths []string `json:"paths"`
+ Checkout string `json:"checkout"`
+}
+
+type gitResetRequest struct {
+ Target string `json:"target"`
+ Mode string `json:"mode"`
+ Checkout string `json:"checkout"`
+ Confirm bool `json:"confirm"`
+}
+
+type gitRevertRequest struct {
+ ID string `json:"id"`
+ Checkout string `json:"checkout"`
+}
+
+type gitBranchCreateRequest struct {
+ Name string `json:"name"`
+ Start string `json:"start"`
+ Checkout string `json:"checkout"`
+}
+
+type gitBranchNameRequest struct {
+ Name string `json:"name"`
+ Checkout string `json:"checkout"`
+}
+
+type gitWorktreeCreateRequest struct {
+ Path string `json:"path"` // 新检出的目录。
+ Branch string `json:"branch"` // 挂到已有分支;和 new_branch 二选一或作起点。
+ NewBranch string `json:"new_branch"` // 同时新建分支再挂上去;空则只用 branch。
+}
+
+type gitConflictWriteRequest struct {
+ Path string `json:"path"`
+ Result string `json:"result"`
+ Checkout string `json:"checkout"`
+}
+
+type gitStashCreateRequest struct {
+ AgentRun string `json:"agent_run"`
+ Checkout string `json:"checkout"`
+}
+
+type gitStashRestoreRequest struct {
+ ID string `json:"id"`
+ Checkout string `json:"checkout"`
+}
+
+type gitUndoClickRequest struct {
+ ID string `json:"id"`
+ Checkout string `json:"checkout"`
+}
+
+// BranchView 给分支页看当前局面和近期分叉图。ahead/behind 记在每条 Branch 上,不合成一对数字。
+type BranchView struct {
+ Current string `json:"current"` // 当前分支名;detached 时为空,界面要单独写「游离 HEAD」。
+ Locals []git.Branch `json:"locals"` // 本地分支,每条自带 ahead/behind。
+ Remotes []git.Branch `json:"remotes"` // 远程跟踪分支(fetch 缓存),不是 remote 地址。
+ Graph git.Graph `json:"graph"` // 近期分叉图,点只用于看。
+}
+
+// ConflictSession 是当时那份检出上的冲突会话,不要串到别的 worktree。
+type ConflictSession struct {
+ Kind string `json:"kind"` // merge | rebase | cherry_pick | revert;空表示当前没有冲突会话。不是 pull。
+ Ours string `json:"ours"` // 我方分支或提交的可读名,对比视图左边用。
+ Theirs string `json:"theirs"` // 对方分支或提交的可读名,对比视图右边用。
+ Items []git.ConflictItem `json:"items"` // 尚未解决的文件;全部写完才能 Continue。
+}
+
+// MessageDraft 是生成的提交说明,用户确认前可以改。
+type MessageDraft struct {
+ Title string `json:"title"` // 生成的标题行。
+ Body string `json:"body"` // 生成的正文。
+}
+
+// AgentSnapshot 是 Agent 改工作区前的副本,不是用户 stash 列表里的条目。
+type AgentSnapshot struct {
+ ID string `json:"id"` // 这份快照自己的 ID,撤销按钮用它找回。
+ Checkout git.Checkout `json:"checkout"` // 创建时的那份检出;恢复必须还在这份检出上。
+ Head string `json:"head"` // 创建时的 HEAD;恢复时先回到这个提交。
+ StashOID string `json:"stash_oid"` // git stash create 得到的悬空提交,含暂存区加已跟踪工作区。
+ HasUntracked bool `json:"has_untracked"` // 创建时工作区有未跟踪文件;stash create 不备份它们,恢复按钮必须写清。
+ AgentRun string `json:"agent_run"` // 对应的 Agent Run,给按钮文案用。
+}
+
+// UndoButton 是给用户看的撤销入口,不出现 reset / revert 命令名。
+type UndoButton struct {
+ ID string `json:"id"` // 按钮 ID,点下去交给 Click。
+ Label string `json:"label"` // 给用户看的文案。
+ Risk string `json:"risk"` // 空表示可安全撤;有则必须展示,例如会丢掉未推送之外的提交。
+ Target string `json:"target"` // last_commit | path | uncommitted | integrate | agent_stash。
+ TargetID string `json:"target_id"` // 目标提交、路径或快照 ID;针对整份工作区或当前整合时可空。
+}
+
+func mapGitErr(err error) error {
+ if err == nil {
+ return nil
+ }
+ if cderr.IsNotFound(err) || cderr.IsConflict(err) || cderr.IsInvalid(err) || cderr.IsUnavailable(err) || cderr.IsUnauthorized(err) {
+ return err
+ }
+ switch {
+ case errors.Is(err, git.ErrNotRepo), errors.Is(err, git.ErrCurrentBranch):
+ return cderr.Invalid("%s", err.Error())
+ case errors.Is(err, git.ErrConflict), errors.Is(err, git.ErrDirty), errors.Is(err, git.ErrIntegrating):
+ return cderr.Conflict("%s", err.Error())
+ default:
+ return cderr.Invalid("%s", err.Error())
+ }
+}
+
+func writeGitError(w http.ResponseWriter, err error) {
+ writeError(w, mapGitErr(err))
+}
+
+func sameCheckout(a, b string) bool {
+ aa, errA := filepath.Abs(a)
+ bb, errB := filepath.Abs(b)
+ if errA != nil || errB != nil {
+ return filepath.Clean(a) == filepath.Clean(b)
+ }
+ if ra, err := filepath.EvalSymlinks(aa); err == nil {
+ aa = ra
+ }
+ if rb, err := filepath.EvalSymlinks(bb); err == nil {
+ bb = rb
+ }
+ return filepath.Clean(aa) == filepath.Clean(bb)
+}
+
+func (a *API) gitRoot() (string, error) {
+ if a != nil && strings.TrimSpace(a.cfg.GitRepo) != "" {
+ return filepath.Abs(a.cfg.GitRepo)
+ }
+ cwd, err := os.Getwd()
+ if err != nil {
+ return "", cderr.Unavailable("cannot resolve current folder")
+ }
+ return cwd, nil
+}
+
+func (a *API) openSite(checkout string) (git.Repo, git.Checkout, error) {
+ root, err := a.gitRoot()
+ if err != nil {
+ return git.Repo{}, git.Checkout{}, err
+ }
+ repo, err := git.Open(root)
+ if err != nil {
+ return git.Repo{}, git.Checkout{}, mapGitErr(err)
+ }
+ path := root
+ if strings.TrimSpace(checkout) != "" {
+ path = checkout
+ }
+ abs, err := filepath.Abs(path)
+ if err != nil {
+ return git.Repo{}, git.Checkout{}, cderr.Invalid("invalid checkout")
+ }
+ if !sameCheckout(abs, root) {
+ trees, err := git.ListWorktrees(repo)
+ if err != nil {
+ return git.Repo{}, git.Checkout{}, mapGitErr(err)
+ }
+ found := false
+ for _, tree := range trees {
+ if sameCheckout(tree.Path, abs) {
+ found = true
+ break
+ }
+ }
+ if !found {
+ return git.Repo{}, git.Checkout{}, cderr.Invalid("checkout is not a worktree of this repo")
+ }
+ }
+ return repo, git.Checkout{Path: abs}, nil
+}
+
+func (a *API) conflictSession(repo git.Repo, co git.Checkout) (ConflictSession, error) {
+ state, err := git.Status(repo, co)
+ if err != nil {
+ return ConflictSession{}, err
+ }
+ ours, theirs, err := git.ConflictNames(repo, co)
+ if err != nil {
+ return ConflictSession{}, err
+ }
+ sess := ConflictSession{Kind: state.Integrating, Ours: ours, Theirs: theirs, Items: []git.ConflictItem{}}
+ for _, file := range state.Files {
+ if !file.Unmerged {
+ continue
+ }
+ item, err := git.ReadConflict(repo, co, file.Path)
+ if err != nil {
+ item = git.ConflictItem{Path: file.Path}
+ }
+ sess.Items = append(sess.Items, item)
+ }
+ return sess, nil
+}
+
+func codedockFile(repo git.Repo, name string) (string, error) {
+ gd, err := git.CommonDir(repo)
+ if err != nil {
+ return "", err
+ }
+ dir := filepath.Join(gd, "codedock")
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return "", err
+ }
+ return filepath.Join(dir, name), nil
+}
+
+func (a *API) loadSnapshots(repo git.Repo) ([]AgentSnapshot, error) {
+ path, err := codedockFile(repo, "snapshots.json")
+ if err != nil {
+ if errors.Is(err, git.ErrNotRepo) {
+ return []AgentSnapshot{}, nil
+ }
+ return nil, err
+ }
+ body, err := os.ReadFile(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return []AgentSnapshot{}, nil
+ }
+ return nil, err
+ }
+ var items []AgentSnapshot
+ if err := json.Unmarshal(body, &items); err != nil {
+ a.logger().Warn("snapshots.json is unreadable; starting from an empty list", "path", path, "error", err)
+ return []AgentSnapshot{}, nil //nolint:nilerr // 坏文件不应阻断撤销面板
+ }
+ if items == nil {
+ return []AgentSnapshot{}, nil
+ }
+ return items, nil
+}
+
+func (a *API) saveSnapshots(repo git.Repo, items []AgentSnapshot) error {
+ path, err := codedockFile(repo, "snapshots.json")
+ if err != nil {
+ return err
+ }
+ body, err := json.Marshal(items)
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(path, body, 0o644)
+}
+
+func latestSnapshot(items []AgentSnapshot, checkout string) AgentSnapshot {
+ for i := len(items) - 1; i >= 0; i-- {
+ if checkout == "" || sameCheckout(items[i].Checkout.Path, checkout) {
+ return items[i]
+ }
+ }
+ return AgentSnapshot{}
+}
+
+func snapshotByID(items []AgentSnapshot, id string) (AgentSnapshot, bool) {
+ for _, item := range items {
+ if item.ID == id {
+ return item, true
+ }
+ }
+ return AgentSnapshot{}, false
+}
+
+func (a *API) gitModel() pkgagent.ModelConfig {
+ opts, err := json.Marshal(map[string]string{
+ "api_key": a.cfg.LLMAPIKey,
+ "base_url": a.cfg.LLMBaseURL,
+ "thinking": "disabled",
+ })
+ if err != nil {
+ opts = json.RawMessage(`{}`)
+ }
+ return pkgagent.ModelConfig{
+ Provider: a.cfg.LLMProvider,
+ Model: a.cfg.LLMModel,
+ Options: opts,
+ }
+}
+
+func checkoutRelPath(root, rel string) (string, error) {
+ rel = filepath.Clean(filepath.FromSlash(rel))
+ if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
+ return "", cderr.Invalid("invalid path")
+ }
+ abs := filepath.Join(root, rel)
+ inside, err := filepath.Rel(root, abs)
+ if err != nil || strings.HasPrefix(inside, "..") {
+ return "", cderr.Invalid("invalid path")
+ }
+ return abs, nil
+}
+
+func undoButtons(state git.SiteState, graph git.Graph, snap AgentSnapshot) []UndoButton {
+ buttons := []UndoButton{}
+ if !state.Empty && state.Head != "" {
+ hasParent := false
+ for _, node := range graph.Nodes {
+ if node.Commit.ID == state.Head && len(node.Commit.Parents) > 0 {
+ hasParent = true
+ break
+ }
+ }
+ if hasParent {
+ risk := ""
+ if state.Upstream != "" && state.Ahead == 0 {
+ risk = "这次提交已经推送,撤销会改写已发布历史"
+ }
+ buttons = append(buttons, UndoButton{
+ ID: "last_commit",
+ Label: "撤销上次提交",
+ Risk: risk,
+ Target: "last_commit",
+ })
+ }
+ if len(state.Files) > 0 {
+ buttons = append(buttons, UndoButton{
+ ID: "uncommitted",
+ Label: "丢弃未提交的改动",
+ Risk: "工作区和暂存区的改动都会丢掉;未跟踪文件会留下",
+ Target: "uncommitted",
+ })
+ }
+ for _, file := range state.Files {
+ if file.Unmerged || file.WorktreeStatus == "?" {
+ continue
+ }
+ buttons = append(buttons, UndoButton{
+ ID: "path:" + file.Path,
+ Label: "还原 " + file.Path,
+ Risk: "会丢掉这个文件的未提交改动",
+ Target: "path",
+ TargetID: file.Path,
+ })
+ }
+ }
+ if state.Integrating != "" {
+ buttons = append(buttons, UndoButton{
+ ID: "integrate",
+ Label: "中止当前整合",
+ Risk: "未完成的整合会被放弃",
+ Target: "integrate",
+ })
+ }
+ if snap.ID != "" {
+ risk := "会回到快照时的提交,之后的提交可能丢掉"
+ if snap.HasUntracked {
+ risk += ";未跟踪文件不在快照里"
+ }
+ buttons = append(buttons, UndoButton{
+ ID: "agent_stash",
+ Label: "恢复 Agent 改文件前的快照",
+ Risk: risk,
+ Target: "agent_stash",
+ TargetID: snap.ID,
+ })
+ }
+ return buttons
+}
+
+// GitStatus 给界面看当前整局。
+func (a *API) GitStatus(w http.ResponseWriter, r *http.Request) {
+ repo, co, err := a.openSite(r.URL.Query().Get("checkout"))
+ if err != nil {
+ writeGitError(w, err)
+ return
+ }
+ state, err := git.Status(repo, co)
+ if err != nil {
+ writeGitError(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, state)
+}
+
+// GitDiff 读已暂存或工作区差异。
+func (a *API) GitDiff(w http.ResponseWriter, r *http.Request) {
+ repo, co, err := a.openSite(r.URL.Query().Get("checkout"))
+ if err != nil {
+ writeGitError(w, err)
+ return
+ }
+ scope := r.URL.Query().Get("scope")
+ if scope == "" {
+ scope = "staged"
+ }
+ files, err := git.Diff(repo, co, scope)
+ if err != nil {
+ writeGitError(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"files": files})
+}
+
+// GitGraph 读近期分叉图。
+func (a *API) GitGraph(w http.ResponseWriter, r *http.Request) {
+ repo, _, err := a.openSite(r.URL.Query().Get("checkout"))
+ if err != nil {
+ writeGitError(w, err)
+ return
+ }
+ graph, err := git.LogGraph(repo, 50)
+ if err != nil {
+ writeGitError(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, graph)
+}
+
+// GitLog 读当前检出线上的近期提交。
+func (a *API) GitLog(w http.ResponseWriter, r *http.Request) {
+ repo, co, err := a.openSite(r.URL.Query().Get("checkout"))
+ if err != nil {
+ writeGitError(w, err)
+ return
+ }
+ limit := 50
+ if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" {
+ n, convErr := strconv.Atoi(raw)
+ if convErr != nil || n <= 0 {
+ writeError(w, cderr.Invalid("invalid limit"))
+ return
+ }
+ limit = n
+ }
+ commits, err := git.Log(repo, co, limit)
+ if err != nil {
+ writeGitError(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"commits": commits})
+}
+
+// GitStage 暂存选中路径。
+func (a *API) GitStage(w http.ResponseWriter, r *http.Request) {
+ var req gitPathsRequest
+ if err := decodeJSON(r, &req); err != nil {
+ writeError(w, err)
+ return
+ }
+ repo, co, err := a.openSite(req.Checkout)
+ if err != nil {
+ writeGitError(w, err)
+ return
+ }
+ if err := git.Stage(repo, co, req.Paths); err != nil {
+ writeGitError(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+}
+
+// GitUnstage 取消暂存选中路径。
+func (a *API) GitUnstage(w http.ResponseWriter, r *http.Request) {
+ var req gitPathsRequest
+ if err := decodeJSON(r, &req); err != nil {
+ writeError(w, err)
+ return
+ }
+ repo, co, err := a.openSite(req.Checkout)
+ if err != nil {
+ writeGitError(w, err)
+ return
+ }
+ if err := git.Unstage(repo, co, req.Paths); err != nil {
+ writeGitError(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+}
+
+// GitDiscard 撤回工作区改动:已跟踪还原成暂存区,未跟踪删除。
+func (a *API) GitDiscard(w http.ResponseWriter, r *http.Request) {
+ var req gitPathsRequest
+ if err := decodeJSON(r, &req); err != nil {
+ writeError(w, err)
+ return
+ }
+ repo, co, err := a.openSite(req.Checkout)
+ if err != nil {
+ writeGitError(w, err)
+ return
+ }
+ if err := git.Discard(repo, co, req.Paths); err != nil {
+ writeGitError(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+}
+
+// GitCommit 用已确认的说明提交;paths 非空则先暂存。
+func (a *API) GitCommit(w http.ResponseWriter, r *http.Request) {
+ var req gitCommitRequest
+ if err := decodeJSON(r, &req); err != nil {
+ writeError(w, err)
+ return
+ }
+ repo, co, err := a.openSite(req.Checkout)
+ if err != nil {
+ writeGitError(w, err)
+ return
+ }
+ if len(req.Paths) > 0 {
+ if err := git.Stage(repo, co, req.Paths); err != nil {
+ writeGitError(w, err)
+ return
+ }
+ }
+ commit, err := git.CreateCommit(repo, co, req.Message)
+ if err != nil {
+ writeGitError(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"commit": commit})
+}
+
+// GitReset 按 mode 重置;mixed / hard 必须 confirm。
+func (a *API) GitReset(w http.ResponseWriter, r *http.Request) {
+ var req gitResetRequest
+ if err := decodeJSON(r, &req); err != nil {
+ writeError(w, err)
+ return
+ }
+ if strings.TrimSpace(req.Target) == "" {
+ writeError(w, cderr.Invalid("target is required"))
+ return
+ }
+ if req.Mode == "" {
+ req.Mode = "mixed"
+ }
+ if req.Mode != "soft" && req.Mode != "mixed" && req.Mode != "hard" {
+ writeError(w, cderr.Invalid("mode must be soft, mixed, or hard"))
+ return
+ }
+ if req.Mode != "soft" && !req.Confirm {
+ writeError(w, cderr.Invalid("mixed/hard reset requires confirm"))
+ return
+ }
+ repo, co, err := a.openSite(req.Checkout)
+ if err != nil {
+ writeGitError(w, err)
+ return
+ }
+ if err := git.Reset(repo, co, req.Target, req.Mode); err != nil {
+ writeGitError(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+}
+
+// GitRevert 用一次新提交回退指定提交。
+func (a *API) GitRevert(w http.ResponseWriter, r *http.Request) {
+ var req gitRevertRequest
+ if err := decodeJSON(r, &req); err != nil {
+ writeError(w, err)
+ return
+ }
+ repo, co, err := a.openSite(req.Checkout)
+ if err != nil {
+ writeGitError(w, err)
+ return
+ }
+ commit, err := git.Revert(repo, co, git.Commit{ID: req.ID})
+ if err != nil {
+ writeGitError(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"commit": commit})
+}
+
+// GitPush 推送当前分支。
+func (a *API) GitPush(w http.ResponseWriter, r *http.Request) {
+ var req gitCheckoutRequest
+ if err := decodeJSON(r, &req); err != nil {
+ writeError(w, err)
+ return
+ }
+ repo, co, err := a.openSite(req.Checkout)
+ if err != nil {
+ writeGitError(w, err)
+ return
+ }
+ if err := git.Push(r.Context(), repo, co); err != nil {
+ writeGitError(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+}
+
+// GitPull 拉取最新;有冲突则交给冲突模块。
+func (a *API) GitPull(w http.ResponseWriter, r *http.Request) {
+ var req gitCheckoutRequest
+ if err := decodeJSON(r, &req); err != nil {
+ writeError(w, err)
+ return
+ }
+ repo, co, err := a.openSite(req.Checkout)
+ if err != nil {
+ writeGitError(w, err)
+ return
+ }
+ if err := git.Pull(r.Context(), repo, co); err != nil {
+ if errors.Is(err, git.ErrConflict) {
+ sess, sessErr := a.conflictSession(repo, co)
+ if sessErr != nil {
+ writeGitError(w, sessErr)
+ return
+ }
+ writeJSON(w, http.StatusConflict, sess)
+ return
+ }
+ writeGitError(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+}
+
+// GitListRemotes 列出已配置的 remote。
+func (a *API) GitListRemotes(w http.ResponseWriter, r *http.Request) {
+ repo, _, err := a.openSite(r.URL.Query().Get("checkout"))
+ if err != nil {
+ writeGitError(w, err)
+ return
+ }
+ remotes, err := git.ListRemotes(repo)
+ if err != nil {
+ writeGitError(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"remotes": remotes})
+}
+
+// GitListWorktrees 列出该仓库的全部检出。
+func (a *API) GitListWorktrees(w http.ResponseWriter, r *http.Request) {
+ repo, _, err := a.openSite("")
+ if err != nil {
+ writeGitError(w, err)
+ return
+ }
+ trees, err := git.ListWorktrees(repo)
+ if err != nil {
+ writeGitError(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"worktrees": trees})
+}
+
+// GitAddWorktree 创建一份检出。
+func (a *API) GitAddWorktree(w http.ResponseWriter, r *http.Request) {
+ var req gitWorktreeCreateRequest
+ if err := decodeJSON(r, &req); err != nil {
+ writeError(w, err)
+ return
+ }
+ repo, _, err := a.openSite("")
+ if err != nil {
+ writeGitError(w, err)
+ return
+ }
+ tree, err := git.AddWorktree(repo, req.Path, req.Branch, req.NewBranch)
+ if err != nil {
+ writeGitError(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"worktree": tree})
+}
+
+// GitListBranches 给分支页看当前局面和近期分叉图。
+func (a *API) GitListBranches(w http.ResponseWriter, r *http.Request) {
+ repo, co, err := a.openSite(r.URL.Query().Get("checkout"))
+ if err != nil {
+ writeGitError(w, err)
+ return
+ }
+ state, err := git.Status(repo, co)
+ if err != nil {
+ writeGitError(w, err)
+ return
+ }
+ listed, err := git.ListBranches(repo)
+ if err != nil {
+ writeGitError(w, err)
+ return
+ }
+ graph, err := git.LogGraph(repo, 50)
+ if err != nil {
+ writeGitError(w, err)
+ return
+ }
+ view := BranchView{Current: state.Branch, Locals: []git.Branch{}, Remotes: []git.Branch{}, Graph: graph}
+ for _, b := range listed {
+ if b.IsRemote {
+ view.Remotes = append(view.Remotes, b)
+ } else {
+ view.Locals = append(view.Locals, b)
+ }
+ }
+ writeJSON(w, http.StatusOK, view)
+}
+
+// GitCreateBranch 从起点建一条分支。
+func (a *API) GitCreateBranch(w http.ResponseWriter, r *http.Request) {
+ var req gitBranchCreateRequest
+ if err := decodeJSON(r, &req); err != nil {
+ writeError(w, err)
+ return
+ }
+ repo, co, err := a.openSite(req.Checkout)
+ if err != nil {
+ writeGitError(w, err)
+ return
+ }
+ if err := git.CreateBranch(repo, co, req.Name, req.Start); err != nil {
+ writeGitError(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+}
+
+// GitSwitchBranch 切到指定分支。
+func (a *API) GitSwitchBranch(w http.ResponseWriter, r *http.Request) {
+ var req gitBranchNameRequest
+ if err := decodeJSON(r, &req); err != nil {
+ writeError(w, err)
+ return
+ }
+ repo, co, err := a.openSite(req.Checkout)
+ if err != nil {
+ writeGitError(w, err)
+ return
+ }
+ if err := git.SwitchBranch(repo, co, req.Name); err != nil {
+ writeGitError(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+}
+
+// GitDeleteBranch 删除一条本地分支。
+func (a *API) GitDeleteBranch(w http.ResponseWriter, r *http.Request) {
+ var req gitBranchNameRequest
+ if err := decodeJSON(r, &req); err != nil {
+ writeError(w, err)
+ return
+ }
+ repo, _, err := a.openSite(req.Checkout)
+ if err != nil {
+ writeGitError(w, err)
+ return
+ }
+ if err := git.DeleteBranch(repo, req.Name); err != nil {
+ writeGitError(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+}
+
+// GitGetConflict 打开当前冲突会话。
+func (a *API) GitGetConflict(w http.ResponseWriter, r *http.Request) {
+ repo, co, err := a.openSite(r.URL.Query().Get("checkout"))
+ if err != nil {
+ writeGitError(w, err)
+ return
+ }
+ sess, err := a.conflictSession(repo, co)
+ if err != nil {
+ writeGitError(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, sess)
+}
+
+// GitWriteConflict 写入某文件的决议。
+func (a *API) GitWriteConflict(w http.ResponseWriter, r *http.Request) {
+ var req gitConflictWriteRequest
+ if err := decodeJSON(r, &req); err != nil {
+ writeError(w, err)
+ return
+ }
+ repo, co, err := a.openSite(req.Checkout)
+ if err != nil {
+ writeGitError(w, err)
+ return
+ }
+ abs, err := checkoutRelPath(co.Path, req.Path)
+ if err != nil {
+ writeError(w, err)
+ return
+ }
+ if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
+ writeGitError(w, err)
+ return
+ }
+ if err := os.WriteFile(abs, []byte(req.Result), 0o644); err != nil {
+ writeGitError(w, err)
+ return
+ }
+ if err := git.Stage(repo, co, []string{req.Path}); err != nil {
+ writeGitError(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+}
+
+// GitContinueConflict 全部写完后继续整合。
+func (a *API) GitContinueConflict(w http.ResponseWriter, r *http.Request) {
+ var req gitCheckoutRequest
+ if err := decodeJSON(r, &req); err != nil {
+ writeError(w, err)
+ return
+ }
+ repo, co, err := a.openSite(req.Checkout)
+ if err != nil {
+ writeGitError(w, err)
+ return
+ }
+ if err := git.ContinueIntegrate(repo, co); err != nil {
+ writeGitError(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+}
+
+// GitAbortConflict 放弃本次整合。
+func (a *API) GitAbortConflict(w http.ResponseWriter, r *http.Request) {
+ var req gitCheckoutRequest
+ if err := decodeJSON(r, &req); err != nil {
+ writeError(w, err)
+ return
+ }
+ repo, co, err := a.openSite(req.Checkout)
+ if err != nil {
+ writeGitError(w, err)
+ return
+ }
+ if err := git.AbortIntegrate(repo, co); err != nil {
+ writeGitError(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+}
+
+// GitCreateSnapshot 复制当时提交和工作区,不挪走现有改动。
+func (a *API) GitCreateSnapshot(w http.ResponseWriter, r *http.Request) {
+ var req gitStashCreateRequest
+ if err := decodeJSON(r, &req); err != nil {
+ writeError(w, err)
+ return
+ }
+ repo, co, err := a.openSite(req.Checkout)
+ if err != nil {
+ writeGitError(w, err)
+ return
+ }
+ state, err := git.Status(repo, co)
+ if err != nil {
+ writeGitError(w, err)
+ return
+ }
+ oid, err := git.CaptureWork(repo, co, req.AgentRun)
+ if err != nil {
+ writeGitError(w, err)
+ return
+ }
+ snap := AgentSnapshot{
+ ID: uuid.NewString(),
+ Checkout: git.Checkout{Path: co.Path, CurrentBranch: state.Branch, CurrentCommit: state.Head, Detached: state.Detached},
+ Head: state.Head,
+ StashOID: oid,
+ HasUntracked: hasUntrackedFiles(state),
+ AgentRun: req.AgentRun,
+ }
+ items, err := a.loadSnapshots(repo)
+ if err != nil {
+ writeGitError(w, err)
+ return
+ }
+ items = append(items, snap)
+ if err := a.saveSnapshots(repo, items); err != nil {
+ writeGitError(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, snap)
+}
+
+func hasUntrackedFiles(state git.SiteState) bool {
+ for _, file := range state.Files {
+ if file.WorktreeStatus == "?" {
+ return true
+ }
+ }
+ return false
+}
+
+// GitLatestSnapshot 取最近一份 Agent 快照。
+func (a *API) GitLatestSnapshot(w http.ResponseWriter, r *http.Request) {
+ repo, co, err := a.openSite(r.URL.Query().Get("checkout"))
+ if err != nil {
+ writeGitError(w, err)
+ return
+ }
+ items, err := a.loadSnapshots(repo)
+ if err != nil {
+ writeGitError(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, latestSnapshot(items, co.Path))
+}
+
+// GitRestoreSnapshot 回到快照时的提交,并把副本铺回工作区。
+func (a *API) GitRestoreSnapshot(w http.ResponseWriter, r *http.Request) {
+ var req gitStashRestoreRequest
+ if err := decodeJSON(r, &req); err != nil {
+ writeError(w, err)
+ return
+ }
+ repo, co, err := a.openSite(req.Checkout)
+ if err != nil {
+ writeGitError(w, err)
+ return
+ }
+ items, err := a.loadSnapshots(repo)
+ if err != nil {
+ writeGitError(w, err)
+ return
+ }
+ var snap AgentSnapshot
+ if req.ID != "" {
+ var ok bool
+ snap, ok = snapshotByID(items, req.ID)
+ if !ok {
+ writeError(w, cderr.NotFound("snapshot not found"))
+ return
+ }
+ } else {
+ snap = latestSnapshot(items, co.Path)
+ if snap.ID == "" {
+ writeError(w, cderr.NotFound("snapshot not found"))
+ return
+ }
+ }
+ if err := restoreSnapshotOn(repo, co, snap); err != nil {
+ writeGitError(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+}
+
+func restoreSnapshotOn(repo git.Repo, co git.Checkout, snap AgentSnapshot) error {
+ if snap.ID == "" {
+ return cderr.NotFound("snapshot not found")
+ }
+ if snap.Checkout.Path != "" && !sameCheckout(snap.Checkout.Path, co.Path) {
+ return cderr.Invalid("snapshot belongs to another checkout")
+ }
+ return git.RestoreWork(repo, co, snap.StashOID, snap.Head)
+}
+
+// GitListUndo 按当前 SiteState 算出能点的撤销按钮。
+func (a *API) GitListUndo(w http.ResponseWriter, r *http.Request) {
+ repo, co, err := a.openSite(r.URL.Query().Get("checkout"))
+ if err != nil {
+ writeGitError(w, err)
+ return
+ }
+ state, err := git.Status(repo, co)
+ if err != nil {
+ writeGitError(w, err)
+ return
+ }
+ graph, err := git.LogGraph(repo, 50)
+ if err != nil {
+ writeGitError(w, err)
+ return
+ }
+ items, err := a.loadSnapshots(repo)
+ if err != nil {
+ writeGitError(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"buttons": undoButtons(state, graph, latestSnapshot(items, co.Path))})
+}
+
+// GitClickUndo 执行该按钮对应的重置、回退或恢复。
+func (a *API) GitClickUndo(w http.ResponseWriter, r *http.Request) {
+ var req gitUndoClickRequest
+ if err := decodeJSON(r, &req); err != nil {
+ writeError(w, err)
+ return
+ }
+ repo, co, err := a.openSite(req.Checkout)
+ if err != nil {
+ writeGitError(w, err)
+ return
+ }
+ switch {
+ case req.ID == "last_commit":
+ err = git.Reset(repo, co, "HEAD~1", "mixed")
+ case req.ID == "uncommitted":
+ err = git.Reset(repo, co, "HEAD", "hard")
+ case req.ID == "integrate":
+ err = git.AbortIntegrate(repo, co)
+ case req.ID == "agent_stash" || strings.HasPrefix(req.ID, "agent_stash:"):
+ items, loadErr := a.loadSnapshots(repo)
+ if loadErr != nil {
+ writeGitError(w, loadErr)
+ return
+ }
+ id := strings.TrimPrefix(req.ID, "agent_stash:")
+ var snap AgentSnapshot
+ if id == "" || id == "agent_stash" {
+ snap = latestSnapshot(items, co.Path)
+ } else {
+ var ok bool
+ snap, ok = snapshotByID(items, id)
+ if !ok {
+ writeError(w, cderr.NotFound("snapshot not found"))
+ return
+ }
+ }
+ err = restoreSnapshotOn(repo, co, snap)
+ case strings.HasPrefix(req.ID, "path:"):
+ err = git.RestorePath(repo, co, strings.TrimPrefix(req.ID, "path:"))
+ default:
+ writeError(w, cderr.Invalid("unknown undo button"))
+ return
+ }
+ if err != nil {
+ writeGitError(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+}
diff --git a/server/internal/handler/git_test.go b/server/internal/handler/git_test.go
new file mode 100644
index 0000000..09c1737
--- /dev/null
+++ b/server/internal/handler/git_test.go
@@ -0,0 +1,493 @@
+package handler_test
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/go-chi/chi/v5"
+
+ "codedock/internal/config"
+ "codedock/internal/handler"
+ pkgagent "codedock/pkg/agent"
+)
+
+func gitCmd(t *testing.T, dir string, args ...string) {
+ t.Helper()
+ cmd := exec.Command("git", args...)
+ cmd.Dir = dir
+ cmd.Env = append(os.Environ(), "GIT_EDITOR=true", "GIT_TERMINAL_PROMPT=0")
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
+ }
+}
+
+func initGitRepo(t *testing.T) string {
+ t.Helper()
+ dir := t.TempDir()
+ gitCmd(t, dir, "init", "-b", "main")
+ gitCmd(t, dir, "config", "user.name", "tester")
+ gitCmd(t, dir, "config", "user.email", "tester@example.com")
+ return dir
+}
+
+func newGitAPI(t *testing.T, repo string) *handler.API {
+ t.Helper()
+ t.Setenv("GIT_REPO", repo)
+ t.Setenv("LLM_PROVIDER", "fake")
+ t.Setenv("LLM_MODEL", "fake")
+ return handler.New(nil, nil, nil, nil, pkgagent.RunConfigSnapshot{}, config.Load(), nil)
+}
+
+func gitRouter(api *handler.API) http.Handler {
+ r := chi.NewRouter()
+ r.Get("/git/status", api.GitStatus)
+ r.Get("/git/diff", api.GitDiff)
+ r.Get("/git/graph", api.GitGraph)
+ r.Get("/git/log", api.GitLog)
+ r.Post("/git/stage", api.GitStage)
+ r.Post("/git/unstage", api.GitUnstage)
+ r.Post("/git/discard", api.GitDiscard)
+ r.Post("/git/commit", api.GitCommit)
+ r.Post("/git/reset", api.GitReset)
+ r.Post("/git/revert", api.GitRevert)
+ r.Post("/git/push", api.GitPush)
+ r.Post("/git/pull", api.GitPull)
+ r.Get("/git/remotes", api.GitListRemotes)
+ r.Get("/git/worktrees", api.GitListWorktrees)
+ r.Post("/git/worktrees", api.GitAddWorktree)
+ r.Get("/git/branches", api.GitListBranches)
+ r.Post("/git/branches", api.GitCreateBranch)
+ r.Post("/git/branches/switch", api.GitSwitchBranch)
+ r.Delete("/git/branches", api.GitDeleteBranch)
+ r.Get("/git/conflict", api.GitGetConflict)
+ r.Post("/git/conflict/write", api.GitWriteConflict)
+ r.Post("/git/conflict/continue", api.GitContinueConflict)
+ r.Post("/git/conflict/abort", api.GitAbortConflict)
+ r.Get("/git/commit-message/prompt", api.GitGetPrompt)
+ r.Put("/git/commit-message/prompt", api.GitSetPrompt)
+ r.Post("/git/commit-message/generate", api.GitGenerateMessage)
+ r.Post("/git/stash", api.GitCreateSnapshot)
+ r.Get("/git/stash/latest", api.GitLatestSnapshot)
+ r.Post("/git/stash/restore", api.GitRestoreSnapshot)
+ r.Get("/git/undo", api.GitListUndo)
+ r.Post("/git/undo", api.GitClickUndo)
+ return r
+}
+
+func doGit(t *testing.T, h http.Handler, method, path, body string) *httptest.ResponseRecorder {
+ t.Helper()
+ var r *http.Request
+ if body == "" {
+ r = httptest.NewRequest(method, path, nil)
+ } else {
+ r = httptest.NewRequest(method, path, bytes.NewBufferString(body))
+ r.Header.Set("Content-Type", "application/json")
+ }
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, r)
+ return rec
+}
+
+func decodeGit[T any](t *testing.T, rec *httptest.ResponseRecorder, dest *T) {
+ t.Helper()
+ if err := json.Unmarshal(rec.Body.Bytes(), dest); err != nil {
+ t.Fatalf("decode %s: %v", rec.Body.String(), err)
+ }
+}
+
+func TestGitSkeletonRoutes(t *testing.T) {
+ t.Setenv("GIT_REPO", t.TempDir())
+ api := handler.New(nil, nil, nil, nil, pkgagent.RunConfigSnapshot{}, config.Load(), nil)
+ r := gitRouter(api)
+ for _, path := range []string{"/git/status", "/git/branches", "/git/undo", "/git/conflict"} {
+ rec := doGit(t, r, http.MethodGet, path, "")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("%s: %d %s", path, rec.Code, rec.Body.String())
+ }
+ }
+}
+
+func TestGitStatusCommitBranchUndo(t *testing.T) {
+ dir := initGitRepo(t)
+ if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("hello"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ api := newGitAPI(t, dir)
+ r := gitRouter(api)
+
+ rec := doGit(t, r, http.MethodGet, "/git/status", "")
+ if rec.Code != http.StatusOK {
+ t.Fatal(rec.Body.String())
+ }
+ var status map[string]any
+ decodeGit(t, rec, &status)
+ if status["is_repo"] != true || status["empty"] != true {
+ t.Fatalf("status: %s", rec.Body.String())
+ }
+
+ rec = doGit(t, r, http.MethodPost, "/git/stage", `{"paths":["a.txt"]}`)
+ if rec.Code != http.StatusOK {
+ t.Fatal(rec.Body.String())
+ }
+ rec = doGit(t, r, http.MethodPost, "/git/commit-message/generate", `{}`)
+ if rec.Code != http.StatusOK {
+ t.Fatal(rec.Body.String())
+ }
+ var draft handler.MessageDraft
+ decodeGit(t, rec, &draft)
+ if draft.Title == "" {
+ t.Fatal("draft title")
+ }
+ rec = doGit(t, r, http.MethodPost, "/git/commit", `{"message":"add a","paths":["a.txt"]}`)
+ if rec.Code != http.StatusOK {
+ t.Fatal(rec.Body.String())
+ }
+
+ rec = doGit(t, r, http.MethodGet, "/git/branches", "")
+ if rec.Code != http.StatusOK {
+ t.Fatal(rec.Body.String())
+ }
+ var view handler.BranchView
+ decodeGit(t, rec, &view)
+ if view.Current != "main" || len(view.Locals) != 1 {
+ t.Fatalf("branches: %+v", view)
+ }
+
+ rec = doGit(t, r, http.MethodPost, "/git/branches", `{"name":"feature"}`)
+ if rec.Code != http.StatusOK {
+ t.Fatal(rec.Body.String())
+ }
+ rec = doGit(t, r, http.MethodPost, "/git/branches/switch", `{"name":"feature"}`)
+ if rec.Code != http.StatusOK {
+ t.Fatal(rec.Body.String())
+ }
+ if err := os.WriteFile(filepath.Join(dir, "b.txt"), []byte("b"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ rec = doGit(t, r, http.MethodPost, "/git/commit", `{"message":"add b","paths":["b.txt"]}`)
+ if rec.Code != http.StatusOK {
+ t.Fatal(rec.Body.String())
+ }
+
+ rec = doGit(t, r, http.MethodGet, "/git/undo", "")
+ if rec.Code != http.StatusOK {
+ t.Fatal(rec.Body.String())
+ }
+ var undo struct {
+ Buttons []handler.UndoButton `json:"buttons"`
+ }
+ decodeGit(t, rec, &undo)
+ foundLast := false
+ for _, b := range undo.Buttons {
+ if b.ID == "last_commit" {
+ foundLast = true
+ }
+ }
+ if !foundLast {
+ t.Fatalf("undo buttons: %+v", undo.Buttons)
+ }
+ rec = doGit(t, r, http.MethodPost, "/git/undo", `{"id":"last_commit"}`)
+ if rec.Code != http.StatusOK {
+ t.Fatal(rec.Body.String())
+ }
+
+ rec = doGit(t, r, http.MethodPost, "/git/reset", `{"target":"HEAD","mode":"hard"}`)
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("reset without confirm: %d %s", rec.Code, rec.Body.String())
+ }
+ rec = doGit(t, r, http.MethodPost, "/git/reset", `{"target":"HEAD","mode":"hard","confirm":true}`)
+ if rec.Code != http.StatusOK {
+ t.Fatal(rec.Body.String())
+ }
+}
+
+func TestGitConflictAbortAndPromptSnapshot(t *testing.T) {
+ dir := initGitRepo(t)
+ if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("base\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ gitCmd(t, dir, "add", "a.txt")
+ gitCmd(t, dir, "commit", "-m", "base")
+ gitCmd(t, dir, "checkout", "-b", "other")
+ if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("theirs\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ gitCmd(t, dir, "add", "a.txt")
+ gitCmd(t, dir, "commit", "-m", "theirs")
+ gitCmd(t, dir, "checkout", "main")
+ if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("ours\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ gitCmd(t, dir, "add", "a.txt")
+ gitCmd(t, dir, "commit", "-m", "ours")
+ cmd := exec.Command("git", "merge", "other")
+ cmd.Dir = dir
+ _ = cmd.Run()
+
+ api := newGitAPI(t, dir)
+ r := gitRouter(api)
+ rec := doGit(t, r, http.MethodGet, "/git/conflict", "")
+ if rec.Code != http.StatusOK {
+ t.Fatal(rec.Body.String())
+ }
+ var sess handler.ConflictSession
+ decodeGit(t, rec, &sess)
+ if sess.Kind != "merge" || len(sess.Items) != 1 || sess.Items[0].Kind != "both_modified" {
+ t.Fatalf("conflict: %+v", sess)
+ }
+ rec = doGit(t, r, http.MethodPost, "/git/conflict/write", `{"path":"a.txt","result":"resolved\n"}`)
+ if rec.Code != http.StatusOK {
+ t.Fatal(rec.Body.String())
+ }
+ rec = doGit(t, r, http.MethodPost, "/git/conflict/continue", `{}`)
+ if rec.Code != http.StatusOK {
+ t.Fatal(rec.Body.String())
+ }
+
+ rec = doGit(t, r, http.MethodPut, "/git/commit-message/prompt", `{"selected":"custom","custom":"写短标题"}`)
+ if rec.Code != http.StatusOK {
+ t.Fatal(rec.Body.String())
+ }
+ rec = doGit(t, r, http.MethodGet, "/git/commit-message/prompt", "")
+ if rec.Code != http.StatusOK {
+ t.Fatal(rec.Body.String())
+ }
+ var prompt handler.PromptConfig
+ decodeGit(t, rec, &prompt)
+ if prompt.Selected != "custom" || prompt.Custom != "写短标题" || prompt.SystemPrompt != "写短标题" || len(prompt.Presets) != 2 {
+ t.Fatalf("prompt: %+v", prompt)
+ }
+
+ if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("snap\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ rec = doGit(t, r, http.MethodPost, "/git/stash", `{"agent_run":"run-1"}`)
+ if rec.Code != http.StatusOK {
+ t.Fatal(rec.Body.String())
+ }
+ var snap handler.AgentSnapshot
+ decodeGit(t, rec, &snap)
+ if snap.ID == "" || snap.StashOID == "" || snap.AgentRun != "run-1" {
+ t.Fatalf("snap: %+v", snap)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("lost\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ rec = doGit(t, r, http.MethodGet, "/git/stash/latest", "")
+ if rec.Code != http.StatusOK {
+ t.Fatal(rec.Body.String())
+ }
+ rec = doGit(t, r, http.MethodPost, "/git/stash/restore", `{"id":"`+snap.ID+`"}`)
+ if rec.Code != http.StatusOK {
+ t.Fatal(rec.Body.String())
+ }
+ body, err := os.ReadFile(filepath.Join(dir, "a.txt"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(body) != "snap\n" {
+ t.Fatalf("restored %q", body)
+ }
+}
+
+func TestGitLog(t *testing.T) {
+ dir := initGitRepo(t)
+ if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("hello"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ gitCmd(t, dir, "add", "a.txt")
+ gitCmd(t, dir, "commit", "-m", "add a")
+ api := newGitAPI(t, dir)
+ r := gitRouter(api)
+ rec := doGit(t, r, http.MethodGet, "/git/log?limit=10", "")
+ if rec.Code != http.StatusOK {
+ t.Fatal(rec.Body.String())
+ }
+ var body struct {
+ Commits []struct {
+ ID string `json:"id"`
+ Title string `json:"title"`
+ } `json:"commits"`
+ }
+ decodeGit(t, rec, &body)
+ if len(body.Commits) != 1 || body.Commits[0].Title != "add a" || body.Commits[0].ID == "" {
+ t.Fatalf("log: %+v", body.Commits)
+ }
+}
+
+func TestGitDiscard(t *testing.T) {
+ dir := initGitRepo(t)
+ if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("base"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ gitCmd(t, dir, "add", "a.txt")
+ gitCmd(t, dir, "commit", "-m", "base")
+ if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("dirty"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.MkdirAll(filepath.Join(dir, "pkg"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "pkg", "new.txt"), []byte("new"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ api := newGitAPI(t, dir)
+ r := gitRouter(api)
+ rec := doGit(t, r, http.MethodPost, "/git/discard", `{"paths":["a.txt","pkg/new.txt"]}`)
+ if rec.Code != http.StatusOK {
+ t.Fatal(rec.Body.String())
+ }
+ body, err := os.ReadFile(filepath.Join(dir, "a.txt"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(body) != "base" {
+ t.Fatalf("tracked: %q", body)
+ }
+ if _, err := os.Stat(filepath.Join(dir, "pkg", "new.txt")); !os.IsNotExist(err) {
+ t.Fatalf("untracked: %v", err)
+ }
+}
+
+func TestGitInvalidCheckout(t *testing.T) {
+ dir := initGitRepo(t)
+ api := newGitAPI(t, dir)
+ r := gitRouter(api)
+ rec := doGit(t, r, http.MethodGet, "/git/status?checkout=/tmp/not-a-worktree", "")
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("code %d %s", rec.Code, rec.Body.String())
+ }
+}
+
+func TestGitUndoRejectsOtherCheckoutSnapshot(t *testing.T) {
+ dir := initGitRepo(t)
+ if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("base\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ gitCmd(t, dir, "add", "a.txt")
+ gitCmd(t, dir, "commit", "-m", "base")
+ gitCmd(t, dir, "branch", "feature")
+ wt := filepath.Join(t.TempDir(), "wt")
+ gitCmd(t, dir, "worktree", "add", wt, "feature")
+
+ api := newGitAPI(t, dir)
+ r := gitRouter(api)
+ if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("main-change\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ rec := doGit(t, r, http.MethodPost, "/git/stash", `{"agent_run":"run-2"}`)
+ if rec.Code != http.StatusOK {
+ t.Fatal(rec.Body.String())
+ }
+ var snap handler.AgentSnapshot
+ decodeGit(t, rec, &snap)
+ if snap.ID == "" {
+ t.Fatal("snapshot id")
+ }
+ body := `{"id":"agent_stash:` + snap.ID + `","checkout":` + jsonQuote(wt) + `}`
+ rec = doGit(t, r, http.MethodPost, "/git/undo", body)
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("undo other checkout: %d %s", rec.Code, rec.Body.String())
+ }
+}
+
+func TestGitCommitMessagePromptPresets(t *testing.T) {
+ dir := initGitRepo(t)
+ api := newGitAPI(t, dir)
+ r := gitRouter(api)
+
+ rec := doGit(t, r, http.MethodGet, "/git/commit-message/prompt", "")
+ if rec.Code != http.StatusOK {
+ t.Fatal(rec.Body.String())
+ }
+ var prompt handler.PromptConfig
+ decodeGit(t, rec, &prompt)
+ if prompt.Selected != "conventional" || prompt.SystemPrompt == "" || len(prompt.Presets) != 2 {
+ t.Fatalf("default prompt: %+v", prompt)
+ }
+
+ rec = doGit(t, r, http.MethodPut, "/git/commit-message/prompt", `{"selected":"nope","custom":""}`)
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("invalid id: %d %s", rec.Code, rec.Body.String())
+ }
+
+ rec = doGit(t, r, http.MethodPut, "/git/commit-message/prompt", `{"selected":"conventional","custom":"keep me"}`)
+ if rec.Code != http.StatusOK {
+ t.Fatal(rec.Body.String())
+ }
+ decodeGit(t, rec, &prompt)
+ if prompt.Selected != "conventional" || prompt.Custom != "keep me" || !strings.Contains(prompt.SystemPrompt, "type(scope)") || !strings.Contains(prompt.SystemPrompt, `- "`) {
+ t.Fatalf("conventional: %+v", prompt)
+ }
+
+ if err := os.MkdirAll(filepath.Join(dir, ".git", "codedock"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, ".git", "codedock", "commit-message.json"), []byte(`{"selected":"standard","custom":""}`), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ rec = doGit(t, r, http.MethodGet, "/git/commit-message/prompt", "")
+ if rec.Code != http.StatusOK {
+ t.Fatal(rec.Body.String())
+ }
+ decodeGit(t, rec, &prompt)
+ if prompt.Selected != "conventional" || !strings.Contains(prompt.SystemPrompt, "type(scope)") {
+ t.Fatalf("legacy standard: %+v", prompt)
+ }
+
+ if err := os.MkdirAll(filepath.Join(dir, ".git", "codedock"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ legacy := filepath.Join(dir, ".git", "codedock", "commit-message-prompt")
+ if err := os.WriteFile(legacy, []byte("旧提示词"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ jsonPath := filepath.Join(dir, ".git", "codedock", "commit-message.json")
+ if err := os.Remove(jsonPath); err != nil && !os.IsNotExist(err) {
+ t.Fatal(err)
+ }
+ rec = doGit(t, r, http.MethodGet, "/git/commit-message/prompt", "")
+ if rec.Code != http.StatusOK {
+ t.Fatal(rec.Body.String())
+ }
+ decodeGit(t, rec, &prompt)
+ if prompt.Selected != "custom" || prompt.Custom != "旧提示词" || prompt.SystemPrompt != "旧提示词" {
+ t.Fatalf("migrate: %+v", prompt)
+ }
+
+ if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("hello"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ rec = doGit(t, r, http.MethodPost, "/git/commit-message/generate", `{}`)
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("nothing staged: %d %s", rec.Code, rec.Body.String())
+ }
+ rec = doGit(t, r, http.MethodPost, "/git/stage", `{"paths":["a.txt"]}`)
+ if rec.Code != http.StatusOK {
+ t.Fatal(rec.Body.String())
+ }
+ rec = doGit(t, r, http.MethodPost, "/git/commit-message/generate", `{}`)
+ if rec.Code != http.StatusOK {
+ t.Fatal(rec.Body.String())
+ }
+ var draft handler.MessageDraft
+ decodeGit(t, rec, &draft)
+ if draft.Title == "" {
+ t.Fatal("draft title")
+ }
+}
+
+func jsonQuote(s string) string {
+ b, err := json.Marshal(s)
+ if err != nil {
+ return `""`
+ }
+ return string(b)
+}
diff --git a/server/internal/handler/loop_test.go b/server/internal/handler/loop_test.go
index 25f1259..3f6c7fb 100644
--- a/server/internal/handler/loop_test.go
+++ b/server/internal/handler/loop_test.go
@@ -20,6 +20,7 @@ import (
"codedock/internal/agent"
"codedock/internal/agent/memory"
agenttools "codedock/internal/agent/tools"
+ "codedock/internal/config"
cderr "codedock/internal/errors"
"codedock/internal/events"
"codedock/internal/handler"
@@ -121,7 +122,7 @@ func newFixture(t *testing.T, extras ...tool.Tool) *fixture {
Model: "fake",
Options: mustJSON(pkgagent.FakeOptions{Turns: []pkgagent.FakeTurn{{Text: "hello"}}}),
})
- api := handler.New(client, queries, runtime, bus, defaults, nil)
+ api := handler.New(client, queries, runtime, bus, defaults, config.Config{}, nil)
return &fixture{api: api, router: testRouter(api), cancel: cancel, queries: queries, runtime: runtime}
}
@@ -956,7 +957,7 @@ func TestRecoverQueuedRun(t *testing.T) {
Options: mustJSON(pkgagent.FakeOptions{Turns: []pkgagent.FakeTurn{{Text: "recovered"}}}),
})
runtime := agent.New(client, queries, bus, registry, nil, agenttools.Ports{})
- api := handler.New(client, queries, runtime, bus, defaults, nil)
+ api := handler.New(client, queries, runtime, bus, defaults, config.Config{}, nil)
f := &fixture{api: api, router: testRouter(api), cancel: cancel}
sessionID := f.createSession(t)
runID := f.start(t, sessionID, handler.StartRunRequest{
diff --git a/server/pkg/agent/openai.go b/server/pkg/agent/openai.go
index 8ffc7ca..b0958a6 100644
--- a/server/pkg/agent/openai.go
+++ b/server/pkg/agent/openai.go
@@ -15,15 +15,23 @@ import (
)
type openaiOptions struct {
- APIKey string `json:"api_key"`
- BaseURL string `json:"base_url"`
+ APIKey string `json:"api_key"`
+ BaseURL string `json:"base_url"`
+ Thinking string `json:"thinking"`
+}
+
+type openaiThinking struct {
+ Type string `json:"type"`
}
type openaiChatRequest struct {
- Model string `json:"model"`
- Stream bool `json:"stream"`
- Messages []openaiChatMessage `json:"messages"`
- Tools []openaiTool `json:"tools,omitempty"`
+ Model string `json:"model"`
+ Stream bool `json:"stream"`
+ Messages []openaiChatMessage `json:"messages"`
+ Tools []openaiTool `json:"tools,omitempty"`
+ MaxTokens int64 `json:"max_tokens,omitempty"`
+ MaxCompletionTokens int64 `json:"max_completion_tokens,omitempty"`
+ Thinking *openaiThinking `json:"thinking,omitempty"`
}
type openaiChatMessage struct {
@@ -79,12 +87,17 @@ func streamOpenAI(ctx context.Context, chat Chat) (ModelStream, error) {
base = "https://api.openai.com/v1"
}
- body, err := json.Marshal(openaiChatRequest{
+ reqBody := openaiChatRequest{
Model: chat.Model.Model,
Stream: true,
Messages: toOpenAIMessages(chat),
Tools: toOpenAITools(chat.Tools),
- })
+ }
+ applyOutputLimit(&reqBody, chat.Model.Model, chat.MaxOutputTokens)
+ if opts.Thinking != "" && supportsThinking(base) {
+ reqBody.Thinking = &openaiThinking{Type: opts.Thinking}
+ }
+ body, err := json.Marshal(reqBody)
if err != nil {
return nil, err
}
@@ -119,6 +132,26 @@ func streamOpenAI(ctx context.Context, chat Chat) (ModelStream, error) {
return stream, nil
}
+func applyOutputLimit(req *openaiChatRequest, model string, n int64) {
+ if req == nil || n <= 0 {
+ return
+ }
+ if isReasoningModel(model) {
+ req.MaxCompletionTokens = n
+ return
+ }
+ req.MaxTokens = n
+}
+
+func isReasoningModel(model string) bool {
+ m := strings.ToLower(strings.TrimSpace(model))
+ return strings.HasPrefix(m, "o1") || strings.HasPrefix(m, "o3") || strings.HasPrefix(m, "o4") || strings.Contains(m, "reasoner")
+}
+
+func supportsThinking(base string) bool {
+ return strings.Contains(strings.ToLower(base), "deepseek")
+}
+
// consumeOpenAI 解析 SSE 增量,拼出最终文本、工具调用和用量后关闭流。
func consumeOpenAI(ctx context.Context, chat Chat, body io.ReadCloser, stream *staticStream) {
defer close(stream.done)
diff --git a/server/pkg/agent/openai_test.go b/server/pkg/agent/openai_test.go
index e676d04..9b032c9 100644
--- a/server/pkg/agent/openai_test.go
+++ b/server/pkg/agent/openai_test.go
@@ -2,11 +2,46 @@ package agent
import (
"encoding/json"
+ "strings"
"testing"
"codedock/pkg/agent/tool"
)
+func TestApplyOutputLimitAndThinkingSupport(t *testing.T) {
+ var chat openaiChatRequest
+ applyOutputLimit(&chat, "deepseek-v4-flash", 320)
+ if chat.MaxTokens != 320 || chat.MaxCompletionTokens != 0 {
+ t.Fatalf("compat model: %+v", chat)
+ }
+ chat = openaiChatRequest{}
+ applyOutputLimit(&chat, "o3-mini", 320)
+ if chat.MaxCompletionTokens != 320 || chat.MaxTokens != 0 {
+ t.Fatalf("reasoning model: %+v", chat)
+ }
+ if supportsThinking("https://api.openai.com/v1") || !supportsThinking("https://api.deepseek.com") {
+ t.Fatal("thinking support")
+ }
+}
+
+func TestOpenAIRequestDisablesThinking(t *testing.T) {
+ body, err := json.Marshal(openaiChatRequest{
+ Model: "deepseek-v4-flash",
+ Stream: true,
+ MaxTokens: 96,
+ Thinking: &openaiThinking{Type: "disabled"},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(string(body), `"thinking":{"type":"disabled"}`) {
+ t.Fatalf("body %s", body)
+ }
+ if !strings.Contains(string(body), `"max_tokens":96`) {
+ t.Fatalf("body %s", body)
+ }
+}
+
// TestMergeToolDelta 校验流式 tool_calls 按 index 拼成一条完整调用。
func TestMergeToolDelta(t *testing.T) {
t.Parallel()
diff --git a/server/pkg/git/branch.go b/server/pkg/git/branch.go
new file mode 100644
index 0000000..dbac5ad
--- /dev/null
+++ b/server/pkg/git/branch.go
@@ -0,0 +1,147 @@
+package git
+
+import (
+ "errors"
+ "fmt"
+ "strings"
+)
+
+// ListBranches 列出本地和远程跟踪分支,含跟踪和 ahead/behind。
+func ListBranches(repo Repo) ([]Branch, error) {
+ if !isRepo(repo.Path) {
+ return []Branch{}, nil
+ }
+ out, err := runGit(repo.Path, "for-each-ref",
+ "--format=%(objectname)%00%(refname)%00%(upstream:short)%00%(upstream:track)%00%(contents:subject)%00%(HEAD)",
+ "refs/heads", "refs/remotes")
+ if err != nil {
+ return nil, err
+ }
+ trees, err := ListWorktrees(repo)
+ if err != nil {
+ return nil, err
+ }
+ occupied := map[string]string{}
+ for _, tree := range trees {
+ if tree.Branch != "" {
+ occupied[tree.Branch] = tree.Path
+ }
+ }
+ branches := []Branch{}
+ for _, line := range strings.Split(out, "\n") {
+ if line == "" {
+ continue
+ }
+ parts := strings.Split(line, "\x00")
+ if len(parts) < 6 {
+ continue
+ }
+ refname := parts[1]
+ branch := Branch{
+ Head: parts[0],
+ Title: parts[4],
+ }
+ switch {
+ case strings.HasPrefix(refname, "refs/heads/"):
+ branch.Name = strings.TrimPrefix(refname, "refs/heads/")
+ branch.IsRemote = false
+ branch.Upstream = parts[2]
+ branch.Ahead, branch.Behind, branch.UpstreamGone = parseTrack(parts[3])
+ branch.IsCurrent = parts[5] == "*"
+ branch.WorktreePath = occupied[branch.Name]
+ case strings.HasPrefix(refname, "refs/remotes/"):
+ name := strings.TrimPrefix(refname, "refs/remotes/")
+ if strings.HasSuffix(name, "/HEAD") {
+ continue
+ }
+ branch.Name = name
+ branch.IsRemote = true
+ default:
+ continue
+ }
+ branches = append(branches, branch)
+ }
+ return branches, nil
+}
+
+// CreateBranch 从起点创建分支;start 空则从当前检出的 HEAD。
+func CreateBranch(repo Repo, checkout Checkout, name, start string) error {
+ dir := checkoutDir(repo, checkout)
+ if err := requireRepo(dir); err != nil {
+ return err
+ }
+ name = strings.TrimSpace(name)
+ if name == "" {
+ return errors.New("branch name is required")
+ }
+ args := []string{"branch", name}
+ if strings.TrimSpace(start) != "" {
+ args = append(args, start)
+ }
+ _, err := runGit(dir, args...)
+ return err
+}
+
+// SwitchBranch 切换到指定分支。脏工作区、正在整合或该分支已被其他检出占用时拒绝。
+func SwitchBranch(repo Repo, checkout Checkout, name string) error {
+ dir := checkoutDir(repo, checkout)
+ if err := requireRepo(dir); err != nil {
+ return err
+ }
+ name = strings.TrimSpace(name)
+ if name == "" {
+ return errEmptyName()
+ }
+ state, err := Status(repo, checkout)
+ if err != nil {
+ return err
+ }
+ if state.Integrating != "" {
+ return ErrIntegrating
+ }
+ if isDirty(state) {
+ return ErrDirty
+ }
+ branches, err := ListBranches(repo)
+ if err != nil {
+ return err
+ }
+ for _, branch := range branches {
+ if branch.Name != name || branch.IsRemote {
+ continue
+ }
+ if branch.WorktreePath != "" && !samePath(branch.WorktreePath, dir) {
+ return errBranchBusy(name, branch.WorktreePath)
+ }
+ }
+ _, err = runGit(dir, "switch", name)
+ return err
+}
+
+// DeleteBranch 删除本地分支;不能删当前分支。
+func DeleteBranch(repo Repo, name string) error {
+ if err := requireRepo(repo.Path); err != nil {
+ return err
+ }
+ name = strings.TrimSpace(name)
+ if name == "" {
+ return errEmptyName()
+ }
+ state, err := Status(repo, Checkout{Path: repo.Path})
+ if err != nil {
+ return err
+ }
+ if state.Branch == name {
+ return ErrCurrentBranch
+ }
+ _, err = runGit(repo.Path, "branch", "-d", name)
+ return err
+}
+
+func errEmptyName() error {
+ return errors.New("branch name is required")
+}
+
+func errBranchBusy(name, path string) error {
+ return fmt.Errorf("branch %s is checked out at %s", name, path)
+}
diff --git a/server/pkg/git/git_test.go b/server/pkg/git/git_test.go
new file mode 100644
index 0000000..3b8774b
--- /dev/null
+++ b/server/pkg/git/git_test.go
@@ -0,0 +1,624 @@
+package git
+
+import (
+ "bytes"
+ "context"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func gitRun(t *testing.T, dir string, args ...string) string {
+ t.Helper()
+ cmd := exec.Command("git", args...)
+ cmd.Dir = dir
+ cmd.Env = append(os.Environ(), "GIT_EDITOR=true", "GIT_TERMINAL_PROMPT=0")
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
+ }
+ return string(out)
+}
+
+func writeRepoFile(t *testing.T, dir, name, body string) {
+ t.Helper()
+ path := filepath.Join(dir, name)
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func initRepo(t *testing.T) (Repo, Checkout) {
+ t.Helper()
+ dir := t.TempDir()
+ gitRun(t, dir, "init", "-b", "main")
+ gitRun(t, dir, "config", "user.name", "tester")
+ gitRun(t, dir, "config", "user.email", "tester@example.com")
+ repo, err := Open(dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return repo, Checkout{Path: repo.Path}
+}
+
+func commitFile(t *testing.T, repo Repo, co Checkout, name, body, message string) Commit {
+ t.Helper()
+ writeRepoFile(t, co.Path, name, body)
+ if err := Stage(repo, co, []string{name}); err != nil {
+ t.Fatal(err)
+ }
+ commit, err := CreateCommit(repo, co, message)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return commit
+}
+
+func TestOpenAndStatusNotRepo(t *testing.T) {
+ repo, err := Open(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ state, err := Status(repo, Checkout{Path: repo.Path})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if state.IsRepo {
+ t.Fatal("expected non-repo")
+ }
+ if state.Path == "" {
+ t.Fatal("path")
+ }
+}
+
+func TestStatusListsNestedUntrackedFiles(t *testing.T) {
+ repo, co := initRepo(t)
+ writeRepoFile(t, co.Path, "pkg/foo/a.txt", "x")
+ writeRepoFile(t, co.Path, "pkg/foo/b.txt", "y")
+ state, err := Status(repo, co)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(state.Files) != 2 {
+ t.Fatalf("nested untracked: %+v", state.Files)
+ }
+ got := map[string]bool{}
+ for _, file := range state.Files {
+ got[file.Path] = file.WorktreeStatus == "?"
+ }
+ if !got["pkg/foo/a.txt"] || !got["pkg/foo/b.txt"] {
+ t.Fatalf("expected files under pkg/foo: %+v", state.Files)
+ }
+}
+
+func TestStatusEmptyAndFirstCommit(t *testing.T) {
+ repo, co := initRepo(t)
+ state, err := Status(repo, co)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !state.IsRepo || !state.Empty || state.Branch != "main" || state.Detached {
+ t.Fatalf("empty status: %+v", state)
+ }
+ writeRepoFile(t, co.Path, "a.txt", "hello")
+ state, err = Status(repo, co)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(state.Files) != 1 || state.Files[0].Path != "a.txt" || state.Files[0].WorktreeStatus != "?" {
+ t.Fatalf("untracked: %+v", state.Files)
+ }
+ if err := Stage(repo, co, []string{"a.txt"}); err != nil {
+ t.Fatal(err)
+ }
+ state, err = Status(repo, co)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(state.Files) != 1 || state.Files[0].StagedStatus != "A" {
+ t.Fatalf("staged: %+v", state.Files)
+ }
+ commit, err := CreateCommit(repo, co, "add a")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if commit.ID == "" || commit.Title != "add a" || commit.Author == "" {
+ t.Fatalf("commit: %+v", commit)
+ }
+ state, err = Status(repo, co)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if state.Empty || state.Head != commit.ID || len(state.Files) != 0 {
+ t.Fatalf("after commit: %+v", state)
+ }
+}
+
+func TestStageUnstageDiffRename(t *testing.T) {
+ repo, co := initRepo(t)
+ commitFile(t, repo, co, "old.txt", "v1", "first")
+ writeRepoFile(t, co.Path, "old.txt", "v2")
+ files, err := Diff(repo, co, "worktree")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(files) != 1 || files[0].Kind != "modified" || files[0].Patch == "" {
+ t.Fatalf("worktree diff: %+v", files)
+ }
+ if err := Stage(repo, co, []string{"old.txt"}); err != nil {
+ t.Fatal(err)
+ }
+ staged, err := Diff(repo, co, "staged")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(staged) != 1 || staged[0].Kind != "modified" {
+ t.Fatalf("staged diff: %+v", staged)
+ }
+ if err := Unstage(repo, co, []string{"old.txt"}); err != nil {
+ t.Fatal(err)
+ }
+ state, err := Status(repo, co)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(state.Files) != 1 || letterDirty(state.Files[0].StagedStatus) {
+ t.Fatalf("unstage: %+v", state.Files)
+ }
+ if err := RestorePath(repo, co, "old.txt"); err != nil {
+ t.Fatal(err)
+ }
+ gitRun(t, co.Path, "mv", "old.txt", "new.txt")
+ state, err = Status(repo, co)
+ if err != nil {
+ t.Fatal(err)
+ }
+ found := false
+ for _, file := range state.Files {
+ if file.Path == "new.txt" && file.OrigPath == "old.txt" {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatalf("rename missing orig: %+v", state.Files)
+ }
+}
+
+func TestBranchesAndGraph(t *testing.T) {
+ repo, co := initRepo(t)
+ first := commitFile(t, repo, co, "a.txt", "1", "first")
+ if err := CreateBranch(repo, co, "feature", first.ID); err != nil {
+ t.Fatal(err)
+ }
+ if err := SwitchBranch(repo, co, "feature"); err != nil {
+ t.Fatal(err)
+ }
+ second := commitFile(t, repo, co, "b.txt", "2", "second")
+ branches, err := ListBranches(repo)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var feature, main Branch
+ for _, b := range branches {
+ if b.Name == "feature" {
+ feature = b
+ }
+ if b.Name == "main" {
+ main = b
+ }
+ }
+ if !feature.IsCurrent || feature.Head != second.ID || main.IsCurrent {
+ t.Fatalf("branches: %+v", branches)
+ }
+ graph, err := LogGraph(repo, 10)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(graph.Nodes) < 2 || len(graph.Edges) < 1 {
+ t.Fatalf("graph: %+v", graph)
+ }
+ if err := SwitchBranch(repo, co, "main"); err != nil {
+ t.Fatal(err)
+ }
+ if err := CreateBranch(repo, co, "extra", ""); err != nil {
+ t.Fatal(err)
+ }
+ if err := DeleteBranch(repo, "extra"); err != nil {
+ t.Fatal(err)
+ }
+ if err := DeleteBranch(repo, "main"); err != ErrCurrentBranch {
+ t.Fatalf("delete current: %v", err)
+ }
+}
+
+func TestSwitchDirtyAndWorktreeBusy(t *testing.T) {
+ repo, co := initRepo(t)
+ commitFile(t, repo, co, "a.txt", "1", "first")
+ if err := CreateBranch(repo, co, "feature", ""); err != nil {
+ t.Fatal(err)
+ }
+ writeRepoFile(t, co.Path, "a.txt", "dirty")
+ if err := SwitchBranch(repo, co, "feature"); err != ErrDirty {
+ t.Fatalf("dirty: %v", err)
+ }
+ if err := RestorePath(repo, co, "a.txt"); err != nil {
+ t.Fatal(err)
+ }
+ other := filepath.Join(filepath.Dir(repo.Path), "wt")
+ tree, err := AddWorktree(repo, other, "feature", "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if tree.Branch != "feature" || tree.Path == "" {
+ t.Fatalf("worktree: %+v", tree)
+ }
+ if err := SwitchBranch(repo, co, "feature"); err == nil {
+ t.Fatal("expected busy branch")
+ }
+ trees, err := ListWorktrees(repo)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(trees) != 2 {
+ t.Fatalf("worktrees: %+v", trees)
+ }
+}
+
+func TestCreateBranchUsesCheckoutHEAD(t *testing.T) {
+ repo, co := initRepo(t)
+ mainCommit := commitFile(t, repo, co, "a.txt", "1", "first")
+ if err := CreateBranch(repo, co, "feature", ""); err != nil {
+ t.Fatal(err)
+ }
+ other := filepath.Join(filepath.Dir(repo.Path), "wt")
+ if _, err := AddWorktree(repo, other, "feature", ""); err != nil {
+ t.Fatal(err)
+ }
+ wt := Checkout{Path: other}
+ featureCommit := commitFile(t, repo, wt, "a.txt", "2", "on feature")
+ if err := CreateBranch(repo, wt, "from-wt", ""); err != nil {
+ t.Fatal(err)
+ }
+ branches, err := ListBranches(repo)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var fromWT Branch
+ for _, b := range branches {
+ if b.Name == "from-wt" {
+ fromWT = b
+ }
+ }
+ if fromWT.Head != featureCommit.ID {
+ t.Fatalf("from-wt head %s want checkout %s (not main %s)", fromWT.Head, featureCommit.ID, mainCommit.ID)
+ }
+}
+
+func TestLogListsCurrentBranchNewestFirst(t *testing.T) {
+ repo, co := initRepo(t)
+ first := commitFile(t, repo, co, "a.txt", "1", "first")
+ second := commitFile(t, repo, co, "a.txt", "2", "second")
+ commits, err := Log(repo, co, 10)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(commits) != 2 || commits[0].ID != second.ID || commits[1].ID != first.ID {
+ t.Fatalf("log: %+v", commits)
+ }
+ if commits[0].Title != "second" || commits[0].Author == "" || commits[0].Date == "" {
+ t.Fatalf("fields: %+v", commits[0])
+ }
+}
+
+func TestDiscardRestoresTrackedAndDeletesUntracked(t *testing.T) {
+ repo, co := initRepo(t)
+ commitFile(t, repo, co, "a.txt", "base", "first")
+ writeRepoFile(t, co.Path, "a.txt", "dirty")
+ writeRepoFile(t, co.Path, "pkg/foo/new.txt", "untracked")
+ if err := Discard(repo, co, []string{"a.txt", "pkg/foo/new.txt"}); err != nil {
+ t.Fatal(err)
+ }
+ body, err := os.ReadFile(filepath.Join(co.Path, "a.txt"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(body) != "base" {
+ t.Fatalf("tracked restore: %q", body)
+ }
+ if _, err := os.Stat(filepath.Join(co.Path, "pkg/foo/new.txt")); !os.IsNotExist(err) {
+ t.Fatalf("untracked still there: %v", err)
+ }
+ if _, err := os.Stat(filepath.Join(co.Path, "pkg")); !os.IsNotExist(err) {
+ t.Fatalf("empty untracked dirs remain: %v", err)
+ }
+ state, err := Status(repo, co)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(state.Files) != 0 {
+ t.Fatalf("status after discard: %+v", state.Files)
+ }
+}
+
+func TestDiscardKeepsStaged(t *testing.T) {
+ repo, co := initRepo(t)
+ commitFile(t, repo, co, "a.txt", "base", "first")
+ writeRepoFile(t, co.Path, "a.txt", "staged")
+ if err := Stage(repo, co, []string{"a.txt"}); err != nil {
+ t.Fatal(err)
+ }
+ writeRepoFile(t, co.Path, "a.txt", "worktree")
+ if err := Discard(repo, co, []string{"a.txt"}); err != nil {
+ t.Fatal(err)
+ }
+ body, err := os.ReadFile(filepath.Join(co.Path, "a.txt"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(body) != "staged" {
+ t.Fatalf("worktree should match index: %q", body)
+ }
+ state, err := Status(repo, co)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(state.Files) != 1 || state.Files[0].StagedStatus != "M" || letterDirty(state.Files[0].WorktreeStatus) {
+ t.Fatalf("keep staged: %+v", state.Files)
+ }
+}
+
+func TestResetAndRevert(t *testing.T) {
+ repo, co := initRepo(t)
+ commitFile(t, repo, co, "a.txt", "1", "first")
+ second := commitFile(t, repo, co, "a.txt", "2", "second")
+ if err := Reset(repo, co, "HEAD~1", "soft"); err != nil {
+ t.Fatal(err)
+ }
+ state, err := Status(repo, co)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if state.Head == second.ID {
+ t.Fatal("soft reset kept same head")
+ }
+ if _, err := CreateCommit(repo, co, "second again"); err != nil {
+ t.Fatal(err)
+ }
+ head, err := readCommit(co.Path, "HEAD")
+ if err != nil {
+ t.Fatal(err)
+ }
+ reverted, err := Revert(repo, co, head)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if reverted.ID == "" || reverted.ID == head.ID {
+ t.Fatalf("revert: %+v", reverted)
+ }
+}
+
+func TestCaptureRestoreWork(t *testing.T) {
+ repo, co := initRepo(t)
+ commitFile(t, repo, co, "a.txt", "base", "first")
+ writeRepoFile(t, co.Path, "a.txt", "changed")
+ if err := Stage(repo, co, []string{"a.txt"}); err != nil {
+ t.Fatal(err)
+ }
+ writeRepoFile(t, co.Path, "a.txt", "changed more")
+ oid, err := CaptureWork(repo, co, "agent-1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if oid == "" {
+ t.Fatal("expected stash oid")
+ }
+ after, err := Status(repo, co)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(after.Files) == 0 {
+ t.Fatal("capture must not move work")
+ }
+ head := after.Head
+ writeRepoFile(t, co.Path, "a.txt", "lost")
+ if err := RestoreWork(repo, co, oid, head); err != nil {
+ t.Fatal(err)
+ }
+ body, err := os.ReadFile(filepath.Join(co.Path, "a.txt"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(body) != "changed more" {
+ t.Fatalf("restored %q", body)
+ }
+}
+
+func TestMergeConflictReadContinueAbort(t *testing.T) {
+ repo, co := initRepo(t)
+ commitFile(t, repo, co, "a.txt", "base\n", "base")
+ if err := CreateBranch(repo, co, "other", ""); err != nil {
+ t.Fatal(err)
+ }
+ if err := SwitchBranch(repo, co, "other"); err != nil {
+ t.Fatal(err)
+ }
+ commitFile(t, repo, co, "a.txt", "theirs\n", "theirs")
+ if err := SwitchBranch(repo, co, "main"); err != nil {
+ t.Fatal(err)
+ }
+ commitFile(t, repo, co, "a.txt", "ours\n", "ours")
+ if _, err := runGit(co.Path, "merge", "other"); err == nil {
+ t.Fatal("expected merge conflict")
+ }
+ state, err := Status(repo, co)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if state.Integrating != "merge" || !hasUnmerged(state) {
+ t.Fatalf("integrating: %+v", state)
+ }
+ item, err := ReadConflict(repo, co, "a.txt")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if item.Kind != "both_modified" || !strings.Contains(item.Ours, "ours") || !strings.Contains(item.Theirs, "theirs") {
+ t.Fatalf("conflict: %+v", item)
+ }
+ ours, theirs, err := ConflictNames(repo, co)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if ours == "" || theirs == "" {
+ t.Fatalf("names %q %q", ours, theirs)
+ }
+ if err := AbortIntegrate(repo, co); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := runGit(co.Path, "merge", "other"); err == nil {
+ t.Fatal("expected merge conflict again")
+ }
+ writeRepoFile(t, co.Path, "a.txt", "resolved\n")
+ if err := Stage(repo, co, []string{"a.txt"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := ContinueIntegrate(repo, co); err != nil {
+ t.Fatal(err)
+ }
+ state, err = Status(repo, co)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if state.Integrating != "" || hasUnmerged(state) {
+ t.Fatalf("after continue: %+v", state)
+ }
+}
+
+func TestPushPullRemote(t *testing.T) {
+ repo, co := initRepo(t)
+ commitFile(t, repo, co, "a.txt", "1", "first")
+ if err := Push(context.Background(), repo, co); err == nil {
+ t.Fatal("push without remote")
+ }
+ bare := t.TempDir()
+ gitRun(t, bare, "init", "--bare", "-b", "main")
+ gitRun(t, co.Path, "remote", "add", "origin", bare)
+ remotes, err := ListRemotes(repo)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(remotes) != 1 || remotes[0].Name != "origin" || remotes[0].FetchURL != bare {
+ t.Fatalf("remotes: %+v", remotes)
+ }
+ if err := Push(context.Background(), repo, co); err == nil {
+ t.Fatal("push without upstream")
+ }
+ gitRun(t, co.Path, "push", "-u", "origin", "main")
+ state, err := Status(repo, co)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if state.Upstream != "origin/main" {
+ t.Fatalf("upstream: %+v", state)
+ }
+ commitFile(t, repo, co, "a.txt", "2", "second")
+ state, err = Status(repo, co)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if state.Ahead != 1 {
+ t.Fatalf("ahead: %+v", state)
+ }
+ if err := Push(context.Background(), repo, co); err != nil {
+ t.Fatal(err)
+ }
+ parent := t.TempDir()
+ otherDir := filepath.Join(parent, "clone")
+ gitRun(t, parent, "clone", bare, otherDir)
+ gitRun(t, otherDir, "config", "user.name", "tester")
+ gitRun(t, otherDir, "config", "user.email", "tester@example.com")
+ other, err := Open(otherDir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ otherCo := Checkout{Path: other.Path}
+ commitFile(t, other, otherCo, "a.txt", "3", "third")
+ if err := Push(context.Background(), other, otherCo); err != nil {
+ t.Fatal(err)
+ }
+ if err := Pull(context.Background(), repo, co); err != nil {
+ t.Fatal(err)
+ }
+ state, err = Status(repo, co)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if state.Behind != 0 || state.Ahead != 0 {
+ t.Fatalf("after pull: %+v", state)
+ }
+}
+
+func TestMutationsRequireRepo(t *testing.T) {
+ repo, err := Open(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ co := Checkout{Path: repo.Path}
+ if err := Stage(repo, co, []string{"a.txt"}); err != ErrNotRepo {
+ t.Fatalf("stage: %v", err)
+ }
+}
+
+func TestAddWorktreeRejectsEscape(t *testing.T) {
+ repo, _ := initRepo(t)
+ escape := filepath.Join(filepath.Dir(filepath.Dir(repo.Path)), "codedock-wt-escape")
+ if _, err := AddWorktree(repo, escape, "feature", ""); err == nil {
+ t.Fatal("expected path outside parent to fail")
+ }
+}
+
+func TestRestoreWorkRejectsMissingSnapshot(t *testing.T) {
+ repo, co := initRepo(t)
+ commitFile(t, repo, co, "a.txt", "base", "first")
+ writeRepoFile(t, co.Path, "a.txt", "dirty")
+ before, err := os.ReadFile(filepath.Join(co.Path, "a.txt"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := RestoreWork(repo, co, "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", "HEAD"); err == nil {
+ t.Fatal("expected missing snapshot to fail")
+ }
+ after, err := os.ReadFile(filepath.Join(co.Path, "a.txt"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(after) != string(before) {
+ t.Fatalf("worktree changed after failed restore: %q", after)
+ }
+}
+
+func TestUntrackedDiffSkipsLargeFile(t *testing.T) {
+ repo, co := initRepo(t)
+ commitFile(t, repo, co, "a.txt", "1", "first")
+ big := filepath.Join(co.Path, "huge.bin")
+ if err := os.WriteFile(big, bytes.Repeat([]byte("x"), maxUntrackedPatchBytes+1), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ files, err := Diff(repo, co, "worktree")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var found DiffFile
+ for _, file := range files {
+ if file.Path == "huge.bin" {
+ found = file
+ }
+ }
+ if !found.Binary || found.Patch != "" {
+ t.Fatalf("large untracked should skip patch: %+v", found)
+ }
+}
diff --git a/server/pkg/git/integrate.go b/server/pkg/git/integrate.go
new file mode 100644
index 0000000..8d4acbd
--- /dev/null
+++ b/server/pkg/git/integrate.go
@@ -0,0 +1,183 @@
+package git
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+// ReadConflict 读某文件的冲突种类和三方内容。
+func ReadConflict(repo Repo, checkout Checkout, path string) (ConflictItem, error) {
+ dir := checkoutDir(repo, checkout)
+ if err := requireRepo(dir); err != nil {
+ return ConflictItem{}, err
+ }
+ clean, err := normalizePaths([]string{path})
+ if err != nil {
+ return ConflictItem{}, err
+ }
+ rel := clean[0]
+ out, err := runGit(dir, "ls-files", "-u", "--", rel)
+ if err != nil {
+ return ConflictItem{}, err
+ }
+ stages := map[int]bool{}
+ for _, line := range strings.Split(out, "\n") {
+ if line == "" {
+ continue
+ }
+ fields := strings.Fields(line)
+ if len(fields) < 3 {
+ continue
+ }
+ switch fields[2] {
+ case "1":
+ stages[1] = true
+ case "2":
+ stages[2] = true
+ case "3":
+ stages[3] = true
+ }
+ }
+ item := ConflictItem{
+ Path: rel,
+ Kind: conflictKind(stages),
+ Base: showStage(dir, 1, rel),
+ Ours: showStage(dir, 2, rel),
+ Theirs: showStage(dir, 3, rel),
+ }
+ return item, nil
+}
+
+func conflictKind(stages map[int]bool) string {
+ switch {
+ case stages[1] && stages[2] && stages[3]:
+ return "both_modified"
+ case stages[1] && stages[2] && !stages[3]:
+ return "deleted_by_them"
+ case stages[1] && !stages[2] && stages[3]:
+ return "deleted_by_us"
+ case !stages[1] && stages[2] && stages[3]:
+ return "both_added"
+ case stages[1] && !stages[2] && !stages[3]:
+ return "both_deleted"
+ case !stages[1] && stages[2] && !stages[3]:
+ return "added_by_us"
+ case !stages[1] && !stages[2] && stages[3]:
+ return "added_by_them"
+ default:
+ return "both_modified"
+ }
+}
+
+func showStage(dir string, stage int, path string) string {
+ out, err := runGit(dir, "show", ":"+itoa(stage)+":"+path)
+ if err != nil {
+ return ""
+ }
+ return out
+}
+
+func itoa(n int) string {
+ return string(rune('0' + n))
+}
+
+// ConflictNames 读当前整合双方的可读名,给对比视图用。
+func ConflictNames(repo Repo, checkout Checkout) (ours, theirs string, err error) {
+ dir := checkoutDir(repo, checkout)
+ if !isRepo(dir) {
+ return "", "", nil
+ }
+ state, err := Status(repo, checkout)
+ if err != nil {
+ return "", "", err
+ }
+ ours = state.Branch
+ if ours == "" {
+ ours = "HEAD"
+ }
+ gd, err := gitDir(dir)
+ if err != nil {
+ return ours, "", nil //nolint:nilerr // rebase onto 可选,没有 git 目录仍返回 ours
+ }
+ switch state.Integrating {
+ case "merge":
+ theirs = nameOf(dir, "MERGE_HEAD")
+ case "rebase":
+ theirs = nameOf(dir, "REBASE_HEAD")
+ if onto, readErr := os.ReadFile(filepath.Join(gd, "rebase-merge", "onto")); readErr == nil {
+ ours = nameOf(dir, strings.TrimSpace(string(onto)))
+ } else if onto, readErr := os.ReadFile(filepath.Join(gd, "rebase-apply", "onto")); readErr == nil {
+ ours = nameOf(dir, strings.TrimSpace(string(onto)))
+ }
+ case "cherry_pick":
+ theirs = nameOf(dir, "CHERRY_PICK_HEAD")
+ case "revert":
+ theirs = nameOf(dir, "REVERT_HEAD")
+ }
+ return ours, theirs, nil
+}
+
+// ContinueIntegrate 继续未完成的整合。
+func ContinueIntegrate(repo Repo, checkout Checkout) error {
+ dir := checkoutDir(repo, checkout)
+ if err := requireRepo(dir); err != nil {
+ return err
+ }
+ state, err := Status(repo, checkout)
+ if err != nil {
+ return err
+ }
+ if state.Integrating == "" {
+ return errors.New("not integrating")
+ }
+ if hasUnmerged(state) {
+ return ErrConflict
+ }
+ var args []string
+ switch state.Integrating {
+ case "merge":
+ args = []string{"merge", "--continue"}
+ case "rebase":
+ args = []string{"rebase", "--continue"}
+ case "cherry_pick":
+ args = []string{"cherry-pick", "--continue"}
+ case "revert":
+ args = []string{"revert", "--continue"}
+ default:
+ return errors.New("unknown integrate kind")
+ }
+ _, err = runGit(dir, args...)
+ return err
+}
+
+// AbortIntegrate 中止未完成的整合。
+func AbortIntegrate(repo Repo, checkout Checkout) error {
+ dir := checkoutDir(repo, checkout)
+ if err := requireRepo(dir); err != nil {
+ return err
+ }
+ state, err := Status(repo, checkout)
+ if err != nil {
+ return err
+ }
+ if state.Integrating == "" {
+ return errors.New("not integrating")
+ }
+ var args []string
+ switch state.Integrating {
+ case "merge":
+ args = []string{"merge", "--abort"}
+ case "rebase":
+ args = []string{"rebase", "--abort"}
+ case "cherry_pick":
+ args = []string{"cherry-pick", "--abort"}
+ case "revert":
+ args = []string{"revert", "--abort"}
+ default:
+ return errors.New("unknown integrate kind")
+ }
+ _, err = runGit(dir, args...)
+ return err
+}
diff --git a/server/pkg/git/remote.go b/server/pkg/git/remote.go
new file mode 100644
index 0000000..e9c05f8
--- /dev/null
+++ b/server/pkg/git/remote.go
@@ -0,0 +1,45 @@
+package git
+
+import "strings"
+
+// ListRemotes 列出已配置的 remote。
+func ListRemotes(repo Repo) ([]Remote, error) {
+ dir := repo.Path
+ if !isRepo(dir) {
+ return []Remote{}, nil
+ }
+ out, err := runGit(dir, "remote", "-v")
+ if err != nil {
+ return nil, err
+ }
+ byName := map[string]*Remote{}
+ order := []string{}
+ for _, line := range strings.Split(out, "\n") {
+ line = strings.TrimSpace(line)
+ if line == "" {
+ continue
+ }
+ fields := strings.Fields(line)
+ if len(fields) < 3 {
+ continue
+ }
+ name, url, kind := fields[0], fields[1], strings.Trim(fields[2], "()")
+ item, ok := byName[name]
+ if !ok {
+ item = &Remote{Name: name, FetchURL: url, PushURL: url}
+ byName[name] = item
+ order = append(order, name)
+ }
+ switch kind {
+ case "fetch":
+ item.FetchURL = url
+ case "push":
+ item.PushURL = url
+ }
+ }
+ remotes := make([]Remote, 0, len(order))
+ for _, name := range order {
+ remotes = append(remotes, *byName[name])
+ }
+ return remotes, nil
+}
diff --git a/server/pkg/git/repo.go b/server/pkg/git/repo.go
new file mode 100644
index 0000000..54b1182
--- /dev/null
+++ b/server/pkg/git/repo.go
@@ -0,0 +1,716 @@
+package git
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+)
+
+// Open 打开本地路径上的仓库句柄,不要求已经是 Git 仓库,不向上找 .git。
+func Open(path string) (Repo, error) {
+ abs, err := filepath.Abs(path)
+ if err != nil {
+ return Repo{}, err
+ }
+ return Repo{Path: abs}, nil
+}
+
+// Status 读这一份检出的整局:分支、跟踪、文件、remote。不是仓库则 IsRepo=false。
+func Status(repo Repo, checkout Checkout) (SiteState, error) {
+ dir, err := filepath.Abs(checkoutDir(repo, checkout))
+ if err != nil {
+ return SiteState{}, err
+ }
+ state := SiteState{Path: dir, Files: []FileStatus{}, Remotes: []Remote{}}
+ if !isRepo(dir) {
+ return state, nil
+ }
+ state.IsRepo = true
+ out, err := runGit(dir, "status", "--porcelain=v2", "--branch", "-uall")
+ if err != nil {
+ return SiteState{}, err
+ }
+ parseStatusV2(&state, out)
+ state.Integrating = integrating(dir)
+ state.DefaultBranch = defaultBranch(dir)
+ if state.Upstream != "" {
+ state.UpstreamGone = upstreamGone(dir, state.Upstream)
+ }
+ remotes, err := ListRemotes(repo)
+ if err != nil {
+ return SiteState{}, err
+ }
+ state.Remotes = remotes
+ return state, nil
+}
+
+func parseStatusV2(state *SiteState, out string) {
+ for _, line := range strings.Split(out, "\n") {
+ if line == "" {
+ continue
+ }
+ switch {
+ case strings.HasPrefix(line, "# branch.oid "):
+ oid := strings.TrimSpace(strings.TrimPrefix(line, "# branch.oid "))
+ if oid == "(initial)" {
+ state.Empty = true
+ state.Head = ""
+ } else {
+ state.Head = oid
+ }
+ case strings.HasPrefix(line, "# branch.head "):
+ head := strings.TrimSpace(strings.TrimPrefix(line, "# branch.head "))
+ if head == "(detached)" {
+ state.Detached = true
+ state.Branch = ""
+ } else {
+ state.Branch = head
+ }
+ case strings.HasPrefix(line, "# branch.upstream "):
+ state.Upstream = strings.TrimSpace(strings.TrimPrefix(line, "# branch.upstream "))
+ case strings.HasPrefix(line, "# branch.ab "):
+ raw := strings.TrimSpace(strings.TrimPrefix(line, "# branch.ab "))
+ fields := strings.Fields(raw)
+ if len(fields) >= 2 {
+ state.Ahead, _ = strconv.Atoi(strings.TrimPrefix(fields[0], "+"))
+ state.Behind, _ = strconv.Atoi(strings.TrimPrefix(fields[1], "-"))
+ }
+ case strings.HasPrefix(line, "1 "):
+ if file, ok := parsePorcelain1(line); ok {
+ state.Files = append(state.Files, file)
+ }
+ case strings.HasPrefix(line, "2 "):
+ if file, ok := parsePorcelain2(line); ok {
+ state.Files = append(state.Files, file)
+ }
+ case strings.HasPrefix(line, "u "):
+ if file, ok := parsePorcelainU(line); ok {
+ state.Files = append(state.Files, file)
+ }
+ case strings.HasPrefix(line, "? "):
+ state.Files = append(state.Files, FileStatus{
+ Path: strings.TrimPrefix(line, "? "),
+ StagedStatus: " ",
+ WorktreeStatus: "?",
+ })
+ }
+ }
+ if state.Empty {
+ state.Detached = false
+ state.Head = ""
+ }
+}
+
+func parsePorcelain1(line string) (FileStatus, bool) {
+ parts := strings.SplitN(line, " ", 9)
+ if len(parts) < 9 || len(parts[1]) < 2 {
+ return FileStatus{}, false
+ }
+ return FileStatus{
+ Path: parts[8],
+ StagedStatus: statusLetter(parts[1][0]),
+ WorktreeStatus: statusLetter(parts[1][1]),
+ }, true
+}
+
+func parsePorcelain2(line string) (FileStatus, bool) {
+ parts := strings.SplitN(line, " ", 10)
+ if len(parts) < 10 || len(parts[1]) < 2 {
+ return FileStatus{}, false
+ }
+ path, orig, _ := strings.Cut(parts[9], "\t")
+ return FileStatus{
+ Path: path,
+ OrigPath: orig,
+ StagedStatus: statusLetter(parts[1][0]),
+ WorktreeStatus: statusLetter(parts[1][1]),
+ }, true
+}
+
+func parsePorcelainU(line string) (FileStatus, bool) {
+ parts := strings.SplitN(line, " ", 11)
+ if len(parts) < 11 || len(parts[1]) < 2 {
+ return FileStatus{}, false
+ }
+ return FileStatus{
+ Path: parts[10],
+ StagedStatus: statusLetter(parts[1][0]),
+ WorktreeStatus: statusLetter(parts[1][1]),
+ Unmerged: true,
+ }, true
+}
+
+// Diff 读已暂存或工作区差异;scope 为 staged | worktree。
+func Diff(repo Repo, checkout Checkout, scope string) ([]DiffFile, error) {
+ dir := checkoutDir(repo, checkout)
+ if !isRepo(dir) {
+ return []DiffFile{}, nil
+ }
+ if scope == "" {
+ scope = "staged"
+ }
+ if scope != "staged" && scope != "worktree" {
+ return nil, fmt.Errorf("scope must be staged or worktree")
+ }
+ args := []string{"diff", "--name-status", "--find-renames"}
+ numArgs := []string{"diff", "--numstat", "--find-renames"}
+ patchArgs := []string{"diff", "--find-renames"}
+ if scope == "staged" {
+ args = append(args, "--cached")
+ numArgs = append(numArgs, "--cached")
+ patchArgs = append(patchArgs, "--cached")
+ }
+ nameOut, err := runGitAllow(dir, []int{1}, args...)
+ if err != nil {
+ return nil, err
+ }
+ numOut, err := runGitAllow(dir, []int{1}, numArgs...)
+ if err != nil {
+ return nil, err
+ }
+ binary := map[string]bool{}
+ for _, line := range strings.Split(numOut, "\n") {
+ if line == "" {
+ continue
+ }
+ fields := strings.SplitN(line, "\t", 3)
+ if len(fields) < 3 {
+ continue
+ }
+ path := fields[2]
+ if strings.Contains(path, " => ") {
+ path = strings.TrimSuffix(strings.Split(path, " => ")[1], "}")
+ path = strings.TrimPrefix(path, "{")
+ }
+ binary[path] = fields[0] == "-" && fields[1] == "-"
+ }
+ files := []DiffFile{}
+ for _, line := range strings.Split(nameOut, "\n") {
+ if line == "" {
+ continue
+ }
+ file, ok := parseNameStatus(line)
+ if !ok {
+ continue
+ }
+ file.Binary = binary[file.Path]
+ if !file.Binary {
+ patch, err := runGitAllow(dir, []int{1}, append(patchArgs, "--", file.Path)...)
+ if err == nil {
+ file.Patch = patch
+ }
+ }
+ files = append(files, file)
+ }
+ if scope == "worktree" {
+ state, err := Status(repo, checkout)
+ if err != nil {
+ return nil, err
+ }
+ seen := map[string]bool{}
+ for _, file := range files {
+ seen[file.Path] = true
+ }
+ for _, file := range state.Files {
+ if file.WorktreeStatus != "?" || seen[file.Path] {
+ continue
+ }
+ files = append(files, untrackedDiff(dir, file.Path))
+ }
+ }
+ return files, nil
+}
+
+func parseNameStatus(line string) (DiffFile, bool) {
+ code, rest, ok := strings.Cut(line, "\t")
+ if !ok || code == "" {
+ return DiffFile{}, false
+ }
+ kind := "modified"
+ switch code[0] {
+ case 'A':
+ kind = "added"
+ case 'M', 'T':
+ kind = "modified"
+ case 'D':
+ kind = "deleted"
+ case 'R', 'C':
+ kind = "renamed"
+ case 'U':
+ kind = "unmerged"
+ }
+ file := DiffFile{Kind: kind}
+ if kind == "renamed" {
+ orig, path, ok := strings.Cut(rest, "\t")
+ if !ok {
+ file.Path = rest
+ return file, true
+ }
+ file.OrigPath = orig
+ file.Path = path
+ return file, true
+ }
+ file.Path = rest
+ return file, true
+}
+
+const maxUntrackedPatchBytes = 256 * 1024
+
+func untrackedDiff(dir, rel string) DiffFile {
+ file := DiffFile{Path: rel, Kind: "added"}
+ abs := filepath.Join(dir, filepath.FromSlash(rel))
+ info, err := os.Stat(abs)
+ if err != nil || info.IsDir() {
+ return file
+ }
+ if info.Size() > maxUntrackedPatchBytes {
+ file.Binary = true
+ return file
+ }
+ body, err := os.ReadFile(abs)
+ if err != nil {
+ return file
+ }
+ if bytes.IndexByte(body, 0) >= 0 {
+ file.Binary = true
+ return file
+ }
+ patch, err := runGitAllow(dir, []int{1}, "diff", "--no-index", "--", os.DevNull, rel)
+ if err == nil && patch != "" {
+ file.Patch = patch
+ return file
+ }
+ var b strings.Builder
+ fmt.Fprintf(&b, "diff --git a/%s b/%s\n--- /dev/null\n+++ b/%s\n", rel, rel, rel)
+ lines := strings.Split(string(body), "\n")
+ fmt.Fprintf(&b, "@@ -0,0 +1,%d @@\n", len(lines))
+ for _, line := range lines {
+ b.WriteByte('+')
+ b.WriteString(line)
+ b.WriteByte('\n')
+ }
+ file.Patch = b.String()
+ return file
+}
+
+// LogGraph 读近期提交、装饰和父子边。
+func LogGraph(repo Repo, limit int) (Graph, error) {
+ dir := repo.Path
+ if !isRepo(dir) {
+ return Graph{Nodes: []GraphNode{}, Edges: []GraphEdge{}}, nil
+ }
+ if limit <= 0 {
+ limit = 50
+ }
+ out, err := runGitAllow(dir, []int{128}, "log", "--all", "--decorate=full", "-n", strconv.Itoa(limit), "--format=%H%x1f%P%x1f%s%x1f%an%x1f%aI%x1f%d%x1f%b%x1e")
+ if err != nil {
+ return Graph{}, err
+ }
+ graph := Graph{Nodes: []GraphNode{}, Edges: []GraphEdge{}}
+ for _, rec := range strings.Split(out, "\x1e") {
+ rec = strings.TrimSpace(rec)
+ if rec == "" {
+ continue
+ }
+ parts := strings.SplitN(rec, "\x1f", 7)
+ if len(parts) < 6 {
+ continue
+ }
+ var parents []string
+ if strings.TrimSpace(parts[1]) != "" {
+ parents = strings.Fields(parts[1])
+ } else {
+ parents = []string{}
+ }
+ body := ""
+ if len(parts) > 6 {
+ body = strings.TrimSpace(parts[6])
+ }
+ commit := Commit{
+ ID: strings.TrimSpace(parts[0]),
+ Parents: parents,
+ Title: parts[2],
+ Body: body,
+ Author: parts[3],
+ Date: strings.TrimSpace(parts[4]),
+ }
+ graph.Nodes = append(graph.Nodes, GraphNode{Commit: commit, Refs: parseDecorations(parts[5])})
+ for _, parent := range parents {
+ graph.Edges = append(graph.Edges, GraphEdge{Child: commit.ID, Parent: parent})
+ }
+ }
+ return graph, nil
+}
+
+// Log 读这份检出当前线上的近期提交,从新到旧。不是 --all 的分叉图。
+func Log(repo Repo, checkout Checkout, limit int) ([]Commit, error) {
+ dir := checkoutDir(repo, checkout)
+ if !isRepo(dir) {
+ return []Commit{}, nil
+ }
+ if limit <= 0 {
+ limit = 50
+ }
+ state, err := Status(repo, checkout)
+ if err != nil {
+ return nil, err
+ }
+ if !state.IsRepo || state.Empty {
+ return []Commit{}, nil
+ }
+ out, err := runGitAllow(dir, []int{128}, "log", "-n", strconv.Itoa(limit), "--format=%H%x1f%P%x1f%s%x1f%an%x1f%aI%x1f%b%x1e")
+ if err != nil {
+ return nil, err
+ }
+ commits := []Commit{}
+ for _, rec := range strings.Split(out, "\x1e") {
+ rec = strings.TrimSpace(rec)
+ if rec == "" {
+ continue
+ }
+ parts := strings.SplitN(rec, "\x1f", 6)
+ if len(parts) < 5 {
+ continue
+ }
+ var parents []string
+ if strings.TrimSpace(parts[1]) != "" {
+ parents = strings.Fields(parts[1])
+ } else {
+ parents = []string{}
+ }
+ body := ""
+ if len(parts) > 5 {
+ body = strings.TrimSpace(parts[5])
+ }
+ commits = append(commits, Commit{
+ ID: strings.TrimSpace(parts[0]),
+ Parents: parents,
+ Title: parts[2],
+ Body: body,
+ Author: parts[3],
+ Date: strings.TrimSpace(parts[4]),
+ })
+ }
+ return commits, nil
+}
+
+func parseDecorations(raw string) []Ref {
+ raw = strings.TrimSpace(raw)
+ raw = strings.TrimPrefix(raw, "(")
+ raw = strings.TrimSuffix(raw, ")")
+ if raw == "" {
+ return []Ref{}
+ }
+ refs := []Ref{}
+ for _, token := range strings.Split(raw, ", ") {
+ token = strings.TrimSpace(token)
+ if token == "" {
+ continue
+ }
+ if strings.HasPrefix(token, "HEAD -> ") {
+ refs = append(refs, Ref{Name: "HEAD", Kind: "head"})
+ token = strings.TrimPrefix(token, "HEAD -> ")
+ }
+ if token == "HEAD" {
+ refs = append(refs, Ref{Name: "HEAD", Kind: "head"})
+ continue
+ }
+ if name, ok := strings.CutPrefix(token, "tag: refs/tags/"); ok {
+ refs = append(refs, Ref{Name: name, Kind: "tag"})
+ continue
+ }
+ if name, ok := strings.CutPrefix(token, "tag: "); ok {
+ refs = append(refs, Ref{Name: strings.TrimPrefix(name, "refs/tags/"), Kind: "tag"})
+ continue
+ }
+ if name, ok := strings.CutPrefix(token, "refs/heads/"); ok {
+ refs = append(refs, Ref{Name: name, Kind: "local"})
+ continue
+ }
+ if name, ok := strings.CutPrefix(token, "refs/remotes/"); ok {
+ refs = append(refs, Ref{Name: name, Kind: "remote"})
+ continue
+ }
+ if name, ok := strings.CutPrefix(token, "refs/tags/"); ok {
+ refs = append(refs, Ref{Name: name, Kind: "tag"})
+ continue
+ }
+ }
+ return refs
+}
+
+// Stage 把路径加入暂存区。
+func Stage(repo Repo, checkout Checkout, paths []string) error {
+ dir := checkoutDir(repo, checkout)
+ if err := requireRepo(dir); err != nil {
+ return err
+ }
+ clean, err := normalizePaths(paths)
+ if err != nil {
+ return err
+ }
+ _, err = runGit(dir, append([]string{"add", "--"}, clean...)...)
+ return err
+}
+
+// Unstage 把路径移出暂存区。
+func Unstage(repo Repo, checkout Checkout, paths []string) error {
+ dir := checkoutDir(repo, checkout)
+ if err := requireRepo(dir); err != nil {
+ return err
+ }
+ clean, err := normalizePaths(paths)
+ if err != nil {
+ return err
+ }
+ state, err := Status(repo, checkout)
+ if err != nil {
+ return err
+ }
+ if state.Empty {
+ _, err = runGit(dir, append([]string{"rm", "--cached", "-q", "--"}, clean...)...)
+ return err
+ }
+ _, err = runGit(dir, append([]string{"restore", "--staged", "--"}, clean...)...)
+ return err
+}
+
+// CreateCommit 用已经写好的说明创建提交。
+func CreateCommit(repo Repo, checkout Checkout, message string) (Commit, error) {
+ dir := checkoutDir(repo, checkout)
+ if err := requireRepo(dir); err != nil {
+ return Commit{}, err
+ }
+ if strings.TrimSpace(message) == "" {
+ return Commit{}, errors.New("message is required")
+ }
+ state, err := Status(repo, checkout)
+ if err != nil {
+ return Commit{}, err
+ }
+ if state.Integrating != "" {
+ return Commit{}, ErrIntegrating
+ }
+ if _, err := runGit(dir, "commit", "-m", message); err != nil {
+ return Commit{}, err
+ }
+ return readCommit(dir, "HEAD")
+}
+
+// Reset 软 / 混合 / 硬重置到目标提交。
+func Reset(repo Repo, checkout Checkout, target, mode string) error {
+ dir := checkoutDir(repo, checkout)
+ if err := requireRepo(dir); err != nil {
+ return err
+ }
+ state, err := Status(repo, checkout)
+ if err != nil {
+ return err
+ }
+ if state.Integrating != "" {
+ return ErrIntegrating
+ }
+ if state.Empty {
+ return errors.New("empty repository")
+ }
+ if strings.TrimSpace(target) == "" {
+ return errors.New("target is required")
+ }
+ switch mode {
+ case "soft", "mixed", "hard":
+ case "":
+ mode = "mixed"
+ default:
+ return fmt.Errorf("invalid reset mode %q", mode)
+ }
+ _, err = runGit(dir, "reset", "--"+mode, target)
+ return err
+}
+
+// Revert 用一次新提交回退指定提交。
+func Revert(repo Repo, checkout Checkout, commit Commit) (Commit, error) {
+ dir := checkoutDir(repo, checkout)
+ if err := requireRepo(dir); err != nil {
+ return Commit{}, err
+ }
+ if strings.TrimSpace(commit.ID) == "" {
+ return Commit{}, errors.New("commit id is required")
+ }
+ state, err := Status(repo, checkout)
+ if err != nil {
+ return Commit{}, err
+ }
+ if state.Integrating != "" {
+ return Commit{}, ErrIntegrating
+ }
+ if _, err := runGit(dir, "revert", "--no-edit", commit.ID); err != nil {
+ after, statusErr := Status(repo, checkout)
+ if statusErr == nil && (after.Integrating != "" || hasUnmerged(after)) {
+ return Commit{}, ErrConflict
+ }
+ return Commit{}, err
+ }
+ return readCommit(dir, "HEAD")
+}
+
+// Push 推到已配置的 remote。
+func Push(ctx context.Context, repo Repo, checkout Checkout) error {
+ dir := checkoutDir(repo, checkout)
+ if err := requireRepo(dir); err != nil {
+ return err
+ }
+ state, err := Status(repo, checkout)
+ if err != nil {
+ return err
+ }
+ if state.Integrating != "" {
+ return ErrIntegrating
+ }
+ if len(state.Remotes) == 0 {
+ return errors.New("no remote configured")
+ }
+ if state.Upstream == "" {
+ return errors.New("no upstream")
+ }
+ if state.UpstreamGone {
+ return errors.New("upstream is gone")
+ }
+ _, err = runGitCtx(ctx, dir, "push")
+ return err
+}
+
+// Pull 拉取并尝试整合;有冲突返回 ErrConflict。
+func Pull(ctx context.Context, repo Repo, checkout Checkout) error {
+ dir := checkoutDir(repo, checkout)
+ if err := requireRepo(dir); err != nil {
+ return err
+ }
+ state, err := Status(repo, checkout)
+ if err != nil {
+ return err
+ }
+ if state.Integrating != "" {
+ return ErrIntegrating
+ }
+ if isDirty(state) {
+ return ErrDirty
+ }
+ if len(state.Remotes) == 0 {
+ return errors.New("no remote configured")
+ }
+ if state.Upstream == "" {
+ return errors.New("no upstream")
+ }
+ if state.UpstreamGone {
+ return errors.New("upstream is gone")
+ }
+ if _, err := runGitCtx(ctx, dir, "pull", "--no-rebase"); err != nil {
+ after, statusErr := Status(repo, checkout)
+ if statusErr == nil && (after.Integrating != "" || hasUnmerged(after)) {
+ return ErrConflict
+ }
+ return err
+ }
+ return nil
+}
+
+// RestorePath 把路径还原成 HEAD 的暂存区和工作区内容。
+func RestorePath(repo Repo, checkout Checkout, path string) error {
+ dir := checkoutDir(repo, checkout)
+ if err := requireRepo(dir); err != nil {
+ return err
+ }
+ clean, err := normalizePaths([]string{path})
+ if err != nil {
+ return err
+ }
+ state, err := Status(repo, checkout)
+ if err != nil {
+ return err
+ }
+ if state.Empty {
+ return errors.New("empty repository")
+ }
+ _, err = runGit(dir, "restore", "--source=HEAD", "--staged", "--worktree", "--", clean[0])
+ return err
+}
+
+// Discard 丢掉工作区改动:已跟踪的还原成暂存区;未跟踪的删除。不改暂存区。
+func Discard(repo Repo, checkout Checkout, paths []string) error {
+ dir := checkoutDir(repo, checkout)
+ if err := requireRepo(dir); err != nil {
+ return err
+ }
+ clean, err := normalizePaths(paths)
+ if err != nil {
+ return err
+ }
+ state, err := Status(repo, checkout)
+ if err != nil {
+ return err
+ }
+ byPath := make(map[string]FileStatus, len(state.Files))
+ for _, file := range state.Files {
+ byPath[file.Path] = file
+ }
+ var tracked []string
+ var untracked []string
+ for _, path := range clean {
+ if path == ".git" || strings.HasPrefix(path, ".git/") {
+ return fmt.Errorf("invalid path %q", path)
+ }
+ file, ok := byPath[path]
+ if !ok {
+ continue
+ }
+ if file.Unmerged {
+ return ErrConflict
+ }
+ if file.WorktreeStatus == "?" {
+ untracked = append(untracked, path)
+ continue
+ }
+ if letterDirty(file.WorktreeStatus) {
+ tracked = append(tracked, path)
+ }
+ }
+ if len(tracked) > 0 {
+ if _, err := runGit(dir, append([]string{"restore", "--worktree", "--"}, tracked...)...); err != nil {
+ return err
+ }
+ }
+ for _, path := range untracked {
+ if err := removeUntracked(dir, path); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func removeUntracked(dir, rel string) error {
+ root, err := filepath.Abs(dir)
+ if err != nil {
+ return err
+ }
+ root = filepath.Clean(root)
+ abs := filepath.Join(root, filepath.FromSlash(rel))
+ back, err := filepath.Rel(root, abs)
+ if err != nil || back == "." || strings.HasPrefix(back, "..") {
+ return fmt.Errorf("invalid path %q", rel)
+ }
+ if err := os.RemoveAll(abs); err != nil {
+ return err
+ }
+ sep := string(os.PathSeparator)
+ for parent := filepath.Dir(abs); parent != root && strings.HasPrefix(parent, root+sep); parent = filepath.Dir(parent) {
+ if err := os.Remove(parent); err != nil { //nolint:nilerr // 目录非空时停止上收
+ break
+ }
+ }
+ return nil
+}
diff --git a/server/pkg/git/run.go b/server/pkg/git/run.go
new file mode 100644
index 0000000..dc5ae30
--- /dev/null
+++ b/server/pkg/git/run.go
@@ -0,0 +1,341 @@
+package git
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+)
+
+var (
+ // ErrNotRepo 表示路径还不是 Git 仓库。
+ ErrNotRepo = errors.New("not a git repository")
+ // ErrDirty 表示工作区不干净。
+ ErrDirty = errors.New("dirty worktree")
+ // ErrIntegrating 表示正在合并或变基。
+ ErrIntegrating = errors.New("integrating")
+ // ErrConflict 表示推拉或整合产生了未解决冲突。
+ ErrConflict = errors.New("conflict")
+ // ErrCurrentBranch 表示不能删除当前分支。
+ ErrCurrentBranch = errors.New("cannot delete current branch")
+)
+
+func checkoutDir(repo Repo, checkout Checkout) string {
+ if strings.TrimSpace(checkout.Path) != "" {
+ return checkout.Path
+ }
+ return repo.Path
+}
+
+func isRepo(dir string) bool {
+ info, err := os.Stat(filepath.Join(dir, ".git"))
+ if err != nil {
+ return false
+ }
+ return info.IsDir() || info.Mode().IsRegular()
+}
+
+func requireRepo(dir string) error {
+ if !isRepo(dir) {
+ return ErrNotRepo
+ }
+ return nil
+}
+
+type gitOutput struct {
+ stdout string
+ stderr string
+ code int
+ err error
+}
+
+func runGitCmd(dir string, args ...string) gitOutput {
+ return runGitCmdCtx(context.Background(), dir, args...)
+}
+
+func runGitCmdCtx(ctx context.Context, dir string, args ...string) gitOutput {
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
+ defer cancel()
+ cmd := exec.CommandContext(ctx, "git", args...)
+ cmd.Dir = dir
+ cmd.Env = append(os.Environ(), "GIT_EDITOR=true", "GIT_TERMINAL_PROMPT=0")
+ var stdout, stderr bytes.Buffer
+ cmd.Stdout = &stdout
+ cmd.Stderr = &stderr
+ err := cmd.Run()
+ out := gitOutput{stdout: stdout.String(), stderr: stderr.String(), err: err}
+ if err != nil {
+ if errors.Is(ctx.Err(), context.DeadlineExceeded) {
+ out.err = fmt.Errorf("git %s timed out", strings.Join(args, " "))
+ out.code = -1
+ return out
+ }
+ var ee *exec.ExitError
+ if errors.As(err, &ee) {
+ out.code = ee.ExitCode()
+ } else {
+ out.code = -1
+ }
+ }
+ return out
+}
+
+func gitErr(out gitOutput) error {
+ if out.err == nil {
+ return nil
+ }
+ msg := strings.TrimSpace(out.stderr)
+ if msg == "" {
+ msg = strings.TrimSpace(out.stdout)
+ }
+ if msg == "" {
+ msg = out.err.Error()
+ }
+ return errors.New(msg)
+}
+
+func runGit(dir string, args ...string) (string, error) {
+ return runGitCtx(context.Background(), dir, args...)
+}
+
+func runGitCtx(ctx context.Context, dir string, args ...string) (string, error) {
+ out := runGitCmdCtx(ctx, dir, args...)
+ if out.err != nil {
+ return "", gitErr(out)
+ }
+ return out.stdout, nil
+}
+
+func runGitAllow(dir string, codes []int, args ...string) (string, error) {
+ out := runGitCmd(dir, args...)
+ if out.err == nil {
+ return out.stdout, nil
+ }
+ for _, code := range codes {
+ if out.code == code {
+ return out.stdout, nil
+ }
+ }
+ return "", gitErr(out)
+}
+
+func fileExists(path string) bool {
+ _, err := os.Stat(path)
+ return err == nil
+}
+
+func gitDir(dir string) (string, error) {
+ out, err := runGit(dir, "rev-parse", "--git-dir")
+ if err != nil {
+ return "", err
+ }
+ p := strings.TrimSpace(out)
+ if !filepath.IsAbs(p) {
+ p = filepath.Join(dir, p)
+ }
+ return filepath.Clean(p), nil
+}
+
+// CommonDir 返回主仓 git 目录(各 worktree 共享)。
+func CommonDir(repo Repo) (string, error) {
+ if !isRepo(repo.Path) {
+ return "", ErrNotRepo
+ }
+ out, err := runGit(repo.Path, "rev-parse", "--git-common-dir")
+ if err != nil {
+ return "", err
+ }
+ p := strings.TrimSpace(out)
+ if !filepath.IsAbs(p) {
+ p = filepath.Join(repo.Path, p)
+ }
+ return filepath.Clean(p), nil
+}
+
+func canonPath(p string) string {
+ abs, err := filepath.Abs(p)
+ if err != nil {
+ return filepath.Clean(p)
+ }
+ resolved, err := filepath.EvalSymlinks(abs)
+ if err != nil {
+ return filepath.Clean(abs)
+ }
+ return filepath.Clean(resolved)
+}
+
+func samePath(a, b string) bool {
+ return canonPath(a) == canonPath(b)
+}
+
+func firstNonEmpty(values ...string) string {
+ for _, value := range values {
+ if strings.TrimSpace(value) != "" {
+ return value
+ }
+ }
+ return ""
+}
+
+func normalizePaths(paths []string) ([]string, error) {
+ out := make([]string, 0, len(paths))
+ for _, path := range paths {
+ path = strings.TrimSpace(path)
+ path = strings.TrimPrefix(path, "./")
+ if path == "" {
+ continue
+ }
+ cleaned := filepath.ToSlash(filepath.Clean(path))
+ if cleaned == ".." || strings.HasPrefix(cleaned, "../") || filepath.IsAbs(path) || strings.HasPrefix(cleaned, "/") {
+ return nil, fmt.Errorf("invalid path %q", path)
+ }
+ out = append(out, cleaned)
+ }
+ if len(out) == 0 {
+ return nil, errors.New("paths required")
+ }
+ return out, nil
+}
+
+func statusLetter(ch byte) string {
+ if ch == '.' {
+ return " "
+ }
+ return string(ch)
+}
+
+func letterDirty(letter string) bool {
+ return letter != "" && letter != " " && letter != "."
+}
+
+func isDirty(state SiteState) bool {
+ for _, file := range state.Files {
+ if file.Unmerged {
+ return true
+ }
+ if letterDirty(file.StagedStatus) {
+ return true
+ }
+ if letterDirty(file.WorktreeStatus) && file.WorktreeStatus != "?" {
+ return true
+ }
+ }
+ return false
+}
+
+func hasUnmerged(state SiteState) bool {
+ for _, file := range state.Files {
+ if file.Unmerged {
+ return true
+ }
+ }
+ return false
+}
+
+func parseTrack(raw string) (ahead, behind int, gone bool) {
+ raw = strings.TrimSpace(raw)
+ raw = strings.Trim(raw, "[]")
+ if raw == "" {
+ return 0, 0, false
+ }
+ if raw == "gone" {
+ return 0, 0, true
+ }
+ for _, part := range strings.Split(raw, ",") {
+ part = strings.TrimSpace(part)
+ switch {
+ case strings.HasPrefix(part, "ahead "):
+ ahead, _ = strconv.Atoi(strings.TrimSpace(strings.TrimPrefix(part, "ahead ")))
+ case strings.HasPrefix(part, "behind "):
+ behind, _ = strconv.Atoi(strings.TrimSpace(strings.TrimPrefix(part, "behind ")))
+ case part == "gone":
+ gone = true
+ }
+ }
+ return ahead, behind, gone
+}
+
+func integrating(dir string) string {
+ gd, err := gitDir(dir)
+ if err != nil {
+ return ""
+ }
+ switch {
+ case fileExists(filepath.Join(gd, "rebase-merge")) || fileExists(filepath.Join(gd, "rebase-apply")) || fileExists(filepath.Join(gd, "REBASE_HEAD")):
+ return "rebase"
+ case fileExists(filepath.Join(gd, "CHERRY_PICK_HEAD")):
+ return "cherry_pick"
+ case fileExists(filepath.Join(gd, "REVERT_HEAD")):
+ return "revert"
+ case fileExists(filepath.Join(gd, "MERGE_HEAD")):
+ return "merge"
+ default:
+ return ""
+ }
+}
+
+func readCommit(dir, rev string) (Commit, error) {
+ out, err := runGit(dir, "log", "-1", "--format=%H%x1f%P%x1f%s%x1f%an%x1f%aI%x1f%b", rev, "--")
+ if err != nil {
+ return Commit{}, err
+ }
+ parts := strings.SplitN(strings.TrimSuffix(out, "\n"), "\x1f", 6)
+ if len(parts) < 5 {
+ return Commit{}, fmt.Errorf("cannot parse commit %s", rev)
+ }
+ var parents []string
+ if strings.TrimSpace(parts[1]) != "" {
+ parents = strings.Fields(parts[1])
+ } else {
+ parents = []string{}
+ }
+ body := ""
+ if len(parts) > 5 {
+ body = strings.TrimSpace(parts[5])
+ }
+ return Commit{
+ ID: strings.TrimSpace(parts[0]),
+ Parents: parents,
+ Title: parts[2],
+ Body: body,
+ Author: parts[3],
+ Date: strings.TrimSpace(parts[4]),
+ }, nil
+}
+
+func nameOf(dir, rev string) string {
+ out, err := runGit(dir, "name-rev", "--name-only", "--no-undefined", rev)
+ if err != nil {
+ out, err = runGit(dir, "rev-parse", "--short", rev)
+ if err != nil {
+ return rev
+ }
+ }
+ return strings.TrimSpace(out)
+}
+
+func defaultBranch(dir string) string {
+ out, err := runGit(dir, "symbolic-ref", "--quiet", "refs/remotes/origin/HEAD")
+ if err != nil {
+ return ""
+ }
+ ref := strings.TrimSpace(out)
+ return strings.TrimPrefix(ref, "refs/remotes/origin/")
+}
+
+func upstreamGone(dir, upstream string) bool {
+ if strings.TrimSpace(upstream) == "" {
+ return false
+ }
+ _, err := runGit(dir, "rev-parse", "--verify", "--quiet", "refs/remotes/"+upstream)
+ return err != nil
+}
diff --git a/server/pkg/git/stash.go b/server/pkg/git/stash.go
new file mode 100644
index 0000000..2247299
--- /dev/null
+++ b/server/pkg/git/stash.go
@@ -0,0 +1,54 @@
+package git
+
+import (
+ "errors"
+ "fmt"
+ "strings"
+)
+
+// CaptureWork 用 stash create 复制暂存区与已跟踪工作区,不改现有内容;返回悬空提交 hash。
+func CaptureWork(repo Repo, checkout Checkout, note string) (string, error) {
+ dir := checkoutDir(repo, checkout)
+ if err := requireRepo(dir); err != nil {
+ return "", err
+ }
+ args := []string{"stash", "create"}
+ if strings.TrimSpace(note) != "" {
+ args = append(args, note)
+ }
+ out, err := runGit(dir, args...)
+ if err != nil {
+ return "", err
+ }
+ return strings.TrimSpace(out), nil
+}
+
+// RestoreWork 回到指定提交并铺回该副本。
+func RestoreWork(repo Repo, checkout Checkout, stashOID, head string) error {
+ dir := checkoutDir(repo, checkout)
+ if err := requireRepo(dir); err != nil {
+ return err
+ }
+ if strings.TrimSpace(head) == "" {
+ return errors.New("head is required")
+ }
+ if strings.TrimSpace(stashOID) != "" {
+ if _, err := runGit(dir, "rev-parse", "--verify", "--quiet", stashOID+"^{commit}"); err != nil {
+ return fmt.Errorf("snapshot %s is unavailable", stashOID)
+ }
+ }
+ if _, err := runGit(dir, "reset", "--hard", head); err != nil {
+ return err
+ }
+ if strings.TrimSpace(stashOID) == "" {
+ return nil
+ }
+ if _, err := runGit(dir, "stash", "apply", "--index", stashOID); err != nil {
+ after, statusErr := Status(repo, checkout)
+ if statusErr == nil && hasUnmerged(after) {
+ return ErrConflict
+ }
+ return err
+ }
+ return nil
+}
diff --git a/server/pkg/git/types.go b/server/pkg/git/types.go
new file mode 100644
index 0000000..f0ffc5a
--- /dev/null
+++ b/server/pkg/git/types.go
@@ -0,0 +1,124 @@
+package git
+
+// Repo 是主仓根,不是某一份 worktree。
+type Repo struct {
+ Path string `json:"path"` // 主仓根路径;worktree 的目录放在 Checkout / Worktree。
+}
+
+// Checkout 是当前操作的那份检出(主仓工作区或某个 worktree)。
+type Checkout struct {
+ Path string `json:"path"` // 这份检出的目录。
+ CurrentBranch string `json:"current_branch"` // 当前分支短名;detached 或还没有首提交时为空。
+ CurrentCommit string `json:"current_commit"` // 当前 HEAD 全文 hash;空仓库为空。
+ Detached bool `json:"detached"` // 是否游离 HEAD;空仓库不是 detached,不要和空分支名混用。
+}
+
+// Remote 是一条 git remote 地址,不是 origin/main 那种远程跟踪分支。
+type Remote struct {
+ Name string `json:"name"` // remote 名,通常是 origin。
+ FetchURL string `json:"fetch_url"` // fetch 用的地址。
+ PushURL string `json:"push_url"` // push 用的地址;和 fetch 不同时才需要分开展示。
+}
+
+// Worktree 是仓库的一份检出。
+type Worktree struct {
+ Path string `json:"path"` // 这份检出的目录。
+ Branch string `json:"branch"` // 挂着的本地分支;detached 时为空。
+ Head string `json:"head"` // 这份检出的 HEAD hash。
+ Detached bool `json:"detached"` // 是否游离 HEAD。
+ Locked bool `json:"locked"` // 是否被 git worktree lock;锁住时不要删。
+}
+
+// FileStatus 是一个路径相对 HEAD / 暂存区的状态。
+type FileStatus struct {
+ Path string `json:"path"` // 仓库内相对路径;重命名后指新路径。
+ OrigPath string `json:"orig_path"` // 重命名或复制的旧路径;不是重命名则为空。
+ StagedStatus string `json:"staged_status"` // porcelain XY 第一位:暂存区相对 HEAD;空格表示暂存区没改。
+ WorktreeStatus string `json:"worktree_status"` // porcelain XY 第二位:工作区相对暂存区;? 表示未跟踪。
+ Unmerged bool `json:"unmerged"` // 未解决冲突;true 时进冲突会话,不要当普通改动。
+}
+
+// SiteState 是一份检出的整局:身份、跟踪、文件、remote。不是「只回文件列表」。
+type SiteState struct {
+ Path string `json:"path"` // 这份检出的绝对路径。
+ IsRepo bool `json:"is_repo"` // 当前文件夹是不是 Git 仓库;不是则下面字段都无意义。
+ Empty bool `json:"empty"` // 还没有任何提交;不能 reset HEAD~1,也没有图。
+ Branch string `json:"branch"` // 当前分支名;detached 时为空。
+ Head string `json:"head"` // 当前 HEAD hash;空仓库为空。
+ Detached bool `json:"detached"` // 是否游离 HEAD。
+ Upstream string `json:"upstream"` // 跟踪的远程分支,如 origin/feat/git;没设置则为空。
+ Ahead int `json:"ahead"` // 比 upstream 超前的提交数;用来判断「能否安全撤上次提交」和要不要推。
+ Behind int `json:"behind"` // 比 upstream 落后的提交数;用来判断要不要拉。
+ UpstreamGone bool `json:"upstream_gone"` // 跟踪目标在远端已删除;推拉前要换跟踪或改 remote。
+ Integrating string `json:"integrating"` // 未完成的整合:merge | rebase | cherry_pick | revert;空表示没有。不是 pull。
+ DefaultBranch string `json:"default_branch"` // origin/HEAD 指向的默认分支短名;没有远端则为空。
+ Files []FileStatus `json:"files"` // 暂存区和工作区的文件(含未跟踪,不含忽略)。
+ Remotes []Remote `json:"remotes"` // 已配置的 remote 地址;推送前用来判断有没有 URL。
+}
+
+// DiffFile 是已暂存或工作区的一份差异。staged / worktree 由调用时的 scope 区分。
+type DiffFile struct {
+ Path string `json:"path"` // 当前路径。
+ OrigPath string `json:"orig_path"` // 重命名来源;不是重命名则为空。
+ Kind string `json:"kind"` // added | modified | deleted | renamed | unmerged。
+ Binary bool `json:"binary"` // 二进制则不要当文本展示,也不要喂给提交说明生成。
+ Patch string `json:"patch"` // unified diff 文本;二进制或无法生成时为空。
+}
+
+// Commit 是一次提交。函数叫 CreateCommit,避免和类型同名。
+type Commit struct {
+ ID string `json:"id"` // 全文 hash;图的节点 ID、重置目标都用它。
+ Parents []string `json:"parents"` // 父提交 hash;首次提交为空,合并提交多于一个,图靠它连边。
+ Title string `json:"title"` // 说明第一行。
+ Body string `json:"body"` // 第一行之后的正文。
+ Author string `json:"author"` // 作者名,给图和时间线展示。
+ Date string `json:"date"` // 作者时间,ISO-8601,给图画新旧。
+}
+
+// Ref 是落在某次提交上的名字,用来给图上的点上色。
+type Ref struct {
+ Name string `json:"name"` // 短名,如 feat/git、origin/main、v1.0。
+ Kind string `json:"kind"` // local | remote | tag | head。
+}
+
+// GraphNode 是近期图上的一个点。
+type GraphNode struct {
+ Commit Commit `json:"commit"` // 这个点对应的提交。
+ Refs []Ref `json:"refs"` // 落在这个提交上的分支 / 标签 / HEAD,用来画分支尖。
+}
+
+// GraphEdge 是父子边。
+type GraphEdge struct {
+ Child string `json:"child"` // 子提交 hash,时间上更新的一方。
+ Parent string `json:"parent"` // 父提交 hash。
+}
+
+// Graph 是近期分叉,不是全仓库考古。点只用于看。
+type Graph struct {
+ Nodes []GraphNode `json:"nodes"`
+ Edges []GraphEdge `json:"edges"`
+}
+
+// Branch 是一条本地分支或远程跟踪分支(refs/remotes 缓存,不是 ls-remote 网上现场)。
+type Branch struct {
+ Name string `json:"name"` // 短名;远程分支带 remote 前缀,如 origin/main。
+ Head string `json:"head"` // 尖端提交 hash,切过去或从图上认点用。
+ IsCurrent bool `json:"is_current"` // 是不是当前检出所在分支。
+ IsRemote bool `json:"is_remote"` // 是不是 refs/remotes;列表要和本地分开画。
+ Upstream string `json:"upstream"` // 这条本地分支跟踪的远程短名;远程分支或无跟踪则为空。
+ Ahead int `json:"ahead"` // 比自己的 upstream 超前的提交数;无 upstream 为 0。
+ Behind int `json:"behind"` // 比自己的 upstream 落后的提交数。
+ UpstreamGone bool `json:"upstream_gone"` // 跟踪目标在远端已删除。
+ WorktreePath string `json:"worktree_path"` // 占用这条分支的检出路径;空表示没被占用。已被占用则不要再 switch 到同一分支。
+ Title string `json:"title"` // 尖端提交标题,给分支列表预览。
+}
+
+// ConflictItem 是未合并文件的种类和三方内容。
+type ConflictItem struct {
+ Path string `json:"path"` // 冲突路径。
+ Kind string `json:"kind"` // both_modified | deleted_by_us | deleted_by_them | both_added | both_deleted | added_by_us | added_by_them;决定展示三方还是「一侧已删除」。
+ Base string `json:"base"` // stage 1 共同祖先内容;某侧从一开始就没有该文件时为空。
+ Ours string `json:"ours"` // stage 2 我方内容;我方删除时为空。
+ Theirs string `json:"theirs"` // stage 3 对方内容;对方删除时为空。
+ Result string `json:"result"` // 用户写入的决议全文;空表示尚未解决。
+}
diff --git a/server/pkg/git/worktree.go b/server/pkg/git/worktree.go
new file mode 100644
index 0000000..1fe63a2
--- /dev/null
+++ b/server/pkg/git/worktree.go
@@ -0,0 +1,120 @@
+package git
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+// ListWorktrees 列出该仓库的全部检出。
+func ListWorktrees(repo Repo) ([]Worktree, error) {
+ if !isRepo(repo.Path) {
+ return []Worktree{}, nil
+ }
+ out, err := runGit(repo.Path, "worktree", "list", "--porcelain")
+ if err != nil {
+ return nil, err
+ }
+ trees := []Worktree{}
+ var current Worktree
+ flush := func() {
+ if current.Path == "" {
+ return
+ }
+ trees = append(trees, current)
+ current = Worktree{}
+ }
+ for _, line := range strings.Split(out, "\n") {
+ switch {
+ case strings.HasPrefix(line, "worktree "):
+ flush()
+ current.Path = strings.TrimSpace(strings.TrimPrefix(line, "worktree "))
+ case strings.HasPrefix(line, "HEAD "):
+ current.Head = strings.TrimSpace(strings.TrimPrefix(line, "HEAD "))
+ case strings.HasPrefix(line, "branch "):
+ ref := strings.TrimSpace(strings.TrimPrefix(line, "branch "))
+ current.Branch = strings.TrimPrefix(ref, "refs/heads/")
+ case line == "detached":
+ current.Detached = true
+ current.Branch = ""
+ case strings.HasPrefix(line, "locked"):
+ current.Locked = true
+ case line == "":
+ flush()
+ }
+ }
+ flush()
+ return trees, nil
+}
+
+// AddWorktree 创建一份检出。newBranch 空则挂到已有分支。
+func AddWorktree(repo Repo, path, branch, newBranch string) (Worktree, error) {
+ if err := requireRepo(repo.Path); err != nil {
+ return Worktree{}, err
+ }
+ abs, err := constrainWorktreeDest(repo.Path, path)
+ if err != nil {
+ return Worktree{}, err
+ }
+ args := []string{"worktree", "add"}
+ if strings.TrimSpace(newBranch) != "" {
+ args = append(args, "-b", newBranch, abs)
+ if strings.TrimSpace(branch) != "" {
+ args = append(args, branch)
+ }
+ } else {
+ if strings.TrimSpace(branch) == "" {
+ return Worktree{}, errors.New("branch is required")
+ }
+ args = append(args, abs, branch)
+ }
+ if _, err := runGit(repo.Path, args...); err != nil {
+ return Worktree{}, err
+ }
+ trees, err := ListWorktrees(repo)
+ if err != nil {
+ return Worktree{}, err
+ }
+ for _, tree := range trees {
+ if samePath(tree.Path, abs) {
+ return tree, nil
+ }
+ }
+ return Worktree{Path: abs, Branch: firstNonEmpty(newBranch, branch)}, nil
+}
+
+func constrainWorktreeDest(repoPath, reqPath string) (string, error) {
+ reqPath = strings.TrimSpace(reqPath)
+ if reqPath == "" {
+ return "", errors.New("worktree path is required")
+ }
+ root, err := filepath.Abs(repoPath)
+ if err != nil {
+ return "", err
+ }
+ root = filepath.Clean(root)
+ parent := filepath.Dir(root)
+ var dest string
+ if filepath.IsAbs(reqPath) {
+ dest = filepath.Clean(reqPath)
+ } else {
+ dest = filepath.Clean(filepath.Join(root, reqPath))
+ }
+ dest, err = filepath.Abs(dest)
+ if err != nil {
+ return "", err
+ }
+ if resolved, err := filepath.EvalSymlinks(dest); err == nil {
+ dest = resolved
+ }
+ parent, err = filepath.Abs(parent)
+ if err != nil {
+ return "", err
+ }
+ rel, err := filepath.Rel(parent, dest)
+ if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
+ return "", errors.New("worktree path is outside the repository parent")
+ }
+ return dest, nil
+}