初始化
This commit is contained in:
321
service/git.go
Normal file
321
service/git.go
Normal file
@@ -0,0 +1,321 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user