Save: don't lie about fsync; keep file metadata; confirm overwrites

- fsync is never retried. After a writeback error Linux may mark the
  pages clean, so a retried fsync could succeed although the data was
  lost, and the rename then replaced a good file with a bad one.
- New files get 0666 & ~umask instead of mkstemp's 0600. Replaced files
  keep their mode (file-type bits masked) and, best effort, their owner
  and group.
- Saving through a symlink writes the link's target instead of
  replacing the link with a regular file (SaveAs did not resolve it).
- A file with several hard links is rewritten in place, so the other
  names see the new content (as vim does by default).
- Save-as (prompt and ':save-as') asks before replacing any existing
  file other than the buffer's own; it used to ask only for unnamed
  buffers. Save-and-quit refuses to overwrite a file changed on disk,
  like save. The first save of a new file records the saved undo state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
This commit is contained in:
Claude
2026-09-24 09:16:04 -07:00
committed by Kyle Isom
parent 061e836fa4
commit ec8159ea2e
3 changed files with 193 additions and 19 deletions
+32 -3
View File
@@ -342,6 +342,20 @@ is_mutating_command(CommandId id)
}
// True if `path` names the file `buf` is backed by (same inode).
static bool
is_buffers_own_file(const Buffer &buf, const std::string &path)
{
if (!buf.IsFileBacked() || buf.Filename().empty())
return false;
try {
return std::filesystem::equivalent(path, buf.Filename());
} catch (...) {
return false;
}
}
// Commands that may run while a prompt is open: prompt editing and
// navigation (their handlers check PromptActive), cancel, and view-only
// commands that do not touch buffer contents or buffer selection.
@@ -907,6 +921,8 @@ cmd_save(CommandContext &ctx)
if (auto *sm = ctx.editor.Swap())
sm->ResetJournal(*buf);
ctx.editor.SetStatus("Saved " + buf->Filename());
if (auto *u = buf->Undo())
u->mark_saved();
return true;
}
}
@@ -980,6 +996,13 @@ cmd_save_as(CommandContext &ctx)
ctx.editor.SetStatus("save-as requires a filename");
return false;
}
// Ask before replacing an existing file other than the buffer's own.
if (std::filesystem::exists(ctx.arg) && !is_buffers_own_file(*buf, ctx.arg)) {
ctx.editor.StartPrompt(Editor::PromptKind::Confirm, "Overwrite", "");
ctx.editor.SetPendingOverwritePath(ctx.arg);
ctx.editor.SetStatus(std::string("Overwrite existing file '") + ctx.arg + "'? (y/N)");
return true;
}
std::string err;
if (!buf->SaveAs(ctx.arg, err)) {
ctx.editor.SetStatus(err);
@@ -1027,6 +1050,12 @@ cmd_save_and_quit(CommandContext &ctx)
Buffer *buf = ctx.editor.CurrentBuffer();
if (buf && buf->Dirty()) {
std::string err;
// Same guard as save: do not silently overwrite a file that changed
// on disk since it was loaded.
if (buf->IsFileBacked() && buf->ExternallyModifiedOnDisk()) {
ctx.editor.SetStatus("File changed on disk; save with C-k s to confirm overwriting, then quit");
return false;
}
if (buf->IsFileBacked()) {
if (buf->Save(err)) {
buf->SetDirty(false);
@@ -2903,9 +2932,9 @@ cmd_newline(CommandContext &ctx)
return in;
};
value = expand_user_path(value);
// If this is a first-time save (unnamed/non-file-backed) and the
// target exists, ask for confirmation before overwriting.
if (!buf->IsFileBacked() && std::filesystem::exists(value)) {
// Ask before overwriting any existing file other than the
// buffer's own (it used to ask only for unnamed buffers).
if (std::filesystem::exists(value) && !is_buffers_own_file(*buf, value)) {
ctx.editor.StartPrompt(Editor::PromptKind::Confirm, "Overwrite", "");
ctx.editor.SetPendingOverwritePath(value);
ctx.editor.SetStatus(