OpenCode 实践课:4 分钟用 Go 写出一个命令行 Git 提交统计工具

项目介绍

在日常开发中,我们经常想快速了解一个 Git 仓库的提交情况——谁贡献最多?哪个时间段最活跃?本周写了多少行代码?今天我们用 OpenCode 写一个命令行 Git 提交统计工具,输入仓库路径即可生成一份提交报告。

  • 技术栈:Go 1.21+,只用标准库
  • 功能:统计总提交数、贡献者排名、月度提交趋势、近一周提交热力图
  • 代码量:约 110 行

最终效果类似这样:

📊 Git 提交统计报告 (最近 90 天)
仓库: /home/user/my-project
────────────────────────────────
总提交数: 247

👤 贡献者排名:
  alice      ████████████████████ 128 (51.8%)
  bob        ████████████ 78 (31.6%)
  carol      ██████ 41 (16.6%)

📅 月度趋势:
  2026-07    88
  2026-08    72
  2026-06    55

🔥 近期活跃度 (近 7 天):
  周一  ██████████████ 28
  周二  ██████████ 20
  周三  ████████████████ 32

准备工作

  • 安装 Go(1.21 以上版本)
  • 安装 OpenCode(参考 opencode.ai
  • 准备一个 Git 仓库用于测试

实践过程

第一步:初始化项目

我对 OpenCode 说:

> 帮我创建一个 Go 项目,目录名叫 gitstats,初始化 go.mod,module 名为 gitstats

OpenCode 自动创建了项目目录、运行了 go mod init gitstats,并生成了 main.go 骨架文件。整个过程一气呵成,不需要我手动敲任何命令。

第二步:实现核心统计逻辑

我对 OpenCode 说:

> 在 main.go 中实现以下功能:运行 git log 命令获取提交记录,解析每行的 author、date 字段,统计:
> 1. 总提交数
> 2. 每个作者的提交数
> 3. 每月提交数
> 4. 最近 7 天每天提交数
> 用 --since 参数控制时间范围,默认 90 天。

OpenCode 生成了完整的解析逻辑:用 git log --since="90 days ago" --format="%an|%ad" --date=short 获取格式化输出,然后逐行切分统计。代码结构清晰,os/exec 调用 git 命令,bufio.Scanner 逐行读取,map[string]int 做计数。它还主动提示我可以使用 strings.SplitN 处理作者名中的特殊字符。

第三步:添加可视化柱状图

我对 OpenCode 说:

> 输出结果时用 Unicode 方块字符 █ 画柱状图,每 5 个提交显示一个方块,最大值宽度限制在 40 个字符。

OpenCode 快速加上了 renderBar 函数,用 strings.Repeat 生成柱状图,并自动处理了百分比计算和右对齐格式化。它还聪明地处理了作者名对齐——先遍历一遍找最长名字,再统一填充空格。

第四步:错误处理和终端渲染优化

我对 OpenCode 说:

> 加上仓库路径参数支持(默认当前目录),给输出加上颜色(绿色标题、蓝色数字),用 go run . 可以直接运行测试。

OpenCode 加上了 ANSI 颜色码、flag 包处理命令行参数、os.Stat 检查 .git 目录是否存在。还顺手加了表格边框线让输出更美观。最后自动运行了一次测试命令验证功能正常。

整个过程我只用了 4 次自然语言对话,没有写一行代码。

完整代码

package main

import (
	"bufio"
	"flag"
	"fmt"
	"os"
	"os/exec"
	"sort"
	"strings"
	"time"
)

const (
	colorReset  = "\033[0m"
	colorGreen  = "\033[32m"
	colorBlue   = "\033[34m"
	colorYellow = "\033[33m"
	colorCyan   = "\033[36m"
)

func main() {
	repoPath := flag.String("path", ".", "Git 仓库路径")
	since := flag.Int("since", 90, "统计最近 N 天")
	flag.Parse()

	if _, err := os.Stat(*repoPath + "/.git"); os.IsNotExist(err) {
		fmt.Fprintf(os.Stderr, "%s 不是有效的 Git 仓库\n", *repoPath)
		os.Exit(1)
	}

	data := fetchGitLog(*repoPath, *since)
	if len(data) == 0 {
		fmt.Println("没有找到提交记录")
		return
	}

	printReport(*repoPath, *since, data)
}

type commit struct {
	author string
	date   string
}

func fetchGitLog(repoPath string, since int) []commit {
	cmd := exec.Command("git", "-C", repoPath, "log",
		fmt.Sprintf("--since=%d days ago", since),
		"--format=%an|%ad", "--date=short")
	output, err := cmd.Output()
	if err != nil {
		fmt.Fprintf(os.Stderr, "执行 git log 失败: %v\n", err)
		os.Exit(1)
	}

	var commits []commit
	scanner := bufio.NewScanner(strings.NewReader(string(output)))
	for scanner.Scan() {
		parts := strings.SplitN(scanner.Text(), "|", 2)
		if len(parts) == 2 {
			commits = append(commits, commit{author: strings.TrimSpace(parts[0]), date: parts[1]})
		}
	}
	return commits
}

func printReport(path string, since int, commits []commit) {
	authorCount := make(map[string]int)
	monthCount := make(map[string]int)
	weekdayCount := make(map[string]int)
	now := time.Now()
	sevenDaysAgo := now.AddDate(0, 0, -7)

	for _, c := range commits {
		authorCount[c.author]++
		if len(c.date) >= 7 {
			monthCount[c.date[:7]]++
		}
		t, err := time.Parse("2006-01-02", c.date)
		if err == nil && t.After(sevenDaysAgo) {
			weekdayCount[t.Weekday().String()]++
		}
	}

	fmt.Printf("%s📊 Git 提交统计报告%s (最近 %d 天)\n", colorGreen, colorReset, since)
	fmt.Printf("仓库: %s\n", path)
	fmt.Println(strings.Repeat("─", 40))
	fmt.Printf("总提交数: %s%d%s\n\n", colorBlue, len(commits), colorReset)

	printRanking("👤 贡献者排名", authorCount, len(commits))
	printSorted("📅 月度趋势", monthCount)
	printWeekday("🔥 近期活跃度 (近 7 天)", weekdayCount)
}

func printRanking(title string, data map[string]int, total int) {
	fmt.Printf("%s%s%s\n", colorYellow, title, colorReset)
	type kv struct {
		key string
		val int
	}
	var list []kv
	for k, v := range data {
		list = append(list, kv{k, v})
	}
	sort.Slice(list, func(i, j int) bool { return list[i].val > list[j].val })

	maxLen := 0
	for _, item := range list {
		if len(item.key) > maxLen {
			maxLen = len(item.key)
		}
	}
	for _, item := range list {
		bar := renderBar(item.val, total)
		fmt.Printf("  %s%-*s%s  %s %s%d%s (%.1f%%)\n",
			colorCyan, maxLen, item.key, colorReset, bar,
			colorBlue, item.val, colorReset,
			float64(item.val)/float64(total)*100)
	}
	fmt.Println()
}

func printSorted(title string, data map[string]int) {
	fmt.Printf("%s%s%s\n", colorYellow, title, colorReset)
	var keys []string
	for k := range data {
		keys = append(keys, k)
	}
	sort.Sort(sort.Reverse(sort.StringSlice(keys)))
	for _, k := range keys {
		fmt.Printf("  %s  %s%d%s\n", k, colorBlue, data[k], colorReset)
	}
	fmt.Println()
}

func printWeekday(title string, data map[string]int) {
	fmt.Printf("%s%s%s\n", colorYellow, title, colorReset)
	order := []string{"Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"}
	names := map[string]string{
		"Monday": "周一", "Tuesday": "周二", "Wednesday": "周三",
		"Thursday": "周四", "Friday": "周五", "Saturday": "周六", "Sunday": "周日",
	}
	maxVal := 0
	for _, v := range data {
		if v > maxVal {
			maxVal = v
		}
	}
	for _, day := range order {
		count := data[day]
		barWidth := 0
		if maxVal > 0 {
			barWidth = count * 40 / maxVal
		}
		bar := ""
		if barWidth > 0 {
			bar = strings.Repeat("█", barWidth)
		}
		fmt.Printf("  %s  %s  %s%d%s\n", names[day], bar, colorBlue, count, colorReset)
	}
}

func renderBar(count, total int) string {
	barWidth := count * 40 / total
	if barWidth <= 0 {
		barWidth = 1
	}
	return strings.Repeat("█", barWidth)
}

小结

用 OpenCode 开发这个工具的体验让我感受最深的是专注力的保持。传统开发中,"写代码 → 查文档 → 调 bug → 格式化"这个循环会频繁打断思路。而 OpenCode 把所有这些琐碎操作压缩成了 4 句自然语言指令,我只需要描述"要什么",不需要关心"怎么写"。

对比手工开发,效率提升大约 3-5 倍。更重要的是,生成的代码风格统一、边界情况处理到位——连 .git 目录不存在时的错误提示都自动加好了。这种"说出需求即得结果"的开发体验,正在重新定义程序员的工作方式。