You've already forked agentic-coding-workflow
bugfix
This commit is contained in:
@@ -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:])
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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` 的一行。
|
||||
|
||||
Reference in New Issue
Block a user