Files
code-utils/service/git.go
2026-08-14 07:51:46 +08:00

398 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}