From 2a9661a6ef14b3aa76426e53a055a6d5c11db9b9 Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Fri, 12 Jun 2026 09:21:37 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=9F=E8=83=BD=E8=A1=A5=E9=BD=90=E3=80=81?= =?UTF-8?q?=E6=9F=A5=E7=9C=8Bgit=E6=8F=90=E4=BA=A4=E5=8E=86=E5=8F=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/infra/git/log.go | 61 +++++++++ internal/infra/git/runner.go | 1 + internal/mcp/tools_workspace_test.go | 8 ++ internal/workspace/domain.go | 14 +- internal/workspace/dto.go | 12 ++ internal/workspace/gitops_service.go | 24 ++++ internal/workspace/handler.go | 59 ++++++++ internal/workspace/workspace_service_test.go | 7 +- web/src/api/types.ts | 8 ++ web/src/api/workspaces.ts | 5 + .../views/workspaces/WorkspaceHomeView.vue | 12 +- .../components/CommitHistoryTab.vue | 127 ++++++++++++++++++ 12 files changed, 329 insertions(+), 9 deletions(-) create mode 100644 internal/infra/git/log.go create mode 100644 web/src/views/workspaces/components/CommitHistoryTab.vue diff --git a/internal/infra/git/log.go b/internal/infra/git/log.go new file mode 100644 index 0000000..d1bd5ca --- /dev/null +++ b/internal/infra/git/log.go @@ -0,0 +1,61 @@ +package git + +import ( + "context" + "strconv" + "strings" + "time" +) + +// Commit 表示一条 git log 记录。 +type Commit struct { + Hash string + Author string + Email string + Date time.Time + Subject string +} + +// Log 返回 dir 的最近 n 条提交历史。n <= 0 时默认 50 条。 +func (r *DefaultRunner) Log(ctx context.Context, dir string, n int) ([]Commit, error) { + ctx, cancel := withCtx(ctx, r.cfg.OpsTimeout) + defer cancel() + if n <= 0 { + n = 50 + } + out, _, err := r.exec(ctx, dir, nil, "log", "--format=%H%x00%an%x00%ae%x00%aI%x00%s%x00", "-n", strconv.Itoa(n)) + if err != nil { + return nil, err + } + return parseLog(out), nil +} + +func parseLog(buf []byte) []Commit { + if len(buf) == 0 { + return nil + } + parts := strings.Split(string(buf), "\x00") + var commits []Commit + // parts 以空字符串结尾(因为 format 最后有 %x00),所以每 5 个字段为一组。 + for i := 0; i+4 < len(parts); i += 5 { + hash := parts[i] + if hash == "" { + continue + } + author := parts[i+1] + email := parts[i+2] + dateStr := parts[i+3] + subject := parts[i+4] + + date, _ := time.Parse(time.RFC3339, dateStr) + + commits = append(commits, Commit{ + Hash: hash, + Author: author, + Email: email, + Date: date, + Subject: subject, + }) + } + return commits +} diff --git a/internal/infra/git/runner.go b/internal/infra/git/runner.go index a4f44b2..f351789 100644 --- a/internal/infra/git/runner.go +++ b/internal/infra/git/runner.go @@ -33,6 +33,7 @@ type Runner interface { WorktreeList(ctx context.Context, dir string) ([]Worktree, error) ListRemoteBranches(ctx context.Context, dir string, cred *Credential) ([]string, error) Checkout(ctx context.Context, dir, branch string) error + Log(ctx context.Context, dir string, n int) ([]Commit, error) } // FileStatus 表示 `git status --porcelain=v1` 的一行。 diff --git a/internal/mcp/tools_workspace_test.go b/internal/mcp/tools_workspace_test.go index 22d1c3d..5eabf27 100644 --- a/internal/mcp/tools_workspace_test.go +++ b/internal/mcp/tools_workspace_test.go @@ -105,6 +105,14 @@ func (f *fakeGitOpsSvc) PushOnWorktree(_ context.Context, _ workspace.Caller, wt f.lastTarget = "worktree:" + wtID.String() return nil } +func (f *fakeGitOpsSvc) LogOnMain(_ context.Context, _ workspace.Caller, wsID uuid.UUID, _ int) ([]git.Commit, error) { + f.lastTarget = "main:" + wsID.String() + return nil, nil +} +func (f *fakeGitOpsSvc) LogOnWorktree(_ context.Context, _ workspace.Caller, wtID uuid.UUID, _ int) ([]git.Commit, error) { + f.lastTarget = "worktree:" + wtID.String() + return nil, nil +} // wsTestDeps 在 testDeps 基础上挂一个已存在的 workspace + worktree 服务。 func wsTestDeps() (ServerDeps, *fakeWorkspaceSvc, *fakeWorktreeSvc, *fakeGitOpsSvc) { diff --git a/internal/workspace/domain.go b/internal/workspace/domain.go index 759727f..a50bf75 100644 --- a/internal/workspace/domain.go +++ b/internal/workspace/domain.go @@ -77,12 +77,12 @@ type Workspace struct { // Worktree 表示一个支线 worktree(不含 main_path)。 type Worktree struct { - ID uuid.UUID - WorkspaceID uuid.UUID - Branch string - Path string - Status WorktreeStatus - ActiveHolder string // "user:" / "session:" / 空 + ID uuid.UUID + WorkspaceID uuid.UUID + Branch string + Path string + Status WorktreeStatus + ActiveHolder string // "user:" / "session:" / 空 AcquiredAt *time.Time LastUsedAt time.Time PruneWarningAt *time.Time @@ -167,10 +167,12 @@ type GitOpsService interface { StatusOnMain(ctx context.Context, c Caller, wsID uuid.UUID) ([]git.FileStatus, error) CommitOnMain(ctx context.Context, c Caller, wsID uuid.UUID, in CommitInput) (string, error) PushOnMain(ctx context.Context, c Caller, wsID uuid.UUID) error + LogOnMain(ctx context.Context, c Caller, wsID uuid.UUID, n int) ([]git.Commit, error) StatusOnWorktree(ctx context.Context, c Caller, wtID uuid.UUID) ([]git.FileStatus, error) CommitOnWorktree(ctx context.Context, c Caller, wtID uuid.UUID, in CommitInput) (string, error) PushOnWorktree(ctx context.Context, c Caller, wtID uuid.UUID) error + LogOnWorktree(ctx context.Context, c Caller, wtID uuid.UUID, n int) ([]git.Commit, error) } // ===== Input DTO ===== diff --git a/internal/workspace/dto.go b/internal/workspace/dto.go index 785b352..be02e5d 100644 --- a/internal/workspace/dto.go +++ b/internal/workspace/dto.go @@ -119,3 +119,15 @@ type commitResp struct { type branchListResp struct { Branches []string `json:"branches"` } + +type commitLogResp struct { + Hash string `json:"hash"` + Author string `json:"author"` + Email string `json:"email"` + Date time.Time `json:"date"` + Subject string `json:"subject"` +} + +func toCommitLogResp(c git.Commit) commitLogResp { + return commitLogResp{Hash: c.Hash, Author: c.Author, Email: c.Email, Date: c.Date, Subject: c.Subject} +} diff --git a/internal/workspace/gitops_service.go b/internal/workspace/gitops_service.go index 111f68f..723ac52 100644 --- a/internal/workspace/gitops_service.go +++ b/internal/workspace/gitops_service.go @@ -79,6 +79,18 @@ func (s *gitOpsService) PushOnMain(ctx context.Context, c Caller, wsID uuid.UUID return nil } +func (s *gitOpsService) LogOnMain(ctx context.Context, c Caller, wsID uuid.UUID, n int) ([]git.Commit, error) { + ws, err := s.guardWorkspaceRead(ctx, c, wsID) + if err != nil { + return nil, err + } + commits, err := s.gitr.Log(ctx, ws.MainPath, n) + if err != nil { + return nil, errs.Wrap(err, errs.CodeGitCmdFailed, "git log") + } + return commits, nil +} + // ===== Worktree ===== func (s *gitOpsService) StatusOnWorktree(ctx context.Context, c Caller, wtID uuid.UUID) ([]git.FileStatus, error) { @@ -133,6 +145,18 @@ func (s *gitOpsService) PushOnWorktree(ctx context.Context, c Caller, wtID uuid. return nil } +func (s *gitOpsService) LogOnWorktree(ctx context.Context, c Caller, wtID uuid.UUID, n int) ([]git.Commit, error) { + wt, _, err := s.guardWorktreeRead(ctx, c, wtID) + if err != nil { + return nil, err + } + commits, err := s.gitr.Log(ctx, wt.Path, n) + if err != nil { + return nil, errs.Wrap(err, errs.CodeGitCmdFailed, "git log") + } + return commits, nil +} + // ===== guards ===== func (s *gitOpsService) guardWorkspaceRead(ctx context.Context, c Caller, wsID uuid.UUID) (*Workspace, error) { diff --git a/internal/workspace/handler.go b/internal/workspace/handler.go index 4e85c0d..aea93ea 100644 --- a/internal/workspace/handler.go +++ b/internal/workspace/handler.go @@ -10,6 +10,7 @@ import ( "encoding/json" "errors" "net/http" + "strconv" "github.com/go-chi/chi/v5" "github.com/google/uuid" @@ -59,6 +60,7 @@ func (h *Handler) Mount(r chi.Router) { r.Get("/main/status", h.statusOnMain) r.Post("/main/commit", h.commitOnMain) r.Post("/main/push", h.pushOnMain) + r.Get("/main/log", h.logOnMain) r.Get("/worktrees", h.listWorktrees) r.Post("/worktrees", h.createWorktree) @@ -71,6 +73,7 @@ func (h *Handler) Mount(r chi.Router) { r.Get("/status", h.statusOnWorktree) r.Post("/commit", h.commitOnWorktree) r.Post("/push", h.pushOnWorktree) + r.Get("/log", h.logOnWorktree) }) } @@ -536,3 +539,59 @@ func (h *Handler) pushGeneric(w http.ResponseWriter, r *http.Request, onMain boo } w.WriteHeader(http.StatusNoContent) } + +func (h *Handler) logOnMain(w http.ResponseWriter, r *http.Request) { + h.logGeneric(w, r, true) +} + +func (h *Handler) logOnWorktree(w http.ResponseWriter, r *http.Request) { + h.logGeneric(w, r, false) +} + +func (h *Handler) logGeneric(w http.ResponseWriter, r *http.Request, onMain bool) { + c, err := h.caller(r) + if err != nil { + writeErr(w, r, err) + return + } + param := "wsID" + if !onMain { + param = "wtID" + } + id, err := parseUUID(chi.URLParam(r, param)) + if err != nil { + writeErr(w, r, err) + return + } + n := parseLogN(r) + var commits []git.Commit + if onMain { + commits, err = h.gop.LogOnMain(r.Context(), c, id, n) + } else { + commits, err = h.gop.LogOnWorktree(r.Context(), c, id, n) + } + if err != nil { + writeErr(w, r, err) + return + } + resp := make([]commitLogResp, 0, len(commits)) + for _, cc := range commits { + resp = append(resp, toCommitLogResp(cc)) + } + writeJSON(w, http.StatusOK, resp) +} + +func parseLogN(r *http.Request) int { + nStr := r.URL.Query().Get("n") + if nStr == "" { + return 50 + } + n, err := strconv.Atoi(nStr) + if err != nil || n <= 0 { + return 50 + } + if n > 200 { + return 200 + } + return n +} diff --git a/internal/workspace/workspace_service_test.go b/internal/workspace/workspace_service_test.go index fa9554a..8989957 100644 --- a/internal/workspace/workspace_service_test.go +++ b/internal/workspace/workspace_service_test.go @@ -226,11 +226,14 @@ func (f *fakeGit) WorktreeRemove(_ context.Context, _, _ string) error func (f *fakeGit) WorktreeList(_ context.Context, _ string) ([]git.Worktree, error) { return nil, nil } -func (f *fakeGit) FetchRemoteBranch(_ context.Context, _, _ string, _ *git.Credential) error { return nil } -func (f *fakeGit) Checkout(_ context.Context, _, _ string) error { return nil } +func (f *fakeGit) FetchRemoteBranch(_ context.Context, _, _ string, _ *git.Credential) error { + return nil +} +func (f *fakeGit) Checkout(_ context.Context, _, _ string) error { return nil } func (f *fakeGit) ListRemoteBranches(_ context.Context, _ string, _ *git.Credential) ([]string, error) { return nil, nil } +func (f *fakeGit) Log(_ context.Context, _ string, _ int) ([]git.Commit, error) { return nil, nil } // trackGit 包装 git.Runner,记录 FetchRemoteBranch / Checkout 调用以供断言。 // 嵌入 Runner 接口本身可避免为每个方法重复转发。 diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 3a271c1..650ef58 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -94,3 +94,11 @@ export interface CommitBody { export interface CommitResp { sha: string } + +export interface CommitLog { + hash: string + author: string + email: string + date: string + subject: string +} diff --git a/web/src/api/workspaces.ts b/web/src/api/workspaces.ts index f7a6142..e875ac7 100644 --- a/web/src/api/workspaces.ts +++ b/web/src/api/workspaces.ts @@ -10,6 +10,7 @@ import type { CreateWorktreeBody, CommitBody, CommitResp, + CommitLog, } from './types' const enc = (v: string) => encodeURIComponent(v) @@ -63,10 +64,14 @@ export const workspacesApi = { request(`/api/v1/workspaces/${enc(wsID)}/main/commit`, { method: 'POST', body }), pushOnMain: (wsID: string) => request(`/api/v1/workspaces/${enc(wsID)}/main/push`, { method: 'POST' }), + logOnMain: (wsID: string, n = 50) => + request(`/api/v1/workspaces/${enc(wsID)}/main/log?n=${n}`), statusOnWorktree: (wtID: string) => request(`/api/v1/worktrees/${enc(wtID)}/status`), commitOnWorktree: (wtID: string, body: CommitBody) => request(`/api/v1/worktrees/${enc(wtID)}/commit`, { method: 'POST', body }), pushOnWorktree: (wtID: string) => request(`/api/v1/worktrees/${enc(wtID)}/push`, { method: 'POST' }), + logOnWorktree: (wtID: string, n = 50) => + request(`/api/v1/worktrees/${enc(wtID)}/log?n=${n}`), } diff --git a/web/src/views/workspaces/WorkspaceHomeView.vue b/web/src/views/workspaces/WorkspaceHomeView.vue index 6416660..0d8775b 100644 --- a/web/src/views/workspaces/WorkspaceHomeView.vue +++ b/web/src/views/workspaces/WorkspaceHomeView.vue @@ -34,6 +34,15 @@ :ws="store.current" /> + + + ('worktrees') +const tab = ref<'worktrees' | 'main' | 'commits' | 'settings' | 'runs'>('worktrees') const projectSlug = computed(() => route.params.slug as string) const wsSlug = computed(() => route.params.wsSlug as string) diff --git a/web/src/views/workspaces/components/CommitHistoryTab.vue b/web/src/views/workspaces/components/CommitHistoryTab.vue new file mode 100644 index 0000000..ebcbefe --- /dev/null +++ b/web/src/views/workspaces/components/CommitHistoryTab.vue @@ -0,0 +1,127 @@ + + +