Support external `.editorconfig` (#46332)

Smit Barmase created

Closes #41832

Extends https://github.com/zed-industries/zed/pull/19455

When an internal `.editorconfig` is detected in the worktree, we
traverse parent directories up to the filesystem root looking for
additional `.editorconfig` files. All discovered external configs are
loaded and cached (shared when multiple worktrees reference the same
parent directories). When computing settings for a file, external
configs are applied first (from furthest to closest), then internal
configs.

For local projects, file watchers are set up for each external config so
changes are applied immediately. When a project is shared via collab,
external configs are sent to guests through the existing
`UpdateWorktreeSettings` proto message (with a new `outside_worktree`
field). SSH remoting works similarly.

Limitations: We don't currently take creation of new external editor
config files into account since they are loaded once on worktree add.

Release Notes:

- Added support for `.editorconfig` files outside the project directory.
Zed now traverses parent directories to find and apply EditorConfig
settings. Use `root = true` in any `.editorconfig` to stop inheriting
settings from parent directories.

Change summary

crates/collab/migrations.sqlite/20221109000000_test_schema.sql |   1 
crates/collab/migrations/20251208000000_test_schema.sql        |   3 
crates/collab/src/db.rs                                        |   1 
crates/collab/src/db/queries/projects.rs                       |   7 
crates/collab/src/db/queries/rooms.rs                          |   1 
crates/collab/src/db/tables/worktree_settings_file.rs          |   1 
crates/collab/src/rpc.rs                                       |   2 
crates/collab/src/tests/editor_tests.rs                        | 107 
crates/language/src/language_settings.rs                       |   4 
crates/project/src/project_settings.rs                         | 149 
crates/project/src/project_tests.rs                            | 575 +++
crates/proto/proto/worktree.proto                              |   1 
crates/settings/src/editorconfig_store.rs                      | 385 ++
crates/settings/src/settings.rs                                |  10 
crates/settings/src/settings_store.rs                          | 296 -
15 files changed, 1,334 insertions(+), 209 deletions(-)

Detailed changes

crates/collab/migrations.sqlite/20221109000000_test_schema.sql 🔗

@@ -146,6 +146,7 @@ CREATE TABLE "worktree_settings_files" (
     "path" VARCHAR NOT NULL,
     "content" TEXT,
     "kind" VARCHAR,
+    "outside_worktree" BOOL NOT NULL DEFAULT FALSE,
     PRIMARY KEY (project_id, worktree_id, path),
     FOREIGN KEY (project_id, worktree_id) REFERENCES worktrees (project_id, id) ON DELETE CASCADE
 );

crates/collab/migrations/20251208000000_test_schema.sql 🔗

@@ -503,7 +503,8 @@ CREATE TABLE public.worktree_settings_files (
     worktree_id bigint NOT NULL,
     path character varying NOT NULL,
     content text NOT NULL,
-    kind character varying
+    kind character varying,
+    outside_worktree boolean DEFAULT false NOT NULL
 );
 
 CREATE TABLE public.worktrees (

crates/collab/src/db.rs 🔗

@@ -649,6 +649,7 @@ pub struct WorktreeSettingsFile {
     pub path: String,
     pub content: String,
     pub kind: LocalSettingsKind,
+    pub outside_worktree: bool,
 }
 
 pub struct NewExtensionVersion {

crates/collab/src/db/queries/projects.rs 🔗

@@ -760,6 +760,7 @@ impl Database {
                     path: ActiveValue::Set(update.path.clone()),
                     content: ActiveValue::Set(content.clone()),
                     kind: ActiveValue::Set(kind),
+                    outside_worktree: ActiveValue::Set(update.outside_worktree.unwrap_or(false)),
                 })
                 .on_conflict(
                     OnConflict::columns([
@@ -767,7 +768,10 @@ impl Database {
                         worktree_settings_file::Column::WorktreeId,
                         worktree_settings_file::Column::Path,
                     ])
-                    .update_column(worktree_settings_file::Column::Content)
+                    .update_columns([
+                        worktree_settings_file::Column::Content,
+                        worktree_settings_file::Column::OutsideWorktree,
+                    ])
                     .to_owned(),
                 )
                 .exec(&*tx)
@@ -1050,6 +1054,7 @@ impl Database {
                         path: db_settings_file.path,
                         content: db_settings_file.content,
                         kind: db_settings_file.kind,
+                        outside_worktree: db_settings_file.outside_worktree,
                     });
                 }
             }

crates/collab/src/db/queries/rooms.rs 🔗

@@ -834,6 +834,7 @@ impl Database {
                         path: db_settings_file.path,
                         content: db_settings_file.content,
                         kind: db_settings_file.kind,
+                        outside_worktree: db_settings_file.outside_worktree,
                     });
                 }
             }

crates/collab/src/rpc.rs 🔗

@@ -1555,6 +1555,7 @@ fn notify_rejoined_projects(
                         path: settings_file.path,
                         content: Some(settings_file.content),
                         kind: Some(settings_file.kind.to_proto().into()),
+                        outside_worktree: Some(settings_file.outside_worktree),
                     },
                 )?;
             }
@@ -1987,6 +1988,7 @@ async fn join_project(
                     path: settings_file.path,
                     content: Some(settings_file.content),
                     kind: Some(settings_file.kind.to_proto() as i32),
+                    outside_worktree: Some(settings_file.outside_worktree),
                 },
             )?;
         }

crates/collab/src/tests/editor_tests.rs 🔗

@@ -21,7 +21,7 @@ use gpui::{
     App, Rgba, SharedString, TestAppContext, UpdateGlobal, VisualContext, VisualTestContext,
 };
 use indoc::indoc;
