From 4578697dddff1a3b6e8b42b78b825e5f14f83712 Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Wed, 11 Mar 2026 16:33:02 -0400 Subject: [PATCH] fix(goose): load .gitignore in prompt_manager for hint file filtering (#7795) Co-authored-by: Douwe Osinga --- crates/goose/src/agents/prompt_manager.rs | 10 +- crates/goose/src/hints/load_hints.rs | 114 +++++++++++++++++++++- crates/goose/src/hints/mod.rs | 4 +- 3 files changed, 117 insertions(+), 11 deletions(-) diff --git a/crates/goose/src/agents/prompt_manager.rs b/crates/goose/src/agents/prompt_manager.rs index c3cd3ccf..4049056a 100644 --- a/crates/goose/src/agents/prompt_manager.rs +++ b/crates/goose/src/agents/prompt_manager.rs @@ -7,6 +7,7 @@ use serde_json::Value; use std::collections::HashMap; use crate::agents::extension::ExtensionInfo; +use crate::hints::load_hints::build_gitignore; use crate::hints::{get_context_filenames, load_hint_files, SubdirectoryHintTracker}; use crate::{ config::{Config, GooseMode}, @@ -90,14 +91,7 @@ impl<'a> SystemPromptBuilder<'a, PromptManager> { pub fn with_hints(mut self, working_dir: &Path) -> Self { let hints_filenames = get_context_filenames(); - let ignore_patterns = { - let builder = ignore::gitignore::GitignoreBuilder::new(working_dir); - builder.build().unwrap_or_else(|_| { - ignore::gitignore::GitignoreBuilder::new(working_dir) - .build() - .expect("Failed to build default gitignore") - }) - }; + let ignore_patterns = build_gitignore(working_dir); let hints = load_hint_files(working_dir, &hints_filenames, &ignore_patterns); diff --git a/crates/goose/src/hints/load_hints.rs b/crates/goose/src/hints/load_hints.rs index 4b9b2cde..4050b530 100644 --- a/crates/goose/src/hints/load_hints.rs +++ b/crates/goose/src/hints/load_hints.rs @@ -1,4 +1,4 @@ -use ignore::gitignore::Gitignore; +use ignore::gitignore::{Gitignore, GitignoreBuilder}; use std::{ collections::HashSet, path::{Path, PathBuf}, @@ -201,6 +201,27 @@ fn get_local_directories(git_root: Option<&Path>, cwd: &Path) -> Vec { } } +/// Build a `Gitignore` that includes `.gitignore` files from the git root +/// down to `cwd`, matching git's hierarchical ignore semantics. When there +/// is no git root, only `cwd/.gitignore` is loaded. +pub fn build_gitignore(cwd: &Path) -> Gitignore { + let git_root = find_git_root(cwd); + let directories = get_local_directories(git_root, cwd); + + let mut builder = GitignoreBuilder::new(cwd); + for dir in &directories { + let gitignore_path = dir.join(".gitignore"); + if gitignore_path.is_file() { + builder.add(&gitignore_path); + } + } + builder.build().unwrap_or_else(|_| { + GitignoreBuilder::new(cwd) + .build() + .expect("Failed to build default gitignore") + }) +} + pub fn load_hint_files( cwd: &Path, hints_filenames: &[String], @@ -719,3 +740,94 @@ End of hints"#; assert!(hints.is_empty()); } } + +#[cfg(test)] +mod gitignore_tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + #[test] + fn test_hints_with_gitignore_filters_referenced_files() { + let dir = TempDir::new().unwrap(); + let project_root = dir.path(); + + fs::create_dir(project_root.join(".git")).unwrap(); + fs::write(project_root.join("allowed.md"), "Allowed content").unwrap(); + fs::write(project_root.join("secret.env"), "SECRET_KEY=abc123").unwrap(); + fs::write(project_root.join(".gitignore"), "*.env\n").unwrap(); + + let hints_content = "Project hints\n@allowed.md\n@secret.env\nEnd of hints"; + fs::write(project_root.join(GOOSE_HINTS_FILENAME), hints_content).unwrap(); + + let gitignore = build_gitignore(project_root); + + let hints = load_hint_files( + project_root, + &[GOOSE_HINTS_FILENAME.to_string()], + &gitignore, + ); + + assert!(hints.contains("Allowed content")); + assert!(!hints.contains("SECRET_KEY=abc123")); + assert!(hints.contains("@secret.env")); + } + + #[test] + fn test_build_gitignore_loads_from_git_root_in_subdirectory() { + let dir = TempDir::new().unwrap(); + let project_root = dir.path(); + + fs::create_dir(project_root.join(".git")).unwrap(); + // Root .gitignore ignores .env files + fs::write(project_root.join(".gitignore"), "*.env\n").unwrap(); + fs::write(project_root.join("secret.env"), "SECRET_KEY=abc123").unwrap(); + fs::write(project_root.join("allowed.md"), "Allowed content").unwrap(); + + let subdir = project_root.join("subdir"); + fs::create_dir(&subdir).unwrap(); + + let hints_content = "Subdir hints\n@../allowed.md\n@../secret.env\nEnd of hints"; + fs::write(subdir.join(GOOSE_HINTS_FILENAME), hints_content).unwrap(); + + // Build gitignore from the subdirectory — should still pick up root .gitignore + let gitignore = build_gitignore(&subdir); + + let hints = load_hint_files(&subdir, &[GOOSE_HINTS_FILENAME.to_string()], &gitignore); + + assert!(hints.contains("Allowed content")); + assert!(!hints.contains("SECRET_KEY=abc123")); + assert!(hints.contains("@../secret.env")); + } + + #[test] + fn test_build_gitignore_merges_nested_gitignores() { + let dir = TempDir::new().unwrap(); + let project_root = dir.path(); + + fs::create_dir(project_root.join(".git")).unwrap(); + // Root ignores *.log + fs::write(project_root.join(".gitignore"), "*.log\n").unwrap(); + + let subdir = project_root.join("subdir"); + fs::create_dir(&subdir).unwrap(); + // Subdir ignores *.tmp + fs::write(subdir.join(".gitignore"), "*.tmp\n").unwrap(); + + fs::write(project_root.join("debug.log"), "debug log").unwrap(); + fs::write(subdir.join("cache.tmp"), "temp data").unwrap(); + fs::write(subdir.join("readme.md"), "Readme content").unwrap(); + + let hints_content = "Hints\n@../debug.log\n@cache.tmp\n@readme.md\nEnd"; + fs::write(subdir.join(GOOSE_HINTS_FILENAME), hints_content).unwrap(); + + let gitignore = build_gitignore(&subdir); + let hints = load_hint_files(&subdir, &[GOOSE_HINTS_FILENAME.to_string()], &gitignore); + + assert!(hints.contains("Readme content")); + assert!(!hints.contains("debug log")); + assert!(!hints.contains("temp data")); + assert!(hints.contains("@../debug.log")); + assert!(hints.contains("@cache.tmp")); + } +} diff --git a/crates/goose/src/hints/mod.rs b/crates/goose/src/hints/mod.rs index 4d20006b..fe22ed60 100644 --- a/crates/goose/src/hints/mod.rs +++ b/crates/goose/src/hints/mod.rs @@ -2,6 +2,6 @@ mod import_files; pub mod load_hints; pub use load_hints::{ - get_context_filenames, load_hint_files, SubdirectoryHintTracker, AGENTS_MD_FILENAME, - GOOSE_HINTS_FILENAME, + build_gitignore, get_context_filenames, load_hint_files, SubdirectoryHintTracker, + AGENTS_MD_FILENAME, GOOSE_HINTS_FILENAME, };