Fix data loss when project settings opened with ".zed" in `file_scan_exclusions` (#29578)

Ben Kunkle created

Closes #28640

Before creating an entry for a file opened with `open_local_file`, make
sure it doesn't exist, in addition to checking that it isn't already
tracked in the workspace

Release Notes:

- Fixed an issue where the project settings file would be truncated when
opened with `zed: open project settings` if the ".zed" directory was
excluded from the files scanned in a workspace (in
"file_scan_exclusions")

Change summary

crates/zed/src/zed.rs | 153 ++++++++++++++++++++++++++++++++++++++++----
1 file changed, 139 insertions(+), 14 deletions(-)

Detailed changes

crates/zed/src/zed.rs 🔗

@@ -1502,28 +1502,45 @@ fn open_local_file(
     if let Some(worktree) = worktree {
         let tree_id = worktree.read(cx).id();
         cx.spawn_in(window, async move |workspace, cx| {
-            if let Some(dir_path) = settings_relative_path.parent() {
-                if worktree.update(cx, |tree, _| tree.entry_for_path(dir_path).is_none())? {
+            // Check if the file actually exists on disk (even if it's excluded from worktree)
+            let file_exists = {
+                let full_path =
+                    worktree.update(cx, |tree, _| tree.abs_path().join(settings_relative_path))?;
+
+                let fs = project.update(cx, |project, _| project.fs().clone())?;
+                let file_exists = fs
+                    .metadata(&full_path)
+                    .await
+                    .ok()
+                    .flatten()
+                    .map_or(false, |metadata| !metadata.is_dir && !metadata.is_fifo);
+                file_exists
+            };
+
+            if !file_exists {
+                if let Some(dir_path) = settings_relative_path.parent() {
+                    if worktree.update(cx, |tree, _| tree.entry_for_path(dir_path).is_none())? {
+                        project
+                            .update(cx, |project, cx| {
+                                project.create_entry((tree_id, dir_path), true, cx)
+                            })?
+                            .await
+                            .context("worktree was removed")?;
+                    }
+                }
+
+                if worktree.update(cx, |tree, _| {
+                    tree.entry_for_path(settings_relative_path).is_none()
+                })? {
                     project
                         .update(cx, |project, cx| {
-                            project.create_entry((tree_id, dir_path), true, cx)
+                            project.create_entry((tree_id, settings_relative_path), false, cx)
                         })?
                         .await
                         .context("worktree was removed")?;
                 }
             }
 
-            if worktree.update(cx, |tree, _| {
-                tree.entry_for_path(settings_relative_path).is_none()
-            })? {
-                project
-                    .update(cx, |project, cx| {
-                        project.create_entry((tree_id, settings_relative_path), false, cx)
-                    })?
-                    .await
-                    .context("worktree was removed")?;
-            }
-
             let editor = workspace
                 .update_in(cx, |workspace, window, cx| {
                     workspace.open_path((tree_id, settings_relative_path), None, true, window, cx)
@@ -4316,4 +4333,112 @@ mod tests {
             );
         }
     }
+
+    #[gpui::test]
+    async fn test_opening_project_settings_when_excluded(cx: &mut gpui::TestAppContext) {
+        // Use the proper initialization for runtime state
+        let app_state = init_keymap_test(cx);
+
+        eprintln!("Running test_opening_project_settings_when_excluded");
+
+        // 1. Set up a project with some project settings
+        let settings_init =
+            r#"{ "UNIQUEVALUE": true, "git": { "inline_blame": { "enabled": false } } }"#;
+        app_state
+            .fs
+            .as_fake()
+            .insert_tree(
+                Path::new("/root"),
+                json!({
+                    ".zed": {
+                        "settings.json": settings_init
+                    }
+                }),
+            )
+            .await;
+
+        eprintln!("Created project with .zed/settings.json containing UNIQUEVALUE");
+
+        // 2. Create a project with the file system and load it
+        let project = Project::test(app_state.fs.clone(), [Path::new("/root")], cx).await;
+
+        // Save original settings content for comparison
+        let original_settings = app_state
+            .fs
+            .load(Path::new("/root/.zed/settings.json"))
+            .await
+            .unwrap();
+
+        let original_settings_str = original_settings.clone();
+
+        // Verify settings exist on disk and have expected content
+        eprintln!("Original settings content: {}", original_settings_str);
+        assert!(
+            original_settings_str.contains("UNIQUEVALUE"),
+            "Test setup failed - settings file doesn't contain our marker"
+        );
+
+        // 3. Add .zed to file scan exclusions in user settings
+        cx.update_global::<SettingsStore, _>(|store, cx| {
+            store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
+                worktree_settings.file_scan_exclusions = Some(vec![".zed".to_string()]);
+            });
+        });
+
+        eprintln!("Added .zed to file_scan_exclusions in settings");
+
+        // 4. Run tasks to apply settings
+        cx.background_executor.run_until_parked();
+
+        // 5. Critical: Verify .zed is actually excluded from worktree
+        let worktree = cx.update(|cx| project.read(cx).worktrees(cx).next().unwrap().clone());
+
+        let has_zed_entry = cx.update(|cx| worktree.read(cx).entry_for_path(".zed").is_some());
+
+        eprintln!(
+            "Is .zed directory visible in worktree after exclusion: {}",
+            has_zed_entry
+        );
+
+        // This assertion verifies the test is set up correctly to show the bug
+        // If .zed is not excluded, the test will fail here
+        assert!(
+            !has_zed_entry,
+            "Test precondition failed: .zed directory should be excluded but was found in worktree"
+        );
+
+        // 6. Create workspace and trigger the actual function that causes the bug
+        let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
+        window
+            .update(cx, |workspace, window, cx| {
+                // Call the exact function that contains the bug
+                eprintln!("About to call open_project_settings_file");
+                open_project_settings_file(workspace, &OpenProjectSettings, window, cx);
+            })
+            .unwrap();
+
+        // 7. Run background tasks until completion
+        cx.background_executor.run_until_parked();
+
+        // 8. Verify file contents after calling function
+        let new_content = app_state
+            .fs
+            .load(Path::new("/root/.zed/settings.json"))
+            .await
+            .unwrap();
+
+        let new_content_str = new_content.clone();
+        eprintln!("New settings content: {}", new_content_str);
+
+        // The bug causes the settings to be overwritten with empty settings
+        // So if the unique value is no longer present, the bug has been reproduced
+        let bug_exists = !new_content_str.contains("UNIQUEVALUE");
+        eprintln!("Bug reproduced: {}", bug_exists);
+
+        // This assertion should fail if the bug exists - showing the bug is real
+        assert!(
+            new_content_str.contains("UNIQUEVALUE"),
+            "BUG FOUND: Project settings were overwritten when opening via command - original custom content was lost"
+        );
+    }
 }