From 54181a7677d1913fd033f3dbd29e2b88e963cad7 Mon Sep 17 00:00:00 2001 From: Naoto Takai Date: Wed, 28 Jan 2026 21:01:23 +0900 Subject: [PATCH 1/5] feat(git): add diff filtering with file truncation and exclusion Introduces a new filtering module for processing unified diffs with support for line limits per file and glob-based pattern exclusion. Includes Options and Result structs, a Filter function, and comprehensive unit tests covering truncation, exclusion patterns, and deterministic output ordering. --- internal/git/filter.go | 343 ++++++++++++++++++++++++++++++++++++ internal/git/filter_test.go | 330 ++++++++++++++++++++++++++++++++++ 2 files changed, 673 insertions(+) create mode 100644 internal/git/filter.go create mode 100644 internal/git/filter_test.go diff --git a/internal/git/filter.go b/internal/git/filter.go new file mode 100644 index 0000000..8079ab0 --- /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 = 200 + +// 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..2fc743a --- /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: 200}) + 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: 200}) + 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: 200}) + 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: 200}) + result2 := Filter(diff, Options{MaxFileLines: 200}) + + 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) + } + } +} From b824433bb6c4a3c164339578a40bc21bb3e848bf Mon Sep 17 00:00:00 2001 From: Naoto Takai Date: Wed, 28 Jan 2026 21:01:37 +0900 Subject: [PATCH 2/5] feat(config): add FilterConfig struct with diff filtering options Add FilterConfig struct to support configurable diff filtering with max_file_lines, default_exclude_patterns, and exclude_patterns fields. Implement merging of filter configuration from both user and repository level configs, with repo-level settings taking precedence for max_file_lines and default patterns, and exclude_patterns being accumulated across layers. --- internal/config/config.go | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) 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 } From f59ad1f44e9ef89634a09fec5823d7c838c61533 Mon Sep 17 00:00:00 2001 From: Naoto Takai Date: Wed, 28 Jan 2026 21:01:51 +0900 Subject: [PATCH 3/5] feat(app): apply diff filtering with exclusion and truncation Integrate diff filtering into the commitDiff function to apply exclusion patterns and file truncation before sending the diff to the LLM. Configure filtering options from the application config and append a filter notice when files are excluded or truncated. --- internal/app/app.go | 52 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 48 insertions(+), 4 deletions(-) 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) { From e4ad8052a3d22f8a77c97bedfd7db6dabc160c89 Mon Sep 17 00:00:00 2001 From: Naoto Takai Date: Wed, 28 Jan 2026 21:02:02 +0900 Subject: [PATCH 4/5] docs(readme): document diff filtering feature with settings and examples --- README.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/README.md b/README.md index 1f79d1a..74b0775 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: 200) +- `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 200 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. From 907bd741940f7764a4d73748be2a813bcd4fd33b Mon Sep 17 00:00:00 2001 From: Naoto Takai Date: Wed, 28 Jan 2026 21:08:41 +0900 Subject: [PATCH 5/5] feat(filter): reduce default max file lines from 200 to 100 The previous default of 200 lines per file was too permissive and could cause diffs to exceed LLM context limits. Reducing to 100 lines provides better compatibility with token constraints while still capturing meaningful code changes for commit message generation. --- README.md | 4 ++-- internal/git/filter.go | 2 +- internal/git/filter_test.go | 10 +++++----- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 74b0775..bc6cab8 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ 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: 200) +- `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 @@ -126,7 +126,7 @@ When the staged diff is large, it can exceed LLM context limits or degrade commi **Default behavior:** -- Each file is limited to 200 lines (configurable via `filter.max_file_lines`) +- 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:** diff --git a/internal/git/filter.go b/internal/git/filter.go index 8079ab0..4fda551 100644 --- a/internal/git/filter.go +++ b/internal/git/filter.go @@ -8,7 +8,7 @@ import ( ) // DefaultMaxFileLines is the default maximum number of lines per file. -const DefaultMaxFileLines = 200 +const DefaultMaxFileLines = 100 // DefaultExcludePatterns returns the built-in default patterns for files to exclude from diff. func DefaultExcludePatterns() []string { diff --git a/internal/git/filter_test.go b/internal/git/filter_test.go index 2fc743a..65bbd47 100644 --- a/internal/git/filter_test.go +++ b/internal/git/filter_test.go @@ -6,7 +6,7 @@ import ( ) func TestFilter_Empty(t *testing.T) { - result := Filter("", Options{MaxFileLines: 200}) + result := Filter("", Options{MaxFileLines: 100}) if result.Diff != "" { t.Errorf("expected empty diff, got %q", result.Diff) } @@ -31,7 +31,7 @@ index abc123..def456 100644 +import "fmt" func main() {} ` - result := Filter(diff, Options{MaxFileLines: 200}) + result := Filter(diff, Options{MaxFileLines: 100}) if result.Truncated { t.Error("expected Truncated to be false") } @@ -148,7 +148,7 @@ index abc123..def456 100644 @@ -1,1 +1,2 @@ +line c ` - result := Filter(diff, Options{MaxFileLines: 200}) + result := Filter(diff, Options{MaxFileLines: 100}) if result.Truncated { t.Error("expected no truncation") } @@ -183,8 +183,8 @@ index abc123..def456 100644 @@ -1,1 +1,2 @@ +m ` - result1 := Filter(diff, Options{MaxFileLines: 200}) - result2 := Filter(diff, Options{MaxFileLines: 200}) + result1 := Filter(diff, Options{MaxFileLines: 100}) + result2 := Filter(diff, Options{MaxFileLines: 100}) if result1.Diff != result2.Diff { t.Error("expected deterministic output")