Files
openidea/crates/ide-host/tests/vcs_documents.rs
l0ng-aiandClaude Opus 5.5 0a24b79b50 fix(editor): skip clean saves and refresh buffers after disk operations (#920)
Root cause, two parts:
- An explicit save (Save All before a checkout, before running, before Commit) also wrote documents that were not modified, running save actions such as stripping trailing whitespace and adding a final newline on them — files the user never touched gained whitespace-only changes. Saves now skip clean documents, as autosave already did.
- After our own VCS operations rewrote files (checkout, rollback, stash, patch, ignore, commit/worktree hooks, Directory Compare), clean editor buffers were only refreshed by the file watcher, which can miss a rewrite that keeps the length and the timestamp's millisecond. Those operations now reload clean buffers synchronously before returning, keeping each document's chosen encoding; modified buffers keep the existing conflict handling.

Fixes #893

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 07:54:34 +08:00

241 lines
9.8 KiB
Rust

use ide_core::{ChangeSet, NullSink, TextChange};
use ide_host::{Host, HostConfig, Project, ops::vcs};
use std::{path::Path, process::Command, sync::Arc, time::Duration};
const ORIGINAL: &str = "SELECT *\nFROM patients\n\tLIMIT 10 OFFSET 0";
const OTHER: &str = "SELECT id\nFROM patients\n\tLIMIT 20 OFFSET 0";
fn git(root: &Path, args: &[&str]) -> String {
let out = Command::new("git").current_dir(root).args(args).output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
String::from_utf8(out.stdout).unwrap()
}
struct Fixture {
host: Host,
project: Arc<Project>,
path: std::path::PathBuf,
doc: ide_core::DocId,
_temp: tempfile::TempDir,
}
impl Fixture {
async fn new() -> Self {
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let root = base.join("project");
// The command must synchronize documents even when watcher delivery is excluded.
let path = root.join("node_modules/query.sql");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
git(&root, &["init", "-b", "main"]);
git(&root, &["config", "user.name", "Test"]);
git(&root, &["config", "user.email", "test@example.com"]);
git(&root, &["config", "commit.gpgsign", "false"]);
git(&root, &["config", "core.autocrlf", "false"]);
std::fs::write(&path, ORIGINAL).unwrap();
git(&root, &["add", "-f", "node_modules/query.sql"]);
git(&root, &["commit", "-m", "original"]);
git(&root, &["switch", "-c", "other"]);
std::fs::write(&path, OTHER).unwrap();
git(&root, &["commit", "-am", "other"]);
git(&root, &["switch", "main"]);
let host = Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
host.trust.decide(&root, true, false).unwrap();
let project = host.open_project("main", &root).unwrap();
for _ in 0..500 {
if !project.repos().is_empty() {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
let doc = project.documents.open(&path).unwrap().id;
Self { host, project, path, doc, _temp: temp }
}
fn saved_edit(&self, text: &str) {
self.project.documents.replace_all(self.doc, text).unwrap();
self.project.documents.save(self.doc).unwrap();
}
fn assert_clean(&self, text: &str) {
let snapshot = self.project.documents.snapshot(self.doc).unwrap();
assert_eq!(snapshot.text, text);
assert!(!snapshot.dirty);
self.project.documents.save_all().unwrap();
assert_eq!(std::fs::read_to_string(&self.path).unwrap(), text);
}
}
impl Drop for Fixture {
fn drop(&mut self) {
self.host.close_project("main");
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn checkout_reloads_clean_open_documents_before_returning() {
let f = Fixture::new().await;
vcs::vcs_checkout(&f.host, "main", None, "other".into(), false, None).await.unwrap();
f.assert_clean(OTHER);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn rollback_reloads_clean_open_documents_before_returning() {
let f = Fixture::new().await;
f.saved_edit(&format!("{ORIGINAL}\n"));
vcs::vcs_discard(&f.host, "main", vec![f.path.clone()]).await.unwrap();
f.assert_clean(ORIGINAL);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn stash_and_unstash_reload_clean_open_documents_before_returning() {
let f = Fixture::new().await;
let edited = format!("{ORIGINAL}\n");
f.saved_edit(&edited);
vcs::vcs_stash_push(&f.host, "main", None, None, false, false).await.unwrap();
f.assert_clean(ORIGINAL);
vcs::vcs_stash_apply(&f.host, "main", None, 0, true, false, None).await.unwrap();
f.assert_clean(&edited);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn applying_patch_reloads_clean_open_documents_before_returning() {
let f = Fixture::new().await;
std::fs::write(&f.path, OTHER).unwrap();
let patch = git(&f.project.root, &["diff"]);
git(&f.project.root, &["restore", "."]);
vcs::vcs_apply_patch(&f.host, "main", None, patch).await.unwrap();
f.assert_clean(OTHER);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn writing_patch_file_reloads_its_clean_open_document() {
let f = Fixture::new().await;
vcs::vcs_write_patch(&f.host, "main", f.path.clone(), OTHER.into()).await.unwrap();
f.assert_clean(OTHER);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn directory_compare_overwrite_reloads_clean_open_documents() {
let f = Fixture::new().await;
let base = f.project.root.parent().unwrap();
let source = base.join("source");
let destination = base.join("destination");
std::fs::create_dir_all(&source).unwrap();
std::fs::create_dir_all(&destination).unwrap();
std::fs::write(source.join("query.sql"), OTHER).unwrap();
let path = destination.join("query.sql");
std::fs::write(&path, ORIGINAL).unwrap();
let doc = f.project.documents.open(&path).unwrap();
ide_host::ops::fs::copy_merge(&f.host, "main", source, destination, "query.sql".into(), true).await.unwrap();
let snapshot = f.project.documents.snapshot(doc.id).unwrap();
assert_eq!(snapshot.text, OTHER);
assert!(!snapshot.dirty);
f.project.documents.save_all().unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), OTHER);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn failed_working_tree_operation_synchronizes_partial_disk_changes() {
let f = Fixture::new().await;
let result: ide_core::IdeResult<()> = f.project.with_repo_recorded(None, Some("Git merge"), |_| {
std::fs::write(&f.path, OTHER).unwrap();
Err(ide_core::IdeError::invalid("merge stopped with conflicts"))
});
assert!(result.is_err());
f.assert_clean(OTHER);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn working_tree_operation_reloads_same_size_same_timestamp_changes() {
let f = Fixture::new().await;
let modified = std::fs::metadata(&f.path).unwrap().modified().unwrap();
let replacement = ORIGINAL.replace("LIMIT 10", "LIMIT 20");
f.project
.with_repo_recorded(None, Some("Git checkout"), |_| {
std::fs::write(&f.path, &replacement).unwrap();
std::fs::File::options()
.write(true)
.open(&f.path)
.unwrap()
.set_times(std::fs::FileTimes::new().set_modified(modified))
.unwrap();
Ok(())
})
.unwrap();
f.assert_clean(&replacement);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn working_tree_operation_does_not_discard_unsaved_edits() {
let f = Fixture::new().await;
let version = f.project.documents.snapshot(f.doc).unwrap().version;
f.project
.documents
.edit(f.doc, version, ChangeSet { changes: vec![TextChange { from: 0, to: 0, insert: "-- local\n".into() }] })
.unwrap();
vcs::vcs_checkout(&f.host, "main", None, "other".into(), false, None).await.unwrap();
let snapshot = f.project.documents.snapshot(f.doc).unwrap();
assert_eq!(snapshot.text, format!("-- local\n{ORIGINAL}"));
assert!(snapshot.dirty);
assert_eq!(std::fs::read_to_string(&f.path).unwrap(), OTHER);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn adding_local_exclude_reloads_open_ignore_document() {
let f = Fixture::new().await;
let path = f.project.root.join(".git/info/exclude");
let doc = f.project.documents.open(&path).unwrap();
let ignored = f.project.root.join("generated.txt");
std::fs::write(&ignored, "generated\n").unwrap();
vcs::vcs_add_to_ignore(&f.host, "main", vec![ignored], ide_vcs::IgnoreDestination::LocalExclude).await.unwrap();
let disk = std::fs::read_to_string(&path).unwrap();
assert!(disk.contains("/generated.txt"));
assert_eq!(f.project.documents.snapshot(doc.id).unwrap().text, disk);
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn commit_hook_reloads_clean_open_documents() {
use std::os::unix::fs::PermissionsExt;
let f = Fixture::new().await;
f.saved_edit(&format!("{ORIGINAL}\n"));
git(&f.project.root, &["add", "-u"]);
let hook = f.project.root.join(".git/hooks/pre-commit");
std::fs::write(&hook, "#!/bin/sh\nprintf 'SELECT hook;' > node_modules/query.sql\n").unwrap();
std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap();
vcs::vcs_commit(
&f.host,
"main",
None,
"commit with hook".into(),
ide_vcs::CommitOptions { run_hooks: true, ..Default::default() },
)
.await
.unwrap();
f.assert_clean("SELECT hook;");
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn worktree_checkout_hook_reloads_clean_open_documents() {
use std::os::unix::fs::PermissionsExt;
let f = Fixture::new().await;
let hook = f.project.root.join(".git/hooks/post-checkout");
std::fs::write(&hook, "#!/bin/sh\nprintf 'SELECT hook;' > ../project/node_modules/query.sql\n").unwrap();
std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap();
let linked = f.project.root.parent().unwrap().join("linked");
vcs::vcs_add_worktree(&f.host, "main", None, linked, "other".into(), None).await.unwrap();
f.assert_clean("SELECT hook;");
}