feat(vcs): add util.go with GetAllFilesInPath and BuildLineToPositionMap (#84)
CI / test (pull_request) Successful in 18s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 32s
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 57s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 2m40s
CI / test (pull_request) Successful in 18s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 32s
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 57s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 2m40s
Add vcs/util.go containing two utility functions that were specified in issue #78 but omitted from PR #83: - GetAllFilesInPath: recursively fetches all file contents under a path using the vcs.FileReader interface. Returns map[string]string of path -> content. - BuildLineToPositionMap: parses a unified diff and returns per-file mapping of new-file line numbers to GitHub diff-position convention. Position is 1-indexed from @@ hunk headers. Deletion lines are not mapped (no new-file line number). Position counter continues across hunks within the same file but resets for each new file. Unit tests cover: - GetAllFilesInPath: empty dir, flat dir, nested dirs, mixed, errors - BuildLineToPositionMap: single hunk, multi-hunk, deletions not mapped, multiple files, empty diff, no-newline marker, single-line hunk, deleted file
This commit is contained in:
+142
@@ -0,0 +1,142 @@
|
||||
package vcs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// GetAllFilesInPath recursively fetches all file contents under a path using the
|
||||
// provided FileReader. Returns a map of filepath -> content for all files found.
|
||||
// If the path points to an empty directory, returns an empty map.
|
||||
func GetAllFilesInPath(ctx context.Context, client FileReader, owner, repo, path string) (map[string]string, error) {
|
||||
results := make(map[string]string)
|
||||
|
||||
entries, err := client.ListContents(ctx, owner, repo, path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list contents %q: %w", path, err)
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
switch entry.Type {
|
||||
case "file":
|
||||
content, err := client.GetFileContent(ctx, owner, repo, entry.Path, "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get file %q: %w", entry.Path, err)
|
||||
}
|
||||
results[entry.Path] = content
|
||||
case "dir":
|
||||
subResults, err := GetAllFilesInPath(ctx, client, owner, repo, entry.Path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("recurse into %q: %w", entry.Path, err)
|
||||
}
|
||||
for k, v := range subResults {
|
||||
results[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// BuildLineToPositionMap parses a unified diff and returns a per-file map of
|
||||
// new-file line number → diff-position.
|
||||
//
|
||||
// Position is a 1-indexed offset from the @@ hunk line (GitHub convention):
|
||||
// - The @@ hunk header itself counts as position 1 (for the first hunk)
|
||||
// - Every subsequent content line (context, addition, deletion) increments position
|
||||
// - A second @@ hunk in the same file continues the count; it does not reset
|
||||
// - Addition and context lines have new-file line numbers and are mapped
|
||||
// - Deletion lines have a position but no new-file line; they are not in the map
|
||||
// - "\ No newline at end of file" markers do not count as a position
|
||||
//
|
||||
// Returns: map[filename]map[newFileLine]diffPosition
|
||||
func BuildLineToPositionMap(diff string) map[string]map[int]int {
|
||||
result := make(map[string]map[int]int)
|
||||
|
||||
lines := strings.Split(diff, "\n")
|
||||
var currentFile string
|
||||
var position int
|
||||
var newLine int
|
||||
|
||||
for _, line := range lines {
|
||||
// Detect new file in diff — resets position counter per file.
|
||||
if strings.HasPrefix(line, "+++ b/") {
|
||||
currentFile = strings.TrimPrefix(line, "+++ b/")
|
||||
position = 0
|
||||
newLine = 0
|
||||
if result[currentFile] == nil {
|
||||
result[currentFile] = make(map[int]int)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip deleted-file target (no new lines to map).
|
||||
if strings.HasPrefix(line, "+++ /dev/null") {
|
||||
currentFile = ""
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip metadata lines that are not part of position counting.
|
||||
if strings.HasPrefix(line, "diff --git") ||
|
||||
strings.HasPrefix(line, "--- ") ||
|
||||
strings.HasPrefix(line, "index ") ||
|
||||
strings.HasPrefix(line, "\\") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse hunk headers — the @@ line itself occupies a position.
|
||||
if strings.HasPrefix(line, "@@") && currentFile != "" {
|
||||
position++
|
||||
newLine = parseHunkNewStart(line)
|
||||
continue
|
||||
}
|
||||
|
||||
if currentFile == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Content lines within a hunk.
|
||||
switch {
|
||||
case strings.HasPrefix(line, "+"):
|
||||
// Addition: maps to both a position and a new-file line.
|
||||
position++
|
||||
result[currentFile][newLine] = position
|
||||
newLine++
|
||||
case strings.HasPrefix(line, "-"):
|
||||
// Deletion: occupies a position but has no new-file line number.
|
||||
position++
|
||||
case strings.HasPrefix(line, " "):
|
||||
// Context line: maps to both a position and a new-file line.
|
||||
position++
|
||||
result[currentFile][newLine] = position
|
||||
newLine++
|
||||
}
|
||||
// Any other line (empty trailing lines from Split, etc.) is ignored.
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// parseHunkNewStart extracts the new-file starting line number from a hunk header.
|
||||
// Format: @@ -old_start[,old_count] +new_start[,new_count] @@[ optional section heading]
|
||||
func parseHunkNewStart(hunkLine string) int {
|
||||
// Find the +N part after the first @@
|
||||
plusIdx := strings.Index(hunkLine, "+")
|
||||
if plusIdx < 0 {
|
||||
return 1
|
||||
}
|
||||
rest := hunkLine[plusIdx+1:]
|
||||
|
||||
// Read digits until comma, space, or end.
|
||||
if idx := strings.IndexAny(rest, ", @"); idx > 0 {
|
||||
rest = rest[:idx]
|
||||
}
|
||||
|
||||
n, err := strconv.Atoi(rest)
|
||||
if err != nil {
|
||||
return 1
|
||||
}
|
||||
return n
|
||||
}
|
||||
Reference in New Issue
Block a user