322 lines
10 KiB
Go
322 lines
10 KiB
Go
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
|
||
}
|
||
|
||
// Analyze 分析工作区当前分支。
|
||
func (g GitService) Analyze(ctx context.Context, dir string) (model.GitStats, error) {
|
||
return g.AnalyzeRef(ctx, dir, "")
|
||
}
|
||
|
||
// AnalyzeRef 只切换统计视图,不修改用户工作区。
|
||
func (g GitService) AnalyzeRef(ctx context.Context, dir, ref string) (model.GitStats, error) {
|
||
if _, e := g.run(ctx, dir, "rev-parse", "--git-dir"); e != nil {
|
||
return model.GitStats{Available: false, Error: e.Error()}, e
|
||
}
|
||
branch, _ := g.run(ctx, dir, "branch", "--show-current")
|
||
branch = strings.TrimSpace(branch)
|
||
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{}}
|
||
refs, e := g.run(ctx, dir, "for-each-ref", "--format=%(refname:short)%x1f%(objectname)%x1f%(refname)", "refs/heads", "refs/remotes")
|
||
if e != nil {
|
||
out.Available = false
|
||
out.Error = e.Error()
|
||
return out, 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"
|
||
}
|
||
out.Refs = append(out.Refs, model.GitRef{Name: p[0], Hash: ShortHash(p[1]), Kind: kind, Current: p[0] == branch})
|
||
}
|
||
args := []string{"log", "--use-mailmap", "--date=iso-strict", "--pretty=format:@@CC@@%H%x1f%aN%x1f%aE%x1f%aI%x1f%s", "--numstat"}
|
||
if 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, e
|
||
}
|
||
parseLog(log, &out)
|
||
return out, 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
|
||
for _, line := range strings.Split(log, "\n") {
|
||
if strings.HasPrefix(line, "@@CC@@") {
|
||
if cur != nil {
|
||
out.Commits = append(out.Commits, *cur)
|
||
}
|
||
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) >= 2 {
|
||
a, e1 := strconv.ParseInt(p[0], 10, 64)
|
||
d, e2 := strconv.ParseInt(p[1], 10, 64)
|
||
if e1 == nil {
|
||
cur.Added += a
|
||
}
|
||
if e2 == nil {
|
||
cur.Deleted += d
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if cur != nil {
|
||
out.Commits = append(out.Commits, *cur)
|
||
}
|
||
cm := map[string]*model.Contributor{}
|
||
hm := map[string]int64{}
|
||
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) {
|
||
hm[t.Local().Format("2006-01-02")]++
|
||
}
|
||
}
|
||
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, c := range hm {
|
||
out.Heatmap = append(out.Heatmap, model.HeatDay{Date: d, Count: c})
|
||
}
|
||
sort.Slice(out.Heatmap, func(i, j int) bool { return out.Heatmap[i].Date < out.Heatmap[j].Date })
|
||
}
|
||
|
||
func ShortHash(s string) string {
|
||
if len(s) > 7 {
|
||
return s[:7]
|
||
}
|
||
return s
|
||
}
|