diff --git a/README.md b/README.md index 1f79d1a..bc6cab8 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,9 @@ Supported settings: - `prompt` Bundled prompt preset: `default`, `conventional`, `gitmoji`, `karma` - `prompt_file` Path to a custom prompt file (relative to the config file) - `engines..args` Argument list for the engine command (array of strings) +- `filter.max_file_lines` Maximum lines per file in diff (default: 100) +- `filter.exclude_patterns` Additional glob patterns to exclude from diff +- `filter.default_exclude_patterns` Override built-in exclude patterns ### Engines @@ -117,6 +120,38 @@ prompt_file = "prompts/commit.md" Note: `prompt` and `prompt_file` are mutually exclusive within the same config file. If both are set, an error is returned. When settings come from different layers (user config vs repo config), the later layer wins. +### Diff Filtering + +When the staged diff is large, it can exceed LLM context limits or degrade commit message quality. git-ai-commit automatically filters the diff to help LLMs focus on meaningful changes. + +**Default behavior:** + +- Each file is limited to 100 lines (configurable via `filter.max_file_lines`) +- Lock files and generated files are excluded by default + +**Default exclude patterns:** + +- `**/*.lock`, `**/*-lock.json`, `**/*.lock.yaml`, `**/*-lock.yaml`, `**/*.lockfile` +- `**/*.min.js`, `**/*.min.css`, `**/*.map` +- `**/go.sum` + +Example: Customize filtering + +```toml +[filter] +max_file_lines = 300 + +# Add patterns to exclude (merged with defaults) +exclude_patterns = [ + "**/vendor/**", + "**/*.generated.go", + "**/dist/**" +] + +# Or replace defaults entirely +# default_exclude_patterns = ["**/my-lock.json"] +``` + ## Claude Code Plugin If you use [Claude Code](https://docs.anthropic.com/en/docs/claude-code), you can integrate git-ai-commit as a plugin for a more convenient workflow. diff --git a/internal/app/app.go b/internal/app/app.go index 45f98d5..0b0e482 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -45,7 +45,7 @@ func Run(context, contextFile, promptName, promptFile, engineName string, amend, } } - diff, err := commitDiff(amend) + diff, err := commitDiff(amend, cfg) if err != nil { return err } @@ -141,11 +141,55 @@ func sanitizeMessage(message string) string { return clean } -func commitDiff(amend bool) (string, error) { +func commitDiff(amend bool, cfg config.Config) (string, error) { + var diff string + var err error if amend { - return git.LastCommitDiff() + diff, err = git.LastCommitDiff() + } else { + diff, err = git.StagedDiff() } - return git.StagedDiff() + if err != nil { + return "", err + } + + // Determine exclude patterns + patterns := git.DefaultExcludePatterns() + if len(cfg.Filter.DefaultExcludePatterns) > 0 { + patterns = cfg.Filter.DefaultExcludePatterns + } + patterns = append(patterns, cfg.Filter.ExcludePatterns...) + + // Determine max file lines + maxLines := cfg.Filter.MaxFileLines + if maxLines == 0 { + maxLines = git.DefaultMaxFileLines + } + + opts := git.Options{ + MaxFileLines: maxLines, + ExcludePatterns: patterns, + } + result := git.Filter(diff, opts) + + if result.Truncated || len(result.ExcludedFiles) > 0 { + return result.Diff + formatFilterNotice(result), nil + } + return result.Diff, nil +} + +func formatFilterNotice(result git.Result) string { + var parts []string + if len(result.ExcludedFiles) > 0 { + parts = append(parts, fmt.Sprintf("Excluded files: %s", strings.Join(result.ExcludedFiles, ", "))) + } + if len(result.TruncatedFiles) > 0 { + parts = append(parts, fmt.Sprintf("Truncated files: %s", strings.Join(result.TruncatedFiles, ", "))) + } + if len(parts) == 0 { + return "" + } + return "\n\n[Filter notice: " + strings.Join(parts, "; ") + "]" } func stageChanges(addAll bool, includeFiles []string) (func(), error) { diff --git a/internal/config/config.go b/internal/config/config.go index 91522db..e9406ba 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -17,18 +17,27 @@ type Config struct { Prompt string `toml:"prompt"` PromptFile string `toml:"prompt_file"` Engines map[string]EngineConfig `toml:"engines"` + Filter FilterConfig `toml:"filter"` // ResolvedPrompt holds the final prompt text after loading from preset or file. // This is not read from config files directly. ResolvedPrompt string `toml:"-"` } +// FilterConfig holds diff filtering configuration. +type FilterConfig struct { + MaxFileLines int `toml:"max_file_lines"` // Max lines per file (0 = use default) + DefaultExcludePatterns []string `toml:"default_exclude_patterns"` // Override built-in defaults + ExcludePatterns []string `toml:"exclude_patterns"` // Additional patterns to exclude +} + // rawConfig is the TOML structure used to detect mutual exclusivity in a single layer. type rawConfig struct { DefaultEngine string `toml:"engine"` Prompt string `toml:"prompt"` PromptFile string `toml:"prompt_file"` Engines map[string]EngineConfig `toml:"engines"` + Filter FilterConfig `toml:"filter"` } type EngineConfig struct { @@ -129,6 +138,16 @@ func Load() (Config, error) { cfg.Engines[name] = ec } } + // Merge filter config from repo + if repoCfg.Filter.MaxFileLines != 0 { + cfg.Filter.MaxFileLines = repoCfg.Filter.MaxFileLines + } + if len(repoCfg.Filter.DefaultExcludePatterns) > 0 { + cfg.Filter.DefaultExcludePatterns = repoCfg.Filter.DefaultExcludePatterns + } + if len(repoCfg.Filter.ExcludePatterns) > 0 { + cfg.Filter.ExcludePatterns = append(cfg.Filter.ExcludePatterns, repoCfg.Filter.ExcludePatterns...) + } } } @@ -181,6 +200,16 @@ func loadConfigLayer(data []byte, cfg *Config, source string) error { cfg.Engines[name] = ec } } + // Merge filter config + if raw.Filter.MaxFileLines != 0 { + cfg.Filter.MaxFileLines = raw.Filter.MaxFileLines + } + if len(raw.Filter.DefaultExcludePatterns) > 0 { + cfg.Filter.DefaultExcludePatterns = raw.Filter.DefaultExcludePatterns + } + if len(raw.Filter.ExcludePatterns) > 0 { + cfg.Filter.ExcludePatterns = append(cfg.Filter.ExcludePatterns, raw.Filter.ExcludePatterns...) + } return nil } diff --git a/internal/git/filter.go b/internal/git/filter.go new file mode 100644 index 0000000..4fda551 --- /dev/null +++ b/internal/git/filter.go @@ -0,0 +1,343 @@ +package git + +import ( + "fmt" + "path/filepath" + "sort" + "strings" +) + +// DefaultMaxFileLines is the default maximum number of lines per file. +const DefaultMaxFileLines = 100 + +// DefaultExcludePatterns returns the built-in default patterns for files to exclude from diff. +func DefaultExcludePatterns() []string { + return []string{ + "**/*.lock", + "**/*-lock.json", + "**/*.lock.yaml", + "**/*-lock.yaml", + "**/*.lockfile", + "**/*.min.js", + "**/*.min.css", + "**/*.map", + "**/go.sum", + } +} + +// Options holds filtering configuration. +type Options struct { + MaxFileLines int // Maximum lines per file (0 = no limit) + ExcludePatterns []string // Glob patterns for files to exclude +} + +// Result holds the filtering outcome. +type Result struct { + Diff string // The filtered diff output + Truncated bool // True if any file was truncated + TruncatedFiles []string // List of truncated file paths + ExcludedFiles []string // List of excluded file paths +} + +// Filter filters a unified diff according to the given options. +func Filter(diff string, opts Options) Result { + if strings.TrimSpace(diff) == "" { + return Result{Diff: diff} + } + + files := splitDiffByFile(diff) + if len(files) == 0 { + return Result{Diff: diff} + } + + var result Result + var filteredParts []string + + // Sort file names for deterministic output + fileNames := make([]string, 0, len(files)) + for name := range files { + fileNames = append(fileNames, name) + } + sort.Strings(fileNames) + + for _, fileName := range fileNames { + content := files[fileName] + + // Check exclusion patterns + if matchesAnyPattern(fileName, opts.ExcludePatterns) { + result.ExcludedFiles = append(result.ExcludedFiles, fileName) + continue + } + + // Apply line limit if configured + if opts.MaxFileLines > 0 { + truncated, newContent := truncateFileDiff(content, opts.MaxFileLines, fileName) + if truncated { + result.Truncated = true + result.TruncatedFiles = append(result.TruncatedFiles, fileName) + } + content = newContent + } + + filteredParts = append(filteredParts, content) + } + + result.Diff = strings.Join(filteredParts, "") + return result +} + +// splitDiffByFile splits a unified diff into per-file sections. +// Returns a map of file path to diff content (including header). +func splitDiffByFile(diff string) map[string]string { + files := make(map[string]string) + lines := strings.Split(diff, "\n") + + var currentFile string + var currentContent strings.Builder + var inFile bool + + for i := 0; i < len(lines); i++ { + line := lines[i] + + // Detect start of a new file diff + if strings.HasPrefix(line, "diff --git ") { + // Save previous file if any + if inFile && currentFile != "" { + files[currentFile] = currentContent.String() + } + + // Extract file path from "diff --git a/path b/path" + currentFile = extractFilePath(line) + currentContent.Reset() + currentContent.WriteString(line) + currentContent.WriteString("\n") + inFile = true + continue + } + + if inFile { + currentContent.WriteString(line) + currentContent.WriteString("\n") + } + } + + // Save last file + if inFile && currentFile != "" { + files[currentFile] = currentContent.String() + } + + return files +} + +// extractFilePath extracts the file path from a "diff --git a/path b/path" line. +func extractFilePath(line string) string { + // Format: "diff --git a/path/to/file b/path/to/file" + parts := strings.SplitN(line, " ", 4) + if len(parts) < 4 { + return "" + } + // Use the b/ path (destination) + bPath := parts[3] + if strings.HasPrefix(bPath, "b/") { + return bPath[2:] + } + return bPath +} + +// truncateFileDiff truncates a file diff to the specified number of lines. +// Returns (wasTruncated, newContent). +func truncateFileDiff(content string, maxLines int, fileName string) (bool, string) { + lines := strings.Split(content, "\n") + + // Count only the actual diff lines (not headers) + headerEnd := 0 + for i, line := range lines { + if strings.HasPrefix(line, "@@") { + headerEnd = i + break + } + } + + // Count diff content lines (after first @@ marker) + diffLineCount := 0 + for i := headerEnd; i < len(lines); i++ { + line := lines[i] + if strings.HasPrefix(line, "+") || strings.HasPrefix(line, "-") || strings.HasPrefix(line, " ") { + diffLineCount++ + } + } + + if diffLineCount <= maxLines { + return false, content + } + + // Truncate: keep header + maxLines of diff content + var result strings.Builder + contentLinesSeen := 0 + + for _, line := range lines { + // Always include header lines (before first @@) + if !strings.HasPrefix(line, "@@") && contentLinesSeen == 0 { + result.WriteString(line) + result.WriteString("\n") + continue + } + + // Include @@ hunk headers + if strings.HasPrefix(line, "@@") { + result.WriteString(line) + result.WriteString("\n") + continue + } + + // Count and potentially truncate content lines + if strings.HasPrefix(line, "+") || strings.HasPrefix(line, "-") || strings.HasPrefix(line, " ") { + contentLinesSeen++ + if contentLinesSeen <= maxLines { + result.WriteString(line) + result.WriteString("\n") + } + } else { + // Other lines (like "\ No newline at end of file") + if contentLinesSeen <= maxLines { + result.WriteString(line) + result.WriteString("\n") + } + } + } + + // Add truncation marker + result.WriteString(fmt.Sprintf("\n... [%s truncated: showing %d of %d lines]\n", fileName, maxLines, diffLineCount)) + + return true, result.String() +} + +// matchesAnyPattern checks if the file path matches any of the glob patterns. +func matchesAnyPattern(filePath string, patterns []string) bool { + for _, pattern := range patterns { + if matchPattern(filePath, pattern) { + return true + } + } + return false +} + +// matchPattern checks if a file path matches a glob pattern. +// Supports ** for recursive directory matching. +func matchPattern(filePath, pattern string) bool { + // Handle ** patterns specially + if strings.Contains(pattern, "**") { + return matchDoubleStarPattern(filePath, pattern) + } + + // Use standard filepath.Match for simple patterns + matched, err := filepath.Match(pattern, filePath) + if err != nil { + return false + } + if matched { + return true + } + + // Also try matching against just the filename + matched, err = filepath.Match(pattern, filepath.Base(filePath)) + if err != nil { + return false + } + return matched +} + +// matchDoubleStarPattern handles patterns containing **. +func matchDoubleStarPattern(filePath, pattern string) bool { + // Split pattern by ** + parts := strings.Split(pattern, "**") + if len(parts) == 1 { + // No ** found, use standard matching + matched, _ := filepath.Match(pattern, filePath) + return matched + } + + // Handle common case: **/suffix (e.g., **/*.lock) + if parts[0] == "" && len(parts) == 2 { + suffix := strings.TrimPrefix(parts[1], "/") + if suffix == "" { + return true // ** matches everything + } + // Match suffix against file name or any suffix of the path + if matchPathSuffix(filePath, suffix) { + return true + } + } + + // Handle prefix/**/suffix patterns + if len(parts) == 2 { + prefix := strings.TrimSuffix(parts[0], "/") + suffix := strings.TrimPrefix(parts[1], "/") + + // If prefix is empty, we've handled this above + if prefix == "" { + return false + } + + // Check if path starts with prefix + if !strings.HasPrefix(filePath, prefix) && !matchPathPrefix(filePath, prefix) { + return false + } + + // Check if remaining path matches suffix + remaining := strings.TrimPrefix(filePath, prefix) + remaining = strings.TrimPrefix(remaining, "/") + if suffix == "" { + return true + } + return matchPathSuffix(remaining, suffix) + } + + return false +} + +// matchPathSuffix checks if the path or any of its suffixes match the pattern. +func matchPathSuffix(path, pattern string) bool { + // Try direct match + matched, _ := filepath.Match(pattern, path) + if matched { + return true + } + + // Try matching just the filename + matched, _ = filepath.Match(pattern, filepath.Base(path)) + if matched { + return true + } + + // Try matching path suffixes + parts := strings.Split(path, "/") + for i := range parts { + suffix := strings.Join(parts[i:], "/") + matched, _ := filepath.Match(pattern, suffix) + if matched { + return true + } + } + + return false +} + +// matchPathPrefix checks if the path starts with the given prefix pattern. +func matchPathPrefix(path, prefix string) bool { + pathParts := strings.Split(path, "/") + prefixParts := strings.Split(prefix, "/") + + if len(pathParts) < len(prefixParts) { + return false + } + + for i, pp := range prefixParts { + matched, _ := filepath.Match(pp, pathParts[i]) + if !matched { + return false + } + } + + return true +} diff --git a/internal/git/filter_test.go b/internal/git/filter_test.go new file mode 100644 index 0000000..65bbd47 --- /dev/null +++ b/internal/git/filter_test.go @@ -0,0 +1,330 @@ +package git + +import ( + "strings" + "testing" +) + +func TestFilter_Empty(t *testing.T) { + result := Filter("", Options{MaxFileLines: 100}) + if result.Diff != "" { + t.Errorf("expected empty diff, got %q", result.Diff) + } + if result.Truncated { + t.Error("expected Truncated to be false") + } + if len(result.TruncatedFiles) != 0 { + t.Errorf("expected no truncated files, got %v", result.TruncatedFiles) + } + if len(result.ExcludedFiles) != 0 { + t.Errorf("expected no excluded files, got %v", result.ExcludedFiles) + } +} + +func TestFilter_NoTruncation(t *testing.T) { + diff := `diff --git a/file.go b/file.go +index abc123..def456 100644 +--- a/file.go ++++ b/file.go +@@ -1,3 +1,4 @@ + package main ++import "fmt" + func main() {} +` + result := Filter(diff, Options{MaxFileLines: 100}) + if result.Truncated { + t.Error("expected Truncated to be false") + } + if len(result.TruncatedFiles) != 0 { + t.Errorf("expected no truncated files, got %v", result.TruncatedFiles) + } + // Diff content should be preserved + if !strings.Contains(result.Diff, "import \"fmt\"") { + t.Error("expected diff content to be preserved") + } +} + +func TestFilter_TruncateFile(t *testing.T) { + // Create a diff with many lines + var sb strings.Builder + sb.WriteString(`diff --git a/large.go b/large.go +index abc123..def456 100644 +--- a/large.go ++++ b/large.go +@@ -1,100 +1,110 @@ +`) + for i := 0; i < 50; i++ { + sb.WriteString("+new line\n") + } + diff := sb.String() + + result := Filter(diff, Options{MaxFileLines: 10}) + if !result.Truncated { + t.Error("expected Truncated to be true") + } + if len(result.TruncatedFiles) != 1 || result.TruncatedFiles[0] != "large.go" { + t.Errorf("expected truncated file 'large.go', got %v", result.TruncatedFiles) + } + if !strings.Contains(result.Diff, "truncated") { + t.Error("expected truncation marker in diff") + } + if !strings.Contains(result.Diff, "showing 10 of 50 lines") { + t.Errorf("expected truncation info, got %s", result.Diff) + } +} + +func TestFilter_ExcludeFiles(t *testing.T) { + diff := `diff --git a/package-lock.json b/package-lock.json +index abc123..def456 100644 +--- a/package-lock.json ++++ b/package-lock.json +@@ -1,3 +1,4 @@ ++lock content +diff --git a/main.go b/main.go +index abc123..def456 100644 +--- a/main.go ++++ b/main.go +@@ -1,3 +1,4 @@ ++code content +` + result := Filter(diff, Options{ + ExcludePatterns: []string{"**/*-lock.json"}, + }) + + if len(result.ExcludedFiles) != 1 || result.ExcludedFiles[0] != "package-lock.json" { + t.Errorf("expected excluded file 'package-lock.json', got %v", result.ExcludedFiles) + } + if strings.Contains(result.Diff, "package-lock.json") { + t.Error("expected package-lock.json to be excluded from diff") + } + if !strings.Contains(result.Diff, "main.go") { + t.Error("expected main.go to be in diff") + } +} + +func TestFilter_ExcludeGoSum(t *testing.T) { + diff := `diff --git a/go.sum b/go.sum +index abc123..def456 100644 +--- a/go.sum ++++ b/go.sum +@@ -1,3 +1,4 @@ ++module v1.0.0 h1:abc= +diff --git a/main.go b/main.go +index abc123..def456 100644 +--- a/main.go ++++ b/main.go +@@ -1,3 +1,4 @@ ++code +` + result := Filter(diff, Options{ + ExcludePatterns: DefaultExcludePatterns(), + }) + + if len(result.ExcludedFiles) != 1 || result.ExcludedFiles[0] != "go.sum" { + t.Errorf("expected excluded file 'go.sum', got %v", result.ExcludedFiles) + } + if strings.Contains(result.Diff, "go.sum") { + t.Error("expected go.sum to be excluded from diff") + } +} + +func TestFilter_MultipleFiles(t *testing.T) { + diff := `diff --git a/a.go b/a.go +index abc123..def456 100644 +--- a/a.go ++++ b/a.go +@@ -1,1 +1,2 @@ ++line a +diff --git a/b.go b/b.go +index abc123..def456 100644 +--- a/b.go ++++ b/b.go +@@ -1,1 +1,2 @@ ++line b +diff --git a/c.go b/c.go +index abc123..def456 100644 +--- a/c.go ++++ b/c.go +@@ -1,1 +1,2 @@ ++line c +` + result := Filter(diff, Options{MaxFileLines: 100}) + if result.Truncated { + t.Error("expected no truncation") + } + if !strings.Contains(result.Diff, "a.go") { + t.Error("expected a.go in diff") + } + if !strings.Contains(result.Diff, "b.go") { + t.Error("expected b.go in diff") + } + if !strings.Contains(result.Diff, "c.go") { + t.Error("expected c.go in diff") + } +} + +func TestFilter_Deterministic(t *testing.T) { + diff := `diff --git a/z.go b/z.go +index abc123..def456 100644 +--- a/z.go ++++ b/z.go +@@ -1,1 +1,2 @@ ++z +diff --git a/a.go b/a.go +index abc123..def456 100644 +--- a/a.go ++++ b/a.go +@@ -1,1 +1,2 @@ ++a +diff --git a/m.go b/m.go +index abc123..def456 100644 +--- a/m.go ++++ b/m.go +@@ -1,1 +1,2 @@ ++m +` + result1 := Filter(diff, Options{MaxFileLines: 100}) + result2 := Filter(diff, Options{MaxFileLines: 100}) + + if result1.Diff != result2.Diff { + t.Error("expected deterministic output") + } + + // Files should be sorted alphabetically + aIdx := strings.Index(result1.Diff, "a.go") + mIdx := strings.Index(result1.Diff, "m.go") + zIdx := strings.Index(result1.Diff, "z.go") + + if !(aIdx < mIdx && mIdx < zIdx) { + t.Errorf("expected files in alphabetical order, got a=%d, m=%d, z=%d", aIdx, mIdx, zIdx) + } +} + +func TestFilter_ExcludeMinifiedFiles(t *testing.T) { + diff := `diff --git a/app.min.js b/app.min.js +index abc123..def456 100644 +--- a/app.min.js ++++ b/app.min.js +@@ -1,1 +1,2 @@ ++minified +diff --git a/style.min.css b/style.min.css +index abc123..def456 100644 +--- a/style.min.css ++++ b/style.min.css +@@ -1,1 +1,2 @@ ++minified css +diff --git a/app.js b/app.js +index abc123..def456 100644 +--- a/app.js ++++ b/app.js +@@ -1,1 +1,2 @@ ++normal +` + result := Filter(diff, Options{ + ExcludePatterns: DefaultExcludePatterns(), + }) + + if len(result.ExcludedFiles) != 2 { + t.Errorf("expected 2 excluded files, got %v", result.ExcludedFiles) + } + if !strings.Contains(result.Diff, "app.js") { + t.Error("expected app.js in diff") + } + if strings.Contains(result.Diff, "app.min.js") { + t.Error("expected app.min.js to be excluded") + } + if strings.Contains(result.Diff, "style.min.css") { + t.Error("expected style.min.css to be excluded") + } +} + +func TestFilter_NoLimitWhenZero(t *testing.T) { + var sb strings.Builder + sb.WriteString(`diff --git a/large.go b/large.go +index abc123..def456 100644 +--- a/large.go ++++ b/large.go +@@ -1,100 +1,150 @@ +`) + for i := 0; i < 100; i++ { + sb.WriteString("+new line\n") + } + diff := sb.String() + + result := Filter(diff, Options{MaxFileLines: 0}) // 0 means no limit + if result.Truncated { + t.Error("expected no truncation when MaxFileLines is 0") + } +} + +func TestDefaultExcludePatterns(t *testing.T) { + patterns := DefaultExcludePatterns() + if len(patterns) == 0 { + t.Error("expected default patterns") + } + + // Check that expected patterns are present + expected := []string{ + "**/*.lock", + "**/*-lock.json", + "**/go.sum", + } + for _, exp := range expected { + found := false + for _, p := range patterns { + if p == exp { + found = true + break + } + } + if !found { + t.Errorf("expected pattern %q in defaults", exp) + } + } +} + +func TestMatchPattern_DoubleStarGlob(t *testing.T) { + tests := []struct { + path string + pattern string + want bool + }{ + {"go.sum", "**/go.sum", true}, + {"vendor/go.sum", "**/go.sum", true}, + {"package-lock.json", "**/*-lock.json", true}, + {"node_modules/package-lock.json", "**/*-lock.json", true}, + {"deep/nested/package-lock.json", "**/*-lock.json", true}, + {"app.min.js", "**/*.min.js", true}, + {"dist/app.min.js", "**/*.min.js", true}, + {"src/styles/main.min.css", "**/*.min.css", true}, + {"app.js", "**/*.min.js", false}, + {"main.go", "**/go.sum", false}, + {"vendor/module/main.go", "vendor/**", true}, + {"vendor/main.go", "vendor/**", true}, + } + + for _, tt := range tests { + got := matchPattern(tt.path, tt.pattern) + if got != tt.want { + t.Errorf("matchPattern(%q, %q) = %v, want %v", tt.path, tt.pattern, got, tt.want) + } + } +} + +func TestExtractFilePath(t *testing.T) { + tests := []struct { + line string + want string + }{ + {"diff --git a/file.go b/file.go", "file.go"}, + {"diff --git a/path/to/file.go b/path/to/file.go", "path/to/file.go"}, + {"diff --git a/old/name.go b/new/name.go", "new/name.go"}, + } + + for _, tt := range tests { + got := extractFilePath(tt.line) + if got != tt.want { + t.Errorf("extractFilePath(%q) = %q, want %q", tt.line, got, tt.want) + } + } +}