diff --git a/rust/hg-core/src/operations/annotate.rs b/rust/hg-core/src/operations/annotate.rs
index d81714a1c88d5143d3999afaa6e4eab4f32f590e_cnVzdC9oZy1jb3JlL3NyYy9vcGVyYXRpb25zL2Fubm90YXRlLnJz..cc30bead2d6153a7477785aa1495e5f6f155d62e_cnVzdC9oZy1jb3JlL3NyYy9vcGVyYXRpb25zL2Fubm90YXRlLnJz 100644
--- a/rust/hg-core/src/operations/annotate.rs
+++ b/rust/hg-core/src/operations/annotate.rs
@@ -1,5 +1,3 @@
-use std::borrow::Cow;
-
 use crate::{
     bdiff::{self, Lines},
     errors::HgError,
@@ -26,7 +24,7 @@
 pub struct AnnotateOptions {
     pub treat_binary_as_text: bool,
     pub follow_copies: bool,
-    pub whitespace: CleanWhitespace,
+    pub whitespace: Option<CleanWhitespace>,
 }
 
 /// The final result of annotating a file.
@@ -71,6 +69,6 @@
 impl OwnedLines {
     /// Cleans `data` based on `whitespace` and then splits into lines.
     fn split(
-        data: Vec<u8>,
-        whitespace: CleanWhitespace,
+        mut data: Vec<u8>,
+        whitespace: Option<CleanWhitespace>,
     ) -> Result<Self, HgError> {
@@ -76,8 +74,7 @@
     ) -> Result<Self, HgError> {
-        let data = match clean_whitespace(&data, whitespace) {
-            Cow::Borrowed(_) => data,
-            Cow::Owned(data) => data,
-        };
+        if let Some(ws) = whitespace {
+            clean_whitespace(&mut data, ws);
+        }
         Self::try_new(data, |data| bdiff::split_lines(data))
     }
 
@@ -323,11 +320,8 @@
     // Step 3: Read files and split lines. Do the base file with and without
     // whitespace cleaning. Do the rest of the files in parallel with rayon.
     let base_file_original_lines = match options.whitespace {
-        CleanWhitespace::None => None,
-        _ => Some(OwnedLines::split(
-            base_file_data.clone(),
-            CleanWhitespace::None,
-        )?),
+        None => None,
+        _ => Some(OwnedLines::split(base_file_data.clone(), None)?),
     };
     graph[base_id].file = AnnotatedFileState::Read(OwnedLines::split(
         base_file_data,
diff --git a/rust/hg-core/src/utils/strings.rs b/rust/hg-core/src/utils/strings.rs
index d81714a1c88d5143d3999afaa6e4eab4f32f590e_cnVzdC9oZy1jb3JlL3NyYy91dGlscy9zdHJpbmdzLnJz..cc30bead2d6153a7477785aa1495e5f6f155d62e_cnVzdC9oZy1jb3JlL3NyYy91dGlscy9zdHJpbmdzLnJz 100644
--- a/rust/hg-core/src/utils/strings.rs
+++ b/rust/hg-core/src/utils/strings.rs
@@ -1,9 +1,7 @@
 //! Contains string-related utilities.
 
 use crate::utils::hg_path::HgPath;
-use lazy_static::lazy_static;
-use regex::bytes::Regex;
-use std::{borrow::Cow, cell::Cell, fmt, io::Write as _, ops::Deref as _};
+use std::{cell::Cell, fmt, io::Write as _, ops::Deref as _};
 
 /// Useful until rust/issues/56345 is stable
 ///
@@ -304,8 +302,6 @@
 /// Options for [`clean_whitespace`].
 #[derive(Copy, Clone)]
 pub enum CleanWhitespace {
-    /// Do nothing.
-    None,
     /// Remove whitespace at ends of lines.
     AtEol,
     /// Collapse consecutive whitespace characters into a single space.
@@ -315,35 +311,58 @@
 }
 
 /// Normalizes whitespace in text so that it won't apppear in diffs.
-/// Returns `Cow::Borrowed(text)` if the result is unchanged.
-pub fn clean_whitespace(text: &[u8], how: CleanWhitespace) -> Cow<[u8]> {
-    lazy_static! {
-        // To match wsclean in mdiff.py, this includes "\f".
-        static ref AT_EOL: Regex =
-            Regex::new(r"(?m)[ \t\r\f]+$").expect("valid regex");
-        // To match fixws in cext/bdiff.c, this does *not* include "\f".
-        static ref MULTIPLE: Regex =
-            Regex::new(r"[ \t\r]+").expect("valid regex");
-    }
-    let replacement: &[u8] = match how {
-        CleanWhitespace::None => return Cow::Borrowed(text),
-        CleanWhitespace::AtEol => return AT_EOL.replace_all(text, b""),
-        CleanWhitespace::Collapse => b" ",
-        CleanWhitespace::All => b"",
-    };
-    let text = MULTIPLE.replace_all(text, replacement);
-    replace_all_cow(&AT_EOL, text, b"")
-}
-
-/// Helper to call [`Regex::replace_all`] with `Cow` as input and output.
-fn replace_all_cow<'a>(
-    regex: &Regex,
-    haystack: Cow<'a, [u8]>,
-    replacement: &[u8],
-) -> Cow<'a, [u8]> {
-    match haystack {
-        Cow::Borrowed(haystack) => regex.replace_all(haystack, replacement),
-        Cow::Owned(haystack) => {
-            Cow::Owned(regex.replace_all(&haystack, replacement).into_owned())
+pub fn clean_whitespace(text: &mut Vec<u8>, how: CleanWhitespace) {
+    // We copy text[i] to text[w], where w advances more slowly than i.
+    let mut w = 0;
+    match how {
+        // To match wsclean in mdiff.py, this removes "\f" (0xC).
+        CleanWhitespace::AtEol => {
+            let mut newline_dest = 0;
+            for i in 0..text.len() {
+                let char = text[i];
+                match char {
+                    b' ' | b'\t' | b'\r' | 0xC => {}
+                    _ => {
+                        if char == b'\n' {
+                            w = newline_dest;
+                        }
+                        newline_dest = w + 1;
+                    }
+                }
+                text[w] = char;
+                w += 1;
+            }
+        }
+        // To match fixws in cext/bdiff.c, CleanWhitespace::Collapse and
+        // CleanWhitespace::All do *not* remove "\f".
+        CleanWhitespace::Collapse => {
+            for i in 0..text.len() {
+                match text[i] {
+                    b' ' | b'\t' | b'\r' => {
+                        if w == 0 || text[w - 1] != b' ' {
+                            text[w] = b' ';
+                            w += 1;
+                        }
+                    }
+                    b'\n' if w > 0 && text[w - 1] == b' ' => {
+                        text[w - 1] = b'\n';
+                    }
+                    char => {
+                        text[w] = char;
+                        w += 1;
+                    }
+                }
+            }
+        }
+        CleanWhitespace::All => {
+            for i in 0..text.len() {
+                match text[i] {
+                    b' ' | b'\t' | b'\r' => {}
+                    char => {
+                        text[w] = char;
+                        w += 1;
+                    }
+                }
+            }
         }
     }
@@ -348,5 +367,6 @@
         }
     }
+    text.truncate(w);
 }
 
 #[cfg(test)]
@@ -378,4 +398,57 @@
         assert_eq!(short_user(b"First Last <user@example.com>"), b"user");
         assert_eq!(short_user(b"First Last <user.name@example.com>"), b"user");
     }
+
+    fn clean_ws(text: &[u8], how: CleanWhitespace) -> Vec<u8> {
+        let mut vec = text.to_vec();
+        clean_whitespace(&mut vec, how);
+        vec
+    }
+
+    #[test]
+    fn test_clean_whitespace_at_eol() {
+        // To match wsclean in mdiff.py, CleanWhitespace::AtEol only removes
+        // the final line's trailing whitespace if it ends in \n.
+        use CleanWhitespace::AtEol;
+        assert_eq!(clean_ws(b"", AtEol), b"");
+        assert_eq!(clean_ws(b" ", AtEol), b" ");
+        assert_eq!(clean_ws(b"  ", AtEol), b"  ");
+        assert_eq!(clean_ws(b"A", AtEol), b"A");
+        assert_eq!(clean_ws(b"\n\n\n", AtEol), b"\n\n\n");
+        assert_eq!(clean_ws(b" \n", AtEol), b"\n");
+        assert_eq!(clean_ws(b"A \n", AtEol), b"A\n");
+        assert_eq!(clean_ws(b"A B  C\t\r\n", AtEol), b"A B  C\n");
+        assert_eq!(clean_ws(b"A \tB  C\r\nD  ", AtEol), b"A \tB  C\nD  ");
+        assert_eq!(clean_ws(b"A\x0CB\x0C\n", AtEol), b"A\x0CB\n");
+    }
+
+    #[test]
+    fn test_clean_whitespace_collapse() {
+        use CleanWhitespace::Collapse;
+        assert_eq!(clean_ws(b"", Collapse), b"");
+        assert_eq!(clean_ws(b" ", Collapse), b" ");
+        assert_eq!(clean_ws(b"  ", Collapse), b" ");
+        assert_eq!(clean_ws(b"A", Collapse), b"A");
+        assert_eq!(clean_ws(b"\n\n\n", Collapse), b"\n\n\n");
+        assert_eq!(clean_ws(b" \n", Collapse), b"\n");
+        assert_eq!(clean_ws(b"A \n", Collapse), b"A\n");
+        assert_eq!(clean_ws(b"A B  C\t\r\n", Collapse), b"A B C\n");
+        assert_eq!(clean_ws(b"A \tB  C\r\nD  ", Collapse), b"A B C\nD ");
+        assert_eq!(clean_ws(b"A\x0CB\x0C\n", Collapse), b"A\x0CB\x0C\n");
+    }
+
+    #[test]
+    fn test_clean_whitespace_all() {
+        use CleanWhitespace::All;
+        assert_eq!(clean_ws(b"", All), b"");
+        assert_eq!(clean_ws(b" ", All), b"");
+        assert_eq!(clean_ws(b"  ", All), b"");
+        assert_eq!(clean_ws(b"A", All), b"A");
+        assert_eq!(clean_ws(b"\n\n\n", All), b"\n\n\n");
+        assert_eq!(clean_ws(b" \n", All), b"\n");
+        assert_eq!(clean_ws(b"A \n", All), b"A\n");
+        assert_eq!(clean_ws(b"A B  C\t\r\n", All), b"ABC\n");
+        assert_eq!(clean_ws(b"A \tB  C\r\nD  ", All), b"ABC\nD");
+        assert_eq!(clean_ws(b"A\x0CB\x0C\n", All), b"A\x0CB\x0C\n");
+    }
 }
diff --git a/rust/rhg/src/commands/annotate.rs b/rust/rhg/src/commands/annotate.rs
index d81714a1c88d5143d3999afaa6e4eab4f32f590e_cnVzdC9yaGcvc3JjL2NvbW1hbmRzL2Fubm90YXRlLnJz..cc30bead2d6153a7477785aa1495e5f6f155d62e_cnVzdC9yaGcvc3JjL2NvbW1hbmRzL2Fubm90YXRlLnJz 100644
--- a/rust/rhg/src/commands/annotate.rs
+++ b/rust/rhg/src/commands/annotate.rs
@@ -161,5 +161,5 @@
         treat_binary_as_text: args.get_flag("text"),
         follow_copies: !args.get_flag("no-follow"),
         whitespace: if args.get_flag("ignore-all-space") {
-            CleanWhitespace::All
+            Some(CleanWhitespace::All)
         } else if args.get_flag("ignore-space-change") {
@@ -165,3 +165,3 @@
         } else if args.get_flag("ignore-space-change") {
-            CleanWhitespace::Collapse
+            Some(CleanWhitespace::Collapse)
         } else if args.get_flag("ignore-space-at-eol") {
@@ -167,5 +167,5 @@
         } else if args.get_flag("ignore-space-at-eol") {
-            CleanWhitespace::AtEol
+            Some(CleanWhitespace::AtEol)
         } else {
             // We ignore the --ignore-blank-lines flag (present for consistency
             // with other commands) since it has no effect on annotate.
@@ -169,7 +169,7 @@
         } else {
             // We ignore the --ignore-blank-lines flag (present for consistency
             // with other commands) since it has no effect on annotate.
-            CleanWhitespace::None
+            None
         },
     };