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 ` (working-tree vs Ref). // - Ref and Against both set => `git diff ..` (two refs). // - Staged => add `--cached` (index vs HEAD); mutually composes with refs. // - Paths => restrict to the given pathspecs (passed after `--`). // - ContextLines => `-U` 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),故一律拒绝,杜绝参数注入。 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:]) }