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 }