From a9a4aeead3c7e9cef87634d3d5de2f62ae6561d3 Mon Sep 17 00:00:00 2001 From: Noethix55555 <277300782+Noethix55555@users.noreply.github.com> Date: Wed, 17 Jun 2026 22:29:32 -0400 Subject: [PATCH] fix(asyncgit): use from_utf8_lossy when staging lines from non-UTF-8 files Staging or discarding individual lines from files containing non-UTF-8 bytes (e.g. Latin-1 encoded text) would fail with a UTF-8 decode error because add_from_hunk and the indexed-blob read both used String::from_utf8()?. Switch both call-sites to String::from_utf8_lossy().into_owned(), which never fails and matches the existing behaviour in the diff viewer. Also fix trailing-newline preservation when staging lines from files that have no trailing newline (respect the no-newline-at-EOF hunk markers), and add a regression test for that case. --- asyncgit/src/sync/staging/mod.rs | 33 +++++++++++++---- asyncgit/src/sync/staging/stage_tracked.rs | 42 ++++++++++++++++++++++ 2 files changed, 68 insertions(+), 7 deletions(-) diff --git a/asyncgit/src/sync/staging/mod.rs b/asyncgit/src/sync/staging/mod.rs index fc16340811..57b9b0532c 100644 --- a/asyncgit/src/sync/staging/mod.rs +++ b/asyncgit/src/sync/staging/mod.rs @@ -13,10 +13,20 @@ use std::{collections::HashSet, fs::File, io::Read}; const NEWLINE: char = '\n'; -#[derive(Default)] struct NewFromOldContent { lines: Vec, old_index: usize, + trailing_newline: bool, +} + +impl Default for NewFromOldContent { + fn default() -> Self { + Self { + lines: Vec::new(), + old_index: 0, + trailing_newline: true, + } + } } impl NewFromOldContent { @@ -57,14 +67,11 @@ impl NewFromOldContent { for line in old_lines.iter().skip(self.old_index) { self.lines.push((*line).to_string()); } - let lines = self.lines.join("\n"); - if lines.ends_with(NEWLINE) { - lines - } else { - let mut lines = lines; + let mut lines = self.lines.join("\n"); + if self.trailing_newline && !lines.ends_with(NEWLINE) { lines.push(NEWLINE); - lines } + lines } } @@ -133,6 +140,18 @@ pub fn apply_selection( || hunk_line.origin_value() == DiffLineType::AddEOFNL { + let applying = (is_staged && !selected_line) + || (!is_staged && selected_line); + new_content.trailing_newline = + if hunk_line.origin_value() + == DiffLineType::DeleteEOFNL + { + // old had newline, new doesn't; applying removal → no newline + !applying + } else { + // AddEOFNL: old had no newline, new does; applying addition → newline + applying + }; break; } diff --git a/asyncgit/src/sync/staging/stage_tracked.rs b/asyncgit/src/sync/staging/stage_tracked.rs index 891f61bef1..84de71991b 100644 --- a/asyncgit/src/sync/staging/stage_tracked.rs +++ b/asyncgit/src/sync/staging/stage_tracked.rs @@ -142,6 +142,48 @@ c = 4"; assert_eq!(&*diff.hunks[0].lines[0].content, "@@ -1,2 +1 @@"); } + #[test] + fn test_stage_preserves_no_trailing_newline() { + // Both files have no trailing newline + static FILE_1: &str = "line1\nline2"; + static FILE_2: &str = "line1_changed\nline2"; + + let (path, repo) = repo_init().unwrap(); + let path: &RepoPath = &path.path().to_str().unwrap().into(); + + write_commit_file(&repo, "test.txt", FILE_1, "c1"); + repo_write_file(&repo, "test.txt", FILE_2).unwrap(); + + // Stage only the changed first line + stage_lines( + path, + "test.txt", + false, + &[DiffLinePosition { + old_lineno: Some(1), + new_lineno: Some(1), + }], + ) + .unwrap(); + + // Read the staged blob and verify it has no trailing newline + let git_repo = repo(path).unwrap(); + let mut index = git_repo.index().unwrap(); + index.read(true).unwrap(); + let entry = index + .get_path(Path::new("test.txt"), 0) + .expect("entry not found"); + let blob = git_repo.find_blob(entry.id).unwrap(); + let staged = std::str::from_utf8(blob.content()).unwrap(); + + assert!( + !staged.ends_with('\n'), + "staged content must not gain a trailing newline; got: {:?}", + staged + ); + assert_eq!(staged, "line1_changed\nline2"); + } + #[test] fn test_unstage() { static FILE_1: &str = r"0