Files
agentic-coding-workflow/internal/infra/git/diff.go
T
2026-06-22 08:55:57 +08:00

107 lines
3.1 KiB
Go

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:])
}