Files
open-code-review/internal/diff/git.go
T
BASIL K AJIandKite 533e4be253 fix(diff): parse git's quoted pathnames instead of dropping the file (#1498)
* fix(diff): parse git's quoted pathnames instead of dropping the file

Git C-quotes a pathname holding a tab, a newline, a double quote or a
backslash; core.quotepath=false removes non-ASCII from that set and nothing
else. The header regex expects a bare "a/" and "b/", so a quoted header
matched nothing, opened no file section, and the whole changed file left the
review. No warning, and the run still reported success.

The two sides are quoted independently, so all four combinations occur, and
"rename from"/"rename to" are quoted by the same rule and are authoritative
for renames. Both are decoded now. A malformed quoted token is refused rather
than guessed, so a header the decoder does not recognise is left alone.

The untracked listing had the same gap from the other direction: it ran
ls-files without -z and trimmed each line, so a quoted name arrived escaped
and matched nothing on disk, a newline in a name split one file into two, and
leading or trailing spaces were removed from names that legitimately had them.
It now reads NUL-delimited records and trims nothing.

Fixes #1492

* fix(diff): refuse an octal escape that does not fit in a byte

Three octal digits reach 511, and byte(v) truncated silently to the low bits,
handing back a path that is quietly not the one on disk. Git never emits one,
but the decoder already refuses every other escape it does not recognise
rather than guessing, and this is the same case.

Found by OpenCodeReview on this PR.

* docs: Apply batched suggestions from code review

Co-authored-by: Kite <254839944+lizhengfeng101@users.noreply.github.com>

---------

Co-authored-by: Kite <254839944+lizhengfeng101@users.noreply.github.com>
2026-09-21 17:18:19 +08:00

817 lines
29 KiB
Go

// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 alibaba/open-code-review Contributors
package diff
import (
"bytes"
"context"
"errors"
"fmt"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"unicode/utf8"
"github.com/bmatcuk/doublestar/v4"
"github.com/alibaba/open-code-review/internal/gitcmd"
"github.com/alibaba/open-code-review/internal/model"
)
// DiffContextLines defines the number of context lines around each changed hunk.
const DiffContextLines = 3
// providerDirIgnoreDirs: directory prefixes to always exclude from diff results.
var providerDirIgnoreDirs = []string{
".idea/",
".vscode/",
".svn/",
".git/",
"vendor/",
"node_modules/",
"target/",
".happypack/",
".cachefile/",
"_packages/",
"rpm/",
"pkgs/",
}
// Mode defines how the diff is retrieved.
type Mode int
const (
ModeWorkspace Mode = iota // current workspace (staged + unstaged + untracked)
ModeCommit // single commit vs its parent
ModeRange // merge-base(from,to)..to
)
// Provider retrieves and parse git diffs from a repository.
type Provider struct {
repoDir string
mode Mode
runner *gitcmd.Runner
// Range mode parameters
from, to string // from/to refs for range comparison
// Commit mode parameter
commit string // single commit hash/ref
mergeBase string // cached common ancestor for range mode
}
// DiffSet separates the diffs a review may process from files excluded by the
// provider's built-in directory rules. The latter remain unavailable to a
// review, but callers such as Preview can account for them.
//
// excludedAt[i] is Excluded[i]'s index in the changeset order of Included and
// Excluded combined, so ForEachInOrder can walk the two slices as Git listed
// them rather than every provider exclusion first.
type DiffSet struct {
Included []model.Diff
Excluded []model.Diff
excludedAt []int
}
// ForEachInOrder visits Included and Excluded in the original changeset order.
// providerExcluded is true for built-in directory exclusions. Files dropped by
// .gitignore are not visited.
func (s DiffSet) ForEachInOrder(visit func(d model.Diff, providerExcluded bool)) {
next := 0
for i, d := range s.Excluded {
// excludedAt[i] is the mixed-stream index, so the number of included
// files that precede this exclusion is excludedAt[i]-i.
upTo := s.excludedAt[i] - i
for ; next < upTo; next++ {
visit(s.Included[next], false)
}
visit(d, true)
}
for ; next < len(s.Included); next++ {
visit(s.Included[next], false)
}
}
// NewProvider creates a Provider for range mode: from..to (via merge-base).
func NewProvider(repoDir, from, to string, runner *gitcmd.Runner) *Provider {
return &Provider{
repoDir: repoDir,
mode: ModeRange,
from: from,
to: to,
runner: runner,
}
}
// NewCommitProvider creates a Provider for commit mode: show changes introduced by a single commit.
func NewCommitProvider(repoDir, commit string, runner *gitcmd.Runner) *Provider {
return &Provider{
repoDir: repoDir,
mode: ModeCommit,
commit: commit,
runner: runner,
}
}
// NewWorkspaceProvider creates a Provider for workspace mode (current uncommitted changes).
func NewWorkspaceProvider(repoDir string, runner *gitcmd.Runner) *Provider {
return &Provider{
repoDir: repoDir,
mode: ModeWorkspace,
runner: runner,
}
}
// InputResolution carries this run's frozen, immutable commit endpoints, per the
// run-manifest input-mode matrix. An empty field means "not applicable or not
// resolvable" — a root commit and a merge commit have no single comparison base,
// an unborn workspace has no HEAD, and a workspace has no immutable head — and a
// caller must never treat an empty value as a real endpoint or fabricate one.
// ExactRange is populated only when both a unique base and a head resolve.
type InputResolution struct {
ResolvedBase string
ResolvedHead string
ExactRange string
}
// ResolveInput freezes this run's commit endpoints by asking git, following the
// input-mode matrix:
//
// - range: base = merge-base(from,to); head = the commit `to` resolves to;
// exact_range = base..head only when both resolve.
// - commit: head = the commit resolved from `commit`; base is the first
// parent used by the diff, and exact_range = first-parent..head. A root
// commit has no base or exact range.
// - workspace: base = current HEAD when the repository has one (empty on an
// unborn repository); head and range stay empty (a workspace has no immutable
// head).
//
// Commit mode follows the same first-parent comparison used by GetDiff. Root
// commits have no parent, so only their resolved head is available.
//
// It runs read-only git queries and never returns an error: an unresolvable
// endpoint is reported as an empty field, never a fabricated SHA.
func (p *Provider) ResolveInput(ctx context.Context) InputResolution {
switch p.mode {
case ModeRange:
base := p.MergeBase(ctx)
head := p.resolveCommit(ctx, p.to)
r := InputResolution{ResolvedBase: base, ResolvedHead: head}
if base != "" && head != "" {
r.ExactRange = base + ".." + head
}
return r
case ModeCommit:
head := p.resolveCommit(ctx, p.commit)
r := InputResolution{ResolvedHead: head}
if parents := p.commitParents(ctx, p.commit); len(parents) > 0 && head != "" {
// GetDiff renders merge commits against their first parent, so the
// manifest must record that same concrete comparison base.
r.ResolvedBase = parents[0]
r.ExactRange = parents[0] + ".." + head
}
return r
case ModeWorkspace:
// base = current HEAD if the repository has one; an unborn repository has
// no HEAD, so this stays empty rather than fabricating a base.
return InputResolution{ResolvedBase: p.resolveCommit(ctx, "HEAD")}
default:
return InputResolution{}
}
}
// IsRangeMode returns true when comparing two refs.
func (p *Provider) IsRangeMode() bool {
return p.mode == ModeRange
}
// IsCommitMode returns true when analyzing a single commit.
func (p *Provider) IsCommitMode() bool {
return p.mode == ModeCommit
}
// MergeBase returns the computed merge-base commit hash for range mode.
func (p *Provider) MergeBase(ctx context.Context) string {
if p.mode != ModeRange || p.mergeBase != "" {
return p.mergeBase
}
p.mergeBase = p.computeMergeBase(ctx, p.from, p.to)
return p.mergeBase
}
// GetDiff returns the changes available to a review as parsed model.Diff structs.
func (p *Provider) GetDiff(ctx context.Context) ([]model.Diff, error) {
set, err := p.GetDiffSet(ctx)
if err != nil {
return nil, err
}
return set.Included, nil
}
// GetDiffSet returns both reviewable diffs and those excluded by the provider's
// built-in directory rules. It preserves GetDiff's filtering behavior while
// allowing callers that report the whole Git changeset to show those exclusions.
func (p *Provider) GetDiffSet(ctx context.Context) (DiffSet, error) {
var combined strings.Builder
switch p.mode {
case ModeRange:
base := p.MergeBase(ctx)
if base == "" {
return DiffSet{}, fmt.Errorf("cannot find merge-base between %s and %s", p.from, p.to)
}
out, stderr, err := p.runGitSplit(ctx, "-c", "core.quotepath=false", "diff", "--no-ext-diff", "--no-textconv", "--find-renames", "--src-prefix=a/", "--dst-prefix=b/", "--no-color", "-U"+fmt.Sprint(DiffContextLines), "--end-of-options", base, p.to, "--")
if err != nil {
return DiffSet{}, gitFailure("git diff", stderr, err)
}
combined.WriteString(out)
case ModeCommit:
// --diff-merges=first-parent: for merge commits, plain `git show`
// emits a combined diff ("diff --cc"), which ParseDiffText cannot
// parse — the commit would silently yield zero reviewable diffs.
// Diffs against the first parent instead, in regular unified format.
out, stderr, err := p.runGitSplit(ctx, "-c", "core.quotepath=false", "show", "--no-ext-diff", "--no-textconv", "--find-renames", "--src-prefix=a/", "--dst-prefix=b/", "--no-color", "--diff-merges=first-parent", "-U"+fmt.Sprint(DiffContextLines), "--end-of-options", p.commit)
if err != nil {
return DiffSet{}, gitFailure("git show", stderr, err)
}
combined.WriteString(out)
case ModeWorkspace:
// The stderr returned here is the fallback's (`git diff --staged`), and
// that is the one worth surfacing. Reaching the fallback at all means
// `git diff HEAD` already failed, and in the case the fallback exists
// for — a repository with no commits — it failed with "bad revision
// 'HEAD'", which is expected rather than diagnostic. Only the second
// failure describes what actually blocked the review.
tracked, stderr, err := p.workspaceTrackedDiff(ctx)
if err != nil {
return DiffSet{}, gitFailure("workspace tracked diff", stderr, err)
}
combined.WriteString(tracked)
untracked, err := p.untrackedFileDiffs(ctx)
if err != nil {
return DiffSet{}, fmt.Errorf("untracked file diff failed: %w", err)
}
for _, ud := range untracked {
combined.WriteString(ud)
combined.WriteString("\n\n")
}
}
var ref string
switch p.mode {
case ModeRange:
ref = p.to
case ModeCommit:
ref = p.commit
}
diffs, err := ParseDiffText(ctx, combined.String(), p.repoDir, ref, p.runner)
if err != nil {
return DiffSet{}, err
}
return p.partitionDiffs(diffs), nil
}
// loadGitignorePatterns reads and parses .gitignore patterns from the repo root.
func (p *Provider) loadGitignorePatterns() []string {
data, err := os.ReadFile(filepath.Join(p.repoDir, ".gitignore"))
if err != nil {
return nil
}
var patterns []string
for line := range strings.SplitSeq(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
patterns = append(patterns, line)
}
return patterns
}
// isPathExcluded returns true when the given relative file path should be skipped
// based on hardcoded dir rules or .gitignore patterns.
//
// Patterns are resolved the way git resolves them: in file order, with the LAST
// matching pattern deciding, and a leading "!" inverting that pattern's verdict.
// Order matters because the "allow list" idiom (ignore everything with `*`, then
// re-include with `!` lines — github/gitignore ships one per language) is only
// correct under last-match-wins. Treating negations as unmatchable made every
// file in such a repository look excluded, so a review silently covered nothing.
func (p *Provider) isPathExcluded(relPath string, gitignorePatterns []string) bool {
if isProviderDirExcluded(relPath) {
return true
}
excluded := false
for _, pat := range gitignorePatterns {
body, negated := strings.CutPrefix(pat, "!")
if body == "" {
continue
}
// Directory-only patterns (trailing "/") apply to directories, never to
// files. Git uses a negated one such as `!*/` to keep descending into
// subdirectories, not to re-admit the files inside them — honouring it
// here would readmit everything below the root.
if negated && strings.HasSuffix(body, "/") {
continue
}
if matchGitignoreBody(relPath, body) {
excluded = !negated
}
}
return excluded
}
// isProviderDirExcluded reports whether relPath matches the provider's
// unconditional directory blocklist. A .gitignore negation cannot re-admit
// one of these paths.
func isProviderDirExcluded(relPath string) bool {
return ProviderDirPrefix(relPath) != ""
}
// matchGitignorePattern checks if relPath matches a single .gitignore pattern.
//
// Polarity is not this function's concern: a negated pattern reports false, so
// callers testing one pattern in isolation still read it as "does this exclude
// the path". Ordered resolution across a whole pattern list, where negations do
// carry meaning, lives in isPathExcluded.
func matchGitignorePattern(relPath, pat string) bool {
if strings.HasPrefix(pat, "!") {
return false
}
return matchGitignoreBody(relPath, pat)
}
// matchGitignoreBody reports whether relPath matches a single pattern body —
// the pattern with any leading "!" already stripped.
func matchGitignoreBody(relPath, body string) bool {
// Directory-only patterns (trailing /)
if pattern, ok := strings.CutSuffix(body, "/"); ok {
return matchGitignoreDirectory(relPath, pattern)
}
// A leading "/" anchors the pattern to the repository root rather than
// making it a path pattern; "/.golangci.yml" addresses the root file.
anchored := false
if trimmed, ok := strings.CutPrefix(body, "/"); ok {
body, anchored = trimmed, true
}
// "**" is not expressible with filepath.Match, so patterns containing it go
// through doublestar, which implements gitignore's globstar semantics.
if strings.Contains(body, "**") {
matched, err := doublestar.Match(body, relPath)
return err == nil && matched
}
// Patterns without / match basename — unless anchored, where the pattern
// addresses that name at the root only.
if !strings.Contains(body, "/") {
target := filepath.Base(relPath)
if anchored {
target = relPath
}
matched, _ := filepath.Match(body, target)
return matched
}
// Patterns with / match against the full relative path
if matched, _ := filepath.Match(body, relPath); matched {
return true
}
// Also try matching against suffix of path, but not for anchored patterns:
// "/docs/api.md" names one file, not any path ending that way.
//
// The leading "/" makes the suffix start on a path component: without it
// "src/main.go" also matches "othersrc/main.go", because the tail of
// "othersrc" completes the pattern.
if !anchored && strings.HasSuffix(relPath, "/"+body) {
return true
}
return false
}
func matchGitignoreDirectory(relPath, pattern string) bool {
pattern, anchored := strings.CutPrefix(pattern, "/")
if pattern == "" {
return false
}
lastSlash := strings.LastIndex(relPath, "/")
if lastSlash < 0 {
return false
}
components := strings.Split(relPath[:lastSlash], "/")
// Slash-containing patterns are relative to the .gitignore location;
// slashless patterns match a directory name at any depth.
matchFullPath := anchored || strings.Contains(pattern, "/")
for i, component := range components {
candidate := component
if matchFullPath {
candidate = strings.Join(components[:i+1], "/")
}
matched, err := doublestar.Match(pattern, candidate)
if err == nil && matched {
return true
}
}
return false
}
// partitionDiffs keeps diffs filtered by built-in directory rules available
// for reporting while preserving the review input as the Included slice.
func (p *Provider) partitionDiffs(diffs []model.Diff) DiffSet {
patterns := p.loadGitignorePatterns()
result := DiffSet{
Included: make([]model.Diff, 0, len(diffs)),
Excluded: make([]model.Diff, 0),
}
for _, d := range diffs {
path := d.NewPath
if path == "/dev/null" {
path = d.OldPath
}
if isProviderDirExcluded(path) {
result.excludedAt = append(result.excludedAt, len(result.Included)+len(result.Excluded))
result.Excluded = append(result.Excluded, d)
} else if !p.isPathExcluded(path, patterns) {
result.Included = append(result.Included, d)
}
}
return result
}
// ---- Internal helpers ----
func (p *Provider) computeMergeBase(ctx context.Context, from, to string) string {
out, err := p.runGit(ctx, "merge-base", "--end-of-options", from, to)
if err != nil {
return ""
}
return strings.TrimSpace(out)
}
// RemoteIdentity returns a stable, credential-free identity string for the
// repository's "origin" remote, suitable for hashing into the run manifest's
// repository.identity_sha256. It reads the configured origin URL and canonicalizes
// it — dropping any embedded userinfo, query and fragment (so credentials never
// leak), lowercasing the host while keeping any port, and trimming a trailing
// ".git"/"/" — so the same repository yields the same identity regardless of how
// it was cloned. It returns "" when there is no origin remote, or when origin is
// a local-filesystem remote (the caller then omits repository identity).
func (p *Provider) RemoteIdentity(ctx context.Context) string {
out, err := p.runGit(ctx, "remote", "get-url", "origin")
if err != nil {
return ""
}
return canonicalRemote(firstLine(out))
}
// canonicalRemote reduces a git remote URL to a stable, credential-free identity
// string for hashing into repository.identity_sha256, so the same repository
// yields the same identity regardless of transport or embedded credentials.
//
// Network remotes canonicalize to "host[:port]/path": the host is lowercased and
// any port is KEPT (two remotes differing only in port are distinct endpoints),
// while the path preserves case and loses a trailing ".git"/"/".
//
// Local remotes (file://, absolute/relative filesystem paths, Windows drive
// paths, UNC shares) have no stable network identity and no credentials to
// strip; they canonicalize to "" so the caller omits repository identity, the
// same behavior as a missing origin.
//
// An empty or unrecognizable input yields "".
func canonicalRemote(raw string) string {
s := strings.TrimSpace(raw)
if s == "" {
return ""
}
// Drop query (?…) and fragment (#…): never part of repository identity.
if i := strings.IndexAny(s, "?#"); i >= 0 {
s = s[:i]
}
// Local remotes carry no stable network identity (see doc comment). Detect
// them before the scp split so a Windows "C:\…" path is not mistaken for a
// "host:path" with host "c".
if isLocalRemote(s) {
return ""
}
// scheme://[user[:pass]@]host[:port]/path. url.Host is "host[:port]" and
// never includes userinfo, so credentials drop out and the port is kept.
if strings.Contains(s, "://") {
if u, err := url.Parse(s); err == nil && u.Scheme != "" && u.Host != "" {
return joinHostPath(strings.ToLower(u.Host), u.Path)
}
return ""
}
// scp-like: [user@]host:path. The userinfo "@" lives in the host segment,
// which ends at the FIRST ":"; split there first so any "@" inside the path
// is preserved rather than truncated as if it were userinfo.
colon := strings.IndexByte(s, ':')
if colon < 0 {
return ""
}
hostSeg, path := s[:colon], s[colon+1:]
if at := strings.LastIndexByte(hostSeg, '@'); at >= 0 {
hostSeg = hostSeg[at+1:]
}
host := strings.ToLower(hostSeg)
if host == "" {
return ""
}
return joinHostPath(host, path)
}
// joinHostPath assembles the canonical "host[/path]" form, trimming a leading
// "/" and a trailing ".git"/"/" from the path while preserving its case.
func joinHostPath(host, path string) string {
path = strings.TrimPrefix(path, "/")
path = strings.TrimSuffix(path, "/")
path = strings.TrimSuffix(path, ".git")
path = strings.TrimSuffix(path, "/")
if path == "" {
return host
}
return host + "/" + path
}
// isLocalRemote reports whether a remote URL points at the local filesystem
// rather than a network host: a file:// URL, a POSIX absolute/relative/home
// path, a Windows drive path (X:\ or X:/), or a UNC share (\\server\share).
func isLocalRemote(s string) bool {
switch {
case strings.HasPrefix(s, "file://"):
return true
case strings.HasPrefix(s, "/"), strings.HasPrefix(s, "~"):
return true
case strings.HasPrefix(s, "./"), strings.HasPrefix(s, "../"), s == ".", s == "..":
return true
case strings.HasPrefix(s, `\\`): // UNC \\server\share
return true
}
// Windows drive path: X:\ or X:/. Require a separator after the colon so a
// single-letter scp host ("c:path") is not misread — real hosts have a dot.
if len(s) >= 3 && isASCIILetter(s[0]) && s[1] == ':' && (s[2] == '\\' || s[2] == '/') {
return true
}
return false
}
func isASCIILetter(b byte) bool {
return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z')
}
// resolveCommit returns the immutable commit SHA a ref points at, or "" when the
// ref does not resolve to a commit (e.g. an unborn HEAD, or a bad ref). The
// ^{commit} peel collapses a tag or tree ref to its commit; --verify --quiet
// makes an unresolvable ref exit non-zero silently rather than printing an error.
func (p *Provider) resolveCommit(ctx context.Context, ref string) string {
out, err := p.runGit(ctx, "rev-parse", "--verify", "--quiet", "--end-of-options", ref+"^{commit}")
if err != nil {
return ""
}
return firstLine(out)
}
// commitParents returns the parent commit SHAs of ref: zero for a root commit,
// one for an ordinary commit, and 2+ for a merge. It uses `rev-list --parents -n
// 1`, whose single line is "<commit> <parent1> <parent2>…" — the leading commit
// token is dropped, the rest are the parents. `rev-list` (unlike `rev-parse`)
// does not echo --end-of-options, so the marker stays safe against a ref that
// looks like an option. An error yields nil so the caller treats it as "no
// unique base".
func (p *Provider) commitParents(ctx context.Context, ref string) []string {
out, err := p.runGit(ctx, "rev-list", "--parents", "-n", "1", "--end-of-options", ref)
if err != nil {
return nil
}
fields := strings.Fields(firstLine(out))
if len(fields) <= 1 {
return nil // root commit (only the commit itself, no parents) or empty
}
return fields[1:]
}
// firstLine returns the first non-empty trimmed line of git output, so a stray
// trailing newline or an unexpected second line never pollutes a resolved SHA.
func firstLine(out string) string {
for _, line := range strings.Split(out, "\n") {
if s := strings.TrimSpace(line); s != "" {
return s
}
}
return ""
}
// workspaceTrackedDiff returns the tracked-file diff, git's stderr, and the
// failure if there was one. Stderr is returned separately so the caller can
// quote it without risking diff content in the error; see runGitSplit.
func (p *Provider) workspaceTrackedDiff(ctx context.Context) (string, string, error) {
out, _, err := p.runGitSplit(ctx, "-c", "core.quotepath=false", "diff", "--no-ext-diff", "--no-textconv", "--find-renames", "--src-prefix=a/", "--dst-prefix=b/", "--no-color", "-U"+fmt.Sprint(DiffContextLines), "--end-of-options", "HEAD", "--")
if err == nil && out != "" {
return out, "", nil
}
if ctx.Err() != nil {
return "", "", ctx.Err()
}
// Fall back to the staged diff when `git diff HEAD` errored or was empty. This is
// not redundant with the call above: in a repository with no commits yet there is no
// HEAD, so `git diff HEAD` fails with "bad revision 'HEAD'", but `git diff --staged`
// still surfaces staged changes by diffing the index against the empty tree — the only
// way to review a workspace before its first commit.
return p.runGitSplit(ctx, "-c", "core.quotepath=false", "diff", "--no-ext-diff", "--no-textconv", "--find-renames", "--src-prefix=a/", "--dst-prefix=b/", "--no-color", "-U"+fmt.Sprint(DiffContextLines), "--staged", "--")
}
// binarySniffWindow is the number of leading bytes inspected when deciding
// whether an untracked file is binary. Matches git's own heuristic and the
// scan provider's sniff window.
const binarySniffWindow = 8000
// looksBinary reports whether content contains a NUL byte within the sniff
// window — the same marker git uses to report a file as binary.
func looksBinary(content []byte) bool {
if len(content) > binarySniffWindow {
content = content[:binarySniffWindow]
}
return bytes.IndexByte(content, 0) >= 0
}
func untrackedBinaryDiff(path string) string {
return fmt.Sprintf("diff --git a/%s b/%s\nnew file mode 100644\nBinary files /dev/null and b/%s differ\n", path, path, path)
}
func (p *Provider) untrackedFileDiffs(ctx context.Context) ([]string, error) {
files, err := p.untrackedFilesList(ctx)
if err != nil {
return nil, err
}
var results []string
for _, f := range files {
content, rerr := readWorkspaceFileForDiffWithLimit(p.repoDir, f, maxUntrackedFileSize)
if errors.Is(rerr, errWorkspaceFileTooLarge) {
results = append(results, untrackedBinaryDiff(f))
continue
}
if rerr != nil {
continue
}
if looksBinary(content) {
// Emit git's own binary marker so the parser flags the file and
// the selection layer excludes it, matching the tracked path where
// `git diff` itself reports "Binary files ... differ".
results = append(results, untrackedBinaryDiff(f))
continue
}
lineCount := bytes.Count(content, []byte{'\n'})
if len(content) > 0 && content[len(content)-1] != '\n' {
lineCount++
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("diff --git a/%s b/%s\n", f, f))
sb.WriteString("--- /dev/null\n")
sb.WriteString(fmt.Sprintf("+++ b/%s\n", f))
sb.WriteString(fmt.Sprintf("@@ -0,0 +1,%d @@\n", lineCount))
lines := bytes.Split(content, []byte{'\n'})
if len(lines) > 0 && len(lines[len(lines)-1]) == 0 {
lines = lines[:len(lines)-1]
}
for _, line := range lines {
sb.WriteByte('+')
sb.Write(line)
sb.WriteByte('\n')
}
results = append(results, sb.String())
}
return results, nil
}
func (p *Provider) untrackedFilesList(ctx context.Context) ([]string, error) {
// -z, and no trimming of the records it produces. Without it git quotes
// any pathname holding a tab, a newline, a quote or a backslash, so the
// name arrives escaped and matches nothing on disk; a newline in a name
// also splits one file into two entries. Trimming then removes leading and
// trailing spaces, which are filename bytes. Each of those drops an
// untracked file out of the review with no error.
out, stderr, err := p.runGitSplit(ctx, "-c", "core.quotepath=false", "ls-files", "-z", "--others", "--exclude-standard")
if err != nil {
return nil, gitFailure("git ls-files", stderr, err)
}
if out == "" {
return nil, nil
}
patterns := p.loadGitignorePatterns()
var files []string
for _, name := range strings.Split(strings.TrimRight(out, "\x00"), "\x00") {
if name == "" {
continue
}
if !p.isPathExcluded(name, patterns) {
files = append(files, name)
}
}
return files, nil
}
func (p *Provider) runGit(ctx context.Context, args ...string) (string, error) {
if p.runner != nil {
return p.runner.Run(ctx, p.repoDir, args...)
}
cmd := exec.CommandContext(ctx, "git", args...)
cmd.Dir = p.repoDir
out, err := cmd.CombinedOutput()
return string(out), err
}
// runGitSplit runs git with stdout and stderr kept apart, for the callers that
// quote the failure back to the user.
//
// runGit's combined output cannot be quoted safely. Git writes the diff to
// stdout and its diagnosis to stderr, so a command killed mid-write leaves a
// tail made entirely of diff content — repository source, and whatever that
// source happens to contain — with no diagnosis anywhere in it. That string
// does not stay local: reviewResultError hands it to span.RecordError, so it
// reaches whatever telemetry backend is configured. classifyItemError guards
// the run manifest against raw error text for the same reason.
//
// Stderr carries the diagnosis by construction, since die() writes there, so
// quoting it alone loses nothing a reader wants and cannot carry diff.
//
// Mirrors runGitGrep in internal/tool/code_search.go, cancellation guard
// included: a killed process reports the signal rather than the reason, and
// the reason is what the caller needs.
func (p *Provider) runGitSplit(ctx context.Context, args ...string) (string, string, error) {
if p.runner != nil {
stdout, stderr, err := p.runner.RunSplit(ctx, p.repoDir, args...)
if ctx.Err() != nil && err != nil {
return "", "", ctx.Err()
}
return stdout, stderr, err
}
cmd := exec.CommandContext(ctx, "git", args...)
cmd.Dir = p.repoDir
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
if ctx.Err() != nil && err != nil && cmd.ProcessState != nil && cmd.ProcessState.ExitCode() == -1 {
return "", "", ctx.Err()
}
return stdout.String(), stderr.String(), err
}
// gitDiagLimit bounds how much of git's stderr is quoted back in an error.
// Git's own diagnosis is a line or two, so the ceiling is a backstop for the
// pathological case — a flood of warnings ahead of the fatal — rather than the
// common one.
const gitDiagLimit = 2000
// gitFailure builds an error that carries git's own message.
//
// Every diff-producing caller used to drop git's output on the floor, so a
// failure surfaced as a bare "git show failed: exit status 129" — true, and
// useless. Diagnosing one then meant asking the reporter to re-run the command
// by hand to see what git actually said (#972). The exit status alone cannot
// distinguish an unsupported option from a bad revision or a permission error.
//
// Callers pass stderr, never runGit's combined output; see runGitSplit for why.
func gitFailure(op, stderr string, err error) error {
diag := strings.TrimSpace(stderr)
if diag == "" {
return fmt.Errorf("%s failed: %w", op, err)
}
if len(diag) > gitDiagLimit {
// Keep the tail: die() exits the process, so the fatal git ends on is
// the last thing it writes, behind any warnings that preceded it.
diag = diag[len(diag)-gitDiagLimit:]
// Cutting by bytes can land mid-rune. Git speaks the user's locale,
// so this is not hypothetical — #972 came from a Japanese-language
// Windows install. Drop the partial leading rune rather than emit
// invalid UTF-8.
for len(diag) > 0 && !utf8.RuneStart(diag[0]) {
diag = diag[1:]
}
diag = "..." + diag
}
return fmt.Errorf("%s failed: %w: %s", op, err, diag)
}