package service import ( "context" "errors" "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 } return d, nil } // 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 }