gitbutlerapp/gitbutler · warning

changed while preparing the rewrite; leaving it alone

Error message

{} changed while preparing the rewrite; leaving it alone

What it means

replace_file_contents rewrites agent-related files via a temp file + atomic persist. To avoid silently discarding concurrent edits, it re-reads the file before the rename; if the content changed since the original read, it backs off and leaves the file alone so a later sweep re-runs on the fresh content.

Solutions

  1. Treat this as an expected race: simply re-run the cleanup — the error is designed so a later sweep processes the fresh content
  2. Serialize edits: hold a lock (or the same editor/session) so no concurrent writer touches the file during cleanup
  3. Disable auto-save/formatters for files under sweep, or run the sweep while those tools are idle
  4. Check mtime before initiating a rewrite and skip files modified recently

Example fix

// before: blind retry that may keep failing
loop { match cleanup_file(path) { Ok(_) => break, Err(_) => continue } }
// after: back off and retry on fresh content after a delay
match cleanup_file(path) {
    Err(e) if e.to_string().contains("changed while preparing the rewrite") => {
        std::thread::sleep(Duration::from_millis(250));
        cleanup_file(path)?; // re-reads the fresh content
    }
    r => r,
}
Defensive patterns

Strategy: retry

Validate before calling

// re-check mtime before starting the rewrite
let before = std::fs::metadata(path)?.modified()?;
if before.elapsed()?.as_secs() < 1 { /* recently written — wait or skip */ }

Try / catch

match replace_file_contents(path, new_content) {
    Err(e) if e.to_string().contains("changed while preparing the rewrite") => {
        std::thread::sleep(Duration::from_millis(250));
        replace_file_contents(path, compute_new_content(path)?) // re-read fresh
    }
    r => r,
}

Prevention

When it happens

Trigger: cleanup_file-driven rewrite of a file that was modified between the initial read_to_string and the pre-rename check — a concurrent process, editor auto-save, formatter, or another cleanup invocation wrote to the path in that window.

Common situations: An agent session editing the same instructions/notes file while the cleanup sweep runs; linters-on-save firing during the rewrite; two cleanup processes racing on the same file; users editing files by hand while an automated sweep is in flight.

Understand the failure class

Background: "failed to write file", "Could not save figure", "Error saving remote file" — file write failed: causes and fixes across languages and libraries — this error's family across 38 libraries.

Related errors


AI-assisted analysis of gitbutlerapp/gitbutler@58e5313667 (2026-09-18). Data as JSON: /api/errors/cb67cbbc163aa80d. Report an issue: GitHub.

Appendix: source

Thrown at crates/but/src/command/agent/cleanup.rs:133

    path: &Path,
    read_content: &str,
    new_content: &str,
    original: &std::fs::Metadata,
) -> Result<()> {
    use std::io::Write as _;
    let dir = path
        .parent()
        .with_context(|| format!("No parent directory for {}", path.display()))?;
    let mut temp = tempfile::NamedTempFile::new_in(dir)?;
    temp.write_all(new_content.as_bytes())?;
    // Temp files default to owner-only permissions; keep the original mode.
    temp.as_file().set_permissions(original.permissions())?;
    temp.as_file().sync_all()?;
    // The rename below replaces the whole file, so an edit that landed after
    // our read would be silently thrown away with it. Back off if the content
    // moved under us — a later command re-runs the sweep on the fresh content.
    if std::fs::read_to_string(path)? != read_content {
        anyhow::bail!(
            "{} changed while preparing the rewrite; leaving it alone",
            path.display()
        );
    }
    temp.persist(path)
        .with_context(|| format!("Failed to replace {}", path.display()))?;
    Ok(())
}

/// Replace every line-anchored occurrence of the retired bullet that sits
/// inside a managed block. Returns `None` when nothing needs rewriting.
fn replace_retired_bullet(content: &str) -> Option<String> {
    // The cheap check first: on the steady-state clean file this skips the
    // marker scan and allocation entirely.
    if !content.contains(RETIRED_FAST_PATH_BULLET) {
        return None;
    }
    // A malformed block (unmatched marker) is not ours to interpret: skip.

View on GitHub (pinned to 58e5313667)