Skip to content
Merged
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
1 change: 1 addition & 0 deletions crates/forge_config/.forge.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions crates/forge_config/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
150 changes: 130 additions & 20 deletions crates/forge_services/src/tool_services/fs_patch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -376,12 +377,53 @@ fn build_fuzzy_patch(
patch.patched_text
}

async fn apply_replace_operation<F: TextPatchRepository>(
async fn apply_fuzzy_search_fallback<F: FuzzySearchRepository>(
infra: &F,
current_content: String,
search_text: String,
content: &str,
operation: &PatchOperation,
) -> Result<String, Error> {
let range = match infra
.fuzzy_search(&search_text, &current_content, false)
.await
{
Ok(matches) if !matches.is_empty() => matches
.first()
.map(|m| Range::from_search_match(&current_content, m)),
_ => return Err(Error::NoMatch(search_text)),
};

apply_replacement(current_content, range, operation, content)
}

async fn apply_text_patch_fallback<F: TextPatchRepository>(
infra: &F,
current_content: String,
search_text: String,
content: &str,
) -> Result<String, Error> {
let normalized_search = Range::normalize_search_line_endings(&current_content, &search_text);
let normalized_content = Range::normalize_search_line_endings(&current_content, content);
let patch = infra
.build_text_patch(&current_content, &normalized_search, &normalized_content)
.await
.map_err(|error| Error::PatchBuild { message: error.to_string() })?;
Ok(build_fuzzy_patch(
&current_content,
&search_text,
content,
patch,
))
}

async fn apply_replace_operation<F: FuzzySearchRepository + TextPatchRepository>(
infra: &F,
current_content: String,
search: &str,
content: &str,
operation: &PatchOperation,
use_text_patch_fallback: bool,
) -> Result<String, Error> {
match compute_range(&current_content, Some(search), operation) {
Ok(range) => apply_replacement(current_content, range, operation, content),
Expand All @@ -391,20 +433,12 @@ async fn apply_replace_operation<F: TextPatchRepository>(
PatchOperation::Replace | PatchOperation::ReplaceAll | PatchOperation::Swap
) =>
{
let normalized_search =
Range::normalize_search_line_endings(&current_content, &search_text);
let normalized_content =
Range::normalize_search_line_endings(&current_content, content);
let patch = infra
.build_text_patch(&current_content, &normalized_search, &normalized_content)
.await
.map_err(|error| Error::PatchBuild { message: error.to_string() })?;
Ok(build_fuzzy_patch(
&current_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),
}
Expand All @@ -426,7 +460,8 @@ impl<F> ForgeFsPatch<F> {

#[async_trait::async_trait]
impl<
F: FileWriterInfra
F: EnvironmentInfra<Config = ForgeConfig>
+ FileWriterInfra
+ SnapshotRepository
+ ValidationRepository
+ FuzzySearchRepository
Expand Down Expand Up @@ -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?;
Expand Down Expand Up @@ -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 {
Expand All @@ -519,6 +562,7 @@ impl<
&edit.old_string,
&edit.new_string,
&operation,
use_text_patch_fallback,
)
.await?;
}
Expand Down Expand Up @@ -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<Vec<forge_domain::SearchMatch>> {
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<forge_domain::TextPatchBlock> {
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";
Expand Down
5 changes: 5 additions & 0 deletions forge.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading