Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 35 additions & 9 deletions internal/tool/gitignore.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,12 @@ func loadMergedIgnore(dir string) (*GitIgnore, error) {
return merged, nil
}

// loadLLMIgnore loads only the agent-specific ignore rules. It is used when a
// search explicitly opts into gitignored files, so .llmignore remains enforced.
func loadLLMIgnore(dir string) (*GitIgnore, error) {
return LoadGitIgnore(filepath.Join(dir, ".llmignore"))
}

// Matches checks whether the given relative path (e.g. "cmd/late/main.go")
// should be ignored. isDir should be true for directories.
// Implements "last matching pattern wins" — negated patterns (!) can
Expand Down Expand Up @@ -223,14 +229,21 @@ func getRepoRoot() (string, *GitIgnore) {
return cachedRepoRoot, cachedGitIgnore
}

// getGitIgnoreForPath returns the gitignore and its originating directory
// applicable to the given search path.
// getGitIgnoreForPath returns the merged ignore rules and their originating
// directory applicable to the given search path.
//
// It first walks upward from searchPath looking for nested .gitignore files,
// which is essential for monorepos where sub-projects define their own ignore
// rules. If no nested .gitignore is found, it falls back to the CWD-keyed
// cached repo root .gitignore.
// It first walks upward from searchPath looking for nested .gitignore or
// .llmignore files, which is essential for monorepos where sub-projects define
// their own ignore rules. If none is found, it falls back to the CWD-keyed
// cached repo root rules.
func getGitIgnoreForPath(searchPath string) (*GitIgnore, string) {
return getIgnoreForPath(searchPath, false)
}

// getIgnoreForPath returns the ignore rules applicable to searchPath. When
// includeGitignored is true, only .llmignore rules are loaded; otherwise the
// existing merged .gitignore + .llmignore behavior is preserved.
func getIgnoreForPath(searchPath string, includeGitignored bool) (*GitIgnore, string) {
absPath, err := filepath.Abs(searchPath)
if err != nil {
return nil, ""
Expand All @@ -239,12 +252,17 @@ func getGitIgnoreForPath(searchPath string) (*GitIgnore, string) {
// Prime the CWD-keyed cache so we know the repo root boundary.
cachedRoot, _ := getRepoRoot()

// Walk upward from searchPath looking for a nested .gitignore.
// Walk upward from searchPath looking for applicable nested ignore rules.
// Return the closest one found (with its directory as the root so that
// relative path computation in matchesGitIgnore is correct).
dir := absPath
for {
gi, err := loadMergedIgnore(dir)
var gi *GitIgnore
if includeGitignored {
gi, err = loadLLMIgnore(dir)
} else {
gi, err = loadMergedIgnore(dir)
}
if err == nil && gi != nil {
return gi, dir
}
Expand All @@ -263,8 +281,16 @@ func getGitIgnoreForPath(searchPath string) (*GitIgnore, string) {
dir = parent
}

// Fall back to cached repo root .gitignore
// Fall back to the repo root rules. The normal path uses the cache; the
// opt-in path must load .llmignore separately to avoid applying .gitignore.
root, gi := getRepoRoot()
if includeGitignored && root != "" {
li, err := loadLLMIgnore(root)
if err == nil {
return li, root
}
return nil, root
}
return gi, root
}

Expand Down
72 changes: 72 additions & 0 deletions internal/tool/gitignore_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,78 @@ func TestSearchTool_GitIgnoreDirectorySkipped(t *testing.T) {
}
}

func TestSearchTool_IncludeGitignored(t *testing.T) {
ResetGitIgnoreCache()

dir := t.TempDir()
os.MkdirAll(filepath.Join(dir, ".git"), 0755)
os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("temp/\n"), 0644)
os.MkdirAll(filepath.Join(dir, "temp"), 0755)
os.WriteFile(filepath.Join(dir, "temp", "reference.go"), []byte("package reference\n"), 0644)

tool := &SearchTool{}
args := json.RawMessage(`{"pattern":"package","path":"` + filepath.Join(dir, "temp") + `","include_gitignored":true}`)
result, err := tool.Execute(context.Background(), args)
if err != nil {
t.Fatal(err)
}

if !strings.Contains(result, "reference.go") {
t.Errorf("expected gitignored reference.go when include_gitignored is true, got: %q", result)
}
}

func TestSearchTool_IncludeGitignoredStillHonorsLlmIgnore(t *testing.T) {
ResetGitIgnoreCache()

dir := t.TempDir()
os.MkdirAll(filepath.Join(dir, ".git"), 0755)
os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("temp/\n"), 0644)
os.WriteFile(filepath.Join(dir, ".llmignore"), []byte("temp/hidden.go\n"), 0644)
os.MkdirAll(filepath.Join(dir, "temp"), 0755)
os.WriteFile(filepath.Join(dir, "temp", "reference.go"), []byte("package reference\n"), 0644)
os.WriteFile(filepath.Join(dir, "temp", "hidden.go"), []byte("package hidden\n"), 0644)

tool := &SearchTool{}
args := json.RawMessage(`{"pattern":"*.go","path":"` + filepath.Join(dir, "temp") + `","search_names":true,"include_gitignored":true}`)
result, err := tool.Execute(context.Background(), args)
if err != nil {
t.Fatal(err)
}

if !strings.Contains(result, "reference.go") {
t.Errorf("expected gitignored reference.go in filename results, got: %q", result)
}
if strings.Contains(result, "hidden.go") {
t.Errorf("hidden.go should remain excluded by .llmignore, got: %q", result)
}
}

