From d1dc6c3ff0882e53ad175ddd5bdd54f9e5aa49f0 Mon Sep 17 00:00:00 2001 From: Raduan Al-Shedivat <88370223+dbraduan@users.noreply.github.com> Date: Tue, 3 Jun 2025 22:12:40 +0200 Subject: [PATCH] mcp(developer): add fallback on .gitignore if no .gooseignore is present (#2661) --- crates/goose-mcp/src/developer/mod.rs | 207 +++++++++++++++++- .../docs/guides/using-gooseignore.md | 24 +- 2 files changed, 229 insertions(+), 2 deletions(-) diff --git a/crates/goose-mcp/src/developer/mod.rs b/crates/goose-mcp/src/developer/mod.rs index f5a12f1f..03dac338 100644 --- a/crates/goose-mcp/src/developer/mod.rs +++ b/crates/goose-mcp/src/developer/mod.rs @@ -408,10 +408,20 @@ impl DeveloperRouter { if local_ignore_path.is_file() { let _ = builder.add(local_ignore_path); has_ignore_file = true; + } else { + // If no .gooseignore exists, check for .gitignore as fallback + let gitignore_path = cwd.join(".gitignore"); + if gitignore_path.is_file() { + tracing::debug!( + "No .gooseignore found, using .gitignore as fallback for ignore patterns" + ); + let _ = builder.add(gitignore_path); + has_ignore_file = true; + } } // Only use default patterns if no .gooseignore files were found - // If the file is empty, we will not ignore any file + // AND no .gitignore was used as fallback if !has_ignore_file { // Add some sensible defaults let _ = builder.add_line(None, "**/.env"); @@ -1758,4 +1768,199 @@ mod tests { temp_dir.close().unwrap(); } + + #[tokio::test] + #[serial] + async fn test_gitignore_fallback_when_no_gooseignore() { + let temp_dir = tempfile::tempdir().unwrap(); + std::env::set_current_dir(&temp_dir).unwrap(); + + // Create a .gitignore file but no .gooseignore + std::fs::write(temp_dir.path().join(".gitignore"), "*.log\n*.tmp\n.env").unwrap(); + + let router = DeveloperRouter::new(); + + // Test that gitignore patterns are respected + assert!( + router.is_ignored(Path::new("test.log")), + "*.log pattern from .gitignore should be ignored" + ); + assert!( + router.is_ignored(Path::new("build.tmp")), + "*.tmp pattern from .gitignore should be ignored" + ); + assert!( + router.is_ignored(Path::new(".env")), + ".env pattern from .gitignore should be ignored" + ); + assert!( + !router.is_ignored(Path::new("test.txt")), + "test.txt should not be ignored" + ); + + temp_dir.close().unwrap(); + } + + #[tokio::test] + #[serial] + async fn test_gooseignore_takes_precedence_over_gitignore() { + let temp_dir = tempfile::tempdir().unwrap(); + std::env::set_current_dir(&temp_dir).unwrap(); + + // Create both .gooseignore and .gitignore files with different patterns + std::fs::write(temp_dir.path().join(".gooseignore"), "*.secret").unwrap(); + std::fs::write(temp_dir.path().join(".gitignore"), "*.log\ntarget/").unwrap(); + + let router = DeveloperRouter::new(); + + // .gooseignore patterns should be used + assert!( + router.is_ignored(Path::new("test.secret")), + "*.secret pattern from .gooseignore should be ignored" + ); + + // .gitignore patterns should NOT be used when .gooseignore exists + assert!( + !router.is_ignored(Path::new("test.log")), + "*.log pattern from .gitignore should NOT be ignored when .gooseignore exists" + ); + assert!( + !router.is_ignored(Path::new("build.tmp")), + "*.tmp pattern from .gitignore should NOT be ignored when .gooseignore exists" + ); + + temp_dir.close().unwrap(); + } + + #[tokio::test] + #[serial] + async fn test_default_patterns_when_no_ignore_files() { + let temp_dir = tempfile::tempdir().unwrap(); + std::env::set_current_dir(&temp_dir).unwrap(); + + // Don't create any ignore files + let router = DeveloperRouter::new(); + + // Default patterns should be used + assert!( + router.is_ignored(Path::new(".env")), + ".env should be ignored by default patterns" + ); + assert!( + router.is_ignored(Path::new(".env.local")), + ".env.local should be ignored by default patterns" + ); + assert!( + router.is_ignored(Path::new("secrets.txt")), + "secrets.txt should be ignored by default patterns" + ); + assert!( + !router.is_ignored(Path::new("normal.txt")), + "normal.txt should not be ignored" + ); + + temp_dir.close().unwrap(); + } + + #[tokio::test] + #[serial] + async fn test_text_editor_respects_gitignore_fallback() { + let temp_dir = tempfile::tempdir().unwrap(); + std::env::set_current_dir(&temp_dir).unwrap(); + + // Create a .gitignore file but no .gooseignore + std::fs::write(temp_dir.path().join(".gitignore"), "*.log").unwrap(); + + let router = DeveloperRouter::new(); + + // Try to write to a file ignored by .gitignore + let result = router + .call_tool( + "text_editor", + json!({ + "command": "write", + "path": temp_dir.path().join("test.log").to_str().unwrap(), + "file_text": "test content" + }), + dummy_sender(), + ) + .await; + + assert!( + result.is_err(), + "Should not be able to write to file ignored by .gitignore fallback" + ); + assert!(matches!(result.unwrap_err(), ToolError::ExecutionError(_))); + + // Try to write to a non-ignored file + let result = router + .call_tool( + "text_editor", + json!({ + "command": "write", + "path": temp_dir.path().join("allowed.txt").to_str().unwrap(), + "file_text": "test content" + }), + dummy_sender(), + ) + .await; + + assert!( + result.is_ok(), + "Should be able to write to non-ignored file" + ); + + temp_dir.close().unwrap(); + } + + #[tokio::test] + #[serial] + async fn test_bash_respects_gitignore_fallback() { + let temp_dir = tempfile::tempdir().unwrap(); + std::env::set_current_dir(&temp_dir).unwrap(); + + // Create a .gitignore file but no .gooseignore + std::fs::write(temp_dir.path().join(".gitignore"), "*.log").unwrap(); + + let router = DeveloperRouter::new(); + + // Create a file that would be ignored by .gitignore + let log_file_path = temp_dir.path().join("test.log"); + std::fs::write(&log_file_path, "log content").unwrap(); + + // Try to cat the ignored file + let result = router + .call_tool( + "shell", + json!({ + "command": format!("cat {}", log_file_path.to_str().unwrap()) + }), + dummy_sender(), + ) + .await; + + assert!( + result.is_err(), + "Should not be able to cat file ignored by .gitignore fallback" + ); + assert!(matches!(result.unwrap_err(), ToolError::ExecutionError(_))); + + // Try to cat a non-ignored file + let allowed_file_path = temp_dir.path().join("allowed.txt"); + std::fs::write(&allowed_file_path, "allowed content").unwrap(); + + let result = router + .call_tool( + "shell", + json!({ + "command": format!("cat {}", allowed_file_path.to_str().unwrap()) + }), + dummy_sender(), + ) + .await; + + assert!(result.is_ok(), "Should be able to cat non-ignored file"); + + temp_dir.close().unwrap(); + } } diff --git a/documentation/docs/guides/using-gooseignore.md b/documentation/docs/guides/using-gooseignore.md index 484adcfb..8c58de4a 100644 --- a/documentation/docs/guides/using-gooseignore.md +++ b/documentation/docs/guides/using-gooseignore.md @@ -23,6 +23,24 @@ Goose supports two types of `.gooseignore` files: You can use both global and local `.gooseignore` files simultaneously. When both exist, Goose will combine the restrictions from both files to determine which paths are restricted. ::: +## Automatic `.gitignore` fallback + +If no `.gooseignore` file is found in your current directory, Goose will automatically use your `.gitignore` file as a fallback. This means: + +1. **Priority Order**: Goose checks for ignore patterns in this order: + - Global `.gooseignore` (if exists) + - Local `.gooseignore` (if exists) + - Local `.gitignore` (if no local `.gooseignore` and `.gitignore` exists) + - Default patterns (if none of the above exist) + +2. **Seamless Integration**: Projects with existing `.gitignore` files get automatic protection without needing a separate `.gooseignore` file. + +3. **Override Capability**: Creating a local `.gooseignore` file will completely override `.gitignore` patterns for that directory. + +:::info Debug logging +When Goose uses `.gitignore` as a fallback, it will log a message to help you understand which ignore file is being used. +::: + ## Example `.gooseignore` file In your `.gooseignore` file, you can write patterns to match files you want Goose to ignore. Here are some common patterns: @@ -49,7 +67,7 @@ downloads/ # Ignore everything in the "downloads" directory ## Default patterns -By default, if you haven't created any `.gooseignore` files, Goose will not modify files matching these patterns: +By default, if you haven't created any `.gooseignore` files **and no `.gitignore` file exists**, Goose will not modify files matching these patterns: ```plaintext **/.env @@ -57,6 +75,8 @@ By default, if you haven't created any `.gooseignore` files, Goose will not modi **/secrets.* ``` +These default patterns only apply when neither `.gooseignore` nor `.gitignore` files are found in your project. + ## Common use cases Here are some typical scenarios where `.gooseignore` is helpful: @@ -65,4 +85,6 @@ Here are some typical scenarios where `.gooseignore` is helpful: - **Third-Party Code**: Keep Goose from changing external libraries or dependencies - **Important Configurations**: Protect critical configuration files from accidental modifications - **Version Control**: Prevent changes to version control files like `.git` directory +- **Existing Projects**: Most projects already have `.gitignore` files that work automatically as ignore patterns for Goose +- **Custom Restrictions**: Create `.gooseignore` when you need different patterns than your `.gitignore` (e.g., allowing Goose to read files that Git ignores)