Download crates/forge_app/src/changed_files.rs from SaylorTwift/forgecode: direct link, hf CLI and curl.
- Browser
- Download file 10.2 kB
-
https://huggingface.co/SaylorTwift/forgecode/resolve/main/crates/forge_app/src/changed_files.rs
- Command line
-
hf download hf://SaylorTwift/forgecode/crates/forge_app/src/changed_files.rs
-
curl -L -o changed_files.rs https://huggingface.co/SaylorTwift/forgecode/resolve/main/crates/forge_app/src/changed_files.rs
10.2 kB
| use std::sync::Arc; | |
| use forge_domain::{Agent, ContextMessage, Conversation, Role, TextMessage}; | |
| use forge_template::Element; | |
| use crate::utils::format_display_path; | |
| use crate::{EnvironmentInfra, FsReadService}; | |
| /// Service responsible for detecting externally changed files and rendering | |
| /// notifications | |
| pub struct ChangedFiles<S> { | |
| services: Arc<S>, | |
| agent: Agent, | |
| } | |
| impl<S> ChangedFiles<S> { | |
| /// Creates a new ChangedFiles | |
| pub fn new(services: Arc<S>, agent: Agent) -> Self { | |
| Self { services, agent } | |
| } | |
| } | |
| impl<S: FsReadService + EnvironmentInfra<Config = forge_config::ForgeConfig>> ChangedFiles<S> { | |
| /// Detects externally changed files and renders a notification if changes | |
| /// are found. Updates file hashes in conversation metrics to prevent | |
| /// duplicate notifications. | |
| pub async fn update_file_stats(&self, mut conversation: Conversation) -> Conversation { | |
| use crate::file_tracking::FileChangeDetector; | |
| let parallel_file_reads = self | |
| .services | |
| .get_config() | |
| .map(|c| c.max_parallel_file_reads) | |
| .unwrap_or(4); | |
| let changes = FileChangeDetector::new(self.services.clone()) | |
| .detect(&conversation.metrics, parallel_file_reads) | |
| .await; | |
| if changes.is_empty() { | |
| return conversation; | |
| } | |
| // Update file hashes to prevent duplicate notifications | |
| let mut updated_metrics = conversation.metrics.clone(); | |
| for change in &changes { | |
| if let Some(path_str) = change.path.to_str() | |
| && let Some(metrics) = updated_metrics.file_operations.get_mut(path_str) | |
| { | |
| // Update the file hash | |
| metrics.content_hash = change.content_hash.clone(); | |
| } | |
| } | |
| conversation.metrics = updated_metrics; | |
| let cwd = self.services.get_environment().cwd; | |
| let file_elements: Vec<Element> = changes | |
| .iter() | |
| .map(|change| { | |
| let display_path = format_display_path(&change.path, &cwd); | |
| Element::new("file").text(display_path) | |
| }) | |
| .collect(); | |
| let notification = Element::new("information") | |
| .append( | |
| Element::new("critical") | |
| .text("The following files have been modified externally. Please re-read them if its relevant for the task."), | |
| ) | |
| .append(Element::new("files").append(file_elements)) | |
| .to_string(); | |
| let context = conversation.context.take().unwrap_or_default(); | |
| let message = TextMessage::new(Role::User, notification) | |
| .droppable(true) | |
| .model(self.agent.model.clone()); | |
| conversation = conversation.context(context.add_message(ContextMessage::from(message))); | |
| conversation | |
| } | |
| } | |
| mod tests { | |
| use std::collections::HashMap; | |
| use std::path::PathBuf; | |
| use forge_domain::{ | |
| Agent, AgentId, Context, Conversation, ConversationId, Environment, FileOperation, Metrics, | |
| ModelId, ProviderId, ToolKind, | |
| }; | |
| use pretty_assertions::assert_eq; | |
| use super::*; | |
| use crate::services::Content; | |
| use crate::{FsReadService, ReadOutput, compute_hash}; | |
| struct TestServices { | |
| files: HashMap<String, String>, | |
| cwd: Option<PathBuf>, | |
| } | |
| impl FsReadService for TestServices { | |
| async fn read( | |
| &self, | |
| path: String, | |
| _: Option<u64>, | |
| _: Option<u64>, | |
| ) -> anyhow::Result<ReadOutput> { | |
| self.files | |
| .get(&path) | |
| .map(|content| { | |
| let hash = compute_hash(content); | |
| ReadOutput { | |
| content: Content::file(content.clone()), | |
| info: forge_domain::FileInfo::new(1, 1, 1, hash), | |
| } | |
| }) | |
| .ok_or_else(|| anyhow::anyhow!(std::io::Error::from(std::io::ErrorKind::NotFound))) | |
| } | |
| } | |
| impl EnvironmentInfra for TestServices { | |
| type Config = forge_config::ForgeConfig; | |
| fn get_environment(&self) -> Environment { | |
| use fake::{Fake, Faker}; | |
| let mut env: Environment = Faker.fake(); | |
| if let Some(cwd) = &self.cwd { | |
| env.cwd = cwd.clone(); | |
| } else { | |
| // Use a deterministic cwd that won't match any test paths | |
| env.cwd = PathBuf::from("/deterministic/test/cwd"); | |
| } | |
| env | |
| } | |
| fn get_config(&self) -> anyhow::Result<forge_config::ForgeConfig> { | |
| Ok(forge_config::ForgeConfig { max_parallel_file_reads: 4, ..Default::default() }) | |
| } | |
| async fn update_environment( | |
| &self, | |
| _ops: Vec<forge_domain::ConfigOperation>, | |
| ) -> anyhow::Result<()> { | |
| unimplemented!() | |
| } | |
| fn get_env_var(&self, _key: &str) -> Option<String> { | |
| None | |
| } | |
| fn get_env_vars(&self) -> std::collections::BTreeMap<String, String> { | |
| std::collections::BTreeMap::new() | |
| } | |
| } | |
| fn fixture( | |
| files: HashMap<String, String>, | |
| tracked_files: HashMap<String, Option<String>>, | |
| ) -> (ChangedFiles<TestServices>, Conversation) { | |
| fixture_with_cwd(files, tracked_files, None) | |
| } | |
| fn fixture_with_cwd( | |
| files: HashMap<String, String>, | |
| tracked_files: HashMap<String, Option<String>>, | |
| cwd: Option<PathBuf>, | |
| ) -> (ChangedFiles<TestServices>, Conversation) { | |
| let services = Arc::new(TestServices { files, cwd }); | |
| let agent = Agent::new( | |
| AgentId::new("test"), | |
| ProviderId::ANTHROPIC, | |
| ModelId::new("test-model"), | |
| ); | |
| let changed_files = ChangedFiles::new(services, agent); | |
| let mut metrics = Metrics::default(); | |
| for (path, hash) in tracked_files { | |
| metrics | |
| .file_operations | |
| .insert(path, FileOperation::new(ToolKind::Write).content_hash(hash)); | |
| } | |
| let conversation = Conversation::new(ConversationId::generate()).metrics(metrics); | |
| (changed_files, conversation) | |
| } | |
| async fn test_no_changes_detected() { | |
| let content = "hello world"; | |
| let hash = crate::compute_hash(content); | |
| let (service, mut conversation) = fixture( | |
| [("/test/file.txt".into(), content.into())].into(), | |
| [("/test/file.txt".into(), Some(hash))].into(), | |
| ); | |
| conversation.context = Some(Context::default().add_message(ContextMessage::user( | |
| "Hey, there!", | |
| Some(ModelId::new("test")), | |
| ))); | |
| let actual = service.update_file_stats(conversation.clone()).await; | |
| assert_eq!(actual.context.clone().unwrap_or_default().messages.len(), 1); | |
| assert_eq!(actual.context, conversation.context); | |
| } | |
| async fn test_changes_detected_adds_notification() { | |
| let old_hash = crate::compute_hash("old content"); | |
| let new_content = "new content"; | |
| let (service, conversation) = fixture( | |
| [("/test/file.txt".into(), new_content.into())].into(), | |
| [("/test/file.txt".into(), Some(old_hash))].into(), | |
| ); | |
| let actual = service.update_file_stats(conversation).await; | |
| let messages = &actual.context.unwrap().messages; | |
| assert_eq!(messages.len(), 1); | |
| let message = messages[0].content().unwrap().to_string(); | |
| assert!(message.contains("/test/file.txt")); | |
| assert!(message.contains("modified externally")); | |
| } | |
| async fn test_updates_content_hash() { | |
| let old_hash = crate::compute_hash("old content"); | |
| let new_content = "new content"; | |
| let new_hash = crate::compute_hash(new_content); | |
| let (service, conversation) = fixture( | |
| [("/test/file.txt".into(), new_content.into())].into(), | |
| [("/test/file.txt".into(), Some(old_hash))].into(), | |
| ); | |
| let actual = service.update_file_stats(conversation).await; | |
| let updated_hash = actual | |
| .metrics | |
| .file_operations | |
| .get("/test/file.txt") | |
| .and_then(|m| m.content_hash.clone()); | |
| assert_eq!(updated_hash, Some(new_hash)); | |
| } | |
| async fn test_multiple_files_changed() { | |
| let (service, conversation) = fixture( | |
| [ | |
| ("/test/file1.txt".into(), "new 1".into()), | |
| ("/test/file2.txt".into(), "new 2".into()), | |
| ] | |
| .into(), | |
| [ | |
| ("/test/file1.txt".into(), Some(crate::compute_hash("old 1"))), | |
| ("/test/file2.txt".into(), Some(crate::compute_hash("old 2"))), | |
| ] | |
| .into(), | |
| ); | |
| let actual = service.update_file_stats(conversation).await; | |
| let message = actual.context.unwrap().messages[0] | |
| .content() | |
| .unwrap() | |
| .to_string(); | |
| insta::assert_snapshot!(message); | |
| } | |
| async fn test_uses_relative_paths_within_cwd() { | |
| let old_hash = crate::compute_hash("old content"); | |
| let new_content = "new content"; | |
| let cwd = PathBuf::from("/home/user/project"); | |
| let absolute_path = "/home/user/project/src/main.rs"; | |
| let (service, conversation) = fixture_with_cwd( | |
| [(absolute_path.into(), new_content.into())].into(), | |
| [(absolute_path.into(), Some(old_hash))].into(), | |
| Some(cwd), | |
| ); | |
| let actual = service.update_file_stats(conversation).await; | |
| let message = actual.context.unwrap().messages[0] | |
| .content() | |
| .unwrap() | |
| .to_string(); | |
| let expected = "<information>\n<critical>The following files have been modified externally. Please re-read them if its relevant for the task.</critical>\n<files>\n<file>src/main.rs</file>\n</files>\n</information>"; | |
| assert_eq!(message, expected); | |
| } | |
| } | |