From 57df1f908d2e72aa45c2123bdc6817863247c2db Mon Sep 17 00:00:00 2001 From: 2penheimer <2603237065@qq.com> Date: Sun, 30 Aug 2026 09:27:13 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=E6=8C=89=20issue=20#9=20=E6=90=AD?= =?UTF-8?q?=20Git=20HTTP=20=E4=B8=8E=20pkg/git=20=E9=AA=A8=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对照新数据原型先落类型、空方法和路由,产品组合在 Handler,本阶段不写 CLI 逻辑和前端。 Co-authored-by: Cursor --- AGENTS.md | 7 +- docs/architecture.md | 27 +- server/cmd/server/main.go | 2 +- server/cmd/server/router.go | 33 +- server/internal/config/config.go | 2 + server/internal/config/config_test.go | 8 + server/internal/handler/api.go | 6 +- server/internal/handler/git.go | 708 ++++++++++++++++++++++++++ server/internal/handler/git_test.go | 31 ++ server/internal/handler/loop_test.go | 5 +- server/pkg/git/branch.go | 42 ++ server/pkg/git/git_test.go | 35 ++ server/pkg/git/integrate.go | 31 ++ server/pkg/git/remote.go | 8 + server/pkg/git/repo.go | 104 ++++ server/pkg/git/run.go | 86 ++++ server/pkg/git/stash.go | 23 + server/pkg/git/types.go | 124 +++++ server/pkg/git/worktree.go | 31 ++ 19 files changed, 1297 insertions(+), 16 deletions(-) create mode 100644 server/internal/handler/git.go create mode 100644 server/internal/handler/git_test.go create mode 100644 server/pkg/git/branch.go create mode 100644 server/pkg/git/git_test.go create mode 100644 server/pkg/git/integrate.go create mode 100644 server/pkg/git/remote.go create mode 100644 server/pkg/git/repo.go create mode 100644 server/pkg/git/run.go create mode 100644 server/pkg/git/stash.go create mode 100644 server/pkg/git/types.go create mode 100644 server/pkg/git/worktree.go diff --git a/AGENTS.md b/AGENTS.md index 9e37a0a..b7cc9e3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,20 +2,21 @@ 修改 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 页。 ## 目录放置规则 - 服务启动、配置读取、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/`),文件直接在域目录下,不要 `src/`。不依赖 React、Next、DOM、`process.env`。`baseUrl` / `userId` 由调用方注入。 - 无业务 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。 diff --git a/docs/architecture.md b/docs/architecture.md index e8d0da1..ee70f43 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/`,不预建空目录。`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`。不 import `next/*`。新业务新建目录,不预建 Issue / Task / Review / Workspace。本阶段不做 Git 页。 ### `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`(CORS)。Git 目前只有 HTTP,没有 Web 页。 ## 组装关系 @@ -248,7 +261,7 @@ 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`)。 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..c9aaa48 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,37 @@ 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.Post("/stage", api.GitStage) + r.Post("/unstage", api.GitUnstage) + 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/git.go b/server/internal/handler/git.go new file mode 100644 index 0000000..e4200ab --- /dev/null +++ b/server/internal/handler/git.go @@ -0,0 +1,708 @@ +package handler + +import ( + "net/http" + "os" + "path/filepath" + "strings" + + cderr "codedock/internal/errors" + "codedock/pkg/git" +) + +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 gitPromptRequest struct { + SystemPrompt string `json:"system_prompt"` +} + +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"` // 生成的正文。 +} + +// PromptConfig 是生成说明用的 system prompt。 +type PromptConfig struct { + SystemPrompt string `json:"system_prompt"` // 空则用产品默认。 +} + +// 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 (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{}, 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") + } + // TODO: checkout 必须是主仓或 ListWorktrees 里的路径。 + _, _ = git.ListWorktrees(repo) + return repo, git.Checkout{Path: abs}, nil +} + +// GitStatus 给界面看当前整局。 +func (a *API) GitStatus(w http.ResponseWriter, r *http.Request) { + repo, co, err := a.openSite(r.URL.Query().Get("checkout")) + if err != nil { + writeError(w, err) + return + } + state, err := git.Status(repo, co) + if err != nil { + writeError(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 { + writeError(w, err) + return + } + scope := r.URL.Query().Get("scope") + if scope == "" { + scope = "staged" + } + files, err := git.Diff(repo, co, scope) + if err != nil { + writeError(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 { + writeError(w, err) + return + } + graph, err := git.LogGraph(repo, 50) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, graph) +} + +// 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 { + writeError(w, err) + return + } + if err := git.Stage(repo, co, req.Paths); err != nil { + writeError(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 { + writeError(w, err) + return + } + if err := git.Unstage(repo, co, req.Paths); err != nil { + writeError(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 { + writeError(w, err) + return + } + if len(req.Paths) > 0 { + if err := git.Stage(repo, co, req.Paths); err != nil { + writeError(w, err) + return + } + } + commit, err := git.CreateCommit(repo, co, req.Message) + if err != nil { + writeError(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 + } + // TODO: mixed/hard 必须 confirm;Empty 时不能 HEAD~1。 + _ = req.Confirm + repo, co, err := a.openSite(req.Checkout) + if err != nil { + writeError(w, err) + return + } + if err := git.Reset(repo, co, req.Target, req.Mode); err != nil { + writeError(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 { + writeError(w, err) + return + } + commit, err := git.Revert(repo, co, git.Commit{ID: req.ID}) + if err != nil { + writeError(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 { + writeError(w, err) + return + } + if err := git.Push(repo, co); err != nil { + writeError(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 { + writeError(w, err) + return + } + if err := git.Pull(repo, co); err != nil { + // TODO: ErrConflict 时写 ConflictSession 409。 + writeError(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 { + writeError(w, err) + return + } + remotes, err := git.ListRemotes(repo) + if err != nil { + writeError(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 { + writeError(w, err) + return + } + trees, err := git.ListWorktrees(repo) + if err != nil { + writeError(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 { + writeError(w, err) + return + } + tree, err := git.AddWorktree(repo, req.Path, req.Branch, req.NewBranch) + if err != nil { + writeError(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 { + writeError(w, err) + return + } + state, err := git.Status(repo, co) + if err != nil { + writeError(w, err) + return + } + listed, err := git.ListBranches(repo) + if err != nil { + writeError(w, err) + return + } + graph, err := git.LogGraph(repo, 50) + if err != nil { + writeError(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, _, err := a.openSite(req.Checkout) + if err != nil { + writeError(w, err) + return + } + if err := git.CreateBranch(repo, req.Name, req.Start); err != nil { + writeError(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 { + writeError(w, err) + return + } + if err := git.SwitchBranch(repo, co, req.Name); err != nil { + writeError(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 { + writeError(w, err) + return + } + if err := git.DeleteBranch(repo, req.Name); err != nil { + writeError(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 { + writeError(w, err) + return + } + state, err := git.Status(repo, co) + if err != nil { + writeError(w, err) + return + } + sess := ConflictSession{Kind: state.Integrating, 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) + } + 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 { + writeError(w, err) + return + } + // TODO: 写入工作区并 Stage 该路径。 + _ = repo + _ = co + _ = req + 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 { + writeError(w, err) + return + } + if err := git.ContinueIntegrate(repo, co); err != nil { + writeError(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 { + writeError(w, err) + return + } + if err := git.AbortIntegrate(repo, co); err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +// GitGetPrompt 读生成说明用的 system prompt。 +func (a *API) GitGetPrompt(w http.ResponseWriter, r *http.Request) { + // TODO: 读用户自定义 prompt;空则产品默认。 + _ = r + writeJSON(w, http.StatusOK, PromptConfig{}) +} + +// GitSetPrompt 保存用户自定义的 system prompt。 +func (a *API) GitSetPrompt(w http.ResponseWriter, r *http.Request) { + var req gitPromptRequest + if err := decodeJSON(r, &req); err != nil { + writeError(w, err) + return + } + // TODO: 保存 PromptConfig。 + writeJSON(w, http.StatusOK, PromptConfig{SystemPrompt: req.SystemPrompt}) +} + +// 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 { + writeError(w, err) + return + } + files, err := git.Diff(repo, co, "staged") + if err != nil { + writeError(w, err) + return + } + // TODO: 二进制不喂模型;一次短调用,不进 Agent Loop。 + _ = files + writeJSON(w, http.StatusOK, MessageDraft{}) +} + +// 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 { + writeError(w, err) + return + } + state, err := git.Status(repo, co) + if err != nil { + writeError(w, err) + return + } + oid, err := git.CaptureWork(repo, co, req.AgentRun) + if err != nil { + writeError(w, err) + return + } + // TODO: 持久化快照;HasUntracked 来自 Status 未跟踪文件。 + writeJSON(w, http.StatusOK, AgentSnapshot{Checkout: co, Head: state.Head, StashOID: oid, AgentRun: req.AgentRun}) +} + +// GitLatestSnapshot 取最近一份 Agent 快照。 +func (a *API) GitLatestSnapshot(w http.ResponseWriter, r *http.Request) { + _, _, err := a.openSite(r.URL.Query().Get("checkout")) + if err != nil { + writeError(w, err) + return + } + // TODO: 取最近一份;没有则空对象。 + writeJSON(w, http.StatusOK, AgentSnapshot{}) +} + +// 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 { + writeError(w, err) + return + } + // TODO: 用 Latest / ID 找到 Head 与 StashOID。 + if err := git.RestoreWork(repo, co, "", ""); err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +// 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 { + writeError(w, err) + return + } + state, err := git.Status(repo, co) + if err != nil { + writeError(w, err) + return + } + // TODO: 看 Ahead / Empty / Integrating / 是否有 Agent 快照。 + _ = state + writeJSON(w, http.StatusOK, map[string]any{"buttons": []UndoButton{}}) +} + +// 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 { + writeError(w, err) + return + } + // TODO: 按按钮 Target 调 Reset / Revert / AbortIntegrate / RestoreWork。 + _ = repo + _ = co + _ = req + 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..dbfebbb --- /dev/null +++ b/server/internal/handler/git_test.go @@ -0,0 +1,31 @@ +package handler_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + + "codedock/internal/config" + "codedock/internal/handler" + pkgagent "codedock/pkg/agent" +) + +func TestGitSkeletonRoutes(t *testing.T) { + t.Setenv("GIT_REPO", t.TempDir()) + api := handler.New(nil, nil, nil, nil, pkgagent.RunConfigSnapshot{}, config.Load(), nil) + r := chi.NewRouter() + r.Get("/git/status", api.GitStatus) + r.Get("/git/branches", api.GitListBranches) + r.Get("/git/undo", api.GitListUndo) + r.Get("/git/conflict", api.GitGetConflict) + + for _, path := range []string{"/git/status", "/git/branches", "/git/undo", "/git/conflict"} { + rec := httptest.NewRecorder() + r.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil)) + if rec.Code != http.StatusOK { + t.Fatalf("%s: %d %s", path, rec.Code, rec.Body.String()) + } + } +} 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/git/branch.go b/server/pkg/git/branch.go new file mode 100644 index 0000000..03b2a37 --- /dev/null +++ b/server/pkg/git/branch.go @@ -0,0 +1,42 @@ +package git + +// ListBranches 列出本地和远程跟踪分支,含跟踪和 ahead/behind。 +func ListBranches(repo Repo) ([]Branch, error) { + // TODO: for-each-ref refs/heads 与 refs/remotes,分别填 IsRemote / Upstream / WorktreePath。 + _ = repo + return []Branch{}, nil +} + +// CreateBranch 从起点创建分支;start 空则从当前提交。 +func CreateBranch(repo Repo, name, start string) error { + // TODO: git branch name [start] + _ = repo + _ = name + _ = start + return nil +} + +// SwitchBranch 切换到指定分支。脏工作区、正在整合或该分支已被其他检出占用时拒绝。 +func SwitchBranch(repo Repo, checkout Checkout, name string) error { + // TODO: 先 Status 与 ListBranches,再 git switch。 + state, err := Status(repo, checkout) + if err != nil { + return err + } + branches, err := ListBranches(repo) + if err != nil { + return err + } + _ = state + _ = branches + _ = name + return nil +} + +// DeleteBranch 删除本地分支;不能删当前分支。 +func DeleteBranch(repo Repo, name string) error { + // TODO: 当前分支返回 ErrCurrentBranch;git branch -d。 + _ = repo + _ = name + return nil +} diff --git a/server/pkg/git/git_test.go b/server/pkg/git/git_test.go new file mode 100644 index 0000000..7e64603 --- /dev/null +++ b/server/pkg/git/git_test.go @@ -0,0 +1,35 @@ +package git + +import "testing" + +func TestSkeletonCompiles(t *testing.T) { + repo, err := Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + co := Checkout{Path: repo.Path} + if _, err := Status(repo, co); err != nil { + t.Fatal(err) + } + if _, err := Diff(repo, co, "staged"); err != nil { + t.Fatal(err) + } + if _, err := LogGraph(repo, 10); err != nil { + t.Fatal(err) + } + if _, err := ListRemotes(repo); err != nil { + t.Fatal(err) + } + if _, err := ListWorktrees(repo); err != nil { + t.Fatal(err) + } + if _, err := AddWorktree(repo, t.TempDir(), "", "feature"); err != nil { + t.Fatal(err) + } + if _, err := ListBranches(repo); err != nil { + t.Fatal(err) + } + if _, err := CaptureWork(repo, co, "note"); err != nil { + t.Fatal(err) + } +} diff --git a/server/pkg/git/integrate.go b/server/pkg/git/integrate.go new file mode 100644 index 0000000..46228eb --- /dev/null +++ b/server/pkg/git/integrate.go @@ -0,0 +1,31 @@ +package git + +// ReadConflict 读某文件的冲突种类和三方内容。 +func ReadConflict(repo Repo, checkout Checkout, path string) (ConflictItem, error) { + // TODO: ls-files -u 判断 Kind;git show :1/:2/:3:path。 + _ = repo + _ = checkout + return ConflictItem{Path: path}, nil +} + +// ContinueIntegrate 继续未完成的整合。 +func ContinueIntegrate(repo Repo, checkout Checkout) error { + // TODO: 仍有 Unmerged 则 ErrConflict;按 Integrating 跑 merge/rebase/cherry-pick/revert --continue。 + state, err := Status(repo, checkout) + if err != nil { + return err + } + _ = state + return nil +} + +// AbortIntegrate 中止未完成的整合。 +func AbortIntegrate(repo Repo, checkout Checkout) error { + // TODO: 按 Integrating 跑 --abort。 + state, err := Status(repo, checkout) + if err != nil { + return err + } + _ = state + return nil +} diff --git a/server/pkg/git/remote.go b/server/pkg/git/remote.go new file mode 100644 index 0000000..8a26870 --- /dev/null +++ b/server/pkg/git/remote.go @@ -0,0 +1,8 @@ +package git + +// ListRemotes 列出已配置的 remote。 +func ListRemotes(repo Repo) ([]Remote, error) { + // TODO: git remote -v + _ = repo + return []Remote{}, nil +} diff --git a/server/pkg/git/repo.go b/server/pkg/git/repo.go new file mode 100644 index 0000000..a6919c1 --- /dev/null +++ b/server/pkg/git/repo.go @@ -0,0 +1,104 @@ +package git + +// Open 打开本地路径上的仓库句柄,不要求已经是 Git 仓库。 +func Open(path string) (Repo, error) { + // TODO: 规范化绝对路径,不向上找 .git。 + _ = path + return Repo{}, nil +} + +// Status 读这一份检出的整局:分支、跟踪、文件、remote。 +func Status(repo Repo, checkout Checkout) (SiteState, error) { + // TODO: git status --porcelain=v2 --branch;填 Empty / Detached / Upstream / Ahead / Behind / Integrating。 + _, _ = ListRemotes(repo) + return SiteState{Path: checkoutDir(repo, checkout), Files: []FileStatus{}, Remotes: []Remote{}}, nil +} + +// Diff 读已暂存或工作区差异;scope 为 staged | worktree。 +func Diff(repo Repo, checkout Checkout, scope string) ([]DiffFile, error) { + // TODO: git diff / git diff --cached,二进制不要当文本。 + _ = repo + _ = checkout + _ = scope + return []DiffFile{}, nil +} + +// LogGraph 读近期提交、装饰和父子边。 +func LogGraph(repo Repo, limit int) (Graph, error) { + // TODO: git log --all --decorate,只取近期 limit。 + _ = repo + _ = limit + return Graph{Nodes: []GraphNode{}, Edges: []GraphEdge{}}, nil +} + +// Stage 把路径加入暂存区。 +func Stage(repo Repo, checkout Checkout, paths []string) error { + // TODO: git add -- paths + _ = repo + _ = checkout + _ = paths + return nil +} + +// Unstage 把路径移出暂存区。 +func Unstage(repo Repo, checkout Checkout, paths []string) error { + // TODO: git reset HEAD -- paths;空仓用 git rm --cached。 + _ = repo + _ = checkout + _ = paths + return nil +} + +// CreateCommit 用已经写好的说明创建提交。 +func CreateCommit(repo Repo, checkout Checkout, message string) (Commit, error) { + // TODO: git commit -m;读 HEAD 的 id / parents / 作者 / 时间。 + _ = repo + _ = checkout + _ = message + return Commit{}, nil +} + +// Reset 软 / 混合 / 硬重置到目标提交。 +func Reset(repo Repo, checkout Checkout, target, mode string) error { + // TODO: 正在整合则拒绝;git reset --soft|--mixed|--hard。 + state, err := Status(repo, checkout) + if err != nil { + return err + } + _ = state + _ = target + _ = mode + return nil +} + +// Revert 用一次新提交回退指定提交。 +func Revert(repo Repo, checkout Checkout, commit Commit) (Commit, error) { + // TODO: git revert;冲突则返回 ErrConflict。 + _ = repo + _ = checkout + _ = commit + return Commit{}, nil +} + +// Push 推到已配置的 remote。 +func Push(repo Repo, checkout Checkout) error { + // TODO: 无 remote / 无 upstream / gone 时返回可读错误;git push。 + state, err := Status(repo, checkout) + if err != nil { + return err + } + _ = state + return nil +} + +// Pull 拉取并尝试整合;有冲突返回 ErrConflict。 +func Pull(repo Repo, checkout Checkout) error { + // TODO: git pull --no-rebase;失败再看 Integrating / ReadConflict。 + state, err := Status(repo, checkout) + if err != nil { + return err + } + _ = state + _, _ = ReadConflict(repo, checkout, "") + return nil +} diff --git a/server/pkg/git/run.go b/server/pkg/git/run.go new file mode 100644 index 0000000..880eef1 --- /dev/null +++ b/server/pkg/git/run.go @@ -0,0 +1,86 @@ +package git + +import ( + "bytes" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "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 checkout.Path != "" { + return checkout.Path + } + return repo.Path +} + +func runGit(dir string, args ...string) (string, error) { + cmd := exec.Command("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 + done := make(chan error, 1) + go func() { done <- cmd.Run() }() + select { + case err := <-done: + if err != nil { + msg := strings.TrimSpace(stderr.String()) + if msg == "" { + msg = strings.TrimSpace(stdout.String()) + } + if msg == "" { + msg = err.Error() + } + return "", errors.New(msg) + } + return stdout.String(), nil + case <-time.After(60 * time.Second): + _ = cmd.Process.Kill() + return "", fmt.Errorf("git %s timed out", strings.Join(args, " ")) + } +} + +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 +} + +func samePath(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) + } + return filepath.Clean(aa) == filepath.Clean(bb) +} diff --git a/server/pkg/git/stash.go b/server/pkg/git/stash.go new file mode 100644 index 0000000..b2579fb --- /dev/null +++ b/server/pkg/git/stash.go @@ -0,0 +1,23 @@ +package git + +// CaptureWork 用 stash create 复制暂存区与已跟踪工作区,不改现有内容;返回悬空提交 hash。 +func CaptureWork(repo Repo, checkout Checkout, note string) (string, error) { + // TODO: git stash create;不含未跟踪文件。 + state, err := Status(repo, checkout) + if err != nil { + return "", err + } + _ = state + _ = note + return "", nil +} + +// RestoreWork 回到指定提交并铺回该副本。 +func RestoreWork(repo Repo, checkout Checkout, stashOID, head string) error { + // TODO: 先回到 head,再把 stashOID 铺回工作区;未跟踪文件不会出现。 + _ = repo + _ = checkout + _ = stashOID + _ = head + 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..8c175af --- /dev/null +++ b/server/pkg/git/worktree.go @@ -0,0 +1,31 @@ +package git + +// ListWorktrees 列出该仓库的全部检出。 +func ListWorktrees(repo Repo) ([]Worktree, error) { + // TODO: git worktree list --porcelain;填 Detached / Locked。 + _ = repo + return []Worktree{}, nil +} + +// AddWorktree 创建一份检出。newBranch 空则挂到已有分支。 +func AddWorktree(repo Repo, path, branch, newBranch string) (Worktree, error) { + // TODO: git worktree add [-b newBranch] path [branch] + _ = path + _ = branch + _ = newBranch + trees, err := ListWorktrees(repo) + if err != nil { + return Worktree{}, err + } + _ = trees + return Worktree{Path: path, Branch: firstNonEmpty(newBranch, branch)}, nil +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} From cf7f3bf1ad32fafceb8ed48a9fce136186d119f4 Mon Sep 17 00:00:00 2001 From: 2penheimer <2603237065@qq.com> Date: Sun, 30 Aug 2026 12:23:03 +0800 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20=E8=90=BD=E5=9C=B0=20Git=20CLI=20?= =?UTF-8?q?=E5=8E=9F=E8=AF=AD=E4=B8=8E=20HTTP=20=E7=BB=84=E5=90=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把骨架填成可执行的本机 Git 操作,补齐冲突、撤销、说明和快照,并挡住跨检出误恢复。 Co-authored-by: Cursor --- server/internal/handler/git.go | 668 +++++++++++++++++++++++----- server/internal/handler/git_test.go | 327 +++++++++++++- server/pkg/git/branch.go | 143 +++++- server/pkg/git/git_test.go | 466 ++++++++++++++++++- server/pkg/git/integrate.go | 172 ++++++- server/pkg/git/remote.go | 43 +- server/pkg/git/repo.go | 569 +++++++++++++++++++++-- server/pkg/git/run.go | 284 +++++++++++- server/pkg/git/stash.go | 45 +- server/pkg/git/worktree.go | 88 +++- 10 files changed, 2566 insertions(+), 239 deletions(-) diff --git a/server/internal/handler/git.go b/server/internal/handler/git.go index e4200ab..ce0ead9 100644 --- a/server/internal/handler/git.go +++ b/server/internal/handler/git.go @@ -1,15 +1,23 @@ package handler import ( + "context" + "encoding/json" + "errors" "net/http" "os" "path/filepath" "strings" cderr "codedock/internal/errors" + pkgagent "codedock/pkg/agent" "codedock/pkg/git" + + "github.com/google/uuid" ) +const defaultCommitPrompt = "根据已暂存的 diff 写 Git 提交说明。第一行是不超过 72 个字符的标题,空一行后写正文。不要使用代码围栏,不要解释。" + type gitCheckoutRequest struct { Checkout string `json:"checkout"` } @@ -125,6 +133,42 @@ type UndoButton struct { 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) @@ -143,7 +187,7 @@ func (a *API) openSite(checkout string) (git.Repo, git.Checkout, error) { } repo, err := git.Open(root) if err != nil { - return git.Repo{}, git.Checkout{}, err + return git.Repo{}, git.Checkout{}, mapGitErr(err) } path := root if strings.TrimSpace(checkout) != "" { @@ -153,21 +197,264 @@ func (a *API) openSite(checkout string) (git.Repo, git.Checkout, error) { if err != nil { return git.Repo{}, git.Checkout{}, cderr.Invalid("invalid checkout") } - // TODO: checkout 必须是主仓或 ListWorktrees 里的路径。 - _, _ = git.ListWorktrees(repo) + 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) loadPrompt(repo git.Repo) string { + path, err := codedockFile(repo, "commit-message-prompt") + if err != nil { + return defaultCommitPrompt + } + body, err := os.ReadFile(path) + if err != nil || strings.TrimSpace(string(body)) == "" { + return defaultCommitPrompt + } + return string(body) +} + +func (a *API) savePrompt(repo git.Repo, prompt string) error { + path, err := codedockFile(repo, "commit-message-prompt") + if err != nil { + return err + } + return os.WriteFile(path, []byte(prompt), 0o644) +} + +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 { + return []AgentSnapshot{}, nil + } + 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 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 (a *API) gitModel() pkgagent.ModelConfig { + opts, err := json.Marshal(map[string]string{ + "api_key": a.cfg.LLMAPIKey, + "base_url": a.cfg.LLMBaseURL, + }) + if err != nil { + opts = json.RawMessage(`{}`) + } + return pkgagent.ModelConfig{ + Provider: a.cfg.LLMProvider, + Model: a.cfg.LLMModel, + Options: opts, + } +} + +func fallbackDraft(files []git.DiffFile) string { + if len(files) == 0 { + return "" + } + title := files[0].Kind + " " + files[0].Path + var body strings.Builder + for _, file := range files { + body.WriteString(file.Kind) + body.WriteByte(' ') + body.WriteString(file.Path) + body.WriteByte('\n') + } + return title + "\n\n" + strings.TrimSpace(body.String()) +} + +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 { - writeError(w, err) + writeGitError(w, err) return } state, err := git.Status(repo, co) if err != nil { - writeError(w, err) + writeGitError(w, err) return } writeJSON(w, http.StatusOK, state) @@ -177,7 +464,7 @@ func (a *API) GitStatus(w http.ResponseWriter, r *http.Request) { func (a *API) GitDiff(w http.ResponseWriter, r *http.Request) { repo, co, err := a.openSite(r.URL.Query().Get("checkout")) if err != nil { - writeError(w, err) + writeGitError(w, err) return } scope := r.URL.Query().Get("scope") @@ -186,7 +473,7 @@ func (a *API) GitDiff(w http.ResponseWriter, r *http.Request) { } files, err := git.Diff(repo, co, scope) if err != nil { - writeError(w, err) + writeGitError(w, err) return } writeJSON(w, http.StatusOK, map[string]any{"files": files}) @@ -196,12 +483,12 @@ func (a *API) GitDiff(w http.ResponseWriter, r *http.Request) { func (a *API) GitGraph(w http.ResponseWriter, r *http.Request) { repo, _, err := a.openSite(r.URL.Query().Get("checkout")) if err != nil { - writeError(w, err) + writeGitError(w, err) return } graph, err := git.LogGraph(repo, 50) if err != nil { - writeError(w, err) + writeGitError(w, err) return } writeJSON(w, http.StatusOK, graph) @@ -216,11 +503,11 @@ func (a *API) GitStage(w http.ResponseWriter, r *http.Request) { } repo, co, err := a.openSite(req.Checkout) if err != nil { - writeError(w, err) + writeGitError(w, err) return } if err := git.Stage(repo, co, req.Paths); err != nil { - writeError(w, err) + writeGitError(w, err) return } writeJSON(w, http.StatusOK, map[string]any{"ok": true}) @@ -235,11 +522,11 @@ func (a *API) GitUnstage(w http.ResponseWriter, r *http.Request) { } repo, co, err := a.openSite(req.Checkout) if err != nil { - writeError(w, err) + writeGitError(w, err) return } if err := git.Unstage(repo, co, req.Paths); err != nil { - writeError(w, err) + writeGitError(w, err) return } writeJSON(w, http.StatusOK, map[string]any{"ok": true}) @@ -254,18 +541,18 @@ func (a *API) GitCommit(w http.ResponseWriter, r *http.Request) { } repo, co, err := a.openSite(req.Checkout) if err != nil { - writeError(w, err) + writeGitError(w, err) return } if len(req.Paths) > 0 { if err := git.Stage(repo, co, req.Paths); err != nil { - writeError(w, err) + writeGitError(w, err) return } } commit, err := git.CreateCommit(repo, co, req.Message) if err != nil { - writeError(w, err) + writeGitError(w, err) return } writeJSON(w, http.StatusOK, map[string]any{"commit": commit}) @@ -278,15 +565,28 @@ func (a *API) GitReset(w http.ResponseWriter, r *http.Request) { writeError(w, err) return } - // TODO: mixed/hard 必须 confirm;Empty 时不能 HEAD~1。 - _ = req.Confirm + 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 { - writeError(w, err) + writeGitError(w, err) return } if err := git.Reset(repo, co, req.Target, req.Mode); err != nil { - writeError(w, err) + writeGitError(w, err) return } writeJSON(w, http.StatusOK, map[string]any{"ok": true}) @@ -301,12 +601,12 @@ func (a *API) GitRevert(w http.ResponseWriter, r *http.Request) { } repo, co, err := a.openSite(req.Checkout) if err != nil { - writeError(w, err) + writeGitError(w, err) return } commit, err := git.Revert(repo, co, git.Commit{ID: req.ID}) if err != nil { - writeError(w, err) + writeGitError(w, err) return } writeJSON(w, http.StatusOK, map[string]any{"commit": commit}) @@ -321,11 +621,11 @@ func (a *API) GitPush(w http.ResponseWriter, r *http.Request) { } repo, co, err := a.openSite(req.Checkout) if err != nil { - writeError(w, err) + writeGitError(w, err) return } if err := git.Push(repo, co); err != nil { - writeError(w, err) + writeGitError(w, err) return } writeJSON(w, http.StatusOK, map[string]any{"ok": true}) @@ -340,12 +640,20 @@ func (a *API) GitPull(w http.ResponseWriter, r *http.Request) { } repo, co, err := a.openSite(req.Checkout) if err != nil { - writeError(w, err) + writeGitError(w, err) return } if err := git.Pull(repo, co); err != nil { - // TODO: ErrConflict 时写 ConflictSession 409。 - writeError(w, err) + 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}) @@ -355,12 +663,12 @@ func (a *API) GitPull(w http.ResponseWriter, r *http.Request) { func (a *API) GitListRemotes(w http.ResponseWriter, r *http.Request) { repo, _, err := a.openSite(r.URL.Query().Get("checkout")) if err != nil { - writeError(w, err) + writeGitError(w, err) return } remotes, err := git.ListRemotes(repo) if err != nil { - writeError(w, err) + writeGitError(w, err) return } writeJSON(w, http.StatusOK, map[string]any{"remotes": remotes}) @@ -370,12 +678,12 @@ func (a *API) GitListRemotes(w http.ResponseWriter, r *http.Request) { func (a *API) GitListWorktrees(w http.ResponseWriter, r *http.Request) { repo, _, err := a.openSite("") if err != nil { - writeError(w, err) + writeGitError(w, err) return } trees, err := git.ListWorktrees(repo) if err != nil { - writeError(w, err) + writeGitError(w, err) return } writeJSON(w, http.StatusOK, map[string]any{"worktrees": trees}) @@ -390,12 +698,12 @@ func (a *API) GitAddWorktree(w http.ResponseWriter, r *http.Request) { } repo, _, err := a.openSite("") if err != nil { - writeError(w, err) + writeGitError(w, err) return } tree, err := git.AddWorktree(repo, req.Path, req.Branch, req.NewBranch) if err != nil { - writeError(w, err) + writeGitError(w, err) return } writeJSON(w, http.StatusOK, map[string]any{"worktree": tree}) @@ -405,22 +713,22 @@ func (a *API) GitAddWorktree(w http.ResponseWriter, r *http.Request) { func (a *API) GitListBranches(w http.ResponseWriter, r *http.Request) { repo, co, err := a.openSite(r.URL.Query().Get("checkout")) if err != nil { - writeError(w, err) + writeGitError(w, err) return } state, err := git.Status(repo, co) if err != nil { - writeError(w, err) + writeGitError(w, err) return } listed, err := git.ListBranches(repo) if err != nil { - writeError(w, err) + writeGitError(w, err) return } graph, err := git.LogGraph(repo, 50) if err != nil { - writeError(w, err) + writeGitError(w, err) return } view := BranchView{Current: state.Branch, Locals: []git.Branch{}, Remotes: []git.Branch{}, Graph: graph} @@ -441,13 +749,13 @@ func (a *API) GitCreateBranch(w http.ResponseWriter, r *http.Request) { writeError(w, err) return } - repo, _, err := a.openSite(req.Checkout) + repo, co, err := a.openSite(req.Checkout) if err != nil { - writeError(w, err) + writeGitError(w, err) return } - if err := git.CreateBranch(repo, req.Name, req.Start); err != nil { - writeError(w, err) + 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}) @@ -462,11 +770,11 @@ func (a *API) GitSwitchBranch(w http.ResponseWriter, r *http.Request) { } repo, co, err := a.openSite(req.Checkout) if err != nil { - writeError(w, err) + writeGitError(w, err) return } if err := git.SwitchBranch(repo, co, req.Name); err != nil { - writeError(w, err) + writeGitError(w, err) return } writeJSON(w, http.StatusOK, map[string]any{"ok": true}) @@ -481,11 +789,11 @@ func (a *API) GitDeleteBranch(w http.ResponseWriter, r *http.Request) { } repo, _, err := a.openSite(req.Checkout) if err != nil { - writeError(w, err) + writeGitError(w, err) return } if err := git.DeleteBranch(repo, req.Name); err != nil { - writeError(w, err) + writeGitError(w, err) return } writeJSON(w, http.StatusOK, map[string]any{"ok": true}) @@ -495,25 +803,14 @@ func (a *API) GitDeleteBranch(w http.ResponseWriter, r *http.Request) { func (a *API) GitGetConflict(w http.ResponseWriter, r *http.Request) { repo, co, err := a.openSite(r.URL.Query().Get("checkout")) if err != nil { - writeError(w, err) + writeGitError(w, err) return } - state, err := git.Status(repo, co) + sess, err := a.conflictSession(repo, co) if err != nil { - writeError(w, err) + writeGitError(w, err) return } - sess := ConflictSession{Kind: state.Integrating, 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) - } writeJSON(w, http.StatusOK, sess) } @@ -525,14 +822,27 @@ func (a *API) GitWriteConflict(w http.ResponseWriter, r *http.Request) { 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 } - // TODO: 写入工作区并 Stage 该路径。 - _ = repo - _ = co - _ = req + 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}) } @@ -545,11 +855,11 @@ func (a *API) GitContinueConflict(w http.ResponseWriter, r *http.Request) { } repo, co, err := a.openSite(req.Checkout) if err != nil { - writeError(w, err) + writeGitError(w, err) return } if err := git.ContinueIntegrate(repo, co); err != nil { - writeError(w, err) + writeGitError(w, err) return } writeJSON(w, http.StatusOK, map[string]any{"ok": true}) @@ -564,11 +874,11 @@ func (a *API) GitAbortConflict(w http.ResponseWriter, r *http.Request) { } repo, co, err := a.openSite(req.Checkout) if err != nil { - writeError(w, err) + writeGitError(w, err) return } if err := git.AbortIntegrate(repo, co); err != nil { - writeError(w, err) + writeGitError(w, err) return } writeJSON(w, http.StatusOK, map[string]any{"ok": true}) @@ -576,9 +886,12 @@ func (a *API) GitAbortConflict(w http.ResponseWriter, r *http.Request) { // GitGetPrompt 读生成说明用的 system prompt。 func (a *API) GitGetPrompt(w http.ResponseWriter, r *http.Request) { - // TODO: 读用户自定义 prompt;空则产品默认。 - _ = r - writeJSON(w, http.StatusOK, PromptConfig{}) + repo, _, err := a.openSite(r.URL.Query().Get("checkout")) + if err != nil { + writeGitError(w, err) + return + } + writeJSON(w, http.StatusOK, PromptConfig{SystemPrompt: a.loadPrompt(repo)}) } // GitSetPrompt 保存用户自定义的 system prompt。 @@ -588,8 +901,20 @@ func (a *API) GitSetPrompt(w http.ResponseWriter, r *http.Request) { writeError(w, err) return } - // TODO: 保存 PromptConfig。 - writeJSON(w, http.StatusOK, PromptConfig{SystemPrompt: req.SystemPrompt}) + repo, _, err := a.openSite("") + if err != nil { + writeGitError(w, err) + return + } + if err := a.savePrompt(repo, req.SystemPrompt); err != nil { + writeGitError(w, err) + return + } + prompt := req.SystemPrompt + if strings.TrimSpace(prompt) == "" { + prompt = defaultCommitPrompt + } + writeJSON(w, http.StatusOK, PromptConfig{SystemPrompt: prompt}) } // GitGenerateMessage 读已暂存 diff,生成说明草稿。 @@ -601,17 +926,61 @@ func (a *API) GitGenerateMessage(w http.ResponseWriter, r *http.Request) { } repo, co, err := a.openSite(req.Checkout) if err != nil { - writeError(w, err) + writeGitError(w, err) return } files, err := git.Diff(repo, co, "staged") if err != nil { - writeError(w, err) + writeGitError(w, err) return } - // TODO: 二进制不喂模型;一次短调用,不进 Agent Loop。 - _ = files - writeJSON(w, http.StatusOK, MessageDraft{}) + if len(files) == 0 { + writeError(w, cderr.Invalid("nothing staged")) + return + } + draft := a.generateDraft(r.Context(), 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 { + var b strings.Builder + for _, file := range files { + if file.Binary { + b.WriteString("binary " + file.Kind + " " + file.Path + "\n") + continue + } + if file.Patch != "" { + b.WriteString(file.Patch) + b.WriteByte('\n') + continue + } + b.WriteString(file.Kind + " " + file.Path + "\n") + } + stream, err := pkgagent.Stream(ctx, pkgagent.Chat{ + Model: a.gitModel(), + SystemPrompt: a.loadPrompt(repo), + Messages: []pkgagent.Message{{ + Role: pkgagent.RoleUser, + Content: pkgagent.EncodeText(b.String()), + }}, + }) + if err != nil { + return parseDraft(fallbackDraft(files)) + } + defer stream.Close() + result, err := stream.Result(ctx) + if err != nil { + return parseDraft(fallbackDraft(files)) + } + text := strings.TrimSpace(pkgagent.DecodeText(result.Message.Content)) + if text == "" || text == "ok" { + return parseDraft(fallbackDraft(files)) + } + return parseDraft(text) } // GitCreateSnapshot 复制当时提交和工作区,不挪走现有改动。 @@ -623,32 +992,62 @@ func (a *API) GitCreateSnapshot(w http.ResponseWriter, r *http.Request) { } repo, co, err := a.openSite(req.Checkout) if err != nil { - writeError(w, err) + writeGitError(w, err) return } state, err := git.Status(repo, co) if err != nil { - writeError(w, err) + writeGitError(w, err) return } oid, err := git.CaptureWork(repo, co, req.AgentRun) if err != nil { - writeError(w, err) + writeGitError(w, err) return } - // TODO: 持久化快照;HasUntracked 来自 Status 未跟踪文件。 - writeJSON(w, http.StatusOK, AgentSnapshot{Checkout: co, Head: state.Head, StashOID: oid, AgentRun: req.AgentRun}) + 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) { - _, _, err := a.openSite(r.URL.Query().Get("checkout")) + repo, co, err := a.openSite(r.URL.Query().Get("checkout")) if err != nil { - writeError(w, err) + writeGitError(w, err) return } - // TODO: 取最近一份;没有则空对象。 - writeJSON(w, http.StatusOK, AgentSnapshot{}) + items, err := a.loadSnapshots(repo) + if err != nil { + writeGitError(w, err) + return + } + writeJSON(w, http.StatusOK, latestSnapshot(items, co.Path)) } // GitRestoreSnapshot 回到快照时的提交,并把副本铺回工作区。 @@ -660,32 +1059,69 @@ func (a *API) GitRestoreSnapshot(w http.ResponseWriter, r *http.Request) { } repo, co, err := a.openSite(req.Checkout) if err != nil { - writeError(w, err) + writeGitError(w, err) return } - // TODO: 用 Latest / ID 找到 Head 与 StashOID。 - if err := git.RestoreWork(repo, co, "", ""); err != nil { - writeError(w, err) + 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 { - writeError(w, err) + writeGitError(w, err) return } state, err := git.Status(repo, co) if err != nil { - writeError(w, err) + writeGitError(w, err) return } - // TODO: 看 Ahead / Empty / Integrating / 是否有 Agent 快照。 - _ = state - writeJSON(w, http.StatusOK, map[string]any{"buttons": []UndoButton{}}) + 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 执行该按钮对应的重置、回退或恢复。 @@ -697,12 +1133,44 @@ func (a *API) GitClickUndo(w http.ResponseWriter, r *http.Request) { } repo, co, err := a.openSite(req.Checkout) if err != nil { - writeError(w, err) + 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 } - // TODO: 按按钮 Target 调 Reset / Revert / AbortIntegrate / RestoreWork。 - _ = repo - _ = co - _ = req 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 index dbfebbb..aaa72d3 100644 --- a/server/internal/handler/git_test.go +++ b/server/internal/handler/git_test.go @@ -1,8 +1,14 @@ 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" @@ -12,20 +18,329 @@ import ( pkgagent "codedock/pkg/agent" ) -func TestGitSkeletonRoutes(t *testing.T) { - t.Setenv("GIT_REPO", t.TempDir()) - api := handler.New(nil, nil, nil, nil, pkgagent.RunConfigSnapshot{}, config.Load(), nil) +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.Post("/git/stage", api.GitStage) + r.Post("/git/unstage", api.GitUnstage) + 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.Get("/git/undo", api.GitListUndo) + 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 := httptest.NewRecorder() - r.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil)) + 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", `{"system_prompt":"写短标题"}`) + 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.SystemPrompt != "写短标题" { + 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 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 jsonQuote(s string) string { + b, err := json.Marshal(s) + if err != nil { + return `""` + } + return string(b) +} diff --git a/server/pkg/git/branch.go b/server/pkg/git/branch.go index 03b2a37..dbac5ad 100644 --- a/server/pkg/git/branch.go +++ b/server/pkg/git/branch.go @@ -1,42 +1,147 @@ package git +import ( + "errors" + "fmt" + "strings" +) + // ListBranches 列出本地和远程跟踪分支,含跟踪和 ahead/behind。 func ListBranches(repo Repo) ([]Branch, error) { - // TODO: for-each-ref refs/heads 与 refs/remotes,分别填 IsRemote / Upstream / WorktreePath。 - _ = repo - return []Branch{}, nil + 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 空则从当前提交。 -func CreateBranch(repo Repo, name, start string) error { - // TODO: git branch name [start] - _ = repo - _ = name - _ = start - return 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 { - // TODO: 先 Status 与 ListBranches,再 git switch。 + 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 } - _ = state - _ = branches - _ = name - return nil + 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 { - // TODO: 当前分支返回 ErrCurrentBranch;git branch -d。 - _ = repo - _ = name - return nil + 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 index 7e64603..b9ffc72 100644 --- a/server/pkg/git/git_test.go +++ b/server/pkg/git/git_test.go @@ -1,35 +1,479 @@ package git -import "testing" +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) -func TestSkeletonCompiles(t *testing.T) { +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) } - co := Checkout{Path: repo.Path} - if _, err := Status(repo, co); err != nil { + 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 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(t.TempDir(), "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(t.TempDir(), "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 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) } - if _, err := Diff(repo, co, "staged"); err != nil { + state, err = Status(repo, co) + if err != nil { t.Fatal(err) } - if _, err := LogGraph(repo, 10); err != nil { + 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(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 _, err := ListRemotes(repo); err != nil { + if len(remotes) != 1 || remotes[0].Name != "origin" || remotes[0].FetchURL != bare { + t.Fatalf("remotes: %+v", remotes) + } + if err := Push(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 _, err := ListWorktrees(repo); err != nil { + 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 _, err := AddWorktree(repo, t.TempDir(), "", "feature"); err != nil { + if state.Ahead != 1 { + t.Fatalf("ahead: %+v", state) + } + if err := Push(repo, co); err != nil { t.Fatal(err) } - if _, err := ListBranches(repo); err != nil { + 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) } - if _, err := CaptureWork(repo, co, "note"); err != nil { + otherCo := Checkout{Path: other.Path} + commitFile(t, other, otherCo, "a.txt", "3", "third") + if err := Push(other, otherCo); err != nil { t.Fatal(err) } + if err := Pull(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) + } } diff --git a/server/pkg/git/integrate.go b/server/pkg/git/integrate.go index 46228eb..3e47ed3 100644 --- a/server/pkg/git/integrate.go +++ b/server/pkg/git/integrate.go @@ -1,31 +1,183 @@ package git +import ( + "errors" + "os" + "path/filepath" + "strings" +) + // ReadConflict 读某文件的冲突种类和三方内容。 func ReadConflict(repo Repo, checkout Checkout, path string) (ConflictItem, error) { - // TODO: ls-files -u 判断 Kind;git show :1/:2/:3:path。 - _ = repo - _ = checkout - return ConflictItem{Path: path}, nil + 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 + } + 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 { - // TODO: 仍有 Unmerged 则 ErrConflict;按 Integrating 跑 merge/rebase/cherry-pick/revert --continue。 + dir := checkoutDir(repo, checkout) + if err := requireRepo(dir); err != nil { + return err + } state, err := Status(repo, checkout) if err != nil { return err } - _ = state - return nil + 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 { - // TODO: 按 Integrating 跑 --abort。 + dir := checkoutDir(repo, checkout) + if err := requireRepo(dir); err != nil { + return err + } state, err := Status(repo, checkout) if err != nil { return err } - _ = state - return nil + 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 index 8a26870..e9c05f8 100644 --- a/server/pkg/git/remote.go +++ b/server/pkg/git/remote.go @@ -1,8 +1,45 @@ package git +import "strings" + // ListRemotes 列出已配置的 remote。 func ListRemotes(repo Repo) ([]Remote, error) { - // TODO: git remote -v - _ = repo - return []Remote{}, nil + 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 index a6919c1..1dd082a 100644 --- a/server/pkg/git/repo.go +++ b/server/pkg/git/repo.go @@ -1,104 +1,577 @@ package git -// Open 打开本地路径上的仓库句柄,不要求已经是 Git 仓库。 +import ( + "bytes" + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" +) + +// Open 打开本地路径上的仓库句柄,不要求已经是 Git 仓库,不向上找 .git。 func Open(path string) (Repo, error) { - // TODO: 规范化绝对路径,不向上找 .git。 - _ = path - return Repo{}, nil + abs, err := filepath.Abs(path) + if err != nil { + return Repo{}, err + } + return Repo{Path: abs}, nil } -// Status 读这一份检出的整局:分支、跟踪、文件、remote。 +// Status 读这一份检出的整局:分支、跟踪、文件、remote。不是仓库则 IsRepo=false。 func Status(repo Repo, checkout Checkout) (SiteState, error) { - // TODO: git status --porcelain=v2 --branch;填 Empty / Detached / Upstream / Ahead / Behind / Integrating。 - _, _ = ListRemotes(repo) - return SiteState{Path: checkoutDir(repo, checkout), Files: []FileStatus{}, Remotes: []Remote{}}, nil + 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", "-unormal") + 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) { - // TODO: git diff / git diff --cached,二进制不要当文本。 - _ = repo - _ = checkout - _ = scope - return []DiffFile{}, nil + 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 +} + +func untrackedDiff(dir, rel string) DiffFile { + file := DiffFile{Path: rel, Kind: "added"} + body, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel))) + 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) { - // TODO: git log --all --decorate,只取近期 limit。 - _ = repo - _ = limit - return Graph{Nodes: []GraphNode{}, Edges: []GraphEdge{}}, nil + 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 +} + +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 { - // TODO: git add -- paths - _ = repo - _ = checkout - _ = paths - return nil + 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 { - // TODO: git reset HEAD -- paths;空仓用 git rm --cached。 - _ = repo - _ = checkout - _ = paths - return nil + 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) { - // TODO: git commit -m;读 HEAD 的 id / parents / 作者 / 时间。 - _ = repo - _ = checkout - _ = message - return Commit{}, nil + 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 { - // TODO: 正在整合则拒绝;git reset --soft|--mixed|--hard。 + dir := checkoutDir(repo, checkout) + if err := requireRepo(dir); err != nil { + return err + } state, err := Status(repo, checkout) if err != nil { return err } - _ = state - _ = target - _ = mode - return nil + 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) { - // TODO: git revert;冲突则返回 ErrConflict。 - _ = repo - _ = checkout - _ = commit - return Commit{}, nil + 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(repo Repo, checkout Checkout) error { - // TODO: 无 remote / 无 upstream / gone 时返回可读错误;git push。 + dir := checkoutDir(repo, checkout) + if err := requireRepo(dir); err != nil { + return err + } state, err := Status(repo, checkout) if err != nil { return err } - _ = state - return nil + 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 = runGit(dir, "push") + return err } // Pull 拉取并尝试整合;有冲突返回 ErrConflict。 func Pull(repo Repo, checkout Checkout) error { - // TODO: git pull --no-rebase;失败再看 Integrating / ReadConflict。 + dir := checkoutDir(repo, checkout) + if err := requireRepo(dir); err != nil { + return err + } state, err := Status(repo, checkout) if err != nil { return err } - _ = state - _, _ = ReadConflict(repo, checkout, "") + 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 := runGit(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 +} diff --git a/server/pkg/git/run.go b/server/pkg/git/run.go index 880eef1..6aac9b4 100644 --- a/server/pkg/git/run.go +++ b/server/pkg/git/run.go @@ -7,6 +7,7 @@ import ( "os" "os/exec" "path/filepath" + "strconv" "strings" "time" ) @@ -25,13 +26,35 @@ var ( ) func checkoutDir(repo Repo, checkout Checkout) string { - if checkout.Path != "" { + if strings.TrimSpace(checkout.Path) != "" { return checkout.Path } return repo.Path } -func runGit(dir string, args ...string) (string, error) { +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 { cmd := exec.Command("git", args...) cmd.Dir = dir cmd.Env = append(os.Environ(), "GIT_EDITOR=true", "GIT_TERMINAL_PROMPT=0") @@ -42,21 +65,55 @@ func runGit(dir string, args ...string) (string, error) { go func() { done <- cmd.Run() }() select { case err := <-done: + out := gitOutput{stdout: stdout.String(), stderr: stderr.String(), err: err} if err != nil { - msg := strings.TrimSpace(stderr.String()) - if msg == "" { - msg = strings.TrimSpace(stdout.String()) + var ee *exec.ExitError + if errors.As(err, &ee) { + out.code = ee.ExitCode() + } else { + out.code = -1 } - if msg == "" { - msg = err.Error() - } - return "", errors.New(msg) } - return stdout.String(), nil + return out case <-time.After(60 * time.Second): _ = cmd.Process.Kill() - return "", fmt.Errorf("git %s timed out", strings.Join(args, " ")) + return gitOutput{err: fmt.Errorf("git %s timed out", strings.Join(args, " ")), code: -1} + } +} + +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) { + out := runGitCmd(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 { @@ -76,11 +133,206 @@ func gitDir(dir string) (string, error) { 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 { - aa, errA := filepath.Abs(a) - bb, errB := filepath.Abs(b) - if errA != nil || errB != nil { - return filepath.Clean(a) == filepath.Clean(b) + 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 hasUntracked(state SiteState) bool { + for _, file := range state.Files { + if file.WorktreeStatus == "?" { + 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 } - return filepath.Clean(aa) == filepath.Clean(bb) + _, 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 index b2579fb..46f40be 100644 --- a/server/pkg/git/stash.go +++ b/server/pkg/git/stash.go @@ -1,23 +1,48 @@ package git +import ( + "errors" + "strings" +) + // CaptureWork 用 stash create 复制暂存区与已跟踪工作区,不改现有内容;返回悬空提交 hash。 func CaptureWork(repo Repo, checkout Checkout, note string) (string, error) { - // TODO: git stash create;不含未跟踪文件。 - state, err := Status(repo, checkout) + 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 } - _ = state - _ = note - return "", nil + return strings.TrimSpace(out), nil } // RestoreWork 回到指定提交并铺回该副本。 func RestoreWork(repo Repo, checkout Checkout, stashOID, head string) error { - // TODO: 先回到 head,再把 stashOID 铺回工作区;未跟踪文件不会出现。 - _ = repo - _ = checkout - _ = stashOID - _ = head + dir := checkoutDir(repo, checkout) + if err := requireRepo(dir); err != nil { + return err + } + if strings.TrimSpace(head) == "" { + return errors.New("head is required") + } + 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/worktree.go b/server/pkg/git/worktree.go index 8c175af..9489e41 100644 --- a/server/pkg/git/worktree.go +++ b/server/pkg/git/worktree.go @@ -1,31 +1,87 @@ package git +import ( + "errors" + "path/filepath" + "strings" +) + // ListWorktrees 列出该仓库的全部检出。 func ListWorktrees(repo Repo) ([]Worktree, error) { - // TODO: git worktree list --porcelain;填 Detached / Locked。 - _ = repo - return []Worktree{}, nil + 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) { - // TODO: git worktree add [-b newBranch] path [branch] - _ = path - _ = branch - _ = newBranch + if err := requireRepo(repo.Path); err != nil { + return Worktree{}, err + } + if strings.TrimSpace(path) == "" { + return Worktree{}, errors.New("worktree path is required") + } + abs, err := filepath.Abs(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 } - _ = trees - return Worktree{Path: path, Branch: firstNonEmpty(newBranch, branch)}, nil -} - -func firstNonEmpty(values ...string) string { - for _, value := range values { - if value != "" { - return value + for _, tree := range trees { + if samePath(tree.Path, abs) { + return tree, nil } } - return "" + return Worktree{Path: abs, Branch: firstNonEmpty(newBranch, branch)}, nil } From 6452697fe0481c1ab747d0ff3ba590ac93f45e77 Mon Sep 17 00:00:00 2001 From: 2penheimer <2603237065@qq.com> Date: Sun, 30 Aug 2026 21:41:02 +0800 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20=E5=8A=A0=E4=B8=8A=E4=BB=93?= =?UTF-8?q?=E5=BA=93=E9=A1=B5=EF=BC=8C=E6=94=AF=E6=8C=81=E6=9A=82=E5=AD=98?= =?UTF-8?q?=E3=80=81=E6=92=A4=E5=9B=9E=E3=80=81=E6=8F=90=E4=BA=A4=E6=8E=A8?= =?UTF-8?q?=E9=80=81=E5=92=8C=E5=8E=86=E5=8F=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把 Git 用户操作接到独立 /git 页,撤回默认对着沙箱,避免误改本仓工作区。 Co-authored-by: Cursor --- .env.example | 5 + .gitignore | 1 + AGENTS.md | 8 +- README.md | 15 +- apps/web/app/(chat)/layout.tsx | 4 +- apps/web/app/git-host.tsx | 19 +++ apps/web/app/git/page.tsx | 9 ++ apps/web/app/layout.tsx | 8 +- apps/web/app/test-nav.tsx | 38 +++++ apps/web/next.config.ts | 3 + docs/architecture.md | 6 +- package.json | 2 + packages/core/git/client.test.ts | 125 ++++++++++++++++ packages/core/git/client.ts | 140 ++++++++++++++++++ packages/core/git/index.ts | 16 ++ packages/core/git/types.ts | 95 ++++++++++++ packages/core/index.ts | 16 ++ packages/core/package.json | 5 +- packages/views/chat/chat-page.tsx | 11 +- packages/views/git/branch-switcher.tsx | 137 ++++++++++++++++++ packages/views/git/commit-history.tsx | 88 +++++++++++ packages/views/git/diff-panel.tsx | 75 ++++++++++ packages/views/git/discard-confirm.tsx | 80 ++++++++++ packages/views/git/file-tree.tsx | 177 +++++++++++++++++++++++ packages/views/git/git-page.tsx | 94 ++++++++++++ packages/views/git/hooks/use-git-site.ts | 148 +++++++++++++++++++ packages/views/git/index.ts | 3 + packages/views/git/lib/preview.ts | 14 ++ packages/views/git/lib/status.ts | 80 ++++++++++ packages/views/git/lib/tree.test.ts | 97 +++++++++++++ packages/views/git/lib/tree.ts | 68 +++++++++ packages/views/git/provider.tsx | 22 +++ packages/views/git/publish-actions.tsx | 113 +++++++++++++++ packages/views/git/workspace-panel.tsx | 163 +++++++++++++++++++++ packages/views/index.ts | 1 + packages/views/package.json | 4 +- pnpm-lock.yaml | 99 +++++++++++++ scripts/dev-api.sh | 16 ++ scripts/dev.sh | 18 +++ scripts/git-sandbox.sh | 24 +++ server/cmd/server/router.go | 2 + server/internal/handler/git.go | 44 ++++++ server/internal/handler/git_test.go | 61 ++++++++ server/pkg/git/git_test.go | 93 ++++++++++++ server/pkg/git/repo.go | 123 +++++++++++++++- 45 files changed, 2346 insertions(+), 24 deletions(-) create mode 100644 apps/web/app/git-host.tsx create mode 100644 apps/web/app/git/page.tsx create mode 100644 apps/web/app/test-nav.tsx create mode 100644 packages/core/git/client.test.ts create mode 100644 packages/core/git/client.ts create mode 100644 packages/core/git/index.ts create mode 100644 packages/core/git/types.ts create mode 100644 packages/views/git/branch-switcher.tsx create mode 100644 packages/views/git/commit-history.tsx create mode 100644 packages/views/git/diff-panel.tsx create mode 100644 packages/views/git/discard-confirm.tsx create mode 100644 packages/views/git/file-tree.tsx create mode 100644 packages/views/git/git-page.tsx create mode 100644 packages/views/git/hooks/use-git-site.ts create mode 100644 packages/views/git/index.ts create mode 100644 packages/views/git/lib/preview.ts create mode 100644 packages/views/git/lib/status.ts create mode 100644 packages/views/git/lib/tree.test.ts create mode 100644 packages/views/git/lib/tree.ts create mode 100644 packages/views/git/provider.tsx create mode 100644 packages/views/git/publish-actions.tsx create mode 100644 packages/views/git/workspace-panel.tsx create mode 100755 scripts/dev-api.sh create mode 100755 scripts/dev.sh create mode 100755 scripts/git-sandbox.sh 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 b7cc9e3..fa99a50 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ 修改 CodeDock 代码前,先阅读 [`docs/architecture.md`](docs/architecture.md)。该文档是当前目录归属和模块边界的依据。 -Agent Loop 已闭环:用户发文本、装上下文、调模型、产出文字或 Tool、事件落库并由 SSE 消费。默认注册 `ping` 与记忆工具 `memory_read` / `memory_write` / `memory_search`,不实现文件 / Shell / Git **工具**。Git 用户操作走 HTTP + `pkg/git`,不经过 Agent Tool;本阶段不做前端 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`。 ## 目录放置规则 @@ -16,10 +16,10 @@ Agent Loop 已闭环:用户发文本、装上下文、调模型、产出文字 - 进程内事件总线放在 `server/internal/events`。 - 数据库入口和 sqlc 生成代码放在 `server/pkg/db`。 - 数据库结构演进放在 `server/migrations`。 -- 无头业务放在 `packages/core`(`@codedock/core`):按业务域拆(现有 `chat/`),文件直接在域目录下,不要 `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 ee70f43..4024edb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -221,7 +221,7 @@ Handler 直接依赖 `*sqlite.Queries`,不经过 Store 接口。Git 不查库 ### `packages/core` -跨端无头业务,无 UI。按业务域拆目录,文件直接放在 `packages/core//`,不要 `src/`。现有 `chat/`:Session / Message / Run / 审批的 HTTP、SSE、Timeline reducer。有鉴权再加 `auth/`,有记忆再加 `memory/`,Git 前端以后再建 `git/`,不预建空目录。`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` @@ -235,11 +235,11 @@ Handler 直接依赖 `*sqlite.Queries`,不经过 Store 接口。Git 不查库 ### `packages/views` -组合 core + ui。按业务域拆,与 core 对齐,不要 `src/`。现有 `chat/`:`ChatPage`、侧栏、瀑布、审批、prompt。包根 `provider.tsx` 注入 `AgentClient` + `userId`。`ChatPage` 接 `sessionId` 与 `onOpenSession`。不 import `next/*`。新业务新建目录,不预建 Issue / Task / Review / Workspace。本阶段不做 Git 页。 +组合 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)。Git 目前只有 HTTP,没有 Web 页。 +路由、`NEXT_PUBLIC_API_BASE` / `NEXT_PUBLIC_USER_ID`、创建 `AgentClient`、包 `AgentProvider`、`router.push`。本机 Web 直连 `:8080`(CORS)。Git 页在 `(chat)` 组外的 `/git`,只装配 `GitClient`。开发态顶栏(对话 / 仓库)只放 web,views 不知道路径。 ## 组装关系 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..6b8197e --- /dev/null +++ b/packages/core/git/client.test.ts @@ -0,0 +1,125 @@ +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", + }, + ], + }); + } + 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"); + + 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"); +}); + +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..af48b59 --- /dev/null +++ b/packages/core/git/client.ts @@ -0,0 +1,140 @@ +import type { BranchView, Commit, CommitRequest, DiffFile, DiffScope, 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 ?? []; + } + + 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..7e33210 --- /dev/null +++ b/packages/core/git/index.ts @@ -0,0 +1,16 @@ +export { GitClient, GitClientError, type GitClientOptions } from "./client.ts"; +export type { + Branch, + BranchView, + Commit, + CommitRequest, + DiffFile, + DiffScope, + FileStatus, + Graph, + GraphEdge, + GraphNode, + 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..baa9b6a --- /dev/null +++ b/packages/core/git/types.ts @@ -0,0 +1,95 @@ +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; +}; diff --git a/packages/core/index.ts b/packages/core/index.ts index f528574..6df10c7 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -19,6 +19,22 @@ 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, + 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..42ad1e4 --- /dev/null +++ b/packages/views/git/file-tree.tsx @@ -0,0 +1,177 @@ +"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 (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..5d38c56 --- /dev/null +++ b/packages/views/git/git-page.tsx @@ -0,0 +1,94 @@ +"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..d93349a --- /dev/null +++ b/packages/views/git/hooks/use-git-site.ts @@ -0,0 +1,148 @@ +"use client"; + +import type { BranchView, Commit, DiffFile, 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 [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const [loading, setLoading] = useState(true); + + const refresh = useCallback(async () => { + try { + const [nextState, nextBranches, staged, worktree, nextCommits] = await Promise.all([ + client.status(), + client.listBranches(), + client.diff("staged"), + client.diff("worktree"), + client.log(), + ]); + setState(nextState); + setBranches({ + ...nextBranches, + locals: nextBranches.locals ?? [], + remotes: nextBranches.remotes ?? [], + graph: nextBranches.graph ?? { nodes: [], edges: [] }, + }); + setDiffs({ staged, worktree }); + setCommits(nextCommits); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : "无法读取仓库"); + } finally { + setLoading(false); + } + }, [client]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + 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 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, + error, + busy, + loading, + refresh, + reload, + stage, + unstage, + discard, + commit, + 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..8cd76dd --- /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(isStaged), + 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..b0f97c3 --- /dev/null +++ b/packages/views/git/lib/tree.test.ts @@ -0,0 +1,97 @@ +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"), + ]); + assert.deepEqual( + split.staged.map((item) => item.path), + ["staged.ts", "both.ts"], + ); + assert.deepEqual( + split.worktree.map((item) => item.path), + ["dirty.ts", "both.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/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 ? ( +
    + { + setOpen(false); + }} + submit + > + Commit + + { + setOpen(false); + onPush(); + }} + > + Push + +
    + ) : 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..0b5e3b7 --- /dev/null +++ b/packages/views/git/workspace-panel.tsx @@ -0,0 +1,163 @@ +"use client"; + +import type { 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 { PublishActions } from "./publish-actions.tsx"; + +export function WorkspacePanel({ + state, + busy, + preview, + onPreview, + onReload, + onStage, + onUnstage, + onDiscard, + onCommit, + onPush, +}: { + state: SiteState; + busy: boolean; + 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; + 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" }); + } + }); + }; + + const unstage = (paths: string[]) => { + void onUnstage(paths).then(() => { + if (preview && preview.scope === "staged" && paths.includes(preview.path)) { + onPreview({ path: preview.path, scope: "worktree" }); + } + }); + }; + + const confirmDiscard = () => { + if (!discardRequest) { + return; + } + void onDiscard(discardRequest.paths) + .then(() => setDiscardRequest(null)) + .catch(() => undefined); + }; + + return ( +
    +
    { + event.preventDefault(); + const text = message.trim(); + if (!text) { + return; + } + void onCommit(text).then(() => { + setMessage(""); + }); + }} + > + setMessage(event.target.value)} + /> + 0} + canPush={ + !busy && (state.remotes ?? []).length > 0 && Boolean(state.upstream) && !state.upstream_gone + } + onPush={() => void onPush()} + /> + +
    + 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..b60bf9a --- /dev/null +++ b/scripts/dev-api.sh @@ -0,0 +1,16 @@ +#!/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 + GIT_REPO="$sandbox" + else + GIT_REPO="$root" + fi +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..893fc55 --- /dev/null +++ b/scripts/dev.sh @@ -0,0 +1,18 @@ +#!/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 + GIT_REPO="$sandbox" + else + GIT_REPO="$root" + fi +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/router.go b/server/cmd/server/router.go index c9aaa48..3ee4f1b 100644 --- a/server/cmd/server/router.go +++ b/server/cmd/server/router.go @@ -56,8 +56,10 @@ func newRouter(log *slog.Logger, api *handler.API) http.Handler { 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) diff --git a/server/internal/handler/git.go b/server/internal/handler/git.go index ce0ead9..e7cf05f 100644 --- a/server/internal/handler/git.go +++ b/server/internal/handler/git.go @@ -7,6 +7,7 @@ import ( "net/http" "os" "path/filepath" + "strconv" "strings" cderr "codedock/internal/errors" @@ -494,6 +495,30 @@ func (a *API) GitGraph(w http.ResponseWriter, r *http.Request) { 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 @@ -532,6 +557,25 @@ func (a *API) GitUnstage(w http.ResponseWriter, r *http.Request) { 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 diff --git a/server/internal/handler/git_test.go b/server/internal/handler/git_test.go index aaa72d3..833dca0 100644 --- a/server/internal/handler/git_test.go +++ b/server/internal/handler/git_test.go @@ -50,8 +50,10 @@ func gitRouter(api *handler.API) http.Handler { 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) @@ -295,6 +297,65 @@ func TestGitConflictAbortAndPromptSnapshot(t *testing.T) { } } +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) diff --git a/server/pkg/git/git_test.go b/server/pkg/git/git_test.go index b9ffc72..94883ef 100644 --- a/server/pkg/git/git_test.go +++ b/server/pkg/git/git_test.go @@ -74,6 +74,26 @@ func TestOpenAndStatusNotRepo(t *testing.T) { } } +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) @@ -277,6 +297,79 @@ func TestCreateBranchUsesCheckoutHEAD(t *testing.T) { } } +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") diff --git a/server/pkg/git/repo.go b/server/pkg/git/repo.go index 1dd082a..2074ddc 100644 --- a/server/pkg/git/repo.go +++ b/server/pkg/git/repo.go @@ -30,7 +30,7 @@ func Status(repo Repo, checkout Checkout) (SiteState, error) { return state, nil } state.IsRepo = true - out, err := runGit(dir, "status", "--porcelain=v2", "--branch", "-unormal") + out, err := runGit(dir, "status", "--porcelain=v2", "--branch", "-uall") if err != nil { return SiteState{}, err } @@ -335,6 +335,58 @@ func LogGraph(repo Repo, limit int) (Graph, error) { 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, "(") @@ -575,3 +627,72 @@ func RestorePath(repo Repo, checkout Checkout, path string) error { _, 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 { + abs := filepath.Join(dir, filepath.FromSlash(rel)) + back, err := filepath.Rel(dir, abs) + if err != nil || back == "." || strings.HasPrefix(back, "..") { + return fmt.Errorf("invalid path %q", rel) + } + if err := os.RemoveAll(abs); err != nil { + return err + } + for parent := filepath.Dir(abs); parent != dir; parent = filepath.Dir(parent) { + if err := os.Remove(parent); err != nil { + break + } + } + return nil +} From e46e371f23c7643a648f4ce1a0e20287794f3ae0 Mon Sep 17 00:00:00 2001 From: 2penheimer <2603237065@qq.com> Date: Mon, 31 Aug 2026 00:36:05 +0800 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20=E6=8C=89=E6=9A=82=E5=AD=98=20diff?= =?UTF-8?q?=20=E7=94=9F=E6=88=90=20Conventional=20=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 截断预算、关掉 thinking,并按 Copilot 风格写出标题和条目,保证数秒内出稿。 Co-authored-by: Cursor --- packages/core/git/client.test.ts | 24 + packages/core/git/client.ts | 30 +- packages/core/git/index.ts | 4 + packages/core/git/types.ts | 23 + packages/core/index.ts | 4 + packages/views/git/git-page.tsx | 4 + packages/views/git/hooks/use-git-site.ts | 55 ++- packages/views/git/prompt-picker.tsx | 105 +++++ packages/views/git/workspace-panel.tsx | 64 ++- server/cmd/server/cors.go | 2 +- server/internal/handler/commit_message.go | 410 ++++++++++++++++++ .../internal/handler/commit_message_test.go | 152 +++++++ server/internal/handler/git.go | 156 +------ server/internal/handler/git_test.go | 90 +++- server/pkg/agent/openai.go | 36 +- server/pkg/agent/openai_test.go | 19 + 16 files changed, 994 insertions(+), 184 deletions(-) create mode 100644 packages/views/git/prompt-picker.tsx create mode 100644 server/internal/handler/commit_message.go create mode 100644 server/internal/handler/commit_message_test.go diff --git a/packages/core/git/client.test.ts b/packages/core/git/client.test.ts index 6b8197e..815b2ed 100644 --- a/packages/core/git/client.test.ts +++ b/packages/core/git/client.test.ts @@ -61,6 +61,17 @@ test("GitClient status and mutations hit /git routes", async () => { ], }); } + 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 }); }, }); @@ -81,6 +92,13 @@ test("GitClient status and mutations hit /git routes", async () => { 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"); @@ -98,6 +116,12 @@ test("GitClient status and mutations hit /git routes", async () => { 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 () => { diff --git a/packages/core/git/client.ts b/packages/core/git/client.ts index af48b59..a02fa3e 100644 --- a/packages/core/git/client.ts +++ b/packages/core/git/client.ts @@ -1,4 +1,14 @@ -import type { BranchView, Commit, CommitRequest, DiffFile, DiffScope, SiteState } from "./types.ts"; +import type { + BranchView, + Commit, + CommitRequest, + DiffFile, + DiffScope, + MessageDraft, + PromptConfig, + PromptConfigUpdate, + SiteState, +} from "./types.ts"; export class GitClientError extends Error { readonly status: number; @@ -98,6 +108,24 @@ export class GitClient { 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) { diff --git a/packages/core/git/index.ts b/packages/core/git/index.ts index 7e33210..626fead 100644 --- a/packages/core/git/index.ts +++ b/packages/core/git/index.ts @@ -10,6 +10,10 @@ export type { Graph, GraphEdge, GraphNode, + MessageDraft, + PromptConfig, + PromptConfigUpdate, + PromptPreset, Ref, Remote, SiteState, diff --git a/packages/core/git/types.ts b/packages/core/git/types.ts index baa9b6a..f125c35 100644 --- a/packages/core/git/types.ts +++ b/packages/core/git/types.ts @@ -93,3 +93,26 @@ export type DiffFile = { 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 6df10c7..5c4be59 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -31,6 +31,10 @@ export type { Graph, GraphEdge, GraphNode, + MessageDraft, + PromptConfig, + PromptConfigUpdate, + PromptPreset, Ref, Remote, SiteState, diff --git a/packages/views/git/git-page.tsx b/packages/views/git/git-page.tsx index 5d38c56..c499695 100644 --- a/packages/views/git/git-page.tsx +++ b/packages/views/git/git-page.tsx @@ -76,6 +76,8 @@ export function GitPage({ onBack, headerActions }: GitPageProps) { diff --git a/packages/views/git/hooks/use-git-site.ts b/packages/views/git/hooks/use-git-site.ts index d93349a..a40d8a2 100644 --- a/packages/views/git/hooks/use-git-site.ts +++ b/packages/views/git/hooks/use-git-site.ts @@ -1,6 +1,6 @@ "use client"; -import type { BranchView, Commit, DiffFile, SiteState } from "@codedock/core/git"; +import type { BranchView, Commit, DiffFile, MessageDraft, PromptConfig, SiteState } from "@codedock/core/git"; import { useCallback, useEffect, useState } from "react"; import { localNameFromRemote } from "../lib/status.ts"; @@ -41,8 +41,10 @@ export function useGitSite() { 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 () => { @@ -75,6 +77,25 @@ export function useGitSite() { 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); @@ -113,6 +134,34 @@ export function useGitSite() { } }, [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 () => { @@ -132,8 +181,10 @@ export function useGitSite() { branches, diffs, commits, + prompt, error, busy, + generating, loading, refresh, reload, @@ -141,6 +192,8 @@ export function useGitSite() { unstage, discard, commit, + generate, + savePrompt, push, switchBranch, checkoutRemote, diff --git a/packages/views/git/prompt-picker.tsx b/packages/views/git/prompt-picker.tsx new file mode 100644 index 0000000..19ec806 --- /dev/null +++ b/packages/views/git/prompt-picker.tsx @@ -0,0 +1,105 @@ +"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 ?? "提示词"; + + useEffect(() => { + setCustom(prompt?.custom ?? ""); + }, [prompt?.custom]); + + 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]); + + return ( +
    + + {open && prompt ? ( +
    +
    生成说明用的提示词
    +
      + {prompt.presets.map((preset) => { + const active = preset.id === selected; + return ( +
    • + +
    • + ); + })} +
    + {selected === "custom" ? ( +
    +