初始化

This commit is contained in:
李琦
2026-08-11 19:07:05 +08:00
commit d2aeb13a09
94 changed files with 8704 additions and 0 deletions

321
service/git.go Normal file
View 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
}