func TestSearchTool_IncludeGitignoredStillHonorsBuiltInExclusions(t *testing.T) {
ResetGitIgnoreCache()

dir := t.TempDir()
os.MkdirAll(filepath.Join(dir, ".git"), 0755)
os.MkdirAll(filepath.Join(dir, "node_modules", "dependency"), 0755)
os.WriteFile(filepath.Join(dir, ".git", "internal.txt"), []byte("search sentinel\n"), 0644)
os.WriteFile(filepath.Join(dir, "node_modules", "dependency", "index.js"), []byte("search sentinel\n"), 0644)
os.WriteFile(filepath.Join(dir, "visible.txt"), []byte("search sentinel\n"), 0644)

tool := &SearchTool{}
args := json.RawMessage(`{"pattern":"search sentinel","path":"` + dir + `","include_gitignored":true}`)
result, err := tool.Execute(context.Background(), args)
if err != nil {
t.Fatal(err)
}

if !strings.Contains(result, "visible.txt") {
t.Errorf("expected visible.txt in results, got: %q", result)
}
if strings.Contains(result, "internal.txt") || strings.Contains(result, "index.js") {
t.Errorf("built-in exclusions should remain enforced, got: %q", result)
}
}

func TestSearchTool_NoGitIgnoreDir(t *testing.T) {
ResetGitIgnoreCache()

Expand Down
34 changes: 20 additions & 14 deletions internal/tool/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ func (t *SearchTool) Name() string { return "search_tool" }
func (t *SearchTool) Description() string {
return "PREFERRED over bash grep/find/rg. " +
"Search files by regex/literal pattern or by name glob. Returns {path, line, content}. " +
"Honors .gitignore, permission gates, and output caps. " +
"Honors .gitignore and .llmignore, permission gates, and output caps. " +
"Modes: files_with_matches (paths), content (lines+numbers), count (counts). " +
"Set search_names:true to match filenames by glob (e.g. '*.go') instead of searching contents."
}
Expand Down Expand Up @@ -76,6 +76,10 @@ func (t *SearchTool) Parameters() json.RawMessage {
"recursive": {
"type": "boolean",
"description": "Search subdirectories (default: true)."
},
"include_gitignored": {
"type": "boolean",
"description": "If true, include files excluded by .gitignore (default: false). .llmignore and built-in exclusions remain enforced. Prefer a narrow path when enabling this."
}
},
"required": ["pattern"]
Expand All @@ -84,17 +88,18 @@ func (t *SearchTool) Parameters() json.RawMessage {

func (t *SearchTool) Execute(ctx context.Context, args json.RawMessage) (string, error) {
var params struct {
Pattern string `json:"pattern"`
Path string `json:"path"`
Include string `json:"include"`
Exclude string `json:"exclude"`
OutputMode string `json:"output_mode"`
CaseSensitive bool `json:"case_sensitive"`
FixedStrings bool `json:"fixed_strings"`
SearchNames bool `json:"search_names"`
ContextLines int `json:"context_lines"`
MaxResults int `json:"max_results"`
Recursive bool `json:"recursive"`
Pattern string `json:"pattern"`
Path string `json:"path"`
Include string `json:"include"`
Exclude string `json:"exclude"`
OutputMode string `json:"output_mode"`
CaseSensitive bool `json:"case_sensitive"`
FixedStrings bool `json:"fixed_strings"`
SearchNames bool `json:"search_names"`
ContextLines int `json:"context_lines"`
MaxResults int `json:"max_results"`
Recursive bool `json:"recursive"`
IncludeGitignored bool `json:"include_gitignored"`
}
if err := json.Unmarshal(args, &params); err != nil {
return "", fmt.Errorf("invalid search parameters: %w", err)
Expand Down Expand Up @@ -136,8 +141,9 @@ func (t *SearchTool) Execute(ctx context.Context, args json.RawMessage) (string,
searchPath = params.Path
}

// Load .gitignore if available (cached per process from CWD)
gi, repoRoot := getGitIgnoreForPath(searchPath)
// Load the applicable ignore rules. include_gitignored bypasses only
// .gitignore; .llmignore remains enforced as an agent-specific policy.
gi, repoRoot := getIgnoreForPath(searchPath, params.IncludeGitignored)

// --- search_names: filename glob matching fast path ---
if params.SearchNames {
Expand Down
3 changes: 3 additions & 0 deletions internal/tool/search_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ func TestSearchTool_Parameters_HasRequiredFields(t *testing.T) {
if len(schema.Required) != 1 || schema.Required[0] != "pattern" {
t.Errorf("Required = %v, want [\"pattern\"]", schema.Required)
}
if property, ok := schema.Properties["include_gitignored"]; !ok || property.Type != "boolean" {
t.Errorf("include_gitignored property = %v, %v; want boolean property", property, ok)
}
}

func TestSearchTool_RequiresConfirmation(t *testing.T) {
Expand Down