You've already forked agentic-coding-workflow
107 lines
3.1 KiB
Go
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:])
|
|
}
|