a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
389 lines
12 KiB
Go
389 lines
12 KiB
Go
package analysis
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"fmt"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/zzet/gortex/internal/gitcmd"
|
|
"github.com/zzet/gortex/internal/graph"
|
|
)
|
|
|
|
// DiffHunk represents a changed range in a file.
|
|
type DiffHunk struct {
|
|
FilePath string `json:"file_path"`
|
|
StartLine int `json:"start_line"`
|
|
EndLine int `json:"end_line"`
|
|
}
|
|
|
|
// ChangedSymbol is a symbol affected by a git diff hunk.
|
|
type ChangedSymbol struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Kind string `json:"kind"`
|
|
FilePath string `json:"file_path"`
|
|
Line int `json:"start_line"`
|
|
}
|
|
|
|
// DiffResult is the output of git diff → symbol mapping.
|
|
type DiffResult struct {
|
|
Hunks []DiffHunk `json:"hunks"`
|
|
ChangedSymbols []ChangedSymbol `json:"changed_symbols"`
|
|
ChangedFiles []string `json:"changed_files"`
|
|
}
|
|
|
|
// MapGitDiff parses git diff output and maps changed lines to symbols in the graph.
|
|
// scope: "unstaged", "staged", "all", "compare"
|
|
// baseRef: used when scope is "compare" (e.g., "main")
|
|
// repoRoot: absolute path to the repository root
|
|
// repoPrefix: the graph repo prefix anchoring repoRoot's indexed nodes.
|
|
// Multi-repo daemons key file paths as "<prefix>/<rel>" while git emits
|
|
// repo-relative paths; empty in single-repo / unprefixed mode.
|
|
func MapGitDiff(g graph.Store, repoRoot, repoPrefix, scope, baseRef string) (*DiffResult, error) {
|
|
args := buildDiffArgs(scope, baseRef)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
// gitcmd runs `git -C repoRoot args...`; use Run (raw stdout, no trailing
|
|
// trim) so the parsed diff stays byte-identical to the pre-gitcmd output.
|
|
output, err := gitcmd.Run(ctx, repoRoot, args...)
|
|
if err != nil {
|
|
// If git diff returns empty, that's fine
|
|
if len(output) == 0 {
|
|
return &DiffResult{}, nil
|
|
}
|
|
return nil, fmt.Errorf("git diff failed: %w", err)
|
|
}
|
|
|
|
hunks := parseDiffHunks(string(output))
|
|
return joinHunksToSymbols(g, repoPrefix, hunks), nil
|
|
}
|
|
|
|
// JoinFileNodes maps one repo-relative changed-file path to the graph nodes
|
|
// its file defines. Indexed file paths carry the repo prefix in multi-repo
|
|
// mode ("<prefix>/<rel>") while git and forge APIs emit repo-relative paths,
|
|
// so the raw lookup is tried first (single-repo / already-prefixed input)
|
|
// and the prefixed form second.
|
|
func JoinFileNodes(g graph.Store, repoPrefix, path string) []*graph.Node {
|
|
if nodes := g.GetFileNodes(path); len(nodes) > 0 {
|
|
return nodes
|
|
}
|
|
if repoPrefix == "" || strings.HasPrefix(path, repoPrefix+"/") {
|
|
return nil
|
|
}
|
|
return g.GetFileNodes(repoPrefix + "/" + path)
|
|
}
|
|
|
|
// JoinFilePath returns the graph-keyed variant of a repo-relative file path:
|
|
// the raw path when it resolves (or no prefix applies), otherwise the prefixed
|
|
// form when that resolves. Falls back to the raw path when neither does, so
|
|
// the caller's downstream lookup misses exactly as it would have anyway.
|
|
func JoinFilePath(g graph.Store, repoPrefix, path string) string {
|
|
if repoPrefix == "" || strings.HasPrefix(path, repoPrefix+"/") {
|
|
return path
|
|
}
|
|
if len(g.GetFileNodes(path)) > 0 {
|
|
return path
|
|
}
|
|
if prefixed := repoPrefix + "/" + path; len(g.GetFileNodes(prefixed)) > 0 {
|
|
return prefixed
|
|
}
|
|
return path
|
|
}
|
|
|
|
// joinHunksToSymbols builds the hunk→symbol/file join shared by MapGitDiff
|
|
// and MapGitDiffWithLines: every symbol whose line range overlaps a changed
|
|
// hunk in its file, deduped, plus the changed-file set. ChangedFiles keeps
|
|
// the diff-relative paths (callers re-join them with git pathspecs); only
|
|
// the node lookup is prefix-aware.
|
|
func joinHunksToSymbols(g graph.Store, repoPrefix string, hunks []DiffHunk) *DiffResult {
|
|
result := &DiffResult{Hunks: hunks}
|
|
|
|
fileSet := make(map[string]bool)
|
|
symbolSeen := make(map[string]bool)
|
|
|
|
for _, hunk := range hunks {
|
|
fileSet[hunk.FilePath] = true
|
|
|
|
// Find symbols whose line range overlaps the hunk
|
|
for _, n := range JoinFileNodes(g, repoPrefix, hunk.FilePath) {
|
|
if n.Kind == graph.KindFile {
|
|
continue
|
|
}
|
|
// Check if symbol's line range overlaps with the hunk
|
|
if n.StartLine <= hunk.EndLine && n.EndLine >= hunk.StartLine {
|
|
if !symbolSeen[n.ID] {
|
|
symbolSeen[n.ID] = true
|
|
result.ChangedSymbols = append(result.ChangedSymbols, ChangedSymbol{
|
|
ID: n.ID,
|
|
Name: n.Name,
|
|
Kind: string(n.Kind),
|
|
FilePath: n.FilePath,
|
|
Line: n.StartLine,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
for f := range fileSet {
|
|
result.ChangedFiles = append(result.ChangedFiles, f)
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// GitDiffArgs builds the `git diff` argv for a scope ("unstaged", "staged",
|
|
// "all", "compare") with the given context width. The -c overrides pin the
|
|
// diff header prefixes to git's standard a/ b/ form: parseDiffHunks and
|
|
// parseDiffLines anchor on "+++ b/", which a developer's diff.mnemonicPrefix
|
|
// (c/ w/ headers on worktree-side diffs) or diff.noprefix config would
|
|
// otherwise silently zero out — every hunk drops, every diff-driven tool
|
|
// reports an empty changeset.
|
|
func GitDiffArgs(scope, baseRef string, unified int) []string {
|
|
args := []string{
|
|
"-c", "diff.mnemonicPrefix=false",
|
|
"-c", "diff.noprefix=false",
|
|
"diff",
|
|
}
|
|
switch scope {
|
|
case "staged":
|
|
args = append(args, "--cached")
|
|
case "all":
|
|
args = append(args, "HEAD")
|
|
case "compare":
|
|
if baseRef == "" {
|
|
baseRef = "main"
|
|
}
|
|
args = append(args, baseRef+"...HEAD")
|
|
default: // unstaged — bare `git diff`
|
|
}
|
|
return append(args, fmt.Sprintf("--unified=%d", unified))
|
|
}
|
|
|
|
func buildDiffArgs(scope, baseRef string) []string {
|
|
return GitDiffArgs(scope, baseRef, 0)
|
|
}
|
|
|
|
// buildDiffArgsWithContext mirrors buildDiffArgs but emits a context window so
|
|
// the new-side line text survives into the hunk body for snippet grounding.
|
|
func buildDiffArgsWithContext(scope, baseRef string) []string {
|
|
return GitDiffArgs(scope, baseRef, 3)
|
|
}
|
|
|
|
// HunkLine is a single new-side line carried out of a unified diff: added lines
|
|
// (Side "+") and context lines (Side " "), each tagged with its line number in
|
|
// the post-change file. Removed lines never appear (they have no new-side line).
|
|
type HunkLine struct {
|
|
NewLine int `json:"new_line"`
|
|
Side string `json:"side"`
|
|
Text string `json:"text"`
|
|
}
|
|
|
|
// ParseDiffHunks parses unified git-diff output into per-file changed ranges.
|
|
// It is the exported entry point over the same parser MapGitDiff uses and
|
|
// returns results identical to the internal parser.
|
|
func ParseDiffHunks(output string) []DiffHunk {
|
|
return parseDiffHunks(output)
|
|
}
|
|
|
|
// parseDiffLines walks unified diff output and returns, per new-side file path,
|
|
// the added and context lines with their post-change line numbers. Removed
|
|
// lines are skipped; the new-side line counter only advances on added/context.
|
|
// Keys match DiffHunk.FilePath (cleaned, relative) so a hunk and its lines join.
|
|
func parseDiffLines(output string) map[string][]HunkLine {
|
|
lines := make(map[string][]HunkLine)
|
|
var currentFile string
|
|
var newLine int
|
|
|
|
scanner := bufio.NewScanner(strings.NewReader(output))
|
|
scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
|
|
if strings.HasPrefix(line, "+++ b/") {
|
|
currentFile = filepath.Clean(strings.TrimPrefix(line, "+++ b/"))
|
|
newLine = 0
|
|
continue
|
|
}
|
|
if strings.HasPrefix(line, "+++ /dev/null") {
|
|
currentFile = ""
|
|
newLine = 0
|
|
continue
|
|
}
|
|
// Skip the remaining diff-header lines so their leading +/-/space never
|
|
// leaks into the hunk body.
|
|
if strings.HasPrefix(line, "--- ") || strings.HasPrefix(line, "diff ") ||
|
|
strings.HasPrefix(line, "index ") || strings.HasPrefix(line, "new file") ||
|
|
strings.HasPrefix(line, "deleted file") || strings.HasPrefix(line, "rename ") ||
|
|
strings.HasPrefix(line, "similarity ") || strings.HasPrefix(line, "old mode") ||
|
|
strings.HasPrefix(line, "new mode") || strings.HasPrefix(line, "copy ") {
|
|
continue
|
|
}
|
|
|
|
if strings.HasPrefix(line, "@@") {
|
|
if currentFile == "" {
|
|
continue
|
|
}
|
|
start, ok := parseNewStart(line)
|
|
if !ok {
|
|
continue
|
|
}
|
|
newLine = start
|
|
continue
|
|
}
|
|
|
|
if currentFile == "" || newLine == 0 {
|
|
continue
|
|
}
|
|
|
|
switch {
|
|
case strings.HasPrefix(line, "+"):
|
|
lines[currentFile] = append(lines[currentFile], HunkLine{
|
|
NewLine: newLine,
|
|
Side: "+",
|
|
Text: line[1:],
|
|
})
|
|
newLine++
|
|
case strings.HasPrefix(line, "-"):
|
|
// Removed line — no new-side position, do not advance.
|
|
case strings.HasPrefix(line, "\\"):
|
|
// "\ No newline at end of file" marker — not a content line.
|
|
default:
|
|
// Context line (leading space), or a bare blank context line.
|
|
text := line
|
|
if strings.HasPrefix(line, " ") {
|
|
text = line[1:]
|
|
}
|
|
lines[currentFile] = append(lines[currentFile], HunkLine{
|
|
NewLine: newLine,
|
|
Side: " ",
|
|
Text: text,
|
|
})
|
|
newLine++
|
|
}
|
|
}
|
|
|
|
return lines
|
|
}
|
|
|
|
// parseNewStart extracts the new-side starting line from a "@@ -a,b +c,d @@"
|
|
// hunk header.
|
|
func parseNewStart(line string) (int, bool) {
|
|
parts := strings.SplitN(line, "@@", 3)
|
|
if len(parts) < 2 {
|
|
return 0, false
|
|
}
|
|
for _, f := range strings.Fields(strings.TrimSpace(parts[1])) {
|
|
if !strings.HasPrefix(f, "+") {
|
|
continue
|
|
}
|
|
f = strings.TrimPrefix(f, "+")
|
|
rangeP := strings.SplitN(f, ",", 2)
|
|
start, err := strconv.Atoi(rangeP[0])
|
|
if err != nil {
|
|
return 0, false
|
|
}
|
|
return start, true
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
// MapGitDiffWithLines mirrors MapGitDiff but uses a context-bearing diff so it
|
|
// can additionally return, per file, the new-side lines (added + context) with
|
|
// their post-change line numbers — the substrate snippet grounding anchors on.
|
|
// The returned *DiffResult is computed with the same logic as MapGitDiff (only
|
|
// the diff's context width differs), so symbol overlap is unaffected.
|
|
// repoPrefix anchors the node join exactly as in MapGitDiff.
|
|
func MapGitDiffWithLines(g graph.Store, repoRoot, repoPrefix, scope, baseRef string) (*DiffResult, map[string][]HunkLine, error) {
|
|
args := buildDiffArgsWithContext(scope, baseRef)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
// Run (raw stdout) keeps the trailing newline so the final hunk line is
|
|
// never dropped; gitcmd injects `-C repoRoot` for us.
|
|
output, err := gitcmd.Run(ctx, repoRoot, args...)
|
|
if err != nil {
|
|
if len(output) == 0 {
|
|
return &DiffResult{}, map[string][]HunkLine{}, nil
|
|
}
|
|
return nil, nil, fmt.Errorf("git diff failed: %w", err)
|
|
}
|
|
|
|
text := string(output)
|
|
hunks := parseDiffHunks(text)
|
|
lines := parseDiffLines(text)
|
|
|
|
return joinHunksToSymbols(g, repoPrefix, hunks), lines, nil
|
|
}
|
|
|
|
func parseDiffHunks(output string) []DiffHunk {
|
|
var hunks []DiffHunk
|
|
var currentFile string
|
|
|
|
scanner := bufio.NewScanner(strings.NewReader(output))
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
|
|
// Detect file path from diff header
|
|
if strings.HasPrefix(line, "+++ b/") {
|
|
currentFile = strings.TrimPrefix(line, "+++ b/")
|
|
continue
|
|
}
|
|
if strings.HasPrefix(line, "+++ /dev/null") {
|
|
currentFile = ""
|
|
continue
|
|
}
|
|
|
|
// Parse @@ hunk header for the new file's line range
|
|
if strings.HasPrefix(line, "@@") && currentFile != "" {
|
|
hunk := parseHunkHeader(line, currentFile)
|
|
if hunk != nil {
|
|
hunks = append(hunks, *hunk)
|
|
}
|
|
}
|
|
}
|
|
|
|
return hunks
|
|
}
|
|
|
|
func parseHunkHeader(line, filePath string) *DiffHunk {
|
|
// Format: @@ -old,count +new,count @@
|
|
parts := strings.SplitN(line, "@@", 3)
|
|
if len(parts) < 2 {
|
|
return nil
|
|
}
|
|
ranges := strings.TrimSpace(parts[1])
|
|
fields := strings.Fields(ranges)
|
|
|
|
for _, f := range fields {
|
|
if strings.HasPrefix(f, "+") {
|
|
f = strings.TrimPrefix(f, "+")
|
|
rangeP := strings.SplitN(f, ",", 2)
|
|
start, err := strconv.Atoi(rangeP[0])
|
|
if err != nil {
|
|
continue
|
|
}
|
|
count := 1
|
|
if len(rangeP) > 1 {
|
|
count, _ = strconv.Atoi(rangeP[1])
|
|
}
|
|
if count == 0 {
|
|
count = 1
|
|
}
|
|
|
|
// Normalize file path to be relative
|
|
relPath := filepath.Clean(filePath)
|
|
|
|
return &DiffHunk{
|
|
FilePath: relPath,
|
|
StartLine: start,
|
|
EndLine: start + count - 1,
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|