This commit is contained in:
2026-06-22 08:55:57 +08:00
parent dbb87823e8
commit 6ade6e8fa9
325 changed files with 41131 additions and 855 deletions
+106
View File
@@ -0,0 +1,106 @@
package git
import (
"context"
"fmt"
"strings"
)
// diffMaxBytes caps the raw unified-diff stdout to keep MCP payloads / memory
// bounded. Larger diffs are truncated and flagged via DiffResult.Truncated.
const diffMaxBytes = 1 << 20 // 1 MiB
// DiffOptions controls `git diff` argument building.
//
// - Ref/Against both empty => working-tree vs HEAD (the default).
// - Ref set, Against empty => `git diff <Ref>` (working-tree vs Ref).
// - Ref and Against both set => `git diff <Ref>..<Against>` (two refs).
// - Staged => add `--cached` (index vs HEAD); mutually composes with refs.
// - Paths => restrict to the given pathspecs (passed after `--`).
// - ContextLines => `-U<n>` when > 0 (git default 3 otherwise).
type DiffOptions struct {
Ref string
Against string
Staged bool
Paths []string
ContextLines int
}
// DiffResult carries the raw unified-diff text. The viewer parses it; the git
// layer never parses the hunks. Truncated reports whether stdout exceeded the
// 1 MiB cap and was cut.
type DiffResult struct {
Diff string
Truncated bool
}
// Diff returns the unified diff of dir per opts. The output is the raw `git
// diff --no-color` text (capped at 1 MiB). On a clean tree the diff is empty.
func (r *DefaultRunner) Diff(ctx context.Context, dir string, opts DiffOptions) (DiffResult, error) {
// 安全护栏:ref/against 来自 MCP/HTTP 入参,可能未经分支校验。以 '-' 开头的值会被
// git 当作选项解析(如 --output=、-O<file>),故一律拒绝,杜绝参数注入。
if strings.HasPrefix(opts.Ref, "-") || strings.HasPrefix(opts.Against, "-") {
return DiffResult{}, fmt.Errorf("git diff: ref/against must not begin with '-': %q %q", opts.Ref, opts.Against)
}
ctx, cancel := withCtx(ctx, r.cfg.OpsTimeout)
defer cancel()
args := buildDiffArgs(opts)
out, _, err := r.exec(ctx, dir, nil, args...)
if err != nil {
return DiffResult{}, err
}
res := DiffResult{}
if len(out) > diffMaxBytes {
out = out[:diffMaxBytes]
res.Truncated = true
}
res.Diff = string(out)
return res, nil
}
// buildDiffArgs assembles the `git diff` argument slice from opts. Refs are
// never user-supplied paths (workspace package validates branches), so they are
// passed positionally; pathspecs go after the `--` separator to disambiguate.
func buildDiffArgs(opts DiffOptions) []string {
args := []string{"diff", "--no-color"}
if opts.Staged {
args = append(args, "--cached")
}
if opts.ContextLines > 0 {
args = append(args, "-U"+itoa(opts.ContextLines))
}
switch {
case opts.Ref != "" && opts.Against != "":
args = append(args, opts.Ref+".."+opts.Against)
case opts.Ref != "":
args = append(args, opts.Ref)
}
if len(opts.Paths) > 0 {
args = append(args, "--")
args = append(args, opts.Paths...)
}
return args
}
// itoa is a tiny strconv.Itoa avoiding an import just for one call site.
func itoa(n int) string {
if n == 0 {
return "0"
}
neg := n < 0
if neg {
n = -n
}
var buf [20]byte
i := len(buf)
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
if neg {
i--
buf[i] = '-'
}
return string(buf[i:])
}
+125
View File
@@ -0,0 +1,125 @@
package git
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestBuildDiffArgs(t *testing.T) {
t.Parallel()
cases := []struct {
name string
opts DiffOptions
want []string
}{
{
name: "working tree vs HEAD",
opts: DiffOptions{},
want: []string{"diff", "--no-color"},
},
{
name: "staged",
opts: DiffOptions{Staged: true},
want: []string{"diff", "--no-color", "--cached"},
},
{
name: "single ref",
opts: DiffOptions{Ref: "HEAD~1"},
want: []string{"diff", "--no-color", "HEAD~1"},
},
{
name: "ref range",
opts: DiffOptions{Ref: "main", Against: "feature"},
want: []string{"diff", "--no-color", "main..feature"},
},
{
name: "context lines + paths",
opts: DiffOptions{ContextLines: 5, Paths: []string{"a.txt", "dir/b.go"}},
want: []string{"diff", "--no-color", "-U5", "--", "a.txt", "dir/b.go"},
},
{
name: "staged with ref range and paths",
opts: DiffOptions{Staged: true, Ref: "main", Against: "dev", Paths: []string{"x"}},
want: []string{"diff", "--no-color", "--cached", "main..dev", "--", "x"},
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
require.Equal(t, tc.want, buildDiffArgs(tc.opts))
})
}
}
func TestDiff_WorkingTreeAndStaged(t *testing.T) {
t.Parallel()
gitAvailable(t)
bare := makeBareRepo(t)
r := newTestRunner(t)
dst := filepath.Join(t.TempDir(), "wc")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
require.NoError(t, r.Clone(ctx, dst, bare, "main", nil))
// clean tree => empty diff
res, err := r.Diff(ctx, dst, DiffOptions{})
require.NoError(t, err)
require.Empty(t, res.Diff)
require.False(t, res.Truncated)
// modify a tracked file => unified diff against working tree
require.NoError(t, os.WriteFile(filepath.Join(dst, "README.md"), []byte("hello\nworld\n"), 0o644))
res, err = r.Diff(ctx, dst, DiffOptions{})
require.NoError(t, err)
require.Contains(t, res.Diff, "diff --git")
require.Contains(t, res.Diff, "+world")
// stage it; working-tree diff now empty, --cached shows the change
mustGit(t, dst, "add", "README.md")
res, err = r.Diff(ctx, dst, DiffOptions{})
require.NoError(t, err)
require.Empty(t, res.Diff)
res, err = r.Diff(ctx, dst, DiffOptions{Staged: true})
require.NoError(t, err)
require.Contains(t, res.Diff, "+world")
}
func TestDiff_PathsFilter(t *testing.T) {
t.Parallel()
gitAvailable(t)
bare := makeBareRepo(t)
r := newTestRunner(t)
dst := filepath.Join(t.TempDir(), "wc")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
require.NoError(t, r.Clone(ctx, dst, bare, "main", nil))
require.NoError(t, os.WriteFile(filepath.Join(dst, "README.md"), []byte("changed\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dst, "other.txt"), []byte("new\n"), 0o644))
mustGit(t, dst, "add", "other.txt")
// restrict to README only => other.txt not in output
res, err := r.Diff(ctx, dst, DiffOptions{Paths: []string{"README.md"}})
require.NoError(t, err)
require.Contains(t, res.Diff, "README.md")
require.False(t, strings.Contains(res.Diff, "other.txt"))
}
func TestDiff_ErrorPropagation(t *testing.T) {
t.Parallel()
gitAvailable(t)
r := newTestRunner(t)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// not a git repo => git diff fails, error propagates
_, err := r.Diff(ctx, t.TempDir(), DiffOptions{})
require.Error(t, err)
}
+17
View File
@@ -0,0 +1,17 @@
package git
import "context"
// DiffStat 返回单个 commit 的变更摘要(git show --stat),供自动文档生成喂给
// LLM 一个有界的改动概览。commitSHA 由上层从可信来源(git log / commit 返回值)
// 取得,不接受用户任意输入。--no-color 保证输出稳定,-M 检测重命名。
func (r *DefaultRunner) DiffStat(ctx context.Context, dir, commitSHA string) (string, error) {
ctx, cancel := withCtx(ctx, r.cfg.OpsTimeout)
defer cancel()
out, _, err := r.exec(ctx, dir, nil,
"show", "--no-color", "--stat", "--format=%H%n%an%n%s%n%n%b", "-M", commitSHA)
if err != nil {
return "", err
}
return string(out), nil
}
+34
View File
@@ -0,0 +1,34 @@
package git
import (
"bytes"
"context"
)
// ListTrackedFiles 返回 dir 仓库中被 git 跟踪的文件(git ls-files -z),
// 路径相对 worktree 根,NUL 分隔以正确处理含空格/特殊字符的文件名。
// 仅跟踪文件 —— 忽略 .gitignore / 未跟踪 / vendored,避免索引器遍历海量无关文件。
func (r *DefaultRunner) ListTrackedFiles(ctx context.Context, dir string) ([]string, error) {
ctx, cancel := withCtx(ctx, r.cfg.OpsTimeout)
defer cancel()
out, _, err := r.exec(ctx, dir, nil, "ls-files", "-z")
if err != nil {
return nil, err
}
return parseNULList(out), nil
}
// parseNULList 解析 NUL 分隔的文件列表(git ls-files -z)。空输入返回 nil。
func parseNULList(buf []byte) []string {
if len(buf) == 0 {
return nil
}
var out []string
for _, rec := range bytes.Split(buf, []byte{0}) {
if len(rec) == 0 {
continue
}
out = append(out, string(rec))
}
return out
}
+56
View File
@@ -0,0 +1,56 @@
package git
import (
"context"
"os"
"path/filepath"
"sort"
"testing"
"github.com/stretchr/testify/require"
)
func TestListTrackedFiles(t *testing.T) {
gitAvailable(t)
dir := t.TempDir()
mustGit(t, dir, "init", "-b", "main")
mustGit(t, dir, "config", "user.email", "test@example.com")
mustGit(t, dir, "config", "user.name", "Test")
require.NoError(t, os.WriteFile(filepath.Join(dir, "a.go"), []byte("package a\n"), 0o644))
require.NoError(t, os.MkdirAll(filepath.Join(dir, "sub"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "sub", "b.txt"), []byte("b\n"), 0o644))
// Untracked + ignored files must NOT appear.
require.NoError(t, os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("ignored.txt\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dir, "ignored.txt"), []byte("x\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dir, "untracked.txt"), []byte("y\n"), 0o644))
mustGit(t, dir, "add", "a.go", "sub/b.txt", ".gitignore")
mustGit(t, dir, "commit", "-m", "init")
r := newTestRunner(t)
files, err := r.ListTrackedFiles(context.Background(), dir)
require.NoError(t, err)
sort.Strings(files)
require.Equal(t, []string{".gitignore", "a.go", "sub/b.txt"}, files)
}
func TestDiffStat(t *testing.T) {
gitAvailable(t)
dir := t.TempDir()
mustGit(t, dir, "init", "-b", "main")
mustGit(t, dir, "config", "user.email", "test@example.com")
mustGit(t, dir, "config", "user.name", "Test")
require.NoError(t, os.WriteFile(filepath.Join(dir, "f.go"), []byte("package f\n\nfunc F() {}\n"), 0o644))
mustGit(t, dir, "add", "f.go")
mustGit(t, dir, "commit", "-m", "add F")
r := newTestRunner(t)
// HEAD resolves the just-made commit.
commits, err := r.Log(context.Background(), dir, 1)
require.NoError(t, err)
require.Len(t, commits, 1)
stat, err := r.DiffStat(context.Background(), dir, commits[0].Hash)
require.NoError(t, err)
require.Contains(t, stat, "f.go")
require.Contains(t, stat, "add F") // subject from --format
}
+7
View File
@@ -34,6 +34,13 @@ type Runner interface {
ListRemoteBranches(ctx context.Context, dir string, cred *Credential) ([]string, error)
Checkout(ctx context.Context, dir, branch string) error
Log(ctx context.Context, dir string, n int) ([]Commit, error)
Diff(ctx context.Context, dir string, opts DiffOptions) (DiffResult, error)
// ListTrackedFiles returns the git-tracked files at HEAD (git ls-files),
// worktree-relative, so the indexer never walks ignored/vendored/untracked paths.
ListTrackedFiles(ctx context.Context, dir string) ([]string, error)
// DiffStat returns the `--stat` summary for a single commit (git show --stat),
// used by auto-doc generation to feed the LLM a bounded change overview.
DiffStat(ctx context.Context, dir, commitSHA string) (string, error)
}
// FileStatus 表示 `git status --porcelain=v1` 的一行。