Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/copilot/src/copilot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1780,6 +1780,7 @@ mod tests {
fn disk_state(&self) -> language::DiskState {
language::DiskState::Present {
mtime: ::fs::MTime::from_seconds_and_nanos(100, 42),
size: 0,
}
}

Expand Down
6 changes: 3 additions & 3 deletions crates/language/src/buffer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -435,7 +435,7 @@ pub enum DiskState {
/// File created in Zed that has not been saved.
New,
/// File present on the filesystem.
Present { mtime: MTime },
Present { mtime: MTime, size: u64 },
/// Deleted file that was previously present.
Deleted,
/// An old version of a file that was previously present
Expand All @@ -448,7 +448,7 @@ impl DiskState {
pub fn mtime(self) -> Option<MTime> {
match self {
DiskState::New => None,
DiskState::Present { mtime } => Some(mtime),
DiskState::Present { mtime, .. } => Some(mtime),
DiskState::Deleted => None,
DiskState::Historic { .. } => None,
}
Expand Down Expand Up @@ -2373,7 +2373,7 @@ impl Buffer {
};
match file.disk_state() {
DiskState::New => false,
DiskState::Present { mtime } => match self.saved_mtime {
DiskState::Present { mtime, .. } => match self.saved_mtime {
Some(saved_mtime) => {
mtime.bad_is_greater_than(saved_mtime) && self.has_unsaved_edits()
}
Expand Down
5 changes: 4 additions & 1 deletion crates/project/src/buffer_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -532,7 +532,10 @@ impl LocalBufferStore {
let new_file = if let Some(entry) = snapshot_entry {
File {
disk_state: match entry.mtime {
Some(mtime) => DiskState::Present { mtime },
Some(mtime) => DiskState::Present {
mtime,
size: entry.size,
},
None => old_file.disk_state,
},
is_local: true,
Expand Down
5 changes: 4 additions & 1 deletion crates/project/src/image_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -808,7 +808,10 @@ impl LocalImageStore {
let new_file = if let Some(entry) = snapshot_entry {
worktree::File {
disk_state: match entry.mtime {
Some(mtime) => DiskState::Present { mtime },
Some(mtime) => DiskState::Present {
mtime,
size: entry.size,
},
None => old_file.disk_state,
},
is_local: true,
Expand Down
132 changes: 132 additions & 0 deletions crates/project/tests/integration/project_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12249,3 +12249,135 @@ mod disable_ai_settings_tests {
});
}
}

#[gpui::test]
async fn test_buffer_reload_during_truncate_then_write(cx: &mut gpui::TestAppContext) {
// Reproduces #38109: scanner catches a truncated file mid-write,
// buffer reloads to empty, and the final write has the same mtime.
// Without size in DiskState, the buffer stays stuck empty.
use worktree::WorktreeModelHandle as _;

init_test(cx);
cx.executor().allow_parking();

let dir = TempTree::new(json!({
"file.txt": "original content that should not be lost",
}));

let project = Project::test(Arc::new(RealFs::new(None, cx.executor())), [dir.path()], cx).await;
let tree = project.update(cx, |project, cx| project.worktrees(cx).next().unwrap());
tree.flush_fs_events(cx).await;

let buffer = project
.update(cx, |p, cx| {
p.open_local_buffer(dir.path().join("file.txt"), cx)
})
.await
.unwrap();

buffer.read_with(cx, |buffer, _| {
assert_eq!(buffer.text(), "original content that should not be lost");
assert!(!buffer.is_dirty());
});

let file_path = dir.path().join("file.txt");

// Truncate the file (first half of std::fs::write).
std::fs::write(&file_path, "").unwrap();
let truncated_mtime = std::fs::metadata(&file_path).unwrap().modified().unwrap();

// Scanner picks up the truncation.
tree.flush_fs_events(cx).await;

buffer.read_with(cx, |buffer, _| {
assert_eq!(
buffer.text(),
"",
"buffer should be empty after reloading truncated file"
);
});

// Write actual content (second half of std::fs::write).
std::fs::write(&file_path, "new content from AI agent").unwrap();

// Force mtime to match the truncated file's mtime, simulating
// coarse-grained timestamps or rapid sequential writes.
let times = std::fs::FileTimes::new().set_modified(truncated_mtime);
let file_handle = std::fs::File::options()
.write(true)
.open(&file_path)
.unwrap();
file_handle.set_times(times).unwrap();
drop(file_handle);

tree.flush_fs_events(cx).await;

// Size changed (0 -> 25 bytes) even though mtime is the same.
buffer.read_with(cx, |buffer, _| {
let file = buffer.file().expect("buffer should have a file");
assert!(
matches!(file.disk_state(), DiskState::Present { .. }),
"disk state should be Present, got {:?}",
file.disk_state()
);

assert_eq!(
buffer.text(),
"new content from AI agent",
"buffer should recover when size changes even if mtime matches"
);
});
}

#[gpui::test]
async fn test_buffer_recovers_from_truncate_when_mtime_differs(cx: &mut gpui::TestAppContext) {
// Control: same sequence without forcing mtime to match.
// APFS nanosecond precision means the mtime naturally differs,
// so the buffer recovers without the size fix.
use worktree::WorktreeModelHandle as _;

init_test(cx);
cx.executor().allow_parking();

let dir = TempTree::new(json!({
"file.txt": "original content",
}));

let project = Project::test(Arc::new(RealFs::new(None, cx.executor())), [dir.path()], cx).await;
let tree = project.update(cx, |project, cx| project.worktrees(cx).next().unwrap());
tree.flush_fs_events(cx).await;

let buffer = project
.update(cx, |p, cx| {
p.open_local_buffer(dir.path().join("file.txt"), cx)
})
.await
.unwrap();

buffer.read_with(cx, |buffer, _| {
assert_eq!(buffer.text(), "original content");
});

let file_path = dir.path().join("file.txt");

// Truncate
std::fs::write(&file_path, "").unwrap();
tree.flush_fs_events(cx).await;

buffer.read_with(cx, |buffer, _| {
assert_eq!(buffer.text(), "", "buffer should be empty after truncation");
});

// Write new content (mtime will naturally differ on APFS)
std::fs::write(&file_path, "recovered content").unwrap();
tree.flush_fs_events(cx).await;

// Different mtime triggers reload via the existing path.
buffer.read_with(cx, |buffer, _| {
assert_eq!(
buffer.text(),
"recovered content",
"buffer should recover when mtime differs after truncate-then-write"
);
});
}
12 changes: 10 additions & 2 deletions crates/worktree/src/worktree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1322,6 +1322,7 @@ impl LocalWorktree {
path,
disk_state: DiskState::Present {
mtime: metadata.mtime,
size: metadata.len,
},
is_local: true,
is_private,
Expand Down Expand Up @@ -1378,6 +1379,7 @@ impl LocalWorktree {
path,
disk_state: DiskState::Present {
mtime: metadata.mtime,
size: metadata.len,
},
is_local: true,
is_private,
Expand Down Expand Up @@ -1575,6 +1577,7 @@ impl LocalWorktree {
path,
disk_state: DiskState::Present {
mtime: metadata.mtime,
size: metadata.len,
},
entry_id: None,
is_local: true,
Expand Down Expand Up @@ -3280,7 +3283,10 @@ impl File {
worktree,
path: entry.path.clone(),
disk_state: if let Some(mtime) = entry.mtime {
DiskState::Present { mtime }
DiskState::Present {
mtime,
size: entry.size,
}
} else {
DiskState::New
},
Expand Down Expand Up @@ -3309,7 +3315,9 @@ impl File {
} else if proto.is_deleted {
DiskState::Deleted
} else if let Some(mtime) = proto.mtime.map(&Into::into) {
DiskState::Present { mtime }
// Size is not sent over the wire. Remote buffers don't make
// local reload decisions so this is unused in practice.
DiskState::Present { mtime, size: 0 }
} else {
DiskState::New
};
Expand Down