feat: 提交详情能看 diff,也能丢给 AI 审这一次改动。文件检查可单独排除路径,TODO 只认大写,避免把 todo 表和模块当成待办标记。

热力图按容器宽度铺满一年,不再横向滚动。托盘右键换成可换皮肤的弹层;更新下载显示进度,退出后再由脚本拉起安装器,避免还占着 exe。
This commit is contained in:
李琦
2026-08-19 15:46:03 +08:00
parent 1694397245
commit 7c4109f687
38 changed files with 2618 additions and 129 deletions

View File

@@ -3,6 +3,7 @@ package service
import (
"context"
"errors"
"fmt"
"os/exec"
"sort"
"strconv"
@@ -259,9 +260,181 @@ func (g GitService) CommitDetail(ctx context.Context, dir, hash string) (model.G
d.Added += a
d.Deleted += del
}
if patch, e := g.run(ctx, dir, "show", "--format=", "--patch", "--no-color", "--unified=3", hash); e == nil {
attachCommitPatches(&d, patch)
}
return d, nil
}
const (
maxPatchLines = 500
maxPatchBytes = 80_000
maxPatchBytesTotal = 400_000
)
type parsedPatch struct {
Path string
Text string
Binary bool
Truncated bool
}
func attachCommitPatches(d *model.GitCommitDetail, raw string) {
patches := parseCommitPatches(raw)
used := 0
for i := range d.Files {
key := patchFileKey(d.Files[i].Path)
p, ok := patches[key]
if !ok {
p, ok = patches[d.Files[i].Path]
}
if !ok {
continue
}
d.Files[i].Binary = p.Binary
if p.Binary {
continue
}
if used+len(p.Text) > maxPatchBytesTotal {
d.Files[i].Truncated = true
continue
}
d.Files[i].Patch = p.Text
d.Files[i].Truncated = p.Truncated
used += len(p.Text)
}
}
func patchFileKey(path string) string {
if i := strings.LastIndex(path, " => "); i >= 0 {
return strings.Trim(path[i+4:], "{}")
}
return path
}
func parseCommitPatches(raw string) map[string]parsedPatch {
out := map[string]parsedPatch{}
raw = strings.ReplaceAll(strings.TrimSpace(raw), "\r\n", "\n")
if raw == "" {
return out
}
parts := strings.Split(raw, "\ndiff --git ")
for i, part := range parts {
if i == 0 {
if !strings.HasPrefix(part, "diff --git ") {
continue
}
} else {
part = "diff --git " + part
}
p := parseOneDiff(part)
if p.Path == "" {
continue
}
out[p.Path] = p
}
return out
}
func parseOneDiff(block string) parsedPatch {
p := parsedPatch{Path: diffPath(block)}
if strings.Contains(block, "Binary files ") || strings.Contains(block, "GIT binary patch") {
p.Binary = true
return p
}
lines := strings.Split(block, "\n")
kept := make([]string, 0, len(lines))
for _, line := range lines {
if strings.HasPrefix(line, "diff --git ") || strings.HasPrefix(line, "index ") || strings.HasPrefix(line, "new file mode ") || strings.HasPrefix(line, "deleted file mode ") || strings.HasPrefix(line, "similarity index ") || strings.HasPrefix(line, "rename ") || strings.HasPrefix(line, "copy ") {
continue
}
kept = append(kept, line)
}
text := strings.Join(kept, "\n")
if n := strings.Count(text, "\n") + 1; n > maxPatchLines {
kept = strings.Split(text, "\n")[:maxPatchLines]
text = strings.Join(kept, "\n")
p.Truncated = true
}
if len(text) > maxPatchBytes {
text = text[:maxPatchBytes]
p.Truncated = true
}
p.Text = strings.TrimRight(text, "\n")
return p
}
func diffPath(block string) string {
for _, line := range strings.Split(block, "\n") {
if strings.HasPrefix(line, "+++ b/") {
return strings.TrimPrefix(line, "+++ b/")
}
if strings.HasPrefix(line, "+++ /dev/null") {
continue
}
if strings.HasPrefix(line, "--- a/") {
fallback := strings.TrimPrefix(line, "--- a/")
if fallback != "/dev/null" {
// 删除文件没有 +++ b/,用旧路径
if !strings.Contains(block, "+++ b/") {
return fallback
}
}
}
}
first, _, _ := strings.Cut(block, "\n")
rest := strings.TrimPrefix(first, "diff --git ")
if i := strings.LastIndex(rest, " b/"); i >= 0 {
return strings.Trim(rest[i+3:], `"`)
}
return ""
}
const aiDiffMaxBytes = 80_000
// FormatCommitDiffForAI 把提交明细收成给模型看的文本,超长时省略部分文件 patch。
func FormatCommitDiffForAI(d model.GitCommitDetail, maxBytes int) string {
if maxBytes <= 0 {
maxBytes = aiDiffMaxBytes
}
var b strings.Builder
fmt.Fprintf(&b, "## 本次提交\n- 哈希: %s\n- 作者: %s <%s>\n- 时间: %s\n- 说明: %s\n- 变更: +%d / -%d共 %d 个文件\n",
d.Hash, d.Author, d.Email, d.Date, d.Message, d.Added, d.Deleted, len(d.Files))
b.WriteString("\n## 变更文件\n")
for i, f := range d.Files {
if i >= 80 {
fmt.Fprintf(&b, "- … 其余 %d 个文件已省略\n", len(d.Files)-i)
break
}
note := ""
if f.Binary {
note = " [binary]"
} else if f.Truncated {
note = " [truncated]"
}
fmt.Fprintf(&b, "- %s %s +%d/-%d%s\n", f.Status, f.Path, f.Added, f.Deleted, note)
}
b.WriteString("\n## Diff\n")
used := 0
omitted := 0
for _, f := range d.Files {
if f.Binary || f.Patch == "" {
continue
}
block := fmt.Sprintf("### %s\n```diff\n%s\n```\n", f.Path, f.Patch)
if used+len(block) > maxBytes {
omitted++
continue
}
b.WriteString(block)
used += len(block)
}
if omitted > 0 {
fmt.Fprintf(&b, "\n另有 %d 个文件的 diff 因过长未纳入)\n", omitted)
}
return b.String()
}
// CheckoutBranch 在修改工作区前先检查已跟踪和未跟踪变更;发现任何内容都拒绝切换。
func (g GitService) CheckoutBranch(ctx context.Context, dir, ref string) (model.CheckoutResult, error) {
dirty, e := g.run(ctx, dir, "status", "--porcelain")