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")

68
service/git_patch_test.go Normal file
View File

@@ -0,0 +1,68 @@
package service
import (
"strings"
"testing"
"view/model"
)
func TestParseCommitPatches(t *testing.T) {
raw := `diff --git a/main.go b/main.go
index 111..222 100644
--- a/main.go
+++ b/main.go
@@ -1,2 +1,3 @@
package main
+// feature
`
got := parseCommitPatches(raw)
p, ok := got["main.go"]
if !ok || p.Binary || p.Text == "" {
t.Fatalf("patch=%#v", got)
}
if !strings.Contains(p.Text, "+// feature") {
t.Fatalf("missing added line: %q", p.Text)
}
}
func TestParseCommitPatchesBinaryAndDelete(t *testing.T) {
raw := `diff --git a/logo.png b/logo.png
index 111..222 100644
Binary files a/logo.png and b/logo.png differ
diff --git a/gone.go b/gone.go
deleted file mode 100644
index 111..000
--- a/gone.go
+++ /dev/null
@@ -1 +0,0 @@
-package gone
`
got := parseCommitPatches(raw)
if !got["logo.png"].Binary {
t.Fatalf("binary not marked: %#v", got["logo.png"])
}
if got["gone.go"].Text == "" || got["gone.go"].Binary {
t.Fatalf("deleted file patch=%#v", got["gone.go"])
}
}
func TestFormatCommitDiffForAI(t *testing.T) {
d := model.GitCommitDetail{
GitCommit: model.GitCommit{Hash: "abc123", Author: "Ann", Email: "a@b.c", Date: "2026-08-19", Message: "add feature", Added: 2, Deleted: 1},
Files: []model.GitFileChange{
{Path: "main.go", Status: "modified", Added: 2, Deleted: 1, Patch: "+// feature\n-old"},
{Path: "logo.png", Status: "modified", Binary: true},
},
}
text := FormatCommitDiffForAI(d, 0)
if !strings.Contains(text, "add feature") || !strings.Contains(text, "main.go") || !strings.Contains(text, "+// feature") {
t.Fatalf("unexpected prompt:\n%s", text)
}
if !strings.Contains(text, "[binary]") {
t.Fatal("binary file should be listed")
}
tiny := FormatCommitDiffForAI(d, 80)
if !strings.Contains(tiny, "因过长未纳入") && !strings.Contains(tiny, "add feature") {
t.Fatalf("truncated prompt:\n%s", tiny)
}
}

View File