-use language::{FakeLspAdapter, rust_lang};
+use language::{FakeLspAdapter, language_settings::language_settings, rust_lang};
 use lsp::LSP_REQUEST_TIMEOUT;
 use pretty_assertions::assert_eq;
 use project::{
@@ -35,6 +35,7 @@ use serde_json::json;
 use settings::{InlayHintSettingsContent, InlineBlameSettings, SettingsStore};
 use std::{
     collections::BTreeSet,
+    num::NonZeroU32,
     ops::{Deref as _, Range},
     path::{Path, PathBuf},
     sync::{
@@ -3978,6 +3979,110 @@ fn main() { let foo = other::foo(); }"};
     );
 }
 
+#[gpui::test(iterations = 10)]
+async fn test_collaborating_with_external_editorconfig(
+    cx_a: &mut TestAppContext,
+    cx_b: &mut TestAppContext,
+) {
+    let mut server = TestServer::start(cx_a.executor()).await;
+    let client_a = server.create_client(cx_a, "user_a").await;
+    let client_b = server.create_client(cx_b, "user_b").await;
+    server
+        .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
+        .await;
+    let active_call_a = cx_a.read(ActiveCall::global);
+
+    client_a.language_registry().add(rust_lang());
+    client_b.language_registry().add(rust_lang());
+
+    // Set up external .editorconfig in parent directory
+    client_a
+        .fs()
+        .insert_tree(
+            path!("/parent"),
+            json!({
+                ".editorconfig": "[*]\nindent_size = 5\n",
+                "worktree": {
+                    ".editorconfig": "[*]\n",
+                    "src": {
+                        "main.rs": "fn main() {}",
+                    },
+                },
+            }),
+        )
+        .await;
+
+    let (project_a, worktree_id) = client_a
+        .build_local_project(path!("/parent/worktree"), cx_a)
+        .await;
+    let project_id = active_call_a
+        .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
+        .await
+        .unwrap();
+
+    // Open buffer on client A
+    let buffer_a = project_a
+        .update(cx_a, |p, cx| {
+            p.open_buffer((worktree_id, rel_path("src/main.rs")), cx)
+        })
+        .await
+        .unwrap();
+
+    cx_a.run_until_parked();
+
+    // Verify client A sees external editorconfig settings
+    cx_a.read(|cx| {
+        let file = buffer_a.read(cx).file();
+        let settings = language_settings(Some("Rust".into()), file, cx);
+        assert_eq!(Some(settings.tab_size), NonZeroU32::new(5));
+    });
+
+    // Client B joins the project
+    let project_b = client_b.join_remote_project(project_id, cx_b).await;
+    let buffer_b = project_b
+        .update(cx_b, |p, cx| {
+            p.open_buffer((worktree_id, rel_path("src/main.rs")), cx)
+        })
+        .await
+        .unwrap();
+
+    cx_b.run_until_parked();
+
+    // Verify client B also sees external editorconfig settings
+    cx_b.read(|cx| {
+        let file = buffer_b.read(cx).file();
+        let settings = language_settings(Some("Rust".into()), file, cx);
+        assert_eq!(Some(settings.tab_size), NonZeroU32::new(5));
+    });
+
+    // Client A modifies the external .editorconfig
+    client_a
+        .fs()
+        .atomic_write(
+            PathBuf::from(path!("/parent/.editorconfig")),
+            "[*]\nindent_size = 9\n".to_owned(),
+        )
+        .await
+        .unwrap();
+
+    cx_a.run_until_parked();
+    cx_b.run_until_parked();
+
+    // Verify client A sees updated settings
+    cx_a.read(|cx| {
+        let file = buffer_a.read(cx).file();
+        let settings = language_settings(Some("Rust".into()), file, cx);
+        assert_eq!(Some(settings.tab_size), NonZeroU32::new(9));
+    });
+
+    // Verify client B also sees updated settings
+    cx_b.read(|cx| {
+        let file = buffer_b.read(cx).file();
+        let settings = language_settings(Some("Rust".into()), file, cx);
+        assert_eq!(Some(settings.tab_size), NonZeroU32::new(9));
+    });
+}
+
 #[gpui::test]
 async fn test_add_breakpoints(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) {
     let executor = cx_a.executor();

crates/language/src/language_settings.rs 🔗

@@ -451,7 +451,9 @@ impl AllLanguageSettings {
 
         let editorconfig_properties = location.and_then(|location| {
             cx.global::<SettingsStore>()
-                .editorconfig_properties(location.worktree_id, location.path)
+                .editorconfig_store
+                .read(cx)
+                .properties(location.worktree_id, location.path)
         });
         if let Some(editorconfig_properties) = editorconfig_properties {
             let mut settings = settings.clone();

crates/project/src/project_settings.rs 🔗

@@ -20,8 +20,9 @@ use serde::{Deserialize, Serialize};
 pub use settings::DirenvSettings;
 pub use settings::LspSettings;
 use settings::{
-    DapSettingsContent, InvalidSettingsError, LocalSettingsKind, RegisterSetting, Settings,
-    SettingsLocation, SettingsStore, parse_json_with_comments, watch_config_file,
+    DapSettingsContent, EditorconfigEvent, InvalidSettingsError, LocalSettingsKind,
+    LocalSettingsPath, RegisterSetting, Settings, SettingsLocation, SettingsStore,
+    parse_json_with_comments, watch_config_file,
 };
 use std::{cell::OnceCell, collections::BTreeMap, path::PathBuf, sync::Arc, time::Duration};
 use task::{DebugTaskFile, TaskTemplates, VsCodeDebugTaskFile, VsCodeTaskFile};
@@ -670,6 +671,7 @@ pub struct SettingsObserver {
         HashMap<PathTrust, BTreeMap<(WorktreeId, Arc<RelPath>), Option<String>>>,
     _trusted_worktrees_watcher: Option<Subscription>,
     _user_settings_watcher: Option<Subscription>,
+    _editorconfig_watcher: Option<Subscription>,
     _global_task_config_watcher: Task<()>,
     _global_debug_config_watcher: Task<()>,
 }
@@ -708,9 +710,11 @@ impl SettingsObserver {
                                     for ((worktree_id, directory_path), settings_contents) in
                                         pending_local_settings
                                     {
+                                        let path =
+                                            LocalSettingsPath::InWorktree(directory_path.clone());
                                         apply_local_settings(
                                             worktree_id,
-                                            &directory_path,
+                                            path.clone(),
                                             LocalSettingsKind::Settings,
                                             &settings_contents,
                                             cx,
@@ -722,7 +726,7 @@ impl SettingsObserver {
                                                 .send(proto::UpdateWorktreeSettings {
                                                     project_id: settings_observer.project_id,
                                                     worktree_id: worktree_id.to_proto(),
-                                                    path: directory_path.to_proto(),
+                                                    path: path.to_proto(),
                                                     content: settings_contents,
                                                     kind: Some(
                                                         local_settings_kind_to_proto(
@@ -730,6 +734,7 @@ impl SettingsObserver {
                                                         )
                                                         .into(),
                                                     ),
+                                                    outside_worktree: Some(false),
                                                 })
                                                 .log_err();
                                         }
@@ -742,6 +747,36 @@ impl SettingsObserver {
                 )
             });
 
+        let editorconfig_store = cx.global::<SettingsStore>().editorconfig_store.clone();
+        let _editorconfig_watcher = cx.subscribe(
+            &editorconfig_store,
+            |this, _, event: &EditorconfigEvent, cx| {
+                let EditorconfigEvent::ExternalConfigChanged {
+                    path,
+                    content,
+                    affected_worktree_ids,
+                } = event;
+                for worktree_id in affected_worktree_ids {
+                    if let Some(worktree) = this
+                        .worktree_store
+                        .read(cx)
+                        .worktree_for_id(*worktree_id, cx)
+                    {
+                        this.update_settings(
+                            worktree,
+                            [(
+                                path.clone(),
+                                LocalSettingsKind::Editorconfig,
+                                content.clone(),
+                            )],
+                            false,
+                            cx,
+                        );
+                    }
+                }
+            },
+        );
+
         Self {
             worktree_store,
             task_store,
@@ -750,6 +785,7 @@ impl SettingsObserver {
             _trusted_worktrees_watcher,
             pending_local_settings: HashMap::default(),
             _user_settings_watcher: None,
+            _editorconfig_watcher: Some(_editorconfig_watcher),
             project_id: REMOTE_SERVER_PROJECT_ID,
             _global_task_config_watcher: Self::subscribe_to_global_task_file_changes(
                 fs.clone(),
@@ -805,6 +841,7 @@ impl SettingsObserver {
             _trusted_worktrees_watcher: None,
             pending_local_settings: HashMap::default(),
             _user_settings_watcher: user_settings_watcher,
+            _editorconfig_watcher: None,
             _global_task_config_watcher: Self::subscribe_to_global_task_file_changes(
                 fs.clone(),
                 paths::tasks_file().clone(),
@@ -841,19 +878,25 @@ impl SettingsObserver {
                         kind: Some(
                             local_settings_kind_to_proto(LocalSettingsKind::Settings).into(),
                         ),
+                        outside_worktree: Some(false),
                     })
                     .log_err();
             }
-            for (path, content, _) in store.local_editorconfig_settings(worktree.read(cx).id()) {
+            for (path, content, _) in store
+                .editorconfig_store
+                .read(cx)
+                .local_editorconfig_settings(worktree.read(cx).id())
+            {
                 downstream_client
                     .send(proto::UpdateWorktreeSettings {
                         project_id,
                         worktree_id,
                         path: path.to_proto(),
-                        content: Some(content),
+                        content: Some(content.to_owned()),
                         kind: Some(
                             local_settings_kind_to_proto(LocalSettingsKind::Editorconfig).into(),
                         ),
+                        outside_worktree: Some(path.is_outside_worktree()),
                     })
                     .log_err();
             }
@@ -874,7 +917,12 @@ impl SettingsObserver {
                 .with_context(|| format!("unknown kind {kind}"))?,
             None => proto::LocalSettingsKind::Settings,
         };
-        let path = RelPath::from_proto(&envelope.payload.path)?;
+
+        let path = LocalSettingsPath::from_proto(
+            &envelope.payload.path,
+            envelope.payload.outside_worktree.unwrap_or(false),
+        )?;
+
         this.update(&mut cx, |this, cx| {
             let is_via_collab = match &this.mode {
                 SettingsObserverMode::Local(..) => false,
@@ -1012,6 +1060,23 @@ impl SettingsObserver {
                 let Some(settings_dir) = path.parent().map(Arc::from) else {
                     continue;
                 };
+                if matches!(change, PathChange::Loaded) || matches!(change, PathChange::Added) {
+                    let worktree_id = worktree.read(cx).id();
+                    let worktree_path = worktree.read(cx).abs_path();
+                    let fs = fs.clone();
+                    cx.update_global::<SettingsStore, _>(|store, cx| {
+                        store
+                            .editorconfig_store
+                            .update(cx, |editorconfig_store, cx| {
+                                editorconfig_store.discover_local_external_configs_chain(
+                                    worktree_id,
+                                    worktree_path,
+                                    fs,
+                                    cx,
+                                );
+                            });
+                    });
+                }
                 (settings_dir, LocalSettingsKind::Editorconfig)
             } else {
                 continue;
@@ -1088,7 +1153,11 @@ impl SettingsObserver {
                     this.update_settings(
                         worktree,
                         settings_contents.into_iter().map(|(path, kind, content)| {
-                            (path, kind, content.and_then(|c| c.log_err()))
+                            (
+                                LocalSettingsPath::InWorktree(path),
+                                kind,
+                                content.and_then(|c| c.log_err()),
+                            )
                         }),
                         false,
                         cx,
@@ -1102,7 +1171,9 @@ impl SettingsObserver {
     fn update_settings(
         &mut self,
         worktree: Entity<Worktree>,
-        settings_contents: impl IntoIterator<Item = (Arc<RelPath>, LocalSettingsKind, Option<String>)>,
+        settings_contents: impl IntoIterator<
+            Item = (LocalSettingsPath, LocalSettingsKind, Option<String>),
+        >,
         is_via_collab: bool,
         cx: &mut Context<Self>,
     ) {
@@ -1114,10 +1185,10 @@ impl SettingsObserver {
         } else {
             OnceCell::new()
         };
-        for (directory, kind, file_content) in settings_contents {
+        for (directory_path, kind, file_content) in settings_contents {
             let mut applied = true;
-            match kind {
-                LocalSettingsKind::Settings => {
+            match (&directory_path, kind) {
+                (LocalSettingsPath::InWorktree(directory), LocalSettingsKind::Settings) => {
                     if *can_trust_worktree.get_or_init(|| {
                         if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) {
                             trusted_worktrees.update(cx, |trusted_worktrees, cx| {
@@ -1127,7 +1198,13 @@ impl SettingsObserver {
                             true
                         }
                     }) {
-                        apply_local_settings(worktree_id, &directory, kind, &file_content, cx)
+                        apply_local_settings(
+                            worktree_id,
+                            LocalSettingsPath::InWorktree(directory.clone()),
+                            kind,
+                            &file_content,
+                            cx,
+                        )
                     } else {
                         applied = false;
                         self.pending_local_settings
@@ -1136,10 +1213,7 @@ impl SettingsObserver {
                             .insert((worktree_id, directory.clone()), file_content.clone());
                     }
                 }
-                LocalSettingsKind::Editorconfig => {
-                    apply_local_settings(worktree_id, &directory, kind, &file_content, cx)
-                }
-                LocalSettingsKind::Tasks => {
+                (LocalSettingsPath::InWorktree(directory), LocalSettingsKind::Tasks) => {
                     let result = task_store.update(cx, |task_store, cx| {
                         task_store.update_user_tasks(
                             TaskSettingsLocation::Worktree(SettingsLocation {
@@ -1168,7 +1242,7 @@ impl SettingsObserver {
                         }
                     }
                 }
-                LocalSettingsKind::Debug => {
+                (LocalSettingsPath::InWorktree(directory), LocalSettingsKind::Debug) => {
                     let result = task_store.update(cx, |task_store, cx| {
                         task_store.update_user_debug_scenarios(
                             TaskSettingsLocation::Worktree(SettingsLocation {
@@ -1199,6 +1273,17 @@ impl SettingsObserver {
                         }
                     }
                 }
+                (directory, LocalSettingsKind::Editorconfig) => {
+                    apply_local_settings(worktree_id, directory.clone(), kind, &file_content, cx);
+                }
+                (LocalSettingsPath::OutsideWorktree(path), kind) => {
+                    log::error!(
+                        "OutsideWorktree path {:?} with kind {:?} is only supported by editorconfig",
+                        path,
+                        kind
+                    );
+                    continue;
+                }
             };
 
             if applied {
@@ -1207,9 +1292,10 @@ impl SettingsObserver {
                         .send(proto::UpdateWorktreeSettings {
                             project_id: self.project_id,
                             worktree_id: remote_worktree_id.to_proto(),
-                            path: directory.to_proto(),
+                            path: directory_path.to_proto(),
                             content: file_content.clone(),
                             kind: Some(local_settings_kind_to_proto(kind).into()),
+                            outside_worktree: Some(directory_path.is_outside_worktree()),
                         })
                         .log_err();
                 }
@@ -1323,19 +1409,14 @@ impl SettingsObserver {
 
 fn apply_local_settings(
     worktree_id: WorktreeId,
-    directory: &Arc<RelPath>,
+    path: LocalSettingsPath,
     kind: LocalSettingsKind,
     file_content: &Option<String>,
     cx: &mut Context<'_, SettingsObserver>,
 ) {
     cx.update_global::<SettingsStore, _>(|store, cx| {
-        let result = store.set_local_settings(
-            worktree_id,
-            directory.clone(),
-            kind,
-            file_content.as_deref(),
-            cx,
-        );
+        let result =
+            store.set_local_settings(worktree_id, path.clone(), kind, file_content.as_deref(), cx);
 
         match result {
             Err(InvalidSettingsError::LocalSettings { path, message }) => {
@@ -1345,9 +1426,17 @@ fn apply_local_settings(
                 )));
             }
             Err(e) => log::error!("Failed to set local settings: {e}"),
-            Ok(()) => cx.emit(SettingsObserverEvent::LocalSettingsUpdated(Ok(directory
-                .as_std_path()
-                .join(local_settings_file_relative_path().as_std_path())))),
+            Ok(()) => {
+                let settings_path = match &path {
+                    LocalSettingsPath::InWorktree(rel_path) => rel_path
+                        .as_std_path()
+                        .join(local_settings_file_relative_path().as_std_path()),
+                    LocalSettingsPath::OutsideWorktree(abs_path) => abs_path.to_path_buf(),
+                };
+                cx.emit(SettingsObserverEvent::LocalSettingsUpdated(Ok(
+                    settings_path,
+                )))
+            }
         }
     })
 }

crates/project/src/project_tests.rs 🔗

@@ -27,7 +27,7 @@ use language::{
     ManifestName, ManifestProvider, ManifestQuery, OffsetRangeExt, Point, ToPoint, ToolchainList,
     ToolchainLister,
     language_settings::{LanguageSettingsContent, language_settings},
-    rust_lang, tree_sitter_typescript,
+    markdown_lang, rust_lang, tree_sitter_typescript,
 };
 use lsp::{
     DiagnosticSeverity, DocumentChanges, FileOperationFilter, NumberOrString, TextDocumentEdit,
@@ -244,6 +244,579 @@ async fn test_editorconfig_support(cx: &mut gpui::TestAppContext) {
     });
 }
 
+#[gpui::test]
+async fn test_external_editorconfig_support(cx: &mut gpui::TestAppContext) {
+    init_test(cx);
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        path!("/grandparent"),
+        json!({
+            ".editorconfig": "[*]\nindent_size = 4\n",
+            "parent": {
+                ".editorconfig": "[*.rs]\nindent_size = 2\n",
+                "worktree": {
+                    ".editorconfig": "[*.md]\nindent_size = 3\n",
+                    "main.rs": "fn main() {}",
+                    "README.md": "# README",
+                    "other.txt": "other content",
+                }
+            }
+        }),
+    )
+    .await;
+
+    let project = Project::test(fs, [path!("/grandparent/parent/worktree").as_ref()], cx).await;
+
+    let language_registry = project.read_with(cx, |project, _| project.languages().clone());
+    language_registry.add(rust_lang());
+    language_registry.add(markdown_lang());
+
+    let worktree = project.update(cx, |project, cx| project.worktrees(cx).next().unwrap());
+
+    cx.executor().run_until_parked();
+
+    cx.update(|cx| {
+        let tree = worktree.read(cx);
+        let settings_for = |path: &str| {
+            let file_entry = tree.entry_for_path(rel_path(path)).unwrap().clone();
+            let file = File::for_entry(file_entry, worktree.clone());
+            let file_language = project
+                .read(cx)
+                .languages()
+                .load_language_for_file_path(file.path.as_std_path());
+            let file_language = cx
+                .foreground_executor()
+                .block_on(file_language)
+                .expect("Failed to get file language");
+            let file = file as _;
+            language_settings(Some(file_language.name()), Some(&file), cx).into_owned()
+        };
+
+        let settings_rs = settings_for("main.rs");
+        let settings_md = settings_for("README.md");
+        let settings_txt = settings_for("other.txt");
+
+        // main.rs gets indent_size = 2 from parent's external .editorconfig
+        assert_eq!(Some(settings_rs.tab_size), NonZeroU32::new(2));
+
+        // README.md gets indent_size = 3 from internal worktree .editorconfig
+        assert_eq!(Some(settings_md.tab_size), NonZeroU32::new(3));
+
+        // other.txt gets indent_size = 4 from grandparent's external .editorconfig
+        assert_eq!(Some(settings_txt.tab_size), NonZeroU32::new(4));
+    });
+}
+
+#[gpui::test]
+async fn test_external_editorconfig_root_stops_traversal(cx: &mut gpui::TestAppContext) {
+    init_test(cx);
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        path!("/parent"),
+        json!({
+            ".editorconfig": "[*]\nindent_size = 99\n",
+            "worktree": {
+                ".editorconfig": "root = true\n[*]\nindent_size = 2\n",
+                "file.rs": "fn main() {}",
+            }
+        }),
+    )
+    .await;
+
+    let project = Project::test(fs, [path!("/parent/worktree").as_ref()], cx).await;
+
+    let language_registry = project.read_with(cx, |project, _| project.languages().clone());
+    language_registry.add(rust_lang());
+
+    let worktree = project.update(cx, |project, cx| project.worktrees(cx).next().unwrap());
+
+    cx.executor().run_until_parked();
+
+    cx.update(|cx| {
+        let tree = worktree.read(cx);
+        let file_entry = tree.entry_for_path(rel_path("file.rs")).unwrap().clone();
+        let file = File::for_entry(file_entry, worktree.clone());
+        let file_language = project
+            .read(cx)
+            .languages()
+            .load_language_for_file_path(file.path.as_std_path());
+        let file_language = cx
+            .foreground_executor()
+            .block_on(file_language)
+            .expect("Failed to get file language");
+        let file = file as _;
+        let settings = language_settings(Some(file_language.name()), Some(&file), cx).into_owned();
+
+        // file.rs gets indent_size = 2 from worktree's root config, NOT 99 from parent
+        assert_eq!(Some(settings.tab_size), NonZeroU32::new(2));
+    });
+}
+
+#[gpui::test]
+async fn test_external_editorconfig_root_in_parent_stops_traversal(cx: &mut gpui::TestAppContext) {
+    init_test(cx);
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        path!("/grandparent"),
+        json!({
+            ".editorconfig": "[*]\nindent_size = 99\n",
+            "parent": {
+                ".editorconfig": "root = true\n[*]\nindent_size = 4\n",
+                "worktree": {
+                    "file.rs": "fn main() {}",
+                }
+            }
+        }),
+    )
+    .await;
+
+    let project = Project::test(fs, [path!("/grandparent/parent/worktree").as_ref()], cx).await;
+
+    let language_registry = project.read_with(cx, |project, _| project.languages().clone());
+    language_registry.add(rust_lang());
+
+    let worktree = project.update(cx, |project, cx| project.worktrees(cx).next().unwrap());
+
+    cx.executor().run_until_parked();
+
+    cx.update(|cx| {
+        let tree = worktree.read(cx);
+        let file_entry = tree.entry_for_path(rel_path("file.rs")).unwrap().clone();
+        let file = File::for_entry(file_entry, worktree.clone());
+        let file_language = project
+            .read(cx)
+            .languages()
+            .load_language_for_file_path(file.path.as_std_path());
+        let file_language = cx
+            .foreground_executor()
+            .block_on(file_language)
+            .expect("Failed to get file language");
+        let file = file as _;
+        let settings = language_settings(Some(file_language.name()), Some(&file), cx).into_owned();
+
+        // file.rs gets indent_size = 4 from parent's root config, NOT 99 from grandparent
+        assert_eq!(Some(settings.tab_size), NonZeroU32::new(4));
+    });
+}
+
+#[gpui::test]
+async fn test_external_editorconfig_shared_across_worktrees(cx: &mut gpui::TestAppContext) {
+    init_test(cx);
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        path!("/parent"),
+        json!({
+            ".editorconfig": "root = true\n[*]\nindent_size = 5\n",
+            "worktree_a": {
+                "file.rs": "fn a() {}",
+                ".editorconfig": "[*]\ninsert_final_newline = true\n",
+            },
+            "worktree_b": {
+                "file.rs": "fn b() {}",
+                ".editorconfig": "[*]\ninsert_final_newline = false\n",
+            }
+        }),
+    )
+    .await;
+
+    let project = Project::test(
+        fs,
+        [
+            path!("/parent/worktree_a").as_ref(),
+            path!("/parent/worktree_b").as_ref(),
+        ],
+        cx,
+    )
+    .await;
+
+    let language_registry = project.read_with(cx, |project, _| project.languages().clone());
+    language_registry.add(rust_lang());
+
+    cx.executor().run_until_parked();
+
+    cx.update(|cx| {
+        let worktrees: Vec<_> = project.read(cx).worktrees(cx).collect();
+        assert_eq!(worktrees.len(), 2);
+
+        for worktree in worktrees {
+            let tree = worktree.read(cx);
+            let file_entry = tree.entry_for_path(rel_path("file.rs")).unwrap().clone();
+            let file = File::for_entry(file_entry, worktree.clone());
+            let file_language = project
+                .read(cx)
+                .languages()
+                .load_language_for_file_path(file.path.as_std_path());
+            let file_language = cx
+                .foreground_executor()
+                .block_on(file_language)
+                .expect("Failed to get file language");
+            let file = file as _;
+            let settings =
+                language_settings(Some(file_language.name()), Some(&file), cx).into_owned();
+
+            // Both worktrees should get indent_size = 5 from shared parent .editorconfig
+            assert_eq!(Some(settings.tab_size), NonZeroU32::new(5));
+        }
+    });
+}
+
+#[gpui::test]
+async fn test_external_editorconfig_not_loaded_without_internal_config(
+    cx: &mut gpui::TestAppContext,
+) {
+    init_test(cx);
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        path!("/parent"),
+        json!({
+            ".editorconfig": "[*]\nindent_size = 99\n",
+            "worktree": {
+                "file.rs": "fn main() {}",
+            }
+        }),
+    )
+    .await;
+
+    let project = Project::test(fs, [path!("/parent/worktree").as_ref()], cx).await;
+
+    let language_registry = project.read_with(cx, |project, _| project.languages().clone());
+    language_registry.add(rust_lang());
+
+    let worktree = project.update(cx, |project, cx| project.worktrees(cx).next().unwrap());
+
+    cx.executor().run_until_parked();
+
+    cx.update(|cx| {
+        let tree = worktree.read(cx);
+        let file_entry = tree.entry_for_path(rel_path("file.rs")).unwrap().clone();
+        let file = File::for_entry(file_entry, worktree.clone());
+        let file_language = project
+            .read(cx)
+            .languages()
+            .load_language_for_file_path(file.path.as_std_path());
+        let file_language = cx
+            .foreground_executor()
+            .block_on(file_language)
+            .expect("Failed to get file language");
+        let file = file as _;
+        let settings = language_settings(Some(file_language.name()), Some(&file), cx).into_owned();
+
+        // file.rs should have default tab_size = 4, NOT 99 from parent's external .editorconfig
+        // because without an internal .editorconfig, external configs are not loaded
+        assert_eq!(Some(settings.tab_size), NonZeroU32::new(4));
+    });
+}
+
+#[gpui::test]
+async fn test_external_editorconfig_modification_triggers_refresh(cx: &mut gpui::TestAppContext) {
+    init_test(cx);
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        path!("/parent"),
+        json!({
+            ".editorconfig": "[*]\nindent_size = 4\n",
+            "worktree": {
+                ".editorconfig": "[*]\n",
+                "file.rs": "fn main() {}",
+            }
+        }),
+    )
+    .await;
+
+    let project = Project::test(fs.clone(), [path!("/parent/worktree").as_ref()], cx).await;
+
+    let language_registry = project.read_with(cx, |project, _| project.languages().clone());
+    language_registry.add(rust_lang());
+
+    let worktree = project.update(cx, |project, cx| project.worktrees(cx).next().unwrap());
+
+    cx.executor().run_until_parked();
+
+    cx.update(|cx| {
+        let tree = worktree.read(cx);
+        let file_entry = tree.entry_for_path(rel_path("file.rs")).unwrap().clone();
+        let file = File::for_entry(file_entry, worktree.clone());
+        let file_language = project
+            .read(cx)
+            .languages()
+            .load_language_for_file_path(file.path.as_std_path());
+        let file_language = cx
+            .foreground_executor()
+            .block_on(file_language)
+            .expect("Failed to get file language");
+        let file = file as _;
+        let settings = language_settings(Some(file_language.name()), Some(&file), cx).into_owned();
+
+        // Test initial settings: tab_size = 4 from parent's external .editorconfig
+        assert_eq!(Some(settings.tab_size), NonZeroU32::new(4));
+    });
+
+    fs.atomic_write(
+        PathBuf::from(path!("/parent/.editorconfig")),
+        "[*]\nindent_size = 8\n".to_owned(),
+    )
+    .await
+    .unwrap();
+
+    cx.executor().run_until_parked();
+
+    cx.update(|cx| {
+        let tree = worktree.read(cx);
+        let file_entry = tree.entry_for_path(rel_path("file.rs")).unwrap().clone();
+        let file = File::for_entry(file_entry, worktree.clone());
+        let file_language = project
+            .read(cx)
+            .languages()
+            .load_language_for_file_path(file.path.as_std_path());
+        let file_language = cx
+            .foreground_executor()
+            .block_on(file_language)
+            .expect("Failed to get file language");
+        let file = file as _;
+        let settings = language_settings(Some(file_language.name()), Some(&file), cx).into_owned();
+
+        // Test settings updated: tab_size = 8
+        assert_eq!(Some(settings.tab_size), NonZeroU32::new(8));
+    });
+}
+
+#[gpui::test]
+async fn test_adding_worktree_discovers_external_editorconfigs(cx: &mut gpui::TestAppContext) {
+    init_test(cx);
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        path!("/parent"),
+        json!({
+            ".editorconfig": "root = true\n[*]\nindent_size = 7\n",
+            "existing_worktree": {
+                ".editorconfig": "[*]\n",
+                "file.rs": "fn a() {}",
+            },
+            "new_worktree": {
+                ".editorconfig": "[*]\n",
+                "file.rs": "fn b() {}",
+            }
+        }),
+    )
+    .await;
+
+    let project = Project::test(fs, [path!("/parent/existing_worktree").as_ref()], cx).await;
+
+    let language_registry = project.read_with(cx, |project, _| project.languages().clone());
+    language_registry.add(rust_lang());
+
+    cx.executor().run_until_parked();
+
+    cx.update(|cx| {
+        let worktree = project.read(cx).worktrees(cx).next().unwrap();
+        let tree = worktree.read(cx);
+        let file_entry = tree.entry_for_path(rel_path("file.rs")).unwrap().clone();
+        let file = File::for_entry(file_entry, worktree.clone());
+        let file_language = project
+            .read(cx)
+            .languages()
+            .load_language_for_file_path(file.path.as_std_path());
+        let file_language = cx
+            .foreground_executor()
+            .block_on(file_language)
+            .expect("Failed to get file language");
+        let file = file as _;
+        let settings = language_settings(Some(file_language.name()), Some(&file), cx).into_owned();
+
+        // Test existing worktree has tab_size = 7
+        assert_eq!(Some(settings.tab_size), NonZeroU32::new(7));
+    });
+
+    let (new_worktree, _) = project
+        .update(cx, |project, cx| {
+            project.find_or_create_worktree(path!("/parent/new_worktree"), true, cx)
+        })
+        .await
+        .unwrap();
+
+    cx.executor().run_until_parked();
+
+    cx.update(|cx| {
+        let tree = new_worktree.read(cx);
+        let file_entry = tree.entry_for_path(rel_path("file.rs")).unwrap().clone();
+        let file = File::for_entry(file_entry, new_worktree.clone());
+        let file_language = project
+            .read(cx)
+            .languages()
+            .load_language_for_file_path(file.path.as_std_path());
+        let file_language = cx
+            .foreground_executor()
+            .block_on(file_language)
+            .expect("Failed to get file language");
+        let file = file as _;
+        let settings = language_settings(Some(file_language.name()), Some(&file), cx).into_owned();
+
+        // Verify new worktree also has tab_size = 7 from shared parent editorconfig
+        assert_eq!(Some(settings.tab_size), NonZeroU32::new(7));
+    });
+}
+
+#[gpui::test]
+async fn test_removing_worktree_cleans_up_external_editorconfig(cx: &mut gpui::TestAppContext) {
+    init_test(cx);
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        path!("/parent"),
+        json!({
+            ".editorconfig": "[*]\nindent_size = 6\n",
+            "worktree": {
+                ".editorconfig": "[*]\n",
+                "file.rs": "fn main() {}",
+            }
+        }),
+    )
+    .await;
+
+    let project = Project::test(fs, [path!("/parent/worktree").as_ref()], cx).await;
+
+    let language_registry = project.read_with(cx, |project, _| project.languages().clone());
+    language_registry.add(rust_lang());
+
+    let worktree = project.update(cx, |project, cx| project.worktrees(cx).next().unwrap());
+    let worktree_id = worktree.read_with(cx, |tree, _| tree.id());
+
+    cx.executor().run_until_parked();
+
+    cx.update(|cx| {
+        let store = cx.global::<SettingsStore>();
+        let (worktree_ids, external_paths, watcher_paths) =
+            store.editorconfig_store.read(cx).test_state();
+
+        // Test external config is loaded
+        assert!(worktree_ids.contains(&worktree_id));
+        assert!(!external_paths.is_empty());
+        assert!(!watcher_paths.is_empty());
+    });
+
+    project.update(cx, |project, cx| {
+        project.remove_worktree(worktree_id, cx);
+    });
+
+    cx.executor().run_until_parked();
+
+    cx.update(|cx| {
+        let store = cx.global::<SettingsStore>();
+        let (worktree_ids, external_paths, watcher_paths) =
+            store.editorconfig_store.read(cx).test_state();
+
+        // Test worktree state, external configs, and watchers all removed
+        assert!(!worktree_ids.contains(&worktree_id));
+        assert!(external_paths.is_empty());
+        assert!(watcher_paths.is_empty());
+    });
+}
+
+#[gpui::test]
+async fn test_shared_external_editorconfig_cleanup_with_multiple_worktrees(
+    cx: &mut gpui::TestAppContext,
+) {
+    init_test(cx);
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        path!("/parent"),
+        json!({
+            ".editorconfig": "root = true\n[*]\nindent_size = 5\n",
+            "worktree_a": {
+                ".editorconfig": "[*]\n",
+                "file.rs": "fn a() {}",
+            },
+            "worktree_b": {
+                ".editorconfig": "[*]\n",
+                "file.rs": "fn b() {}",
+            }
+        }),
+    )
+    .await;
+
+    let project = Project::test(
+        fs,
+        [
+            path!("/parent/worktree_a").as_ref(),
+            path!("/parent/worktree_b").as_ref(),
+        ],
+        cx,
+    )
+    .await;
+
+    let language_registry = project.read_with(cx, |project, _| project.languages().clone());
+    language_registry.add(rust_lang());
+
+    cx.executor().run_until_parked();
+
+    let (worktree_a_id, worktree_b, worktree_b_id) = cx.update(|cx| {
+        let worktrees: Vec<_> = project.read(cx).worktrees(cx).collect();
+        assert_eq!(worktrees.len(), 2);
+
+        let worktree_a = &worktrees[0];
+        let worktree_b = &worktrees[1];
+        let worktree_a_id = worktree_a.read(cx).id();
+        let worktree_b_id = worktree_b.read(cx).id();
+        (worktree_a_id, worktree_b.clone(), worktree_b_id)
+    });
+
+    cx.update(|cx| {
+        let store = cx.global::<SettingsStore>();
+        let (worktree_ids, external_paths, _) = store.editorconfig_store.read(cx).test_state();
+
+        // Test both worktrees have settings and share external config
+        assert!(worktree_ids.contains(&worktree_a_id));
+        assert!(worktree_ids.contains(&worktree_b_id));
+        assert_eq!(external_paths.len(), 1); // single shared external config
+    });
+
+    project.update(cx, |project, cx| {
+        project.remove_worktree(worktree_a_id, cx);
+    });
+
+    cx.executor().run_until_parked();
+
+    cx.update(|cx| {
+        let store = cx.global::<SettingsStore>();
+        let (worktree_ids, external_paths, watcher_paths) =
+            store.editorconfig_store.read(cx).test_state();
+
+        // Test worktree_a is gone but external config remains for worktree_b
+        assert!(!worktree_ids.contains(&worktree_a_id));
+        assert!(worktree_ids.contains(&worktree_b_id));
+        // External config should still exist because worktree_b uses it
+        assert_eq!(external_paths.len(), 1);
+        assert_eq!(watcher_paths.len(), 1);
+    });
+
+    cx.update(|cx| {
+        let tree = worktree_b.read(cx);
+        let file_entry = tree.entry_for_path(rel_path("file.rs")).unwrap().clone();
+        let file = File::for_entry(file_entry, worktree_b.clone());
+        let file_language = project
+            .read(cx)
+            .languages()
+            .load_language_for_file_path(file.path.as_std_path());
+        let file_language = cx
+            .foreground_executor()
+            .block_on(file_language)
+            .expect("Failed to get file language");
+        let file = file as _;
+        let settings = language_settings(Some(file_language.name()), Some(&file), cx).into_owned();
+
+        // Test worktree_b still has correct settings
+        assert_eq!(Some(settings.tab_size), NonZeroU32::new(5));
+    });
+}
+
 #[gpui::test]
 async fn test_git_provider_project_setting(cx: &mut gpui::TestAppContext) {
     init_test(cx);

crates/proto/proto/worktree.proto 🔗

@@ -146,6 +146,7 @@ message UpdateWorktreeSettings {
   string path = 3;
   optional string content = 4;
   optional LocalSettingsKind kind = 5;
+  optional bool outside_worktree = 6;
 }
 
 enum LocalSettingsKind {

crates/settings/src/editorconfig_store.rs 🔗

@@ -0,0 +1,385 @@
+use anyhow::{Context as _, Result};
+use collections::{BTreeMap, BTreeSet, HashSet};
+use ec4rs::{ConfigParser, PropertiesSource, Section};
+use fs::Fs;
+use futures::StreamExt;
+use gpui::{Context, EventEmitter, Task};
+use paths::EDITORCONFIG_NAME;
+use smallvec::SmallVec;
+use std::{path::Path, str::FromStr, sync::Arc};
+use util::{ResultExt as _, rel_path::RelPath};
+
+use crate::{InvalidSettingsError, LocalSettingsPath, WorktreeId, watch_config_file};
+
+pub type EditorconfigProperties = ec4rs::Properties;
+
+#[derive(Clone)]
+pub struct Editorconfig {
+    pub is_root: bool,
+    pub sections: SmallVec<[Section; 5]>,
+}
+
+impl FromStr for Editorconfig {
+    type Err = anyhow::Error;
+
+    fn from_str(contents: &str) -> Result<Self, Self::Err> {
+        let parser = ConfigParser::new_buffered(contents.as_bytes())
+            .context("creating editorconfig parser")?;
+        let is_root = parser.is_root;
+        let sections = parser
+            .collect::<Result<SmallVec<_>, _>>()
+            .context("parsing editorconfig sections")?;
+        Ok(Self { is_root, sections })
+    }
+}
+
+#[derive(Clone, Debug)]
+pub enum EditorconfigEvent {
+    ExternalConfigChanged {
+        path: LocalSettingsPath,
+        content: Option<String>,
+        affected_worktree_ids: Vec<WorktreeId>,
+    },
+}
+
+impl EventEmitter<EditorconfigEvent> for EditorconfigStore {}
+
+#[derive(Default)]
+pub struct EditorconfigStore {
+    external_configs: BTreeMap<Arc<Path>, (String, Option<Editorconfig>)>,
+    worktree_state: BTreeMap<WorktreeId, EditorconfigWorktreeState>,
+    local_external_config_watchers: BTreeMap<Arc<Path>, Task<()>>,
+    local_external_config_discovery_tasks: BTreeMap<WorktreeId, Task<()>>,
+}
+
+#[derive(Default)]
+struct EditorconfigWorktreeState {
+    internal_configs: BTreeMap<Arc<RelPath>, (String, Option<Editorconfig>)>,
+    external_config_paths: BTreeSet<Arc<Path>>,
+}
+
+impl EditorconfigStore {
+    pub(crate) fn set_configs(
+        &mut self,
+        worktree_id: WorktreeId,
+        path: LocalSettingsPath,
+        content: Option<&str>,
+    ) -> std::result::Result<(), InvalidSettingsError> {
+        match (&path, content) {
+            (LocalSettingsPath::InWorktree(rel_path), None) => {
+                if let Some(state) = self.worktree_state.get_mut(&worktree_id) {
+                    state.internal_configs.remove(rel_path);
+                }
+            }
+            (LocalSettingsPath::OutsideWorktree(abs_path), None) => {
+                if let Some(state) = self.worktree_state.get_mut(&worktree_id) {
+                    state.external_config_paths.remove(abs_path);
+                }
+                let still_in_use = self
+                    .worktree_state
+                    .values()
+                    .any(|state| state.external_config_paths.contains(abs_path));
+                if !still_in_use {
+                    self.external_configs.remove(abs_path);
+                    self.local_external_config_watchers.remove(abs_path);
+                }
+            }
+            (LocalSettingsPath::InWorktree(rel_path), Some(content)) => {
+                let state = self.worktree_state.entry(worktree_id).or_default();
+                let should_update = state
+                    .internal_configs
+                    .get(rel_path)
+                    .map_or(true, |entry| entry.0 != content);
+                if should_update {
+                    let parsed = match content.parse::<Editorconfig>() {
+                        Ok(parsed) => Some(parsed),
+                        Err(e) => {
+                            state
+                                .internal_configs
+                                .insert(rel_path.clone(), (content.to_owned(), None));
+                            return Err(InvalidSettingsError::Editorconfig {
+                                message: e.to_string(),
+                                path: LocalSettingsPath::InWorktree(
+                                    rel_path.join(RelPath::unix(EDITORCONFIG_NAME).unwrap()),
+                                ),
+                            });
+                        }
+                    };
+                    state
+                        .internal_configs
+                        .insert(rel_path.clone(), (content.to_owned(), parsed));
+                }
+            }
+            (LocalSettingsPath::OutsideWorktree(abs_path), Some(content)) => {
+                let state = self.worktree_state.entry(worktree_id).or_default();
+                state.external_config_paths.insert(abs_path.clone());
+                let should_update = self
+                    .external_configs
+                    .get(abs_path)
+                    .map_or(true, |entry| entry.0 != content);
+                if should_update {
+                    let parsed = match content.parse::<Editorconfig>() {
+                        Ok(parsed) => Some(parsed),
+                        Err(e) => {
+                            self.external_configs
+                                .insert(abs_path.clone(), (content.to_owned(), None));
+                            return Err(InvalidSettingsError::Editorconfig {
+                                message: e.to_string(),
+                                path: LocalSettingsPath::OutsideWorktree(
+                                    abs_path.join(EDITORCONFIG_NAME).into(),
+                                ),
+                            });
+                        }
+                    };
+                    self.external_configs
+                        .insert(abs_path.clone(), (content.to_owned(), parsed));
+                }
+            }
+        }
+        Ok(())
+    }
+
+    pub(crate) fn remove_for_worktree(&mut self, root_id: WorktreeId) {
+        self.local_external_config_discovery_tasks.remove(&root_id);
+        let Some(removed) = self.worktree_state.remove(&root_id) else {
+            return;
+        };
+        let paths_in_use: HashSet<_> = self
+            .worktree_state
+            .values()
+            .flat_map(|w| w.external_config_paths.iter())
+            .collect();
+        for path in removed.external_config_paths.iter() {
+            if !paths_in_use.contains(path) {
+                self.external_configs.remove(path);
+                self.local_external_config_watchers.remove(path);
+            }
+        }
+    }
+
+    fn internal_configs(
+        &self,
+        root_id: WorktreeId,
+    ) -> impl '_ + Iterator<Item = (&RelPath, &str, Option<&Editorconfig>)> {
+        self.worktree_state
+            .get(&root_id)
+            .into_iter()
+            .flat_map(|state| {
+                state
+                    .internal_configs
+                    .iter()
+                    .map(|(path, data)| (path.as_ref(), data.0.as_str(), data.1.as_ref()))
+            })
+    }
+
+    fn external_configs(
+        &self,
+        worktree_id: WorktreeId,
+    ) -> impl '_ + Iterator<Item = (&Path, &str, Option<&Editorconfig>)> {
+        self.worktree_state
+            .get(&worktree_id)
+            .into_iter()
+            .flat_map(|state| {
+                state.external_config_paths.iter().filter_map(|path| {
+                    self.external_configs
+                        .get(path)
+                        .map(|entry| (path.as_ref(), entry.0.as_str(), entry.1.as_ref()))
+                })
+            })
+    }
+
+    pub fn local_editorconfig_settings(
+        &self,
+        worktree_id: WorktreeId,
+    ) -> impl '_ + Iterator<Item = (LocalSettingsPath, &str, Option<&Editorconfig>)> {
+        let external = self
+            .external_configs(worktree_id)
+            .map(|(path, content, parsed)| {
+                (
+                    LocalSettingsPath::OutsideWorktree(path.into()),
+                    content,
+                    parsed,
+                )
+            });
+        let internal = self
+            .internal_configs(worktree_id)
+            .map(|(path, content, parsed)| {
+                (LocalSettingsPath::InWorktree(path.into()), content, parsed)
+            });
+        external.chain(internal)
+    }
+
+    pub fn discover_local_external_configs_chain(
+        &mut self,
+        worktree_id: WorktreeId,
+        worktree_path: Arc<Path>,
+        fs: Arc<dyn Fs>,
+        cx: &mut Context<Self>,
+    ) {
+        // We should only have one discovery task per worktree.
+        if self
+            .local_external_config_discovery_tasks
+            .contains_key(&worktree_id)
+        {
+            return;
+        }
+
+        let task = cx.spawn({
+            let fs = fs.clone();
+            async move |this, cx| {
+                let discovered_paths = {
+                    let mut paths = Vec::new();
+                    let mut current = worktree_path.parent().map(|p| p.to_path_buf());
+                    while let Some(dir) = current {
+                        let dir_path: Arc<Path> = Arc::from(dir.as_path());
+                        let path = dir.join(EDITORCONFIG_NAME);
+                        if fs.load(&path).await.is_ok() {
+                            paths.push(dir_path);
+                        }
+                        current = dir.parent().map(|p| p.to_path_buf());
+                    }
+                    paths
+                };
+
+                this.update(cx, |this, cx| {
+                    for dir_path in discovered_paths {
+                        // We insert it here so that watchers can send events to appropriate worktrees.
+                        // external_config_paths gets populated again in set_configs.
+                        this.worktree_state
+                            .entry(worktree_id)
+                            .or_default()
+                            .external_config_paths
+                            .insert(dir_path.clone());
+                        match this.local_external_config_watchers.entry(dir_path.clone()) {
+                            std::collections::btree_map::Entry::Occupied(_) => {
+                                if let Some(existing_config) = this.external_configs.get(&dir_path)
+                                {
+                                    cx.emit(EditorconfigEvent::ExternalConfigChanged {
+                                        path: LocalSettingsPath::OutsideWorktree(dir_path),
+                                        content: Some(existing_config.0.clone()),
+                                        affected_worktree_ids: vec![worktree_id],
+                                    });
+                                } else {
+                                    log::error!("Watcher exists for {dir_path:?} but no config found in external_configs");
+                                }
+                            }
+                            std::collections::btree_map::Entry::Vacant(entry) => {
+                                let watcher =
+                                    Self::watch_local_external_config(fs.clone(), dir_path, cx);
+                                entry.insert(watcher);
+                            }
+                        }
+                    }
+                })
+                .ok();
+            }
+        });
+
+        self.local_external_config_discovery_tasks
+            .insert(worktree_id, task);
+    }
+
+    fn watch_local_external_config(
+        fs: Arc<dyn Fs>,
+        dir_path: Arc<Path>,
+        cx: &mut Context<Self>,
+    ) -> Task<()> {
+        let config_path = dir_path.join(EDITORCONFIG_NAME);
+        let mut config_rx = watch_config_file(cx.background_executor(), fs, config_path);
+
+        cx.spawn(async move |this, cx| {
+            while let Some(content) = config_rx.next().await {
+                let content = Some(content).filter(|c| !c.is_empty());
+                let dir_path = dir_path.clone();
+                this.update(cx, |this, cx| {
+                    let affected_worktree_ids: Vec<WorktreeId> = this
+                        .worktree_state
+                        .iter()
+                        .filter_map(|(id, state)| {
+                            state
+                                .external_config_paths
+                                .contains(&dir_path)
+                                .then_some(*id)
+                        })
+                        .collect();
+
+                    cx.emit(EditorconfigEvent::ExternalConfigChanged {
+                        path: LocalSettingsPath::OutsideWorktree(dir_path),
+                        content,
+                        affected_worktree_ids,
+                    });
+                })
+                .ok();
+            }
+        })
+    }
+
+    pub fn properties(
+        &self,
+        for_worktree: WorktreeId,
+        for_path: &RelPath,
+    ) -> Option<EditorconfigProperties> {
+        let mut properties = EditorconfigProperties::new();
+        let state = self.worktree_state.get(&for_worktree);
+        let empty_path: Arc<RelPath> = RelPath::empty().into();
+        let internal_root_config_is_root = state
+            .and_then(|state| state.internal_configs.get(&empty_path))
+            .and_then(|data| data.1.as_ref())
+            .is_some_and(|ec| ec.is_root);
+
+        if !internal_root_config_is_root {
+            for (_, _, parsed_editorconfig) in self.external_configs(for_worktree) {
+                if let Some(parsed_editorconfig) = parsed_editorconfig {
+                    if parsed_editorconfig.is_root {
+                        properties = EditorconfigProperties::new();
+                    }
+                    for section in &parsed_editorconfig.sections {
+                        section
+                            .apply_to(&mut properties, for_path.as_std_path())
+                            .log_err()?;
+                    }
+                }
+            }
+        }
+
+        for (directory_with_config, _, parsed_editorconfig) in self.internal_configs(for_worktree) {
+            if !for_path.starts_with(directory_with_config) {
+                properties.use_fallbacks();
+                return Some(properties);
+            }
+            let parsed_editorconfig = parsed_editorconfig?;
+            if parsed_editorconfig.is_root {
+                properties = EditorconfigProperties::new();
+            }
+            for section in &parsed_editorconfig.sections {
+                section
+                    .apply_to(&mut properties, for_path.as_std_path())
+                    .log_err()?;
+            }
+        }
+
+        properties.use_fallbacks();
+        Some(properties)
+    }
+}
+
+#[cfg(any(test, feature = "test-support"))]
+impl EditorconfigStore {
+    pub fn test_state(&self) -> (Vec<WorktreeId>, Vec<Arc<Path>>, Vec<Arc<Path>>) {
+        let worktree_ids: Vec<_> = self.worktree_state.keys().copied().collect();
+        let external_paths: Vec<_> = self.external_configs.keys().cloned().collect();
+        let watcher_paths: Vec<_> = self
+            .local_external_config_watchers
+            .keys()
+            .cloned()
+            .collect();
+        (worktree_ids, external_paths, watcher_paths)
+    }
+
+    pub fn external_config_paths_for_worktree(&self, worktree_id: WorktreeId) -> Vec<Arc<Path>> {
+        self.worktree_state
+            .get(&worktree_id)
+            .map(|state| state.external_config_paths.iter().cloned().collect())
+            .unwrap_or_default()
+    }
+}

crates/settings/src/settings.rs 🔗

@@ -1,6 +1,7 @@
 mod base_keymap_setting;
 mod content_into_gpui;
 mod editable_setting_control;
+mod editorconfig_store;
 mod keymap_file;
 mod settings_file;
 mod settings_store;
@@ -33,6 +34,9 @@ pub use ::settings_content::*;
 pub use base_keymap_setting::*;
 pub use content_into_gpui::IntoGpui;
 pub use editable_setting_control::*;
+pub use editorconfig_store::{
+    Editorconfig, EditorconfigEvent, EditorconfigProperties, EditorconfigStore,
+};
 pub use keymap_file::{
     KeyBindingValidator, KeyBindingValidatorRegistration, KeybindSource, KeybindUpdateOperation,
     KeybindUpdateTarget, KeymapFile, KeymapFileLoadResult,
@@ -40,9 +44,9 @@ pub use keymap_file::{
 pub use settings_file::*;
 pub use settings_json::*;
 pub use settings_store::{
-    InvalidSettingsError, LSP_SETTINGS_SCHEMA_URL_PREFIX, LocalSettingsKind, MigrationStatus,
-    Settings, SettingsFile, SettingsJsonSchemaParams, SettingsKey, SettingsLocation,
-    SettingsParseResult, SettingsStore,
+    InvalidSettingsError, LSP_SETTINGS_SCHEMA_URL_PREFIX, LocalSettingsKind, LocalSettingsPath,
+    MigrationStatus, Settings, SettingsFile, SettingsJsonSchemaParams, SettingsKey,
+    SettingsLocation, SettingsParseResult, SettingsStore,
 };
 
 pub use vscode_import::{VsCodeSettings, VsCodeSettingsSource};

crates/settings/src/settings_store.rs 🔗

@@ -1,25 +1,26 @@
 use anyhow::{Context as _, Result};
 use collections::{BTreeMap, HashMap, btree_map, hash_map};
-use ec4rs::{ConfigParser, PropertiesSource, Section};
 use fs::Fs;
 use futures::{
     FutureExt, StreamExt,
     channel::{mpsc, oneshot},
     future::LocalBoxFuture,
 };
-use gpui::{App, AsyncApp, BorrowAppContext, Global, SharedString, Task, UpdateGlobal};
+use gpui::{
+    App, AppContext, AsyncApp, BorrowAppContext, Entity, Global, SharedString, Task, UpdateGlobal,
+};
 
-use paths::{EDITORCONFIG_NAME, local_settings_file_relative_path, task_file_name};
+use paths::{local_settings_file_relative_path, task_file_name};
 use schemars::{JsonSchema, json_schema};
 use serde_json::Value;
-use smallvec::SmallVec;
+use settings_content::ParseStatus;
 use std::{
     any::{Any, TypeId, type_name},
     fmt::Debug,
     ops::Range,
-    path::PathBuf,
+    path::{Path, PathBuf},
     rc::Rc,
-    str::{self, FromStr},
+    str,
     sync::Arc,
 };
 use util::{
@@ -28,17 +29,17 @@ use util::{
     schemars::{AllowTrailingCommas, DefaultDenyUnknownFields, replace_subschema},
 };
 
-pub type EditorconfigProperties = ec4rs::Properties;
+use crate::editorconfig_store::EditorconfigStore;
 
-use crate::settings_content::{
-    ExtensionsSettingsContent, FontFamilyName, IconThemeName, LanguageSettingsContent,
-    LanguageToSettingsMap, LspSettings, LspSettingsMap, ProjectSettingsContent, SettingsContent,
-    ThemeName, UserSettingsContent,
-};
 use crate::{
-    ActiveSettingsProfileName, ParseStatus, UserSettingsContentExt, VsCodeSettings, WorktreeId,
+    ActiveSettingsProfileName, FontFamilyName, IconThemeName, LanguageSettingsContent,
+    LanguageToSettingsMap, LspSettings, LspSettingsMap, ThemeName, UserSettingsContentExt,
+    VsCodeSettings, WorktreeId,
+    settings_content::{
+        ExtensionsSettingsContent, ProjectSettingsContent, RootUserSettings, SettingsContent,
+        UserSettingsContent, merge_from::MergeFrom,
+    },
 };
-use settings_content::{RootUserSettings, merge_from::MergeFrom};
 
 use settings_json::{infer_json_indent_size, update_value_in_json_text};
 
@@ -153,7 +154,7 @@ pub struct SettingsStore {
     merged_settings: Rc<SettingsContent>,
 
     local_settings: BTreeMap<(WorktreeId, Arc<RelPath>), SettingsContent>,
-    raw_editorconfig_settings: BTreeMap<(WorktreeId, Arc<RelPath>), (String, Option<Editorconfig>)>,
+    pub editorconfig_store: Entity<EditorconfigStore>,
 
     _setting_file_updates: Task<()>,
     setting_file_updates_tx:
@@ -201,26 +202,6 @@ impl Ord for SettingsFile {
     }
 }
 
-#[derive(Clone)]
-pub struct Editorconfig {
-    pub is_root: bool,
-    pub sections: SmallVec<[Section; 5]>,
-}
-
-impl FromStr for Editorconfig {
-    type Err = anyhow::Error;
-
-    fn from_str(contents: &str) -> Result<Self, Self::Err> {
-        let parser = ConfigParser::new_buffered(contents.as_bytes())
-            .context("creating editorconfig parser")?;
-        let is_root = parser.is_root;
-        let sections = parser
-            .collect::<Result<SmallVec<_>, _>>()
-            .context("parsing editorconfig sections")?;
-        Ok(Self { is_root, sections })
-    }
-}
-
 #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
 pub enum LocalSettingsKind {
     Settings,
@@ -229,6 +210,33 @@ pub enum LocalSettingsKind {
     Debug,
 }
 
+#[derive(Clone, Debug, PartialEq, Eq, Hash)]
+pub enum LocalSettingsPath {
+    InWorktree(Arc<RelPath>),
+    OutsideWorktree(Arc<Path>),
+}
+
+impl LocalSettingsPath {
+    pub fn is_outside_worktree(&self) -> bool {
+        matches!(self, Self::OutsideWorktree(_))
+    }
+
+    pub fn to_proto(&self) -> String {
+        match self {
+            Self::InWorktree(path) => path.to_proto(),
+            Self::OutsideWorktree(path) => path.to_string_lossy().to_string(),
+        }
+    }
+
+    pub fn from_proto(path: &str, is_outside_worktree: bool) -> anyhow::Result<Self> {
+        if is_outside_worktree {
+            Ok(Self::OutsideWorktree(PathBuf::from(path).into()))
+        } else {
+            Ok(Self::InWorktree(RelPath::from_proto(path)?))
+        }
+    }
+}
+
 impl Global for SettingsStore {}
 
 #[doc(hidden)]
@@ -263,7 +271,7 @@ pub struct SettingsJsonSchemaParams<'a> {
 }
 
 impl SettingsStore {
-    pub fn new(cx: &App, default_settings: &str) -> Self {
+    pub fn new(cx: &mut App, default_settings: &str) -> Self {
         let (setting_file_updates_tx, mut setting_file_updates_rx) = mpsc::unbounded();
         let default_settings: Rc<SettingsContent> =
             SettingsContent::parse_json_with_comments(default_settings)
@@ -279,7 +287,7 @@ impl SettingsStore {
 
             merged_settings: default_settings,
             local_settings: BTreeMap::default(),
-            raw_editorconfig_settings: BTreeMap::default(),
+            editorconfig_store: cx.new(|_| EditorconfigStore::default()),
             setting_file_updates_tx,
             _setting_file_updates: cx.spawn(async move |cx| {
                 while let Some(setting_file_update) = setting_file_updates_rx.next().await {
@@ -835,19 +843,17 @@ impl SettingsStore {
     pub fn set_local_settings(
         &mut self,
         root_id: WorktreeId,
-        directory_path: Arc<RelPath>,
+        path: LocalSettingsPath,
         kind: LocalSettingsKind,
         settings_content: Option<&str>,
         cx: &mut App,
     ) -> std::result::Result<(), InvalidSettingsError> {
+        let content = settings_content
+            .map(|content| content.trim())
+            .filter(|content| !content.is_empty());
         let mut zed_settings_changed = false;
-        match (
-            kind,
-            settings_content
-                .map(|content| content.trim())
-                .filter(|content| !content.is_empty()),
-        ) {
-            (LocalSettingsKind::Tasks, _) => {
+        match (path.clone(), kind, content) {
+            (LocalSettingsPath::InWorktree(directory_path), LocalSettingsKind::Tasks, _) => {
                 return Err(InvalidSettingsError::Tasks {
                     message: "Attempted to submit tasks into the settings store".to_string(),
                     path: directory_path
@@ -856,7 +862,7 @@ impl SettingsStore {
                         .to_path_buf(),
                 });
             }
-            (LocalSettingsKind::Debug, _) => {
+            (LocalSettingsPath::InWorktree(directory_path), LocalSettingsKind::Debug, _) => {
                 return Err(InvalidSettingsError::Debug {
                     message: "Attempted to submit debugger config into the settings store"
                         .to_string(),
@@ -866,19 +872,19 @@ impl SettingsStore {
                         .to_path_buf(),
                 });
             }
-            (LocalSettingsKind::Settings, None) => {
+            (LocalSettingsPath::InWorktree(directory_path), LocalSettingsKind::Settings, None) => {
                 zed_settings_changed = self
                     .local_settings
                     .remove(&(root_id, directory_path.clone()))
                     .is_some();
                 self.file_errors
-                    .remove(&SettingsFile::Project((root_id, directory_path.clone())));
-            }
-            (LocalSettingsKind::Editorconfig, None) => {
-                self.raw_editorconfig_settings
-                    .remove(&(root_id, directory_path.clone()));
+                    .remove(&SettingsFile::Project((root_id, directory_path)));
             }
-            (LocalSettingsKind::Settings, Some(settings_contents)) => {
+            (
+                LocalSettingsPath::InWorktree(directory_path),
+                LocalSettingsKind::Settings,
+                Some(settings_contents),
+            ) => {
                 let (new_settings, parse_result) = self
                     .parse_and_migrate_zed_settings::<ProjectSettingsContent>(
                         settings_contents,
@@ -892,7 +898,7 @@ impl SettingsStore {
                     }),
                 }?;
                 if let Some(new_settings) = new_settings {
-                    match self.local_settings.entry((root_id, directory_path.clone())) {
+                    match self.local_settings.entry((root_id, directory_path)) {
                         btree_map::Entry::Vacant(v) => {
                             v.insert(SettingsContent {
                                 project: new_settings,
@@ -912,50 +918,24 @@ impl SettingsStore {
                     }
                 }
             }
-            (LocalSettingsKind::Editorconfig, Some(editorconfig_contents)) => {
-                match self
-                    .raw_editorconfig_settings
-                    .entry((root_id, directory_path.clone()))
-                {
-                    btree_map::Entry::Vacant(v) => match editorconfig_contents.parse() {
-                        Ok(new_contents) => {
-                            v.insert((editorconfig_contents.to_owned(), Some(new_contents)));
-                        }
-                        Err(e) => {
-                            v.insert((editorconfig_contents.to_owned(), None));
-                            return Err(InvalidSettingsError::Editorconfig {
-                                message: e.to_string(),
-                                path: directory_path
-                                    .join(RelPath::unix(EDITORCONFIG_NAME).unwrap()),
-                            });
-                        }
-                    },
-                    btree_map::Entry::Occupied(mut o) => {
-                        if o.get().0 != editorconfig_contents {
-                            match editorconfig_contents.parse() {
-                                Ok(new_contents) => {
-                                    o.insert((
-                                        editorconfig_contents.to_owned(),
-                                        Some(new_contents),
-                                    ));
-                                }
-                                Err(e) => {
-                                    o.insert((editorconfig_contents.to_owned(), None));
-                                    return Err(InvalidSettingsError::Editorconfig {
-                                        message: e.to_string(),
-                                        path: directory_path
-                                            .join(RelPath::unix(EDITORCONFIG_NAME).unwrap()),
-                                    });
-                                }
-                            }
-                        }
-                    }
-                }
+            (directory_path, LocalSettingsKind::Editorconfig, editorconfig_contents) => {
+                self.editorconfig_store.update(cx, |store, _| {
+                    store.set_configs(root_id, directory_path, editorconfig_contents)
+                })?;
+            }
+            (LocalSettingsPath::OutsideWorktree(path), kind, _) => {
+                log::error!(
+                    "OutsideWorktree path {:?} with kind {:?} is only supported by editorconfig",
+                    path,
+                    kind
+                );
+                return Ok(());
+            }
+        }
+        if let LocalSettingsPath::InWorktree(directory_path) = &path {
+            if zed_settings_changed {
+                self.recompute_values(Some((root_id, &directory_path)), cx);
             }
-        };
-
-        if zed_settings_changed {
-            self.recompute_values(Some((root_id, &directory_path)), cx);
         }
         Ok(())
     }
@@ -980,8 +960,10 @@ impl SettingsStore {
     pub fn clear_local_settings(&mut self, root_id: WorktreeId, cx: &mut App) -> Result<()> {
         self.local_settings
             .retain(|(worktree_id, _), _| worktree_id != &root_id);
-        self.raw_editorconfig_settings
-            .retain(|(worktree_id, _), _| worktree_id != &root_id);
+
+        self.editorconfig_store
+            .update(cx, |store, _cx| store.remove_for_worktree(root_id));
+
         for setting_value in self.setting_values.values_mut() {
             setting_value.clear_local_values(root_id);
         }
@@ -1004,23 +986,6 @@ impl SettingsStore {
             .map(|((_, path), content)| (path.clone(), &content.project))
     }
 
-    pub fn local_editorconfig_settings(
-        &self,
-        root_id: WorktreeId,
-    ) -> impl '_ + Iterator<Item = (Arc<RelPath>, String, Option<Editorconfig>)> {
-        self.raw_editorconfig_settings
-            .range(
-                (root_id, RelPath::empty().into())
-                    ..(
-                        WorktreeId::from_usize(root_id.to_usize() + 1),
-                        RelPath::empty().into(),
-                    ),
-            )
-            .map(|((_, path), (content, parsed_content))| {
-                (path.clone(), content.clone(), parsed_content.clone())
-            })
-    }
-
     pub fn json_schema(&self, params: &SettingsJsonSchemaParams) -> Value {
         let mut generator = schemars::generate::SchemaSettings::draft2019_09()
             .with_transform(DefaultDenyUnknownFields)
@@ -1181,35 +1146,6 @@ impl SettingsStore {
             }
         }
     }
-
-    pub fn editorconfig_properties(
-        &self,
-        for_worktree: WorktreeId,
-        for_path: &RelPath,
-    ) -> Option<EditorconfigProperties> {
-        let mut properties = EditorconfigProperties::new();
-
-        for (directory_with_config, _, parsed_editorconfig) in
-            self.local_editorconfig_settings(for_worktree)
-        {
-            if !for_path.starts_with(&directory_with_config) {
-                properties.use_fallbacks();
-                return Some(properties);
-            }
-            let parsed_editorconfig = parsed_editorconfig?;
-            if parsed_editorconfig.is_root {
-                properties = EditorconfigProperties::new();
-            }
-            for section in parsed_editorconfig.sections {
-                section
-                    .apply_to(&mut properties, for_path.as_std_path())
-                    .log_err()?;
-            }
-        }
-
-        properties.use_fallbacks();
-        Some(properties)
-    }
 }
 
 /// The result of parsing settings, including any migration attempts
@@ -1296,13 +1232,31 @@ impl SettingsParseResult {
 
 #[derive(Debug, Clone, PartialEq)]
 pub enum InvalidSettingsError {
-    LocalSettings { path: Arc<RelPath>, message: String },
-    UserSettings { message: String },
-    ServerSettings { message: String },
-    DefaultSettings { message: String },
-    Editorconfig { path: Arc<RelPath>, message: String },
-    Tasks { path: PathBuf, message: String },
-    Debug { path: PathBuf, message: String },
+    LocalSettings {
+        path: Arc<RelPath>,
+        message: String,
+    },
+    UserSettings {
+        message: String,
+    },
+    ServerSettings {
+        message: String,
+    },
+    DefaultSettings {
+        message: String,
+    },
+    Editorconfig {
+        path: LocalSettingsPath,
+        message: String,
+    },
+    Tasks {
+        path: PathBuf,
+        message: String,
+    },
+    Debug {
+        path: PathBuf,
+        message: String,
+    },
 }
 
 impl std::fmt::Display for InvalidSettingsError {
@@ -1505,7 +1459,7 @@ mod tests {
         store
             .set_local_settings(
                 WorktreeId::from_usize(1),
-                rel_path("root1").into(),
+                LocalSettingsPath::InWorktree(rel_path("root1").into()),
                 LocalSettingsKind::Settings,
                 Some(r#"{ "tab_size": 5 }"#),
                 cx,
@@ -1514,7 +1468,7 @@ mod tests {
         store
             .set_local_settings(
                 WorktreeId::from_usize(1),
-                rel_path("root1/subdir").into(),
+                LocalSettingsPath::InWorktree(rel_path("root1/subdir").into()),
                 LocalSettingsKind::Settings,
                 Some(r#"{ "preferred_line_length": 50 }"#),
                 cx,
@@ -1524,7 +1478,7 @@ mod tests {
         store
             .set_local_settings(
                 WorktreeId::from_usize(1),
-                rel_path("root2").into(),
+                LocalSettingsPath::InWorktree(rel_path("root2").into()),
                 LocalSettingsKind::Settings,
                 Some(r#"{ "tab_size": 9, "auto_update": true}"#),
                 cx,
@@ -1995,7 +1949,7 @@ mod tests {
         store
             .set_local_settings(
                 local.0,
-                local.1.clone(),
+                LocalSettingsPath::InWorktree(local.1.clone()),
                 LocalSettingsKind::Settings,
                 Some(r#"{}"#),
                 cx,
@@ -2029,7 +1983,7 @@ mod tests {
         store
             .set_local_settings(
                 local.0,
-                local.1.clone(),
+                LocalSettingsPath::InWorktree(local.1.clone()),
                 LocalSettingsKind::Settings,
                 Some(r#"{"preferred_line_length": 80}"#),
                 cx,
@@ -2086,7 +2040,7 @@ mod tests {
         store
             .set_local_settings(
                 local_1.0,
-                local_1.1.clone(),
+                LocalSettingsPath::InWorktree(local_1.1.clone()),
                 LocalSettingsKind::Settings,
                 Some(r#"{"preferred_line_length": 1}"#),
                 cx,
@@ -2095,7 +2049,7 @@ mod tests {
         store
             .set_local_settings(
                 local_1_child.0,
-                local_1_child.1.clone(),
+                LocalSettingsPath::InWorktree(local_1_child.1.clone()),
                 LocalSettingsKind::Settings,
                 Some(r#"{}"#),
                 cx,
@@ -2104,7 +2058,7 @@ mod tests {
         store
             .set_local_settings(
                 local_2.0,
-                local_2.1.clone(),
+                LocalSettingsPath::InWorktree(local_2.1.clone()),
                 LocalSettingsKind::Settings,
                 Some(r#"{"preferred_line_length": 2}"#),
                 cx,
@@ -2113,7 +2067,7 @@ mod tests {
         store
             .set_local_settings(
                 local_2_child.0,
-                local_2_child.1.clone(),
+                LocalSettingsPath::InWorktree(local_2_child.1.clone()),
                 LocalSettingsKind::Settings,
                 Some(r#"{}"#),
                 cx,
@@ -2135,7 +2089,7 @@ mod tests {
         store
             .set_local_settings(
                 local_1_adjacent_child.0,
-                local_1_adjacent_child.1.clone(),
+                LocalSettingsPath::InWorktree(local_1_adjacent_child.1.clone()),
                 LocalSettingsKind::Settings,
                 Some(r#"{}"#),
                 cx,
@@ -2144,7 +2098,7 @@ mod tests {
         store
             .set_local_settings(
                 local_1_child.0,
-                local_1_child.1.clone(),
+                LocalSettingsPath::InWorktree(local_1_child.1.clone()),
                 LocalSettingsKind::Settings,
                 Some(r#"{"preferred_line_length": 3}"#),
                 cx,
@@ -2158,7 +2112,7 @@ mod tests {
         store
             .set_local_settings(
                 local_1_adjacent_child.0,
-                local_1_adjacent_child.1,
+                LocalSettingsPath::InWorktree(local_1_adjacent_child.1),
                 LocalSettingsKind::Settings,
                 Some(r#"{"preferred_line_length": 3}"#),
                 cx,
@@ -2167,7 +2121,7 @@ mod tests {
         store
             .set_local_settings(
                 local_1_child.0,
-                local_1_child.1.clone(),
+                LocalSettingsPath::InWorktree(local_1_child.1.clone()),
                 LocalSettingsKind::Settings,
                 Some(r#"{}"#),
                 cx,
@@ -2202,7 +2156,7 @@ mod tests {
         store
             .set_local_settings(
                 wt0_root.0,
-                wt0_root.1.clone(),
+                LocalSettingsPath::InWorktree(wt0_root.1.clone()),
                 LocalSettingsKind::Settings,
                 Some(r#"{"preferred_line_length": 80}"#),
                 cx,
@@ -2211,7 +2165,7 @@ mod tests {
         store
             .set_local_settings(
                 wt0_child1.0,
-                wt0_child1.1.clone(),
+                LocalSettingsPath::InWorktree(wt0_child1.1.clone()),
                 LocalSettingsKind::Settings,
                 Some(r#"{"preferred_line_length": 120}"#),
                 cx,
@@ -2220,7 +2174,7 @@ mod tests {
         store
             .set_local_settings(
                 wt0_child2.0,
-                wt0_child2.1.clone(),
+                LocalSettingsPath::InWorktree(wt0_child2.1.clone()),
                 LocalSettingsKind::Settings,
                 Some(r#"{}"#),
                 cx,
@@ -2230,7 +2184,7 @@ mod tests {
         store
             .set_local_settings(
                 wt1_root.0,
-                wt1_root.1.clone(),
+                LocalSettingsPath::InWorktree(wt1_root.1.clone()),
                 LocalSettingsKind::Settings,
                 Some(r#"{"preferred_line_length": 90}"#),
                 cx,
@@ -2239,7 +2193,7 @@ mod tests {
         store
             .set_local_settings(
                 wt1_subdir.0,
-                wt1_subdir.1.clone(),
+                LocalSettingsPath::InWorktree(wt1_subdir.1.clone()),
                 LocalSettingsKind::Settings,
                 Some(r#"{}"#),
                 cx,
@@ -2290,7 +2244,7 @@ mod tests {
         store
             .set_local_settings(
                 wt0_deep_child.0,
-                wt0_deep_child.1.clone(),
+                LocalSettingsPath::InWorktree(wt0_deep_child.1.clone()),
                 LocalSettingsKind::Settings,
                 Some(r#"{"preferred_line_length": 140}"#),
                 cx,