From f13170d6f2676528a0752e35202a189d18f3713a Mon Sep 17 00:00:00 2001 From: Naoto Takai Date: Tue, 3 Feb 2026 19:43:17 +0900 Subject: [PATCH 1/3] feat(filter): add exact-path file exclusion support Add ExcludeFiles field to Options for excluding files by exact path match, complementing the existing glob pattern-based exclusion. Implement containsFile helper for efficient exact-path matching and add comprehensive test coverage for single file, multiple files, no-match, and combined pattern scenarios. --- internal/git/filter.go | 17 +++++ internal/git/filter_test.go | 147 ++++++++++++++++++++++++++++++++++++ 2 files changed, 164 insertions(+) diff --git a/internal/git/filter.go b/internal/git/filter.go index 4fda551..009d845 100644 --- a/internal/git/filter.go +++ b/internal/git/filter.go @@ -29,6 +29,7 @@ func DefaultExcludePatterns() []string { type Options struct { MaxFileLines int // Maximum lines per file (0 = no limit) ExcludePatterns []string // Glob patterns for files to exclude + ExcludeFiles []string // Exact file paths to exclude } // Result holds the filtering outcome. @@ -63,6 +64,12 @@ func Filter(diff string, opts Options) Result { for _, fileName := range fileNames { content := files[fileName] + // Check exact file exclusions + if containsFile(fileName, opts.ExcludeFiles) { + result.ExcludedFiles = append(result.ExcludedFiles, fileName) + continue + } + // Check exclusion patterns if matchesAnyPattern(fileName, opts.ExcludePatterns) { result.ExcludedFiles = append(result.ExcludedFiles, fileName) @@ -212,6 +219,16 @@ func truncateFileDiff(content string, maxLines int, fileName string) (bool, stri return true, result.String() } +// containsFile checks if filePath is in the given list of exact paths. +func containsFile(filePath string, files []string) bool { + for _, f := range files { + if f == filePath { + return true + } + } + return false +} + // matchesAnyPattern checks if the file path matches any of the glob patterns. func matchesAnyPattern(filePath string, patterns []string) bool { for _, pattern := range patterns { diff --git a/internal/git/filter_test.go b/internal/git/filter_test.go index 65bbd47..ae9aedf 100644 --- a/internal/git/filter_test.go +++ b/internal/git/filter_test.go @@ -311,6 +311,153 @@ func TestMatchPattern_DoubleStarGlob(t *testing.T) { } } +func TestFilter_ExcludeFilesByExactPath(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{ + ExcludeFiles: []string{"go.sum"}, + }) + + 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") + } + if !strings.Contains(result.Diff, "main.go") { + t.Error("expected main.go to be in diff") + } +} + +func TestFilter_ExcludeFilesMultiple(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 @@ ++sum content +diff --git a/main.go b/main.go +index abc123..def456 100644 +--- a/main.go ++++ b/main.go +@@ -1,3 +1,4 @@ ++code +diff --git a/vendor/deps.go b/vendor/deps.go +index abc123..def456 100644 +--- a/vendor/deps.go ++++ b/vendor/deps.go +@@ -1,3 +1,4 @@ ++deps +` + result := Filter(diff, Options{ + ExcludeFiles: []string{"go.sum", "vendor/deps.go"}, + }) + + if len(result.ExcludedFiles) != 2 { + t.Errorf("expected 2 excluded files, got %v", result.ExcludedFiles) + } + if strings.Contains(result.Diff, "go.sum") { + t.Error("expected go.sum to be excluded from diff") + } + if strings.Contains(result.Diff, "vendor/deps.go") { + t.Error("expected vendor/deps.go to be excluded from diff") + } + if !strings.Contains(result.Diff, "main.go") { + t.Error("expected main.go to be in diff") + } +} + +func TestFilter_ExcludeFilesNoMatch(t *testing.T) { + diff := `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{ + ExcludeFiles: []string{"nonexistent.go"}, + }) + + if len(result.ExcludedFiles) != 0 { + t.Errorf("expected no excluded files, got %v", result.ExcludedFiles) + } + if !strings.Contains(result.Diff, "main.go") { + t.Error("expected main.go to be in diff") + } +} + +func TestFilter_ExcludeFilesCombinedWithPatterns(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 @@ ++sum content +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 +` + result := Filter(diff, Options{ + ExcludeFiles: []string{"go.sum"}, + ExcludePatterns: []string{"**/*-lock.json"}, + }) + + if len(result.ExcludedFiles) != 2 { + t.Errorf("expected 2 excluded files, got %v", result.ExcludedFiles) + } + if strings.Contains(result.Diff, "go.sum") { + t.Error("expected go.sum to be excluded from diff") + } + 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 TestContainsFile(t *testing.T) { + tests := []struct { + filePath string + files []string + want bool + }{ + {"go.sum", []string{"go.sum"}, true}, + {"vendor/deps.go", []string{"vendor/deps.go"}, true}, + {"main.go", []string{"go.sum", "vendor/deps.go"}, false}, + {"go.sum", []string{}, false}, + {"go.sum", nil, false}, + } + + for _, tt := range tests { + got := containsFile(tt.filePath, tt.files) + if got != tt.want { + t.Errorf("containsFile(%q, %v) = %v, want %v", tt.filePath, tt.files, got, tt.want) + } + } +} + func TestExtractFilePath(t *testing.T) { tests := []struct { line string From 4e7f888cea3612ac173b7b2f5826fbf2ea50ae94 Mon Sep 17 00:00:00 2001 From: Naoto Takai Date: Tue, 3 Feb 2026 19:43:28 +0900 Subject: [PATCH 2/3] feat(app): thread excludeFiles parameter through app.Run() and commitDiff() --- internal/app/app.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index 0b0e482..25cf098 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -11,7 +11,7 @@ import ( "git-ai-commit/internal/prompt" ) -func Run(context, contextFile, promptName, promptFile, engineName string, amend, addAll bool, includeFiles []string, debugPrompt, debugCommand bool) (err error) { +func Run(context, contextFile, promptName, promptFile, engineName string, amend, addAll bool, includeFiles, excludeFiles []string, debugPrompt, debugCommand bool) (err error) { cfg, err := config.Load() if err != nil { return err @@ -45,7 +45,7 @@ func Run(context, contextFile, promptName, promptFile, engineName string, amend, } } - diff, err := commitDiff(amend, cfg) + diff, err := commitDiff(amend, cfg, excludeFiles) if err != nil { return err } @@ -141,7 +141,7 @@ func sanitizeMessage(message string) string { return clean } -func commitDiff(amend bool, cfg config.Config) (string, error) { +func commitDiff(amend bool, cfg config.Config, excludeFiles []string) (string, error) { var diff string var err error if amend { @@ -169,6 +169,7 @@ func commitDiff(amend bool, cfg config.Config) (string, error) { opts := git.Options{ MaxFileLines: maxLines, ExcludePatterns: patterns, + ExcludeFiles: excludeFiles, } result := git.Filter(diff, opts) From 97595c90236ef9fe2adda760d343793e37c42063 Mon Sep 17 00:00:00 2001 From: Naoto Takai Date: Tue, 3 Feb 2026 19:43:41 +0900 Subject: [PATCH 3/3] feat(cli): add --exclude/-x option for filtering files from message generation --- cmd/git-ai-commit/main.go | 30 +++++++++++++- cmd/git-ai-commit/main_test.go | 75 ++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 cmd/git-ai-commit/main_test.go diff --git a/cmd/git-ai-commit/main.go b/cmd/git-ai-commit/main.go index f348b3c..a4c536e 100644 --- a/cmd/git-ai-commit/main.go +++ b/cmd/git-ai-commit/main.go @@ -23,6 +23,7 @@ type options struct { amend bool addAll bool includeFiles []string + excludeFiles []string debugPrompt bool debugCommand bool } @@ -51,6 +52,7 @@ func main() { opts.amend, opts.addAll, opts.includeFiles, + opts.excludeFiles, opts.debugPrompt, opts.debugCommand, ); err != nil { @@ -76,7 +78,7 @@ func parseArgs(args []string) (options, error) { return opts, errHelp case "version": return opts, errVersion - case "context", "context-file", "prompt", "prompt-file", "engine", "include": + case "context", "context-file", "prompt", "prompt-file", "engine", "include", "exclude": if !hasValue { if i+1 >= len(args) { return opts, fmt.Errorf("missing value for --%s", name) @@ -132,6 +134,11 @@ func applyLongOption(opts *options, name, value string) error { return fmt.Errorf("missing value for --include") } opts.includeFiles = append(opts.includeFiles, value) + case "exclude": + if value == "" { + return fmt.Errorf("missing value for --exclude") + } + opts.excludeFiles = append(opts.excludeFiles, value) default: return fmt.Errorf("unknown option --%s", name) } @@ -167,6 +174,26 @@ func parseShortOptions(opts *options, arg string, args []string, index *int) err return fmt.Errorf("missing value for -i") } opts.includeFiles = append(opts.includeFiles, value) + case 'x': + value := "" + if i+1 < len(cluster) { + if cluster[i+1] == '=' { + value = cluster[i+2:] + } else { + value = cluster[i+1:] + } + i = len(cluster) + } else { + if *index+1 >= len(args) { + return fmt.Errorf("missing value for -x") + } + *index++ + value = args[*index] + } + if value == "" { + return fmt.Errorf("missing value for -x") + } + opts.excludeFiles = append(opts.excludeFiles, value) case 'h': return errHelp default: @@ -189,6 +216,7 @@ func printUsage(out *os.File) { fmt.Fprintln(out, " --amend Amend the previous commit") fmt.Fprintln(out, " -a, --all Stage modified and deleted files before generating the message") fmt.Fprintln(out, " -i, --include VALUE Stage specific files before generating the message") + fmt.Fprintln(out, " -x, --exclude VALUE Hide specific files from the diff for message generation") fmt.Fprintln(out, " --debug-prompt Print the prompt before executing the engine") fmt.Fprintln(out, " --debug-command Print the engine command before execution") fmt.Fprintln(out, " -h, --help Show help") diff --git a/cmd/git-ai-commit/main_test.go b/cmd/git-ai-commit/main_test.go new file mode 100644 index 0000000..75110e8 --- /dev/null +++ b/cmd/git-ai-commit/main_test.go @@ -0,0 +1,75 @@ +package main + +import ( + "testing" +) + +func TestParseArgs_ExcludeLong(t *testing.T) { + opts, err := parseArgs([]string{"--exclude", "go.sum"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(opts.excludeFiles) != 1 || opts.excludeFiles[0] != "go.sum" { + t.Errorf("expected excludeFiles=[go.sum], got %v", opts.excludeFiles) + } +} + +func TestParseArgs_ExcludeLongEquals(t *testing.T) { + opts, err := parseArgs([]string{"--exclude=go.sum"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(opts.excludeFiles) != 1 || opts.excludeFiles[0] != "go.sum" { + t.Errorf("expected excludeFiles=[go.sum], got %v", opts.excludeFiles) + } +} + +func TestParseArgs_ExcludeShort(t *testing.T) { + opts, err := parseArgs([]string{"-x", "go.sum"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(opts.excludeFiles) != 1 || opts.excludeFiles[0] != "go.sum" { + t.Errorf("expected excludeFiles=[go.sum], got %v", opts.excludeFiles) + } +} + +func TestParseArgs_ExcludeMultiple(t *testing.T) { + opts, err := parseArgs([]string{"--exclude", "go.sum", "-x", "vendor/deps.go"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(opts.excludeFiles) != 2 { + t.Fatalf("expected 2 excludeFiles, got %v", opts.excludeFiles) + } + if opts.excludeFiles[0] != "go.sum" || opts.excludeFiles[1] != "vendor/deps.go" { + t.Errorf("expected [go.sum, vendor/deps.go], got %v", opts.excludeFiles) + } +} + +func TestParseArgs_ExcludeMissingValue(t *testing.T) { + _, err := parseArgs([]string{"--exclude"}) + if err == nil { + t.Fatal("expected error for missing value") + } +} + +func TestParseArgs_ExcludeShortMissingValue(t *testing.T) { + _, err := parseArgs([]string{"-x"}) + if err == nil { + t.Fatal("expected error for missing value") + } +} + +func TestParseArgs_ExcludeShortCluster(t *testing.T) { + opts, err := parseArgs([]string{"-ax", "go.sum"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !opts.addAll { + t.Error("expected addAll to be true") + } + if len(opts.excludeFiles) != 1 || opts.excludeFiles[0] != "go.sum" { + t.Errorf("expected excludeFiles=[go.sum], got %v", opts.excludeFiles) + } +}