Files
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

185 lines
6.5 KiB
Go

// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 alibaba/open-code-review Contributors
// Package diff parses unified git diff output into structured Diff objects.
package diff
import (
"context"
"fmt"
"os"
"os/exec"
"regexp"
"strings"
"time"
"github.com/alibaba/open-code-review/internal/gitcmd"
"github.com/alibaba/open-code-review/internal/model"
)
var (
diffHeaderRe = regexp.MustCompile(`^diff --git a/(.+?) b/(.+)$`)
// Anchored: git emits the marker at column 0 ("Binary files a/x and b/y
// differ"). Content lines inside hunks always carry a leading "+", "-"
// or " " prefix, so an anchored match can never misfire on file content.
binaryRe = regexp.MustCompile(`^Binary files `)
)
// splitDiffLines splits unified diff text on "\n" and drops the carriage
// return that CRLF-terminated diff text leaves at the end of every line.
//
// Git writes its own structural lines with "\n", but the diff text can reach
// us already converted: a .patch checked out under core.autocrlf=true, or
// output captured through a Windows shell. Left in place, that "\r" is not
// cosmetic. It rides into the "diff --git a/(.+?) b/(.+)$" capture, so NewPath
// becomes "file.go\r" and the file can no longer be opened for review; and it
// defeats the exact comparisons against "--- /dev/null" and "+++ /dev/null",
// which silently costs a diff its IsNew or IsDeleted flag.
//
// Stripping it matches how the rest of the package already treats a trailing
// carriage return: resolver.go trims it before matching lines, and filereader
// and viewer do the same when reading file content.
func splitDiffLines(text string) []string {
lines := strings.Split(text, "\n")
for i, line := range lines {
lines[i] = strings.TrimSuffix(line, "\r")
}
return lines
}
// parseDiffHeaderLine extracts the two pathnames from a "diff --git" line,
// falling back to parseQuotedDiffHeader when git has quoted a side (see there).
func parseDiffHeaderLine(line string) (oldPath string, newPath string, ok bool) {
if m := diffHeaderRe.FindStringSubmatch(line); m != nil {
return m[1], m[2], true
}
return parseQuotedDiffHeader(line)
}
// ParseDiffText splits the unified diff text into per-file Diff structs.
// ref, if non-empty, is a git ref used to read new-file content via
// git show instead of reading from the working tree.
// runner, if non-nil, is used to execute git subprocesses through a
// shared concurrency limiter.
func ParseDiffText(ctx context.Context, diffText string, repoDir string, ref string, runner *gitcmd.Runner) ([]model.Diff, error) {
lines := splitDiffLines(diffText)
var diffs []model.Diff
var current *model.Diff
var buf strings.Builder
// inHunk tracks whether the current line sits inside a "@@" hunk of the
// current file's section. Only hunk content lines carry a leading
// "+"/"-"/" " marker, so insertion/deletion counting and the binary
// marker must look at hunk state: outside a hunk, "+++ b/file" and
// "--- a/file" are headers, not content; inside a hunk, an added line
// like "++i" renders as "+++i" and still counts as an insertion.
inHunk := false
ctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
defer cancel()
for _, line := range lines {
if oldPath, newPath, ok := parseDiffHeaderLine(line); ok {
// Flush previous diff
if current != nil {
current.Diff = strings.TrimSuffix(buf.String(), "\n")
finalizeDiff(ctx, current, repoDir, ref, runner)
diffs = append(diffs, *current)
buf.Reset()
}
current = &model.Diff{
OldPath: oldPath,
NewPath: newPath,
}
inHunk = false
}
if current == nil {
continue
}
switch {
case strings.HasPrefix(line, "@@"):
inHunk = true
// The object IDs and mode in Git's extended "index" header are not
// useful review context. Keep index text in hunks, where it is file
// content and therefore carries a diff prefix.
case !inHunk && strings.HasPrefix(line, "index "):
continue
case !inHunk && binaryRe.MatchString(line):
current.IsBinary = true
// Extended header lines (unambiguous: content lines always carry a
// leading "+", "-" or " " prefix, so a bare prefix match is safe).
case strings.HasPrefix(line, "new file mode "):
current.IsNew = true
case strings.HasPrefix(line, "deleted file mode "):
current.IsDeleted = true
case strings.HasPrefix(line, "rename from "):
// Authoritative old path for renames; more reliable than the
// "diff --git" header when paths contain spaces.
current.OldPath = unquoteRenamePath(strings.TrimPrefix(line, "rename from "))
current.IsRenamed = true
case strings.HasPrefix(line, "rename to "):
current.NewPath = unquoteRenamePath(strings.TrimPrefix(line, "rename to "))
current.IsRenamed = true
// git emits "--- /dev/null" / "+++ /dev/null" without a/ b/ prefixes.
// Guarded by inHunk: inside a hunk the same strings can be content
// (e.g. an added line "++ /dev/null").
case !inHunk && line == "--- /dev/null":
current.IsNew = true
case !inHunk && line == "+++ /dev/null":
current.IsDeleted = true
case inHunk && strings.HasPrefix(line, "+"):
current.Insertions++
case inHunk && strings.HasPrefix(line, "-"):
current.Deletions++
}
buf.WriteString(line)
buf.WriteString("\n")
}
// Flush last diff
if current != nil {
current.Diff = strings.TrimSuffix(buf.String(), "\n")
finalizeDiff(ctx, current, repoDir, ref, runner)
diffs = append(diffs, *current)
}
return diffs, nil
}
// finalizeDiff reads the new file content. When ref is non-empty it uses
// git show to read the file at that ref; otherwise it reads from disk.
func finalizeDiff(ctx context.Context, d *model.Diff, repoDir string, ref string, runner *gitcmd.Runner) {
if d.IsDeleted || d.NewPath == "/dev/null" {
d.NewPath = "/dev/null"
return
}
if d.IsBinary {
return
}
if ref != "" {
args := []string{"-c", "core.quotepath=false", "show", "--end-of-options", ref + ":" + d.NewPath}
var output []byte
var err error
if runner != nil {
output, err = runner.Output(ctx, repoDir, args...)
} else {
cmd := exec.CommandContext(ctx, "git", args...)
cmd.Dir = repoDir
output, err = cmd.Output()
}
if err != nil {
fmt.Fprintf(os.Stderr, "[ocr] WARNING: cannot read file %s at ref %s: %v\n",
d.NewPath, ref, err)
return
}
d.NewFileContent = string(output)
return
}
content, err := readWorkspaceFileForDiff(repoDir, d.NewPath)
if err != nil {
fmt.Fprintf(os.Stderr, "[ocr] WARNING: cannot read file %s for review: %v\n", d.NewPath, err)
return
}
d.NewFileContent = string(content)
}