@@ -21,7 +21,7 @@ const (
// 它不会重新遍历整个仓库,只读取已记录的小型源码文件,用于发现 TODO、长文件等轻量风险。
type InsightService struct{}
func (InsightService) Analyze(ctx context.Context, project model.Project, structure model.StructureStats, git model.GitStats) (model.ProjectInsights, error) {
func (InsightService) Analyze(ctx context.Context, project model.Project, structure model.StructureStats, git model.GitStats, excludes []model.ExclusionRule) (model.ProjectInsights, error) {
desc, e := platform.ResolvePath(project.Path)
if e != nil {
return model.ProjectInsights{}, e
@@ -40,6 +40,7 @@ func (InsightService) Analyze(ctx context.Context, project model.Project, struct
}
checkLanguageHealth(project.Languages, add)
structure = filterStructureForInsights(structure, excludes)
out.Summary.LargeFiles = len(structure.LargeFiles)
checkStructureHealth(structure, add)
checkGitHealth(git, add)
@@ -162,7 +163,7 @@ func inspectTextFile(path string) (int, []model.InsightIssue) {
kind, severity = "FIXME", "medium"
case strings.Contains(upper, "HACK"):
kind, severity = "HACK", "medium"
case strings.Contains(upper, "TODO"):
case hasUpperTODO(text):
kind = "TODO"
}
if kind != "" {
@@ -172,6 +173,70 @@ func inspectTextFile(path string) (int, []model.InsightIssue) {
return line, issues
}
func filterStructureForInsights(s model.StructureStats, rules []model.ExclusionRule) model.StructureStats {
if len(rules) == 0 {
return s
}
out := s
out.Files = out.Files[:0:0]
out.LargeFiles = out.LargeFiles[:0:0]
out.Folders = out.Folders[:0:0]
for _, f := range s.Files {
if pathExcluded(f.Path, rules) {
continue
}
out.Files = append(out.Files, f)
}
for _, f := range s.LargeFiles {
if pathExcluded(f.Path, rules) {
continue
}
out.LargeFiles = append(out.LargeFiles, f)
}
for _, f := range s.Folders {
if pathExcluded(f.Name, rules) {
continue
}
out.Folders = append(out.Folders, f)
}
return out
}
func pathExcluded(path string, rules []model.ExclusionRule) bool {
path = strings.TrimSpace(path)
if path == "" || len(rules) == 0 {
return false
}
slash := filepath.ToSlash(path)
for _, r := range rules {
if MatchExcludedPattern(r.Pattern, slash) {
return true
}
}
return false
}
// hasUpperTODO 只认大写 TODO 标记,避免命中 todo 表名、模块名或变量。
func hasUpperTODO(text string) bool {
for i := 0; i <= len(text)-4; i++ {
if text[i:i+4] != "TODO" {
continue
}
if i > 0 && isIdentByte(text[i-1]) {
continue
}
if i+4 < len(text) && isIdentByte(text[i+4]) {
continue
}
return true
}
return false
}
func isIdentByte(b byte) bool {
return b == '_' || (b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z') || (b >= '0' && b <= '9')
}
func isInsightTextFile(ext string) bool {
switch strings.ToLower(ext) {
case ".go", ".php", ".js", ".ts", ".vue", ".jsx", ".tsx", ".css", ".scss", ".sass", ".less", ".html", ".md", ".json", ".yaml", ".yml", ".xml", ".sql", ".py", ".java", ".cs", ".rb", ".rs", ".c", ".cpp", ".h":

66
service/insights_test.go Normal file
View File

@@ -0,0 +1,66 @@
package service
import (
"testing"
"view/model"
)
func TestHasUpperTODO(t *testing.T) {
cases := []struct {
text string
want bool
}{
{"// TODO: fix later", true},
{"# TODO", true},
{" TODO()", true},
{"create table todo", false},
{"type TodoService struct {}", false},
{"const todos = []", false},
{"TODOS := 1", false},
{"myTODO", false},
{"TODO_ITEM", false},
{"// todo: later", false},
{"// Todo: later", false},
}
for _, c := range cases {
if got := hasUpperTODO(c.text); got != c.want {
t.Errorf("hasUpperTODO(%q) = %v, want %v", c.text, got, c.want)
}
}
}
func TestPathExcludedAndFilterStructure(t *testing.T) {
rules := []model.ExclusionRule{{Pattern: "vendor"}, {Pattern: "src/todo"}, {Pattern: "*.pb.go"}}
if !pathExcluded("vendor/lib.go", rules) {
t.Fatal("vendor dir should be excluded")
}
if !pathExcluded("src/todo/list.go", rules) {
t.Fatal("src/todo dir should be excluded")
}
if !pathExcluded("api/user.pb.go", rules) {
t.Fatal("*.pb.go should be excluded")
}
if pathExcluded("src/app/main.go", rules) {
t.Fatal("normal source should stay")
}
s := model.StructureStats{
Files: []model.FileEntry{
{Path: "src/app/main.go"},
{Path: "vendor/lib.go"},
{Path: "src/todo/list.go"},
},
LargeFiles: []model.FileEntry{{Path: "vendor/huge.bin"}},
Folders: []model.FolderStat{{Name: "vendor"}, {Name: "src"}},
}
got := filterStructureForInsights(s, rules)
if len(got.Files) != 1 || got.Files[0].Path != "src/app/main.go" {
t.Fatalf("files=%#v", got.Files)
}
if len(got.LargeFiles) != 0 {
t.Fatalf("large=%#v", got.LargeFiles)
}
if len(got.Folders) != 1 || got.Folders[0].Name != "src" {
t.Fatalf("folders=%#v", got.Folders)
}
}

View File

@@ -9,6 +9,7 @@ import (
gitignore "github.com/sabhiram/go-gitignore"
"io/fs"
"os"
"path"
"path/filepath"
"sort"
"strings"
@@ -23,12 +24,22 @@ type Scanner struct{}
func MatchExcludedPattern(pattern, rel string) bool {
pattern = filepath.ToSlash(strings.TrimSpace(pattern))
rel = filepath.ToSlash(rel)
if pattern == "" || rel == "" {
return false
}
if strings.Contains(pattern, "/") {
ok, _ := filepath.Match(filepath.FromSlash(pattern), filepath.FromSlash(rel))
pattern = strings.TrimSuffix(pattern, "/")
if ok, _ := path.Match(pattern, rel); ok {
return true
}
if strings.HasPrefix(rel, pattern+"/") {
return true
}
ok, _ := path.Match(pattern+"/*", rel)
return ok
}
for _, part := range strings.Split(rel, "/") {
if ok, _ := filepath.Match(pattern, part); ok {
if ok, _ := path.Match(pattern, part); ok {
return true
}
}