feat: 提交详情能看 diff,也能丢给 AI 审这一次改动。文件检查可单独排除路径,TODO 只认大写,避免把 todo 表和模块当成待办标记。
热力图按容器宽度铺满一年,不再横向滚动。托盘右键换成可换皮肤的弹层;更新下载显示进度,退出后再由脚本拉起安装器,避免还占着 exe。
This commit is contained in:
173
service/git.go
173
service/git.go
@@ -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
68
service/git_patch_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
66
service/insights_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user