package service import ( "context" "errors" "fmt" "os/exec" "sort" "strconv" "strings" "time" "view/model" "view/platform" ) // GitError 使用稳定错误码作为 Error() 返回值,同时保留命令输出细节。 // 这样旧调用方仍能按错误码判断,诊断接口也能把真实原因展示给用户。 type GitError struct { Code string Detail string } func (e *GitError) Error() string { return e.Code } // GitErrorDetail 提取 GitError 的详细原因,用于中文日志或诊断面板。 func GitErrorDetail(err error) string { var ge *GitError if errors.As(err, &ge) { return ge.Detail } if err != nil { return err.Error() } return "" } // GitService 根据项目路径自动选择 Windows Git 或 WSL 内 Git。 // 所有命令都经过 platform.RunHidden,确保分析时不会弹出 CMD/PowerShell/WSL 窗口。 type GitService struct{} func (g GitService) run(ctx context.Context, dir string, args ...string) (string, error) { desc, e := platform.ResolvePath(dir) if e != nil { return "", e } if desc.WSL { // WSL 的裸 "-- git ..." 在部分环境会交给默认 shell 解析, // Git format 参数中的 "%(...)" 会被 bash 当成语法。--exec 可直接执行 git。 base := []string{"-d", desc.Distro, "--exec", "git", "-C", desc.LinuxPath} out, e := platform.RunHidden(ctx, "wsl.exe", append(base, args...)...) if e != nil { return "", mapGitError(desc, e) } return out, nil } if _, e = exec.LookPath("git"); e != nil { return "", &GitError{Code: "GIT_NOT_INSTALLED", Detail: e.Error()} } out, e := platform.RunHidden(ctx, "git", append([]string{"-C", desc.WindowsPath}, args...)...) if e != nil { return "", mapGitError(desc, e) } return out, nil } func mapGitError(desc platform.PathDescriptor, err error) error { detail := strings.TrimSpace(err.Error()) lower := strings.ToLower(detail) code := "GIT_COMMAND_FAILED" switch { case strings.Contains(lower, "not a git repository"): code = "NOT_GIT_REPOSITORY" case strings.Contains(lower, "detected dubious ownership"): code = "GIT_SAFE_DIRECTORY" case strings.Contains(lower, "permission denied") || strings.Contains(lower, "could not open"): code = "GIT_PERMISSION_DENIED" case strings.Contains(lower, "executable file not found") || strings.Contains(lower, "not recognized"): if desc.WSL { code = "WSL_NOT_INSTALLED" } else { code = "GIT_NOT_INSTALLED" } case strings.Contains(lower, "git: not found") || strings.Contains(lower, "git: command not found"): code = "WSL_GIT_NOT_INSTALLED" case strings.Contains(lower, "there is no distribution") || strings.Contains(lower, "specified distribution") || strings.Contains(lower, "wsl_e_distro_not_found"): code = "WSL_DISTRO_NOT_FOUND" case strings.Contains(lower, "cannot access") && desc.WSL: code = "WSL_PATH_UNREADABLE" case strings.Contains(lower, "unknown revision") || strings.Contains(lower, "bad revision"): code = "GIT_REF_NOT_FOUND" } return &GitError{Code: code, Detail: detail} } // Diagnostics 分步探测 Git 状态,供详情页解释“为什么 Git 统计是 0”。 func (g GitService) Diagnostics(ctx context.Context, dir string, ref string) model.GitDiagnostics { desc, e := platform.ResolvePath(dir) d := model.GitDiagnostics{} if e != nil { d.ErrorCode = e.Error() d.Detail = e.Error() return d } d.IsWSL, d.Distro, d.LinuxPath = desc.WSL, desc.Distro, desc.LinuxPath if _, e = g.run(ctx, dir, "rev-parse", "--git-dir"); e != nil { d.ErrorCode = e.Error() d.Detail = GitErrorDetail(e) return d } branch, _ := g.run(ctx, dir, "branch", "--show-current") refs, e := g.run(ctx, dir, "for-each-ref", "--format=%(refname:short)%x1f%(objectname)%x1f%(refname)", "refs/heads", "refs/remotes") if e != nil { d.ErrorCode = e.Error() d.Detail = GitErrorDetail(e) return d } if ref == "" { ref = strings.TrimSpace(branch) } refCount := 0 for _, line := range strings.Split(refs, "\n") { if strings.TrimSpace(line) != "" { refCount++ } } d.Available = true d.WorkspaceBranch = strings.TrimSpace(branch) d.ViewRef = strings.TrimSpace(ref) d.RefCount = refCount return d } // gitLogMaxCommits 限制单次 git log 解析的提交数量,避免超大仓库拖垮分析。 const gitLogMaxCommits = 20000 // GitAnalyzeOptions 控制 Git 分析的范围与增量起点。 type GitAnalyzeOptions struct { Ref string // 统计视图引用;为空表示当前分支 AllBranches bool // true 时统计所有分支(gitScope=all) SinceHash string // 已入库的最新提交哈希;非空且仍在历史内时执行增量分析 } // Analyze 分析工作区当前分支。 func (g GitService) Analyze(ctx context.Context, dir string) (model.GitStats, error) { stats, _, e := g.AnalyzeWithOptions(ctx, dir, GitAnalyzeOptions{}) return stats, e } // AnalyzeRef 只切换统计视图,不修改用户工作区。 func (g GitService) AnalyzeRef(ctx context.Context, dir, ref string) (model.GitStats, error) { stats, _, e := g.AnalyzeWithOptions(ctx, dir, GitAnalyzeOptions{Ref: ref}) return stats, e } // AnalyzeWithOptions 返回统计结果和 incremental 标记: // incremental=true 时结果只包含 SinceHash 之后的新提交,调用方应执行合并而非整表替换。 func (g GitService) AnalyzeWithOptions(ctx context.Context, dir string, opt GitAnalyzeOptions) (model.GitStats, bool, error) { if _, e := g.run(ctx, dir, "rev-parse", "--git-dir"); e != nil { return model.GitStats{Available: false, Error: e.Error()}, false, e } branch, _ := g.run(ctx, dir, "branch", "--show-current") branch = strings.TrimSpace(branch) ref := opt.Ref if ref == "" { ref = branch } out := model.GitStats{Available: true, CurrentBranch: branch, WorkspaceBranch: branch, ViewRef: ref, Commits: []model.GitCommit{}, Refs: []model.GitRef{}, Contributors: []model.Contributor{}, Heatmap: []model.HeatDay{}, Hotspots: []model.GitFileHotspot{}} refs, e := g.run(ctx, dir, "for-each-ref", "--format=%(refname:short)%x1f%(objectname)%x1f%(refname)", "refs/heads", "refs/remotes", "refs/tags") if e != nil { out.Available = false out.Error = e.Error() return out, false, e } for _, line := range strings.Split(refs, "\n") { p := strings.Split(line, "\x1f") if len(p) != 3 { p = strings.Split(line, "%x1f") } if len(p) != 3 { continue } kind := "local" if strings.HasPrefix(p[2], "refs/remotes/") { kind = "remote" } else if strings.HasPrefix(p[2], "refs/tags/") { kind = "tag" } out.Refs = append(out.Refs, model.GitRef{Name: p[0], Hash: ShortHash(p[1]), Kind: kind, Current: p[0] == branch}) } incremental := opt.SinceHash != "" && g.canIncrement(ctx, dir, opt.SinceHash, ref, opt.AllBranches) args := []string{"log", "--use-mailmap", "--date=iso-strict", "--pretty=format:@@CC@@%H%x1f%aN%x1f%aE%x1f%aI%x1f%s", "--numstat", "--max-count", strconv.Itoa(gitLogMaxCommits)} if opt.AllBranches { args = append(args, "--all") } if incremental { args = append(args, "^"+opt.SinceHash) } if !opt.AllBranches && strings.TrimSpace(ref) != "" { args = append(args, ref) } log, e := g.run(ctx, dir, args...) if e != nil { out.Available = false out.Error = e.Error() return out, false, e } parseLog(log, &out) return out, incremental, nil } // canIncrement 校验增量起点仍然存在且属于目标历史,否则回退全量分析。 func (g GitService) canIncrement(ctx context.Context, dir, since, ref string, all bool) bool { if _, e := g.run(ctx, dir, "rev-parse", "--verify", "--quiet", since+"^{commit}"); e != nil { return false } target := strings.TrimSpace(ref) if all || target == "" { target = "HEAD" } _, e := g.run(ctx, dir, "merge-base", "--is-ancestor", since, target) return e == nil } // CommitDetail 按需读取单次提交和逐文件增删行,避免 Git 首页一次传输过多数据。 func (g GitService) CommitDetail(ctx context.Context, dir, hash string) (model.GitCommitDetail, error) { meta, e := g.run(ctx, dir, "show", "-s", "--use-mailmap", "--date=iso-strict", "--format=%H%x1f%aN%x1f%aE%x1f%aI%x1f%s", hash) if e != nil { return model.GitCommitDetail{}, e } p := strings.Split(meta, "\x1f") if len(p) != 5 { return model.GitCommitDetail{}, &GitError{Code: "GIT_COMMIT_PARSE_FAILED", Detail: meta} } d := model.GitCommitDetail{GitCommit: model.GitCommit{Hash: p[0], Author: p[1], Email: strings.ToLower(p[2]), Date: p[3], Message: p[4]}, Files: []model.GitFileChange{}} nums, e := g.run(ctx, dir, "show", "--format=", "--numstat", hash) if e != nil { return d, e } for _, line := range strings.Split(nums, "\n") { x := strings.Split(line, "\t") if len(x) < 3 { continue } a, ea := strconv.ParseInt(x[0], 10, 64) del, ed := strconv.ParseInt(x[1], 10, 64) if ea != nil { a = 0 } if ed != nil { del = 0 } status := "modified" if a > 0 && del == 0 { status = "added" } if del > 0 && a == 0 { status = "deleted" } d.Files = append(d.Files, model.GitFileChange{Path: x[2], Status: status, Added: a, Deleted: del}) d.Added += a d.Deleted += del } if patch, e := g.run(ctx, dir, "show", "--format=", "--patch", "--no-color", "--unified=3", hash); e == nil { attachCommitPatches(&d, patch) } return d, nil } const ( maxPatchLines = 500 maxPatchBytes = 80_000 maxPatchBytesTotal = 400_000 ) type parsedPatch struct { Path string Text string Binary bool Truncated bool } func attachCommitPatches(d *model.GitCommitDetail, raw string) { patches := parseCommitPatches(raw) used := 0 for i := range d.Files { key := patchFileKey(d.Files[i].Path) p, ok := patches[key] if !ok { p, ok = patches[d.Files[i].Path] } if !ok { continue } d.Files[i].Binary = p.Binary if p.Binary { continue } if used+len(p.Text) > maxPatchBytesTotal { d.Files[i].Truncated = true continue } d.Files[i].Patch = p.Text d.Files[i].Truncated = p.Truncated used += len(p.Text) } } func patchFileKey(path string) string { if i := strings.LastIndex(path, " => "); i >= 0 { return strings.Trim(path[i+4:], "{}") } return path } func parseCommitPatches(raw string) map[string]parsedPatch { out := map[string]parsedPatch{} raw = strings.ReplaceAll(strings.TrimSpace(raw), "\r\n", "\n") if raw == "" { return out } parts := strings.Split(raw, "\ndiff --git ") for i, part := range parts { if i == 0 { if !strings.HasPrefix(part, "diff --git ") { continue } } else { part = "diff --git " + part } p := parseOneDiff(part) if p.Path == "" { continue } out[p.Path] = p } return out } func parseOneDiff(block string) parsedPatch { p := parsedPatch{Path: diffPath(block)} if strings.Contains(block, "Binary files ") || strings.Contains(block, "GIT binary patch") { p.Binary = true return p } lines := strings.Split(block, "\n") kept := make([]string, 0, len(lines)) for _, line := range lines { if strings.HasPrefix(line, "diff --git ") || strings.HasPrefix(line, "index ") || strings.HasPrefix(line, "new file mode ") || strings.HasPrefix(line, "deleted file mode ") || strings.HasPrefix(line, "similarity index ") || strings.HasPrefix(line, "rename ") || strings.HasPrefix(line, "copy ") { continue } kept = append(kept, line) } text := strings.Join(kept, "\n") if n := strings.Count(text, "\n") + 1; n > maxPatchLines { kept = strings.Split(text, "\n")[:maxPatchLines] text = strings.Join(kept, "\n") p.Truncated = true } if len(text) > maxPatchBytes { text = text[:maxPatchBytes] p.Truncated = true } p.Text = strings.TrimRight(text, "\n") return p } func diffPath(block string) string { for _, line := range strings.Split(block, "\n") { if strings.HasPrefix(line, "+++ b/") { return strings.TrimPrefix(line, "+++ b/") } if strings.HasPrefix(line, "+++ /dev/null") { continue } if strings.HasPrefix(line, "--- a/") { fallback := strings.TrimPrefix(line, "--- a/") if fallback != "/dev/null" { // 删除文件没有 +++ b/,用旧路径 if !strings.Contains(block, "+++ b/") { return fallback } } } } first, _, _ := strings.Cut(block, "\n") rest := strings.TrimPrefix(first, "diff --git ") if i := strings.LastIndex(rest, " b/"); i >= 0 { return strings.Trim(rest[i+3:], `"`) } return "" } const aiDiffMaxBytes = 80_000 // FormatCommitDiffForAI 把提交明细收成给模型看的文本,超长时省略部分文件 patch。 func FormatCommitDiffForAI(d model.GitCommitDetail, maxBytes int) string { if maxBytes <= 0 { maxBytes = aiDiffMaxBytes } var b strings.Builder fmt.Fprintf(&b, "## 本次提交\n- 哈希: %s\n- 作者: %s <%s>\n- 时间: %s\n- 说明: %s\n- 变更: +%d / -%d,共 %d 个文件\n", d.Hash, d.Author, d.Email, d.Date, d.Message, d.Added, d.Deleted, len(d.Files)) b.WriteString("\n## 变更文件\n") for i, f := range d.Files { if i >= 80 { fmt.Fprintf(&b, "- … 其余 %d 个文件已省略\n", len(d.Files)-i) break } note := "" if f.Binary { note = " [binary]" } else if f.Truncated { note = " [truncated]" } fmt.Fprintf(&b, "- %s %s +%d/-%d%s\n", f.Status, f.Path, f.Added, f.Deleted, note) } b.WriteString("\n## Diff\n") used := 0 omitted := 0 for _, f := range d.Files { if f.Binary || f.Patch == "" { continue } block := fmt.Sprintf("### %s\n```diff\n%s\n```\n", f.Path, f.Patch) if used+len(block) > maxBytes { omitted++ continue } b.WriteString(block) used += len(block) } if omitted > 0 { fmt.Fprintf(&b, "\n(另有 %d 个文件的 diff 因过长未纳入)\n", omitted) } return b.String() } // CheckoutBranch 在修改工作区前先检查已跟踪和未跟踪变更;发现任何内容都拒绝切换。 func (g GitService) CheckoutBranch(ctx context.Context, dir, ref string) (model.CheckoutResult, error) { dirty, e := g.run(ctx, dir, "status", "--porcelain") if e != nil { return model.CheckoutResult{}, e } if strings.TrimSpace(dirty) != "" { return model.CheckoutResult{}, &GitError{Code: "GIT_WORKTREE_DIRTY", Detail: dirty} } kind, _ := g.run(ctx, dir, "for-each-ref", "--format=%(refname)", "refs/remotes/"+ref) if strings.TrimSpace(kind) != "" { local := ref if i := strings.Index(ref, "/"); i >= 0 { local = ref[i+1:] } if _, x := g.run(ctx, dir, "show-ref", "--verify", "--quiet", "refs/heads/"+local); x == nil { _, e = g.run(ctx, dir, "switch", local) } else { _, e = g.run(ctx, dir, "switch", "--track", ref) } } else { _, e = g.run(ctx, dir, "switch", ref) } if e != nil { return model.CheckoutResult{}, e } branch, _ := g.run(ctx, dir, "branch", "--show-current") return model.CheckoutResult{Branch: strings.TrimSpace(branch), Message: "分支切换成功"}, nil } func parseLog(log string, out *model.GitStats) { var cur *model.GitCommit fm := map[string]*model.GitFileHotspot{} flush := func() { if cur != nil { out.Commits = append(out.Commits, *cur) } } for _, line := range strings.Split(log, "\n") { if strings.HasPrefix(line, "@@CC@@") { flush() p := strings.Split(strings.TrimPrefix(line, "@@CC@@"), "\x1f") if len(p) == 5 { cur = &model.GitCommit{Hash: p[0], Author: p[1], Email: strings.ToLower(p[2]), Date: p[3], Message: p[4]} } else { cur = nil } continue } if cur != nil { p := strings.Split(line, "\t") if len(p) >= 3 { a, e1 := strconv.ParseInt(p[0], 10, 64) d, e2 := strconv.ParseInt(p[1], 10, 64) if e1 != nil { a = 0 } if e2 != nil { d = 0 } cur.Added += a cur.Deleted += d path := strings.TrimSpace(p[2]) if path != "" { h := fm[path] if h == nil { h = &model.GitFileHotspot{Path: path} fm[path] = h } h.Changes++ h.Added += a h.Deleted += d } } } } flush() cm := map[string]*model.Contributor{} hm := map[string]*model.HeatDay{} cut := time.Now().AddDate(-1, 0, 0) for _, c := range out.Commits { out.Added += c.Added out.Deleted += c.Deleted x := cm[c.Email] if x == nil { x = &model.Contributor{Name: c.Author, Email: c.Email} cm[c.Email] = x } x.Commits++ x.Added += c.Added x.Deleted += c.Deleted if t, e := time.Parse(time.RFC3339, c.Date); e == nil && t.After(cut) { key := t.Local().Format("2006-01-02") d := hm[key] if d == nil { d = &model.HeatDay{Date: key} hm[key] = d } d.Count++ d.Added += c.Added d.Deleted += c.Deleted } } out.CommitCount = int64(len(out.Commits)) for _, x := range cm { out.Contributors = append(out.Contributors, *x) } out.ContributorCount = int64(len(out.Contributors)) sort.Slice(out.Contributors, func(i, j int) bool { return out.Contributors[i].Commits > out.Contributors[j].Commits }) for _, d := range hm { out.Heatmap = append(out.Heatmap, *d) } sort.Slice(out.Heatmap, func(i, j int) bool { return out.Heatmap[i].Date < out.Heatmap[j].Date }) for _, h := range fm { out.Hotspots = append(out.Hotspots, *h) } sort.Slice(out.Hotspots, func(i, j int) bool { if out.Hotspots[i].Changes != out.Hotspots[j].Changes { return out.Hotspots[i].Changes > out.Hotspots[j].Changes } return out.Hotspots[i].Added+out.Hotspots[i].Deleted > out.Hotspots[j].Added+out.Hotspots[j].Deleted }) if len(out.Hotspots) > 50 { out.Hotspots = out.Hotspots[:50] } } func ShortHash(s string) string { if len(s) > 7 { return s[:7] } return s }