// Package gitx 封装对 git 命令行与裸仓库的读写操作。 package gitx import ( "bufio" "bytes" "context" "errors" "fmt" "io" "io/fs" "os" "os/exec" "path/filepath" "regexp" "sort" "strconv" "strings" "sync/atomic" "time" ) // EmptyTreeSHA 是 git 中"空树"对象的哈希,用于与首次提交做 diff。 const EmptyTreeSHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904" // MaxBlobSize 是单个文件允许被读入内存的硬上限(64MB)。 // 超过这个体积的内容不应该整份进入内存——raw 下载走流式,见 BlobReader。 const MaxBlobSize = 64 << 20 // ErrTooLarge 表示内容体积超过读取上限。 var ErrTooLarge = errors.New("内容过大") // defaultTimeout 是单条 git 命令的默认上限。 // // 以前所有 git 调用都是 exec.Command:一旦 git 卡死(网络盘上的仓库、 // 损坏的 object、极端情况下 git 自身的缺陷),对应的 goroutine 会被 // 永久占住,而 HTTP 请求还在等它返回。改为统一带超时的 CommandContext。 var defaultTimeout atomic.Int64 func init() { defaultTimeout.Store(int64(60 * time.Second)) } // SetCommandTimeout 调整单条 git 命令的超时上限。 func SetCommandTimeout(d time.Duration) { if d > 0 { defaultTimeout.Store(int64(d)) } } func commandTimeout() time.Duration { return time.Duration(defaultTimeout.Load()) } var repoNameRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`) // ValidRef 校验来自 URL 的引用名(分支 / 标签 / commit)是否可以安全地 // 交给 git 命令行。 // // git 命令行把以 "-" 开头的参数当作选项,"feature/a b" 这类含空格的引用 // 会被拆成多个参数,`--output=...` 之类的值甚至可能让 git 写文件。 // 与其依赖每个调用点都记得加 `--`,不如在入口处统一拒绝。 func ValidRef(ref string) bool { if ref == "" || len(ref) > 255 { return false } if strings.HasPrefix(ref, "-") || strings.HasPrefix(ref, "!") { return false } if strings.Contains(ref, "..") || strings.Contains(ref, "@{") { return false } for _, r := range ref { switch { case r < 0x20 || r == 0x7f: return false case r == ' ' || r == '~' || r == '^' || r == ':' || r == '?' || r == '*' || r == '[' || r == '\\' || r == '\'' || r == '"': return false } } return true } // Version 返回 git 版本号。 func Version() string { out, err := exec.Command("git", "--version").Output() if err != nil { return "unknown" } return strings.TrimSpace(string(out)) } // Available 检查 git 是否可用。 func Available() bool { _, err := exec.LookPath("git") return err == nil } // ValidRepoName 校验仓库名是否合法。 func ValidRepoName(name string) bool { if !repoNameRe.MatchString(name) { return false } if strings.Contains(name, "..") || strings.HasSuffix(strings.ToLower(name), ".git") { return false } return true } func run(dir string, args ...string) ([]byte, error) { return runLimit(context.Background(), dir, 0, args...) } // runLimit 执行一条 git 命令并返回其标准输出。 // // max > 0 时最多接收 max 字节,超出即返回 ErrTooLarge——这一点很重要: // 之前 Blob() 会把整个文件读进内存才让调用方判断大小,一个 2GB 的 // 仓库文件就能把进程撑爆。 func runLimit(ctx context.Context, dir string, max int, args ...string) ([]byte, error) { if ctx == nil { ctx = context.Background() } // 传入的 ctx 若自带更短的超时(例如 HTTP 请求上下文),尊重调用方。 if _, ok := ctx.Deadline(); !ok { var cancel context.CancelFunc ctx, cancel = context.WithTimeout(ctx, commandTimeout()) defer cancel() } cmd := exec.CommandContext(ctx, "git", args...) if dir != "" { cmd.Dir = dir } cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "LC_ALL=C") var stdout, stderr bytes.Buffer var sink io.Writer = &stdout var lb *limitedBuffer if max > 0 { lb = &limitedBuffer{max: max} sink = lb } cmd.Stdout = sink cmd.Stderr = &stderr err := cmd.Run() // 截断优先于错误判断:git 在 stdout 被关闭后会以 EPIPE 失败, // 那不是"命令出错",而是"内容太大读不完"。 if lb != nil && lb.truncated { return nil, ErrTooLarge } out := stdout.Bytes() if lb != nil { // 限量模式下 stdout 是空的,真正的内容在限流缓冲里。 out = lb.buf.Bytes() } if err != nil { if errors.Is(ctx.Err(), context.DeadlineExceeded) { return out, fmt.Errorf("git %s: 命令超过 %s 未返回,已中止", args[0], commandTimeout()) } msg := strings.TrimSpace(stderr.String()) if msg == "" { msg = err.Error() } return out, fmt.Errorf("git %s: %s", strings.Join(args, " "), msg) } return out, nil } // InitBare 创建一个裸仓库。 func InitBare(path, defaultBranch string) error { if defaultBranch == "" { defaultBranch = "main" } if !ValidRef(defaultBranch) { return fmt.Errorf("非法的默认分支名: %q", defaultBranch) } if _, err := run("", "init", "--bare", "--initial-branch="+defaultBranch, path); err != nil { // 兼容老版本 git:退化为默认分支再修正 HEAD if _, err2 := run("", "init", "--bare", path); err2 != nil { return err } _, _ = run(path, "symbolic-ref", "HEAD", "refs/heads/"+defaultBranch) } // 关闭自动 gc,避免在推送关键时刻执行重打包 _, _ = run(path, "config", "gc.auto", "0") return nil } // IsBareRepo 判断路径下是否是(空或非空的)裸仓库。 func IsBareRepo(path string) bool { if st, err := os.Stat(filepath.Join(path, "HEAD")); err != nil || st.IsDir() { return false } if st, err := os.Stat(filepath.Join(path, "objects")); err != nil || !st.IsDir() { return false } return true } // Refs 返回所有 ref 的 名称 -> SHA 映射。 func Refs(repo string) (map[string]string, error) { out, err := run(repo, "for-each-ref", "--format=%(refname)%00%(objectname)") if err != nil { return nil, err } m := map[string]string{} for _, line := range strings.Split(string(out), "\n") { line = strings.TrimRight(line, "\r") if line == "" { continue } parts := strings.SplitN(line, "\x00", 2) if len(parts) != 2 { continue } m[parts[0]] = parts[1] } return m, nil } // Commit 是一次提交的元信息。 type Commit struct { SHA string Author string Email string When time.Time Parents []string Subject string } // Short 返回短 SHA。 func (c Commit) Short() string { if len(c.SHA) > 7 { return c.SHA[:7] } return c.SHA } // IsMerge 判断是否为合并提交。 func (c Commit) IsMerge() bool { return len(c.Parents) > 1 } const logFormat = "%H%x1f%an%x1f%ae%x1f%at%x1f%P%x1f%s%x1e" func parseCommitLine(line string) (Commit, bool) { line = strings.TrimRight(line, "\r") line = strings.TrimPrefix(line, "\x1e") line = strings.TrimSuffix(line, "\x1e") if !strings.Contains(line, "\x1f") { return Commit{}, false } f := strings.SplitN(line, "\x1f", 6) if len(f) < 6 { return Commit{}, false } ts, _ := strconv.ParseInt(f[3], 10, 64) c := Commit{ SHA: f[0], Author: f[1], Email: f[2], When: time.Unix(ts, 0), Subject: f[5], } if p := strings.Fields(f[4]); len(p) > 0 { c.Parents = p } return c, true } // Log 返回某个 ref 的提交列表。 func Log(repo, ref string, limit, skip int) ([]Commit, error) { args := []string{"log", "--no-color", "--format=" + logFormat} if limit > 0 { args = append(args, "-n", strconv.Itoa(limit)) } if skip > 0 { args = append(args, "--skip="+strconv.Itoa(skip)) } args = append(args, ref) out, err := run(repo, args...) if err != nil { return nil, err } return parseCommits(string(out)), nil } func parseCommits(out string) []Commit { var commits []Commit for _, line := range strings.Split(out, "\n") { if c, ok := parseCommitLine(line); ok { commits = append(commits, c) } } return commits } // Reserve 判断 ref 是否可以解析为提交。 func Resolve(repo, ref string) (string, error) { out, err := run(repo, "rev-parse", "--verify", "--quiet", ref+"^{commit}") if err != nil { return "", err } return strings.TrimSpace(string(out)), nil } // CountCommits 统计 ref 上的提交数量。 func CountCommits(repo, ref string) int { out, err := run(repo, "rev-list", "--count", ref) if err != nil { return 0 } n, _ := strconv.Atoi(strings.TrimSpace(string(out))) return n } // FileCount 统计文件数量。 func FileCount(repo, ref string) int { out, err := run(repo, "ls-tree", "-r", "--name-only", ref) if err != nil { return 0 } n := 0 for _, l := range strings.Split(string(out), "\n") { if strings.TrimSpace(l) != "" { n++ } } return n } // Ref 是分支或标签。 type Ref struct { Name string SHA string When time.Time Subject string IsTag bool } // Branches 返回全部分支。 func Branches(repo string) ([]Ref, error) { return refList(repo, "refs/heads", false) } // Tags 返回全部标签。 func Tags(repo string) ([]Ref, error) { return refList(repo, "refs/tags", true) } func refList(repo, prefix string, isTag bool) ([]Ref, error) { // 注意:for-each-ref 的 format 不支持 %xNN 转义(仅 git log/show 支持), // 因此这里用 %00 作为字段分隔符。 out, err := run(repo, "for-each-ref", "--sort=-committerdate", "--format=%(refname:short)%00%(objectname)%00%(committerdate:unix)%00%(subject)", prefix) if err != nil { return nil, err } var refs []Ref for _, line := range strings.Split(string(out), "\n") { line = strings.TrimRight(line, "\r") if strings.TrimSpace(line) == "" { continue } f := strings.SplitN(line, "\x00", 4) if len(f) < 4 { continue } ts, _ := strconv.ParseInt(f[2], 10, 64) refs = append(refs, Ref{Name: f[0], SHA: f[1], When: time.Unix(ts, 0), Subject: f[3], IsTag: isTag}) } return refs, nil } // TreeEntry 是目录中的一项。 type TreeEntry struct { Mode string Type string // blob / tree / commit(子模块) SHA string Name string Size int64 Path string } // IsDir 判断是否为目录。 func (e TreeEntry) IsDir() bool { return e.Type == "tree" } // Tree 列出某个目录下的内容(dir 为 "" 表示根目录)。 func Tree(repo, ref, dir string) ([]TreeEntry, error) { spec := ref if dir != "" { spec = ref + ":" + strings.Trim(dir, "/") } out, err := run(repo, "ls-tree", "-l", spec) if err != nil { return nil, err } var entries []TreeEntry for _, line := range strings.Split(string(out), "\n") { line = strings.TrimRight(line, "\r") if strings.TrimSpace(line) == "" { continue } tab := strings.Index(line, "\t") if tab < 0 { continue } meta := strings.Fields(line[:tab]) name := line[tab+1:] if len(meta) < 4 { continue } e := TreeEntry{Mode: meta[0], Type: meta[1], SHA: meta[2], Name: name} if meta[3] != "-" { e.Size, _ = strconv.ParseInt(meta[3], 10, 64) } e.Path = name if dir != "" { e.Path = strings.Trim(dir, "/") + "/" + name } entries = append(entries, e) } // 目录在前,其次按名称排序 sort.SliceStable(entries, func(i, j int) bool { if entries[i].IsDir() != entries[j].IsDir() { return entries[i].IsDir() } return strings.ToLower(entries[i].Name) < strings.ToLower(entries[j].Name) }) return entries, nil } // TreeExists 判断路径是否存在于该 ref 中。 func TreeExists(repo, ref, path string) bool { if path == "" { return true } _, err := run(repo, "cat-file", "-t", ref+":"+strings.Trim(path, "/")) return err == nil } // LastCommitForPath 返回影响指定路径的最近一次提交。 func LastCommitForPath(repo, ref, path string) (Commit, bool) { args := []string{"log", "--no-color", "--format=" + logFormat, "-n", "1", ref} if strings.Trim(path, "/") != "" { args = append(args, "--", strings.Trim(path, "/")) } out, err := run(repo, args...) if err != nil { return Commit{}, false } commits := parseCommits(string(out)) if len(commits) == 0 { return Commit{}, false } return commits[0], true } // Blob 读取文件内容,最多 MaxBlobSize。 func Blob(repo, ref, path string) ([]byte, error) { return BlobLimited(repo, ref, path, MaxBlobSize) } // BlobLimited 读取文件内容,超过 max 字节时返回 ErrTooLarge。 // // 关键点:先用 cat-file -s 拿对象大小再决定是否读,让超大文件永远不进入 // 内存;即便该查询失败(比如极老版本 git),runLimit 的输出上限仍会兜住。 func BlobLimited(repo, ref, path string, max int) ([]byte, error) { if max > 0 && BlobSize(repo, ref, path) > int64(max) { return nil, ErrTooLarge } return runLimit(context.Background(), repo, max, "show", blobSpec(ref, path)) } // BlobReader 以流式方式把文件内容写入 w,用于原始文件下载。 // 走管道而不是整份读进内存,因此下载 GB 级文件也不会把进程撑爆。 func BlobReader(repo, ref, path string, w io.Writer) error { ctx, cancel := context.WithTimeout(context.Background(), commandTimeout()) defer cancel() cmd := exec.CommandContext(ctx, "git", "cat-file", "blob", blobSpec(ref, path)) cmd.Dir = repo cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "LC_ALL=C") cmd.Stdout = w cmd.Stderr = io.Discard return cmd.Run() } func blobSpec(ref, path string) string { return ref + ":" + strings.Trim(path, "/") } // BlobSize 返回文件字节数。 func BlobSize(repo, ref, path string) int64 { out, err := run(repo, "cat-file", "-s", blobSpec(ref, path)) if err != nil { return 0 } n, _ := strconv.ParseInt(strings.TrimSpace(string(out)), 10, 64) return n } // maxCountedLines 是行数统计的扫描上限:超过就返回上限值, // 免得在一个 10 亿行的文件上空转。 const maxCountedLines = 5_000_000 // maxScanToken 是行扫描的单行上限。超过它就算「一行」,不再继续读。 const maxScanToken = 1 << 20 // LineCount 流式统计文件行数,不把文件读进内存。 func LineCount(repo, ref, path string) (int, error) { ctx, cancel := context.WithTimeout(context.Background(), commandTimeout()) defer cancel() cmd := exec.CommandContext(ctx, "git", "cat-file", "blob", blobSpec(ref, path)) cmd.Dir = repo cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "LC_ALL=C") // 兜底:万一下面哪个环节没能让 git 自行退出,2 秒后强制杀掉, // 绝不让一个「统计行数」的只读操作挂住整个请求。 cmd.WaitDelay = 2 * time.Second pipe, err := cmd.StdoutPipe() if err != nil { return 0, err } if err := cmd.Start(); err != nil { return 0, err } sc := bufio.NewScanner(pipe) sc.Buffer(make([]byte, 0, 64<<10), maxScanToken) n := 0 for sc.Scan() { n++ if n >= maxCountedLines { break } } scanErr := sc.Err() // 关键:先关掉读端。scanner 提前停止(超长行、达到行数上限、出错)后 // 没人再读管道,git 会阻塞在 write 上;此时 Wait 会一直等到命令超时。 // 关掉读端后 git 立刻收到 EPIPE 退出,Wait 才能正常返回。 _ = pipe.Close() _ = cmd.Wait() switch { case scanErr == nil: return n, nil case errors.Is(scanErr, bufio.ErrTooLong) && n >= 0: // 触到单行上限:这行本身算一行,只是没数完。 return n + 1, nil default: return n, nil } } // IsBinary 用内容启发式判断是否为二进制文件。 func IsBinary(b []byte) bool { n := len(b) if n > 8000 { n = 8000 } return bytes.IndexByte(b[:n], 0) >= 0 } // LastCommits 返回目录下每个文件的最近一次提交(单次 git 调用,最多回看 limit 个提交)。 func LastCommits(repo, ref, dir string, limit int) map[string]Commit { // 字段顺序必须与 parseCommitLine 保持一致,否则整条记录会被判为非法而丢弃。 args := []string{"log", "--no-color", "--name-only", "--format=%x1e%H%x1f%an%x1f%ae%x1f%at%x1f%P%x1f%s", "-n", strconv.Itoa(limit), ref} if dir != "" { args = append(args, "--", strings.Trim(dir, "/")) } out, err := run(repo, args...) if err != nil { return nil } result := map[string]Commit{} var cur *Commit for _, line := range strings.Split(string(out), "\n") { line = strings.TrimRight(line, "\r") if strings.HasPrefix(line, "\x1e") { if c, ok := parseCommitLine(line); ok { cur = &c } continue } if strings.TrimSpace(line) == "" || cur == nil { continue } if _, seen := result[line]; !seen { result[line] = *cur } } return result } // FileStat 是单文件增删行统计。 type FileStat struct { Path string Additions int Deletions int Binary bool } // CommitDetail 是提交详情。 type CommitDetail struct { Commit Message string Files []FileStat Diff string Truncated bool } // Show 读取一次提交的完整信息。 func Show(repo, sha string) (*CommitDetail, error) { out, err := run(repo, "show", "-s", "--no-color", "--format=%H%x1f%an%x1f%ae%x1f%at%x1f%P%x1f%B", sha) if err != nil { return nil, err } raw := strings.TrimRight(string(out), "\n") f := strings.SplitN(raw, "\x1f", 6) if len(f) < 6 { return nil, errors.New("无法解析提交信息") } ts, _ := strconv.ParseInt(f[3], 10, 64) d := &CommitDetail{} d.SHA = f[0] d.Author = f[1] d.Email = f[2] d.When = time.Unix(ts, 0) d.Parents = strings.Fields(f[4]) d.Subject = strings.SplitN(f[5], "\n", 2)[0] d.Message = f[5] if ns, err := run(repo, "show", "--numstat", "--format=", "--no-color", sha); err == nil { for _, line := range strings.Split(string(ns), "\n") { parts := strings.SplitN(strings.TrimRight(line, "\r"), "\t", 3) if len(parts) != 3 || parts[2] == "" { continue } st := FileStat{Path: parts[2]} if parts[0] == "-" { st.Binary = true } else { st.Additions, _ = strconv.Atoi(parts[0]) st.Deletions, _ = strconv.Atoi(parts[1]) } d.Files = append(d.Files, st) } } buf := &limitedBuffer{max: 400 << 10} if out, err := runLimit(context.Background(), repo, 400<<10, "show", "--patch", "--no-color", "--format=", "-M", sha); err == nil { buf.Write(out) } d.Diff = buf.String() d.Truncated = buf.truncated return d, nil } type limitedBuffer struct { buf bytes.Buffer max int truncated bool } func (l *limitedBuffer) Write(p []byte) (int, error) { if l.buf.Len() >= l.max { l.truncated = true return len(p), nil } room := l.max - l.buf.Len() if room < len(p) { l.truncated = true l.buf.Write(p[:room]) return len(p), nil } l.buf.Write(p) return len(p), nil } func (l *limitedBuffer) String() string { return l.buf.String() } // Contributor 是贡献者统计。 type Contributor struct { Name string Email string Count int } // Contributors 统计贡献者(按提交数排序)。 func Contributors(repo, ref string, limit int) []Contributor { out, err := run(repo, "shortlog", "-sne", ref) if err != nil { return nil } var list []Contributor for _, line := range strings.Split(string(out), "\n") { line = strings.TrimRight(line, "\r") if strings.TrimSpace(line) == "" { continue } f := strings.SplitN(line, "\t", 2) if len(f) != 2 { continue } count, _ := strconv.Atoi(strings.TrimSpace(f[0])) name, email := f[1], "" if i := strings.LastIndex(f[1], "<"); i > 0 { name = strings.TrimSpace(f[1][:i]) email = strings.Trim(strings.TrimSpace(f[1][i:]), "<>") } list = append(list, Contributor{Name: name, Email: email, Count: count}) } if limit > 0 && len(list) > limit { list = list[:limit] } return list } // LangStat 是语言占比统计。 type LangStat struct { Name string Color string Files int Percent float64 } var langByExt = map[string][2]string{ ".go": {"Go", "#00ADD8"}, ".js": {"JavaScript", "#F1E05A"}, ".mjs": {"JavaScript", "#F1E05A"}, ".cjs": {"JavaScript", "#F1E05A"}, ".jsx": {"JavaScript", "#F1E05A"}, ".ts": {"TypeScript", "#3178C6"}, ".tsx": {"TypeScript", "#3178C6"}, ".py": {"Python", "#3572A5"}, ".java": {"Java", "#B07219"}, ".kt": {"Kotlin", "#A97BFF"}, ".rb": {"Ruby", "#701516"}, ".rs": {"Rust", "#DEA584"}, ".c": {"C", "#555555"}, ".h": {"C", "#555555"}, ".cpp": {"C++", "#F34B7D"}, ".cc": {"C++", "#F34B7D"}, ".hpp": {"C++", "#F34B7D"}, ".cs": {"C#", "#178600"}, ".php": {"PHP", "#4F5D95"}, ".swift": {"Swift", "#F05138"}, ".sh": {"Shell", "#89E051"}, ".bash": {"Shell", "#89E051"}, ".html": {"HTML", "#E34C26"}, ".css": {"CSS", "#563D7C"}, ".scss": {"SCSS", "#C6538C"}, ".vue": {"Vue", "#41B883"}, ".svelte": {"Svelte", "#FF3E00"}, ".dart": {"Dart", "#00B4AB"}, ".sql": {"SQL", "#E38C00"}, ".lua": {"Lua", "#000080"}, ".r": {"R", "#198CE7"}, ".ex": {"Elixir", "#6E4A7E"}, ".erl": {"Erlang", "#B83998"}, ".scala": {"Scala", "#C22D40"}, ".pl": {"Perl", "#0298C3"}, ".zig": {"Zig", "#EC915C"}, ".proto": {"Protocol Buffers", "#6E4A7E"}, } // Languages 统计代码语言占比(按文件数)。 func Languages(repo, ref string, top int) []LangStat { out, err := run(repo, "ls-tree", "-r", "--name-only", ref) if err != nil { return nil } counts := map[string]int{} total := 0 for _, line := range strings.Split(string(out), "\n") { line = strings.TrimRight(line, "\r") if strings.TrimSpace(line) == "" { continue } ext := strings.ToLower(filepath.Ext(line)) lang, ok := langByExt[ext] if !ok { continue } counts[lang[0]]++ total++ } if total == 0 { return nil } var list []LangStat for name, n := range counts { color := "#888888" for _, v := range langByExt { if v[0] == name { color = v[1] break } } list = append(list, LangStat{Name: name, Color: color, Files: n, Percent: float64(n) / float64(total) * 100}) } sort.Slice(list, func(i, j int) bool { return list[i].Files > list[j].Files }) if top > 0 && len(list) > top { list = list[:top] } return list } // readmeNames 是 README 候选文件名。 var readmeNames = []string{"readme.md", "readme.markdown", "readme.rst", "readme.txt", "readme"} // maxReadmeSize 限制 README 的读取体积:README 会被整份渲染进页面, // 没必要也没有安全理由把一个 100MB 的 README 读进内存。 const maxReadmeSize = 2 << 20 // Readme 查找并返回根目录的 README 内容。 func Readme(repo, ref string) (string, []byte, bool) { entries, err := Tree(repo, ref, "") if err != nil { return "", nil, false } for _, want := range readmeNames { for _, e := range entries { if !e.IsDir() && strings.ToLower(e.Name) == want { content, err := BlobLimited(repo, ref, e.Name, maxReadmeSize) if err != nil { // 过大或读取失败都视为"没有可展示的 README",页面继续渲染其他内容。 return "", nil, false } return e.Name, content, true } } } return "", nil, false } // RepoSize 统计仓库目录占用字节数。 func RepoSize(repo string) int64 { var total int64 _ = filepath.WalkDir(repo, func(path string, d fs.DirEntry, err error) error { if err != nil { return nil } if !d.IsDir() { if info, err := d.Info(); err == nil { total += info.Size() } } return nil }) return total } // CommitsBetween 返回 old..new 之间的提交(含每个提交的增删行数)。 func CommitsBetween(repo, oldSHA, newSHA string, limit int) ([]PushCommit, error) { rng := newSHA if oldSHA != "" && oldSHA != strings.Repeat("0", 40) { rng = oldSHA + ".." + newSHA } args := []string{"log", "--no-color", "--numstat", "--format=" + logFormat, "-n", strconv.Itoa(limit), rng} out, err := run(repo, args...) if err != nil { if oldSHA != "" { // 可能是新分支 / 强制推送,退化为只看新提交 return CommitsBetween(repo, "", newSHA, limit) } return nil, err } var commits []PushCommit var cur *PushCommit for _, line := range strings.Split(string(out), "\n") { line = strings.TrimRight(line, "\r") if c, ok := parseCommitLine(line); ok { commits = append(commits, PushCommit{SHA: c.SHA, Author: c.Author, When: c.When.Unix(), Subject: c.Subject}) cur = &commits[len(commits)-1] continue } if cur == nil || strings.TrimSpace(line) == "" { continue } parts := strings.SplitN(line, "\t", 3) if len(parts) != 3 { continue } cur.Files++ if parts[0] == "-" { continue } add, _ := strconv.Atoi(parts[0]) del, _ := strconv.Atoi(parts[1]) cur.Additions += add cur.Deletions += del } return commits, nil } // PushCommit 是用于留痕展示的提交摘要。 type PushCommit struct { SHA string Subject string Author string When int64 Additions int Deletions int Files int } // Short 返回短 SHA。 func (c PushCommit) Short() string { if len(c.SHA) > 7 { return c.SHA[:7] } return c.SHA } // DiffStat 返回两次提交之间的变更统计文本。 func DiffStat(repo, oldSHA, newSHA string) string { if oldSHA == "" || oldSHA == strings.Repeat("0", 40) { oldSHA = EmptyTreeSHA } if newSHA == "" || newSHA == strings.Repeat("0", 40) { return "" } out, err := run(repo, "diff", "--stat", "--no-color", oldSHA, newSHA) if err != nil { return "" } return strings.TrimSpace(string(out)) } // SetDefaultBranch 修改裸仓库的 HEAD 指向。 func SetDefaultBranch(repo, branch string) error { if !ValidRef(branch) { return fmt.Errorf("非法的分支名: %q", branch) } _, err := run(repo, "symbolic-ref", "HEAD", "refs/heads/"+branch) return err } // CurrentBranch 读取裸仓库 HEAD 指向的分支。 func CurrentBranch(repo string) string { out, err := run(repo, "symbolic-ref", "--short", "HEAD") if err != nil { return "" } return strings.TrimSpace(string(out)) } // HasCommits 判断仓库是否有任何提交。 func HasCommits(repo string) bool { refs, err := Refs(repo) if err != nil { return false } for name := range refs { if strings.HasPrefix(name, "refs/heads/") || strings.HasPrefix(name, "refs/tags/") { return true } } return false } // CopyReader 用于把请求体透传给 git 命令。 func CopyReader(dst io.Writer, src io.Reader) error { _, err := io.Copy(dst, src) return err }