mirror of
https://github.com/alibaba/open-code-review.git
synced 2026-07-11 18:29:06 +00:00
strings.Split produces an empty trailing element when the git diff output ends with \n. The empty element was written into the last file's buffer, and TrimSuffix only removed one \n — leaving the last Diff field with an extra trailing newline that earlier files didn't have. Switch to TrimRight so all Diff values are consistently clean.
150 lines
4.5 KiB
Go
150 lines
4.5 KiB
Go
// Package diff parses unified git diff output into structured Diff objects.
|
|
package diff
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/open-code-review/open-code-review/internal/gitcmd"
|
|
"github.com/open-code-review/open-code-review/internal/model"
|
|
)
|
|
|
|
var (
|
|
diffHeaderRe = regexp.MustCompile(`^diff --git a/(.+?) b/(.+)$`)
|
|
binaryRe = regexp.MustCompile(`Binary files `)
|
|
)
|
|
|
|
// 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 := strings.Split(diffText, "\n")
|
|
var diffs []model.Diff
|
|
var current *model.Diff
|
|
var buf strings.Builder
|
|
|
|
ctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
|
|
defer cancel()
|
|
|
|
for _, line := range lines {
|
|
if m := diffHeaderRe.FindStringSubmatch(line); m != nil {
|
|
// Flush previous diff
|
|
if current != nil {
|
|
current.Diff = strings.TrimRight(buf.String(), "\n")
|
|
finalizeDiff(ctx, current, repoDir, ref, runner)
|
|
diffs = append(diffs, *current)
|
|
buf.Reset()
|
|
}
|
|
current = &model.Diff{
|
|
OldPath: m[1],
|
|
NewPath: m[2],
|
|
}
|
|
}
|
|
if current == nil {
|
|
continue
|
|
}
|
|
|
|
switch {
|
|
case 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 = strings.TrimPrefix(line, "rename from ")
|
|
current.IsRenamed = true
|
|
case strings.HasPrefix(line, "rename to "):
|
|
current.NewPath = strings.TrimPrefix(line, "rename to ")
|
|
current.IsRenamed = true
|
|
// git emits "--- /dev/null" / "+++ /dev/null" without a/ b/ prefixes.
|
|
case line == "--- /dev/null":
|
|
current.IsNew = true
|
|
case line == "+++ /dev/null":
|
|
current.IsDeleted = true
|
|
case strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++"):
|
|
current.Insertions++
|
|
case strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "---"):
|
|
current.Deletions++
|
|
}
|
|
buf.WriteString(line)
|
|
buf.WriteString("\n")
|
|
}
|
|
|
|
// Flush last diff
|
|
if current != nil {
|
|
current.Diff = strings.TrimRight(buf.String(), "\n")
|
|
finalizeDiff(ctx, current, repoDir, ref, runner)
|
|
diffs = append(diffs, *current)
|
|
}
|
|
|
|
return diffs, nil
|
|
}
|
|
|
|
// StripDiffHeaders removes the "diff --git" and "index" header lines from
|
|
// a unified diff, keeping all other lines (---, +++, @@, extended headers,
|
|
// and actual content) intact. This reduces token usage when embedding diffs
|
|
// in LLM prompts without losing any review-relevant information.
|
|
func StripDiffHeaders(raw string) string {
|
|
var out strings.Builder
|
|
first := true
|
|
for _, line := range strings.Split(raw, "\n") {
|
|
if strings.HasPrefix(line, "diff --git ") {
|
|
continue
|
|
}
|
|
if strings.HasPrefix(line, "index ") && strings.Contains(line, "..") {
|
|
continue
|
|
}
|
|
if !first {
|
|
out.WriteByte('\n')
|
|
}
|
|
first = false
|
|
out.WriteString(line)
|
|
}
|
|
return out.String()
|
|
}
|
|
|
|
// 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 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)
|
|
}
|