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")
|
||||
|
||||
Reference in New Issue
Block a user