diff --git a/crates/forge_config/.forge.toml b/crates/forge_config/.forge.toml index 7df89c2839..2104408aa7 100644 --- a/crates/forge_config/.forge.toml +++ b/crates/forge_config/.forge.toml @@ -33,6 +33,7 @@ currency_symbol = "$" currency_conversion_rate = 1.0 subagents = true use_forge_committer = true +use_text_patch_fallback = false [retry] backoff_factor = 2 diff --git a/crates/forge_config/src/config.rs b/crates/forge_config/src/config.rs index 7373d2807d..9de4df92b2 100644 --- a/crates/forge_config/src/config.rs +++ b/crates/forge_config/src/config.rs @@ -287,6 +287,13 @@ pub struct ForgeConfig { #[serde(default)] pub verify_todos: bool, + /// Switches patch replacement fallback from the legacy fuzzy-search range + /// lookup to the newer text-patch gRPC API. + /// Defaults to `false` so patching continues to use the legacy fallback + /// behavior unless explicitly enabled in `forge.toml`. + #[serde(default)] + pub use_text_patch_fallback: bool, + /// Whether the deep research agent is available. /// /// When set to `true`, the Sage agent is added to the agent list and diff --git a/crates/forge_services/src/tool_services/fs_patch.rs b/crates/forge_services/src/tool_services/fs_patch.rs index 31471bb491..7d99ac01b6 100644 --- a/crates/forge_services/src/tool_services/fs_patch.rs +++ b/crates/forge_services/src/tool_services/fs_patch.rs @@ -3,7 +3,8 @@ use std::sync::Arc; use bytes::Bytes; use forge_app::domain::PatchOperation; -use forge_app::{FileWriterInfra, FsPatchService, PatchOutput, compute_hash}; +use forge_app::{EnvironmentInfra, FileWriterInfra, FsPatchService, PatchOutput, compute_hash}; +use forge_config::ForgeConfig; use forge_domain::{ FuzzySearchRepository, SearchMatch, SnapshotRepository, TextPatchBlock, TextPatchRepository, ValidationRepository, @@ -376,12 +377,53 @@ fn build_fuzzy_patch( patch.patched_text } -async fn apply_replace_operation( +async fn apply_fuzzy_search_fallback( + infra: &F, + current_content: String, + search_text: String, + content: &str, + operation: &PatchOperation, +) -> Result { + let range = match infra + .fuzzy_search(&search_text, ¤t_content, false) + .await + { + Ok(matches) if !matches.is_empty() => matches + .first() + .map(|m| Range::from_search_match(¤t_content, m)), + _ => return Err(Error::NoMatch(search_text)), + }; + + apply_replacement(current_content, range, operation, content) +} + +async fn apply_text_patch_fallback( + infra: &F, + current_content: String, + search_text: String, + content: &str, +) -> Result { + let normalized_search = Range::normalize_search_line_endings(¤t_content, &search_text); + let normalized_content = Range::normalize_search_line_endings(¤t_content, content); + let patch = infra + .build_text_patch(¤t_content, &normalized_search, &normalized_content) + .await + .map_err(|error| Error::PatchBuild { message: error.to_string() })?; + Ok(build_fuzzy_patch( + ¤t_content, + &search_text, + content, + patch, + )) +} + +async fn apply_replace_operation( infra: &F, current_content: String, search: &str, content: &str, operation: &PatchOperation, + use_text_patch_fallback: bool, ) -> Result { match compute_range(¤t_content, Some(search), operation) { Ok(range) => apply_replacement(current_content, range, operation, content), @@ -391,20 +433,12 @@ async fn apply_replace_operation( PatchOperation::Replace | PatchOperation::ReplaceAll | PatchOperation::Swap ) => { - let normalized_search = - Range::normalize_search_line_endings(¤t_content, &search_text); - let normalized_content = - Range::normalize_search_line_endings(¤t_content, content); - let patch = infra - .build_text_patch(¤t_content, &normalized_search, &normalized_content) - .await - .map_err(|error| Error::PatchBuild { message: error.to_string() })?; - Ok(build_fuzzy_patch( - ¤t_content, - &search_text, - content, - patch, - )) + if use_text_patch_fallback { + apply_text_patch_fallback(infra, current_content, search_text, content).await + } else { + apply_fuzzy_search_fallback(infra, current_content, search_text, content, operation) + .await + } } Err(e) => Err(e), } @@ -426,7 +460,8 @@ impl ForgeFsPatch { #[async_trait::async_trait] impl< - F: FileWriterInfra + F: EnvironmentInfra + + FileWriterInfra + SnapshotRepository + ValidationRepository + FuzzySearchRepository @@ -458,10 +493,17 @@ impl< // Save the old content before modification for diff generation let old_content = current_content.clone(); + let use_text_patch_fallback = self.infra.get_config()?.use_text_patch_fallback; - current_content = - apply_replace_operation(&*self.infra, current_content, &search, &content, &operation) - .await?; + current_content = apply_replace_operation( + &*self.infra, + current_content, + &search, + &content, + &operation, + use_text_patch_fallback, + ) + .await?; // SNAPSHOT COORDINATION: Always capture snapshot before modifying self.infra.insert_snapshot(path).await?; @@ -503,6 +545,7 @@ impl< .map_err(Error::FileOperation)?; // Save the old content before modification for diff generation let old_content = current_content.clone(); + let use_text_patch_fallback = self.infra.get_config()?.use_text_patch_fallback; // Apply each edit sequentially for edit in &edits { @@ -519,6 +562,7 @@ impl< &edit.old_string, &edit.new_string, &operation, + use_text_patch_fallback, ) .await?; } @@ -556,6 +600,72 @@ mod tests { use forge_domain::SearchMatch; use pretty_assertions::assert_eq; + #[test] + fn test_apply_replace_operation_uses_fuzzy_search_when_text_patch_fallback_disabled() { + let fixture = tokio::runtime::Runtime::new().unwrap(); + + let actual = fixture.block_on(super::apply_replace_operation( + &FallbackRepository, + "alpha\nbeta\ngamma".to_string(), + "betaa", + "delta", + &PatchOperation::Replace, + false, + )); + + let expected = "alpha\ndelta\ngamma"; + assert_eq!(actual.unwrap(), expected); + } + + #[test] + fn test_apply_replace_operation_uses_text_patch_when_enabled() { + let fixture = tokio::runtime::Runtime::new().unwrap(); + + let actual = fixture.block_on(super::apply_replace_operation( + &FallbackRepository, + "alpha\nbeta\ngamma".to_string(), + "betaa", + "delta", + &PatchOperation::Replace, + true, + )); + + let expected = "patched via text patch"; + assert_eq!(actual.unwrap(), expected); + } + + #[derive(Default)] + struct FallbackRepository; + + #[async_trait::async_trait] + impl forge_domain::FuzzySearchRepository for FallbackRepository { + async fn fuzzy_search( + &self, + _needle: &str, + _haystack: &str, + _search_all: bool, + ) -> anyhow::Result> { + let actual = vec![forge_domain::SearchMatch { start_line: 1, end_line: 1 }]; + Ok(actual) + } + } + + #[async_trait::async_trait] + impl forge_domain::TextPatchRepository for FallbackRepository { + async fn build_text_patch( + &self, + _haystack: &str, + _old_string: &str, + _new_string: &str, + ) -> anyhow::Result { + let actual = forge_domain::TextPatchBlock { + patch: "@@ -1 +1 @@".to_string(), + patched_text: "patched via text patch".to_string(), + }; + Ok(actual) + } + } + #[test] fn test_range_from_search_match_single_line() { let source = "line1\nline2\nline3"; diff --git a/forge.schema.json b/forge.schema.json index 31a24dde0f..872cd28dcb 100644 --- a/forge.schema.json +++ b/forge.schema.json @@ -360,6 +360,11 @@ "type": "boolean", "default": false }, + "use_text_patch_fallback": { + "description": "Switches patch replacement fallback from the legacy fuzzy-search range\nlookup to the newer text-patch gRPC API.\nDefaults to `false` so patching continues to use the legacy fallback\nbehavior unless explicitly enabled in `forge.toml`.", + "type": "boolean", + "default": false + }, "verify_todos": { "description": "Enables the pending todos hook that checks for incomplete todo items\nwhen a task ends and reminds the LLM about them.", "type": "boolean",