gitcat
1// Package gitx 封装对 git 命令行与裸仓库的读写操作。
2package gitx
3
4import (
5 "bufio"
6 "bytes"
7 "context"
8 "errors"
9 "fmt"
10 "io"
11 "io/fs"
12 "os"
13 "os/exec"
14 "path/filepath"
15 "regexp"
16 "sort"
17 "strconv"
18 "strings"
19 "sync/atomic"
20 "time"
21)
22
23// EmptyTreeSHA 是 git 中"空树"对象的哈希,用于与首次提交做 diff。
24const EmptyTreeSHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
25
26// MaxBlobSize 是单个文件允许被读入内存的硬上限(64MB)。
27// 超过这个体积的内容不应该整份进入内存——raw 下载走流式,见 BlobReader。
28const MaxBlobSize = 64 << 20
29
30// ErrTooLarge 表示内容体积超过读取上限。
31var ErrTooLarge = errors.New("内容过大")
32
33// defaultTimeout 是单条 git 命令的默认上限。
34//
35// 以前所有 git 调用都是 exec.Command:一旦 git 卡死(网络盘上的仓库、
36// 损坏的 object、极端情况下 git 自身的缺陷),对应的 goroutine 会被
37// 永久占住,而 HTTP 请求还在等它返回。改为统一带超时的 CommandContext。
38var defaultTimeout atomic.Int64
39
40func init() { defaultTimeout.Store(int64(60 * time.Second)) }
41
42// SetCommandTimeout 调整单条 git 命令的超时上限。
43func SetCommandTimeout(d time.Duration) {
44 if d > 0 {
45 defaultTimeout.Store(int64(d))
46 }
47}
48
49func commandTimeout() time.Duration {
50 return time.Duration(defaultTimeout.Load())
51}
52
53var repoNameRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
54
55// ValidRef 校验来自 URL 的引用名(分支 / 标签 / commit)是否可以安全地
56// 交给 git 命令行。
57//
58// git 命令行把以 "-" 开头的参数当作选项,"feature/a b" 这类含空格的引用
59// 会被拆成多个参数,`--output=...` 之类的值甚至可能让 git 写文件。
60// 与其依赖每个调用点都记得加 `--`,不如在入口处统一拒绝。
61func ValidRef(ref string) bool {
62 if ref == "" || len(ref) > 255 {
63 return false
64 }
65 if strings.HasPrefix(ref, "-") || strings.HasPrefix(ref, "!") {
66 return false
67 }
68 if strings.Contains(ref, "..") || strings.Contains(ref, "@{") {
69 return false
70 }
71 for _, r := range ref {
72 switch {
73 case r < 0x20 || r == 0x7f:
74 return false
75 case r == ' ' || r == '~' || r == '^' || r == ':' || r == '?' ||
76 r == '*' || r == '[' || r == '\\' || r == '\'' || r == '"':
77 return false
78 }
79 }
80 return true
81}
82
83// Version 返回 git 版本号。
84func Version() string {
85 out, err := exec.Command("git", "--version").Output()
86 if err != nil {
87 return "unknown"
88 }
89 return strings.TrimSpace(string(out))
90}
91
92// Available 检查 git 是否可用。
93func Available() bool {
94 _, err := exec.LookPath("git")
95 return err == nil
96}
97
98// ValidRepoName 校验仓库名是否合法。
99func ValidRepoName(name string) bool {
100 if !repoNameRe.MatchString(name) {
101 return false
102 }
103 if strings.Contains(name, "..") || strings.HasSuffix(strings.ToLower(name), ".git") {
104 return false
105 }
106 return true
107}
108
109func run(dir string, args ...string) ([]byte, error) {
110 return runLimit(context.Background(), dir, 0, args...)
111}
112
113// runLimit 执行一条 git 命令并返回其标准输出。
114//
115// max > 0 时最多接收 max 字节,超出即返回 ErrTooLarge——这一点很重要:
116// 之前 Blob() 会把整个文件读进内存才让调用方判断大小,一个 2GB 的
117// 仓库文件就能把进程撑爆。
118func runLimit(ctx context.Context, dir string, max int, args ...string) ([]byte, error) {
119 if ctx == nil {
120 ctx = context.Background()
121 }
122 // 传入的 ctx 若自带更短的超时(例如 HTTP 请求上下文),尊重调用方。
123 if _, ok := ctx.Deadline(); !ok {
124 var cancel context.CancelFunc
125 ctx, cancel = context.WithTimeout(ctx, commandTimeout())
126 defer cancel()
127 }
128 cmd := exec.CommandContext(ctx, "git", args...)
129 if dir != "" {
130 cmd.Dir = dir
131 }
132 cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "LC_ALL=C")
133 var stdout, stderr bytes.Buffer
134 var sink io.Writer = &stdout
135 var lb *limitedBuffer
136 if max > 0 {
137 lb = &limitedBuffer{max: max}
138 sink = lb
139 }
140 cmd.Stdout = sink
141 cmd.Stderr = &stderr
142 err := cmd.Run()
143
144 // 截断优先于错误判断:git 在 stdout 被关闭后会以 EPIPE 失败,
145 // 那不是"命令出错",而是"内容太大读不完"。
146 if lb != nil && lb.truncated {
147 return nil, ErrTooLarge
148 }
149 out := stdout.Bytes()
150 if lb != nil {
151 // 限量模式下 stdout 是空的,真正的内容在限流缓冲里。
152 out = lb.buf.Bytes()
153 }
154 if err != nil {
155 if errors.Is(ctx.Err(), context.DeadlineExceeded) {
156 return out, fmt.Errorf("git %s: 命令超过 %s 未返回,已中止", args[0], commandTimeout())
157 }
158 msg := strings.TrimSpace(stderr.String())
159 if msg == "" {
160 msg = err.Error()
161 }
162 return out, fmt.Errorf("git %s: %s", strings.Join(args, " "), msg)
163 }
164 return out, nil
165}
166
167// InitBare 创建一个裸仓库。
168func InitBare(path, defaultBranch string) error {
169 if defaultBranch == "" {
170 defaultBranch = "main"
171 }
172 if !ValidRef(defaultBranch) {
173 return fmt.Errorf("非法的默认分支名: %q", defaultBranch)
174 }
175 if _, err := run("", "init", "--bare", "--initial-branch="+defaultBranch, path); err != nil {
176 // 兼容老版本 git:退化为默认分支再修正 HEAD
177 if _, err2 := run("", "init", "--bare", path); err2 != nil {
178 return err
179 }
180 _, _ = run(path, "symbolic-ref", "HEAD", "refs/heads/"+defaultBranch)
181 }
182 // 关闭自动 gc,避免在推送关键时刻执行重打包
183 _, _ = run(path, "config", "gc.auto", "0")
184 return nil
185}
186
187// IsBareRepo 判断路径下是否是(空或非空的)裸仓库。
188func IsBareRepo(path string) bool {
189 if st, err := os.Stat(filepath.Join(path, "HEAD")); err != nil || st.IsDir() {
190 return false
191 }
192 if st, err := os.Stat(filepath.Join(path, "objects")); err != nil || !st.IsDir() {
193 return false
194 }
195 return true
196}
197
198// Refs 返回所有 ref 的 名称 -> SHA 映射。
199func Refs(repo string) (map[string]string, error) {
200 out, err := run(repo, "for-each-ref", "--format=%(refname)%00%(objectname)")
201 if err != nil {
202 return nil, err
203 }
204 m := map[string]string{}
205 for _, line := range strings.Split(string(out), "\n") {
206 line = strings.TrimRight(line, "\r")
207 if line == "" {
208 continue
209 }
210 parts := strings.SplitN(line, "\x00", 2)
211 if len(parts) != 2 {
212 continue
213 }
214 m[parts[0]] = parts[1]
215 }
216 return m, nil
217}
218
219// Commit 是一次提交的元信息。
220type Commit struct {
221 SHA string
222 Author string
223 Email string
224 When time.Time
225 Parents []string
226 Subject string
227}
228
229// Short 返回短 SHA。
230func (c Commit) Short() string {
231 if len(c.SHA) > 7 {
232 return c.SHA[:7]
233 }
234 return c.SHA
235}
236
237// IsMerge 判断是否为合并提交。
238func (c Commit) IsMerge() bool { return len(c.Parents) > 1 }
239
240const logFormat = "%H%x1f%an%x1f%ae%x1f%at%x1f%P%x1f%s%x1e"
241
242func parseCommitLine(line string) (Commit, bool) {
243 line = strings.TrimRight(line, "\r")
244 line = strings.TrimPrefix(line, "\x1e")
245 line = strings.TrimSuffix(line, "\x1e")
246 if !strings.Contains(line, "\x1f") {
247 return Commit{}, false
248 }
249 f := strings.SplitN(line, "\x1f", 6)
250 if len(f) < 6 {
251 return Commit{}, false
252 }
253 ts, _ := strconv.ParseInt(f[3], 10, 64)
254 c := Commit{
255 SHA: f[0],
256 Author: f[1],
257 Email: f[2],
258 When: time.Unix(ts, 0),
259 Subject: f[5],
260 }
261 if p := strings.Fields(f[4]); len(p) > 0 {
262 c.Parents = p
263 }
264 return c, true
265}
266
267// Log 返回某个 ref 的提交列表。
268func Log(repo, ref string, limit, skip int) ([]Commit, error) {
269 args := []string{"log", "--no-color", "--format=" + logFormat}
270 if limit > 0 {
271 args = append(args, "-n", strconv.Itoa(limit))
272 }
273 if skip > 0 {
274 args = append(args, "--skip="+strconv.Itoa(skip))
275 }
276 args = append(args, ref)
277 out, err := run(repo, args...)
278 if err != nil {
279 return nil, err
280 }
281 return parseCommits(string(out)), nil
282}
283
284func parseCommits(out string) []Commit {
285 var commits []Commit
286 for _, line := range strings.Split(out, "\n") {
287 if c, ok := parseCommitLine(line); ok {
288 commits = append(commits, c)
289 }
290 }
291 return commits
292}
293
294// Reserve 判断 ref 是否可以解析为提交。
295func Resolve(repo, ref string) (string, error) {
296 out, err := run(repo, "rev-parse", "--verify", "--quiet", ref+"^{commit}")
297 if err != nil {
298 return "", err
299 }
300 return strings.TrimSpace(string(out)), nil
301}
302
303// CountCommits 统计 ref 上的提交数量。
304func CountCommits(repo, ref string) int {
305 out, err := run(repo, "rev-list", "--count", ref)
306 if err != nil {
307 return 0
308 }
309 n, _ := strconv.Atoi(strings.TrimSpace(string(out)))
310 return n
311}
312
313// FileCount 统计文件数量。
314func FileCount(repo, ref string) int {
315 out, err := run(repo, "ls-tree", "-r", "--name-only", ref)
316 if err != nil {
317 return 0
318 }
319 n := 0
320 for _, l := range strings.Split(string(out), "\n") {
321 if strings.TrimSpace(l) != "" {
322 n++
323 }
324 }
325 return n
326}
327
328// Ref 是分支或标签。
329type Ref struct {
330 Name string
331 SHA string
332 When time.Time
333 Subject string
334 IsTag bool
335}
336
337// Branches 返回全部分支。
338func Branches(repo string) ([]Ref, error) {
339 return refList(repo, "refs/heads", false)
340}
341
342// Tags 返回全部标签。
343func Tags(repo string) ([]Ref, error) {
344 return refList(repo, "refs/tags", true)
345}
346
347func refList(repo, prefix string, isTag bool) ([]Ref, error) {
348 // 注意:for-each-ref 的 format 不支持 %xNN 转义(仅 git log/show 支持),
349 // 因此这里用 %00 作为字段分隔符。
350 out, err := run(repo, "for-each-ref", "--sort=-committerdate",
351 "--format=%(refname:short)%00%(objectname)%00%(committerdate:unix)%00%(subject)", prefix)
352 if err != nil {
353 return nil, err
354 }
355 var refs []Ref
356 for _, line := range strings.Split(string(out), "\n") {
357 line = strings.TrimRight(line, "\r")
358 if strings.TrimSpace(line) == "" {
359 continue
360 }
361 f := strings.SplitN(line, "\x00", 4)
362 if len(f) < 4 {
363 continue
364 }
365 ts, _ := strconv.ParseInt(f[2], 10, 64)
366 refs = append(refs, Ref{Name: f[0], SHA: f[1], When: time.Unix(ts, 0), Subject: f[3], IsTag: isTag})
367 }
368 return refs, nil
369}
370
371// TreeEntry 是目录中的一项。
372type TreeEntry struct {
373 Mode string
374 Type string // blob / tree / commit(子模块)
375 SHA string
376 Name string
377 Size int64
378 Path string
379}
380
381// IsDir 判断是否为目录。
382func (e TreeEntry) IsDir() bool { return e.Type == "tree" }
383
384// Tree 列出某个目录下的内容(dir 为 "" 表示根目录)。
385func Tree(repo, ref, dir string) ([]TreeEntry, error) {
386 spec := ref
387 if dir != "" {
388 spec = ref + ":" + strings.Trim(dir, "/")
389 }
390 out, err := run(repo, "ls-tree", "-l", spec)
391 if err != nil {
392 return nil, err
393 }
394 var entries []TreeEntry
395 for _, line := range strings.Split(string(out), "\n") {
396 line = strings.TrimRight(line, "\r")
397 if strings.TrimSpace(line) == "" {
398 continue
399 }
400 tab := strings.Index(line, "\t")
401 if tab < 0 {
402 continue
403 }
404 meta := strings.Fields(line[:tab])
405 name := line[tab+1:]
406 if len(meta) < 4 {
407 continue
408 }
409 e := TreeEntry{Mode: meta[0], Type: meta[1], SHA: meta[2], Name: name}
410 if meta[3] != "-" {
411 e.Size, _ = strconv.ParseInt(meta[3], 10, 64)
412 }
413 e.Path = name
414 if dir != "" {
415 e.Path = strings.Trim(dir, "/") + "/" + name
416 }
417 entries = append(entries, e)
418 }
419 // 目录在前,其次按名称排序
420 sort.SliceStable(entries, func(i, j int) bool {
421 if entries[i].IsDir() != entries[j].IsDir() {
422 return entries[i].IsDir()
423 }
424 return strings.ToLower(entries[i].Name) < strings.ToLower(entries[j].Name)
425 })
426 return entries, nil
427}
428
429// TreeExists 判断路径是否存在于该 ref 中。
430func TreeExists(repo, ref, path string) bool {
431 if path == "" {
432 return true
433 }
434 _, err := run(repo, "cat-file", "-t", ref+":"+strings.Trim(path, "/"))
435 return err == nil
436}
437
438// LastCommitForPath 返回影响指定路径的最近一次提交。
439func LastCommitForPath(repo, ref, path string) (Commit, bool) {
440 args := []string{"log", "--no-color", "--format=" + logFormat, "-n", "1", ref}
441 if strings.Trim(path, "/") != "" {
442 args = append(args, "--", strings.Trim(path, "/"))
443 }
444 out, err := run(repo, args...)
445 if err != nil {
446 return Commit{}, false
447 }
448 commits := parseCommits(string(out))
449 if len(commits) == 0 {
450 return Commit{}, false
451 }
452 return commits[0], true
453}
454
455// Blob 读取文件内容,最多 MaxBlobSize。
456func Blob(repo, ref, path string) ([]byte, error) {
457 return BlobLimited(repo, ref, path, MaxBlobSize)
458}
459
460// BlobLimited 读取文件内容,超过 max 字节时返回 ErrTooLarge。
461//
462// 关键点:先用 cat-file -s 拿对象大小再决定是否读,让超大文件永远不进入
463// 内存;即便该查询失败(比如极老版本 git),runLimit 的输出上限仍会兜住。
464func BlobLimited(repo, ref, path string, max int) ([]byte, error) {
465 if max > 0 && BlobSize(repo, ref, path) > int64(max) {
466 return nil, ErrTooLarge
467 }
468 return runLimit(context.Background(), repo, max, "show", blobSpec(ref, path))
469}
470
471// BlobReader 以流式方式把文件内容写入 w,用于原始文件下载。
472// 走管道而不是整份读进内存,因此下载 GB 级文件也不会把进程撑爆。
473func BlobReader(repo, ref, path string, w io.Writer) error {
474 ctx, cancel := context.WithTimeout(context.Background(), commandTimeout())
475 defer cancel()
476 cmd := exec.CommandContext(ctx, "git", "cat-file", "blob", blobSpec(ref, path))
477 cmd.Dir = repo
478 cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "LC_ALL=C")
479 cmd.Stdout = w
480 cmd.Stderr = io.Discard
481 return cmd.Run()
482}
483
484func blobSpec(ref, path string) string {
485 return ref + ":" + strings.Trim(path, "/")
486}
487
488// BlobSize 返回文件字节数。
489func BlobSize(repo, ref, path string) int64 {
490 out, err := run(repo, "cat-file", "-s", blobSpec(ref, path))
491 if err != nil {
492 return 0
493 }
494 n, _ := strconv.ParseInt(strings.TrimSpace(string(out)), 10, 64)
495 return n
496}
497
498// maxCountedLines 是行数统计的扫描上限:超过就返回上限值,
499// 免得在一个 10 亿行的文件上空转。
500const maxCountedLines = 5_000_000
501
502// maxScanToken 是行扫描的单行上限。超过它就算「一行」,不再继续读。
503const maxScanToken = 1 << 20
504
505// LineCount 流式统计文件行数,不把文件读进内存。
506func LineCount(repo, ref, path string) (int, error) {
507 ctx, cancel := context.WithTimeout(context.Background(), commandTimeout())
508 defer cancel()
509 cmd := exec.CommandContext(ctx, "git", "cat-file", "blob", blobSpec(ref, path))
510 cmd.Dir = repo
511 cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "LC_ALL=C")
512 // 兜底:万一下面哪个环节没能让 git 自行退出,2 秒后强制杀掉,
513 // 绝不让一个「统计行数」的只读操作挂住整个请求。
514 cmd.WaitDelay = 2 * time.Second
515
516 pipe, err := cmd.StdoutPipe()
517 if err != nil {
518 return 0, err
519 }
520 if err := cmd.Start(); err != nil {
521 return 0, err
522 }
523
524 sc := bufio.NewScanner(pipe)
525 sc.Buffer(make([]byte, 0, 64<<10), maxScanToken)
526 n := 0
527 for sc.Scan() {
528 n++
529 if n >= maxCountedLines {
530 break
531 }
532 }
533 scanErr := sc.Err()
534
535 // 关键:先关掉读端。scanner 提前停止(超长行、达到行数上限、出错)后
536 // 没人再读管道,git 会阻塞在 write 上;此时 Wait 会一直等到命令超时。
537 // 关掉读端后 git 立刻收到 EPIPE 退出,Wait 才能正常返回。
538 _ = pipe.Close()
539 _ = cmd.Wait()
540
541 switch {
542 case scanErr == nil:
543 return n, nil
544 case errors.Is(scanErr, bufio.ErrTooLong) && n >= 0:
545 // 触到单行上限:这行本身算一行,只是没数完。
546 return n + 1, nil
547 default:
548 return n, nil
549 }
550}
551
552// IsBinary 用内容启发式判断是否为二进制文件。
553func IsBinary(b []byte) bool {
554 n := len(b)
555 if n > 8000 {
556 n = 8000
557 }
558 return bytes.IndexByte(b[:n], 0) >= 0
559}
560
561// LastCommits 返回目录下每个文件的最近一次提交(单次 git 调用,最多回看 limit 个提交)。
562func LastCommits(repo, ref, dir string, limit int) map[string]Commit {
563 // 字段顺序必须与 parseCommitLine 保持一致,否则整条记录会被判为非法而丢弃。
564 args := []string{"log", "--no-color", "--name-only", "--format=%x1e%H%x1f%an%x1f%ae%x1f%at%x1f%P%x1f%s",
565 "-n", strconv.Itoa(limit), ref}
566 if dir != "" {
567 args = append(args, "--", strings.Trim(dir, "/"))
568 }
569 out, err := run(repo, args...)
570 if err != nil {
571 return nil
572 }
573 result := map[string]Commit{}
574 var cur *Commit
575 for _, line := range strings.Split(string(out), "\n") {
576 line = strings.TrimRight(line, "\r")
577 if strings.HasPrefix(line, "\x1e") {
578 if c, ok := parseCommitLine(line); ok {
579 cur = &c
580 }
581 continue
582 }
583 if strings.TrimSpace(line) == "" || cur == nil {
584 continue
585 }
586 if _, seen := result[line]; !seen {
587 result[line] = *cur
588 }
589 }
590 return result
591}
592
593// FileStat 是单文件增删行统计。
594type FileStat struct {
595 Path string
596 Additions int
597 Deletions int
598 Binary bool
599}
600
601// CommitDetail 是提交详情。
602type CommitDetail struct {
603 Commit
604 Message string
605 Files []FileStat
606 Diff string
607 Truncated bool
608}
609
610// Show 读取一次提交的完整信息。
611func Show(repo, sha string) (*CommitDetail, error) {
612 out, err := run(repo, "show", "-s", "--no-color", "--format=%H%x1f%an%x1f%ae%x1f%at%x1f%P%x1f%B", sha)
613 if err != nil {
614 return nil, err
615 }
616 raw := strings.TrimRight(string(out), "\n")
617 f := strings.SplitN(raw, "\x1f", 6)
618 if len(f) < 6 {
619 return nil, errors.New("无法解析提交信息")
620 }
621 ts, _ := strconv.ParseInt(f[3], 10, 64)
622 d := &CommitDetail{}
623 d.SHA = f[0]
624 d.Author = f[1]
625 d.Email = f[2]
626 d.When = time.Unix(ts, 0)
627 d.Parents = strings.Fields(f[4])
628 d.Subject = strings.SplitN(f[5], "\n", 2)[0]
629 d.Message = f[5]
630
631 if ns, err := run(repo, "show", "--numstat", "--format=", "--no-color", sha); err == nil {
632 for _, line := range strings.Split(string(ns), "\n") {
633 parts := strings.SplitN(strings.TrimRight(line, "\r"), "\t", 3)
634 if len(parts) != 3 || parts[2] == "" {
635 continue
636 }
637 st := FileStat{Path: parts[2]}
638 if parts[0] == "-" {
639 st.Binary = true
640 } else {
641 st.Additions, _ = strconv.Atoi(parts[0])
642 st.Deletions, _ = strconv.Atoi(parts[1])
643 }
644 d.Files = append(d.Files, st)
645 }
646 }
647
648 buf := &limitedBuffer{max: 400 << 10}
649 if out, err := runLimit(context.Background(), repo, 400<<10, "show", "--patch", "--no-color", "--format=", "-M", sha); err == nil {
650 buf.Write(out)
651 }
652 d.Diff = buf.String()
653 d.Truncated = buf.truncated
654 return d, nil
655}
656
657type limitedBuffer struct {
658 buf bytes.Buffer
659 max int
660 truncated bool
661}
662
663func (l *limitedBuffer) Write(p []byte) (int, error) {
664 if l.buf.Len() >= l.max {
665 l.truncated = true
666 return len(p), nil
667 }
668 room := l.max - l.buf.Len()
669 if room < len(p) {
670 l.truncated = true
671 l.buf.Write(p[:room])
672 return len(p), nil
673 }
674 l.buf.Write(p)
675 return len(p), nil
676}
677
678func (l *limitedBuffer) String() string { return l.buf.String() }
679
680// Contributor 是贡献者统计。
681type Contributor struct {
682 Name string
683 Email string
684 Count int
685}
686
687// Contributors 统计贡献者(按提交数排序)。
688func Contributors(repo, ref string, limit int) []Contributor {
689 out, err := run(repo, "shortlog", "-sne", ref)
690 if err != nil {
691 return nil
692 }
693 var list []Contributor
694 for _, line := range strings.Split(string(out), "\n") {
695 line = strings.TrimRight(line, "\r")
696 if strings.TrimSpace(line) == "" {
697 continue
698 }
699 f := strings.SplitN(line, "\t", 2)
700 if len(f) != 2 {
701 continue
702 }
703 count, _ := strconv.Atoi(strings.TrimSpace(f[0]))
704 name, email := f[1], ""
705 if i := strings.LastIndex(f[1], "<"); i > 0 {
706 name = strings.TrimSpace(f[1][:i])
707 email = strings.Trim(strings.TrimSpace(f[1][i:]), "<>")
708 }
709 list = append(list, Contributor{Name: name, Email: email, Count: count})
710 }
711 if limit > 0 && len(list) > limit {
712 list = list[:limit]
713 }
714 return list
715}
716
717// LangStat 是语言占比统计。
718type LangStat struct {
719 Name string
720 Color string
721 Files int
722 Percent float64
723}
724
725var langByExt = map[string][2]string{
726 ".go": {"Go", "#00ADD8"},
727 ".js": {"JavaScript", "#F1E05A"},
728 ".mjs": {"JavaScript", "#F1E05A"},
729 ".cjs": {"JavaScript", "#F1E05A"},
730 ".jsx": {"JavaScript", "#F1E05A"},
731 ".ts": {"TypeScript", "#3178C6"},
732 ".tsx": {"TypeScript", "#3178C6"},
733 ".py": {"Python", "#3572A5"},
734 ".java": {"Java", "#B07219"},
735 ".kt": {"Kotlin", "#A97BFF"},
736 ".rb": {"Ruby", "#701516"},
737 ".rs": {"Rust", "#DEA584"},
738 ".c": {"C", "#555555"},
739 ".h": {"C", "#555555"},
740 ".cpp": {"C++", "#F34B7D"},
741 ".cc": {"C++", "#F34B7D"},
742 ".hpp": {"C++", "#F34B7D"},
743 ".cs": {"C#", "#178600"},
744 ".php": {"PHP", "#4F5D95"},
745 ".swift": {"Swift", "#F05138"},
746 ".sh": {"Shell", "#89E051"},
747 ".bash": {"Shell", "#89E051"},
748 ".html": {"HTML", "#E34C26"},
749 ".css": {"CSS", "#563D7C"},
750 ".scss": {"SCSS", "#C6538C"},
751 ".vue": {"Vue", "#41B883"},
752 ".svelte": {"Svelte", "#FF3E00"},
753 ".dart": {"Dart", "#00B4AB"},
754 ".sql": {"SQL", "#E38C00"},
755 ".lua": {"Lua", "#000080"},
756 ".r": {"R", "#198CE7"},
757 ".ex": {"Elixir", "#6E4A7E"},
758 ".erl": {"Erlang", "#B83998"},
759 ".scala": {"Scala", "#C22D40"},
760 ".pl": {"Perl", "#0298C3"},
761 ".zig": {"Zig", "#EC915C"},
762 ".proto": {"Protocol Buffers", "#6E4A7E"},
763}
764
765// Languages 统计代码语言占比(按文件数)。
766func Languages(repo, ref string, top int) []LangStat {
767 out, err := run(repo, "ls-tree", "-r", "--name-only", ref)
768 if err != nil {
769 return nil
770 }
771 counts := map[string]int{}
772 total := 0
773 for _, line := range strings.Split(string(out), "\n") {
774 line = strings.TrimRight(line, "\r")
775 if strings.TrimSpace(line) == "" {
776 continue
777 }
778 ext := strings.ToLower(filepath.Ext(line))
779 lang, ok := langByExt[ext]
780 if !ok {
781 continue
782 }
783 counts[lang[0]]++
784 total++
785 }
786 if total == 0 {
787 return nil
788 }
789 var list []LangStat
790 for name, n := range counts {
791 color := "#888888"
792 for _, v := range langByExt {
793 if v[0] == name {
794 color = v[1]
795 break
796 }
797 }
798 list = append(list, LangStat{Name: name, Color: color, Files: n, Percent: float64(n) / float64(total) * 100})
799 }
800 sort.Slice(list, func(i, j int) bool { return list[i].Files > list[j].Files })
801 if top > 0 && len(list) > top {
802 list = list[:top]
803 }
804 return list
805}
806
807// readmeNames 是 README 候选文件名。
808var readmeNames = []string{"readme.md", "readme.markdown", "readme.rst", "readme.txt", "readme"}
809
810// maxReadmeSize 限制 README 的读取体积:README 会被整份渲染进页面,
811// 没必要也没有安全理由把一个 100MB 的 README 读进内存。
812const maxReadmeSize = 2 << 20
813
814// Readme 查找并返回根目录的 README 内容。
815func Readme(repo, ref string) (string, []byte, bool) {
816 entries, err := Tree(repo, ref, "")
817 if err != nil {
818 return "", nil, false
819 }
820 for _, want := range readmeNames {
821 for _, e := range entries {
822 if !e.IsDir() && strings.ToLower(e.Name) == want {
823 content, err := BlobLimited(repo, ref, e.Name, maxReadmeSize)
824 if err != nil {
825 // 过大或读取失败都视为"没有可展示的 README",页面继续渲染其他内容。
826 return "", nil, false
827 }
828 return e.Name, content, true
829 }
830 }
831 }
832 return "", nil, false
833}
834
835// RepoSize 统计仓库目录占用字节数。
836func RepoSize(repo string) int64 {
837 var total int64
838 _ = filepath.WalkDir(repo, func(path string, d fs.DirEntry, err error) error {
839 if err != nil {
840 return nil
841 }
842 if !d.IsDir() {
843 if info, err := d.Info(); err == nil {
844 total += info.Size()
845 }
846 }
847 return nil
848 })
849 return total
850}
851
852// CommitsBetween 返回 old..new 之间的提交(含每个提交的增删行数)。
853func CommitsBetween(repo, oldSHA, newSHA string, limit int) ([]PushCommit, error) {
854 rng := newSHA
855 if oldSHA != "" && oldSHA != strings.Repeat("0", 40) {
856 rng = oldSHA + ".." + newSHA
857 }
858 args := []string{"log", "--no-color", "--numstat", "--format=" + logFormat, "-n", strconv.Itoa(limit), rng}
859 out, err := run(repo, args...)
860 if err != nil {
861 if oldSHA != "" {
862 // 可能是新分支 / 强制推送,退化为只看新提交
863 return CommitsBetween(repo, "", newSHA, limit)
864 }
865 return nil, err
866 }
867 var commits []PushCommit
868 var cur *PushCommit
869 for _, line := range strings.Split(string(out), "\n") {
870 line = strings.TrimRight(line, "\r")
871 if c, ok := parseCommitLine(line); ok {
872 commits = append(commits, PushCommit{SHA: c.SHA, Author: c.Author, When: c.When.Unix(), Subject: c.Subject})
873 cur = &commits[len(commits)-1]
874 continue
875 }
876 if cur == nil || strings.TrimSpace(line) == "" {
877 continue
878 }
879 parts := strings.SplitN(line, "\t", 3)
880 if len(parts) != 3 {
881 continue
882 }
883 cur.Files++
884 if parts[0] == "-" {
885 continue
886 }
887 add, _ := strconv.Atoi(parts[0])
888 del, _ := strconv.Atoi(parts[1])
889 cur.Additions += add
890 cur.Deletions += del
891 }
892 return commits, nil
893}
894
895// PushCommit 是用于留痕展示的提交摘要。
896type PushCommit struct {
897 SHA string
898 Subject string
899 Author string
900 When int64
901 Additions int
902 Deletions int
903 Files int
904}
905
906// Short 返回短 SHA。
907func (c PushCommit) Short() string {
908 if len(c.SHA) > 7 {
909 return c.SHA[:7]
910 }
911 return c.SHA
912}
913
914// DiffStat 返回两次提交之间的变更统计文本。
915func DiffStat(repo, oldSHA, newSHA string) string {
916 if oldSHA == "" || oldSHA == strings.Repeat("0", 40) {
917 oldSHA = EmptyTreeSHA
918 }
919 if newSHA == "" || newSHA == strings.Repeat("0", 40) {
920 return ""
921 }
922 out, err := run(repo, "diff", "--stat", "--no-color", oldSHA, newSHA)
923 if err != nil {
924 return ""
925 }
926 return strings.TrimSpace(string(out))
927}
928
929// SetDefaultBranch 修改裸仓库的 HEAD 指向。
930func SetDefaultBranch(repo, branch string) error {
931 if !ValidRef(branch) {
932 return fmt.Errorf("非法的分支名: %q", branch)
933 }
934 _, err := run(repo, "symbolic-ref", "HEAD", "refs/heads/"+branch)
935 return err
936}
937
938// CurrentBranch 读取裸仓库 HEAD 指向的分支。
939func CurrentBranch(repo string) string {
940 out, err := run(repo, "symbolic-ref", "--short", "HEAD")
941 if err != nil {
942 return ""
943 }
944 return strings.TrimSpace(string(out))
945}
946
947// HasCommits 判断仓库是否有任何提交。
948func HasCommits(repo string) bool {
949 refs, err := Refs(repo)
950 if err != nil {
951 return false
952 }
953 for name := range refs {
954 if strings.HasPrefix(name, "refs/heads/") || strings.HasPrefix(name, "refs/tags/") {
955 return true
956 }
957 }
958 return false
959}
960
961// CopyReader 用于把请求体透传给 git 命令。
962func CopyReader(dst io.Writer, src io.Reader) error {
963 _, err := io.Copy(dst, src)
964 return err
965}