mirror of
https://github.com/alibaba/open-code-review.git
synced 2026-10-29 09:07:19 +08:00
* 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>
185 lines
6.5 KiB
Go
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)
|
|
}
|