Skip to content

Commit 059b937

Browse files
author
Austin Emmons
committed
fix: preserve no-newline markers and untracked hunk staging
1 parent 2fbad93 commit 059b937

2 files changed

Lines changed: 255 additions & 47 deletions

File tree

src-tauri/src/shared/git_ui_core/commands.rs

Lines changed: 181 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -462,6 +462,7 @@ pub(super) struct ParsedPatchLine {
462462
pub(super) old_anchor: usize,
463463
pub(super) new_anchor: usize,
464464
pub(super) text: String,
465+
pub(super) no_newline_after: bool,
465466
}
466467

467468
#[derive(Debug, Clone)]
@@ -527,32 +528,34 @@ pub(super) fn parse_zero_context_patch(diff_patch: &str) -> Result<ParsedPatch,
527528
}
528529

529530
if let Some(text) = body_line.strip_prefix('+') {
530-
if !body_line.starts_with("+++") {
531-
parsed_lines.push(ParsedPatchLine {
532-
line_type: SelectionLineType::Add,
533-
old_line: None,
534-
new_line: Some(new_cursor),
535-
old_anchor: old_cursor,
536-
new_anchor: new_cursor,
537-
text: text.to_string(),
538-
});
539-
new_cursor += 1;
540-
}
531+
parsed_lines.push(ParsedPatchLine {
532+
line_type: SelectionLineType::Add,
533+
old_line: None,
534+
new_line: Some(new_cursor),
535+
old_anchor: old_cursor,
536+
new_anchor: new_cursor,
537+
text: text.to_string(),
538+
no_newline_after: false,
539+
});
540+
new_cursor += 1;
541541
} else if let Some(text) = body_line.strip_prefix('-') {
542-
if !body_line.starts_with("---") {
543-
parsed_lines.push(ParsedPatchLine {
544-
line_type: SelectionLineType::Del,
545-
old_line: Some(old_cursor),
546-
new_line: None,
547-
old_anchor: old_cursor,
548-
new_anchor: new_cursor,
549-
text: text.to_string(),
550-
});
551-
old_cursor += 1;
552-
}
542+
parsed_lines.push(ParsedPatchLine {
543+
line_type: SelectionLineType::Del,
544+
old_line: Some(old_cursor),
545+
new_line: None,
546+
old_anchor: old_cursor,
547+
new_anchor: new_cursor,
548+
text: text.to_string(),
549+
no_newline_after: false,
550+
});
551+
old_cursor += 1;
553552
} else if body_line.starts_with(' ') {
554553
old_cursor += 1;
555554
new_cursor += 1;
555+
} else if body_line == "\\ No newline at end of file" {
556+
if let Some(last_line) = parsed_lines.last_mut() {
557+
last_line.no_newline_after = true;
558+
}
556559
}
557560
inner_index += 1;
558561
}
@@ -879,6 +882,9 @@ fn append_full_hunk_with_context(
879882
'-'
880883
};
881884
output.push(format!("{prefix}{}", line.text));
885+
if line.no_newline_after {
886+
output.push("\\ No newline at end of file".to_string());
887+
}
882888
}
883889

884890
if after_count > 0 {
@@ -928,6 +934,9 @@ fn build_selected_patch(
928934
'-'
929935
};
930936
output.push(format!("{prefix}{}", line.text));
937+
if line.no_newline_after {
938+
output.push("\\ No newline at end of file".to_string());
939+
}
931940
}
932941
group.clear();
933942
};
@@ -1111,6 +1120,49 @@ fn build_display_hunk_patch(
11111120
Ok((patch, hunk.lines.len()))
11121121
}
11131122

1123+
async fn load_selection_source_patch(
1124+
repo_root: &Path,
1125+
action_path: &str,
1126+
source: &str,
1127+
ignore_whitespace_changes: bool,
1128+
) -> Result<String, String> {
1129+
let repo = Repository::open(repo_root).map_err(|e| e.to_string())?;
1130+
let status = repo
1131+
.status_file(Path::new(action_path))
1132+
.unwrap_or(Status::empty());
1133+
let is_untracked_worktree_file =
1134+
status.contains(Status::WT_NEW) && !status.contains(Status::INDEX_NEW);
1135+
1136+
let mut args = vec!["diff"];
1137+
if source == "unstaged" && is_untracked_worktree_file {
1138+
args.push("--no-index");
1139+
args.push("--no-color");
1140+
args.push("-U0");
1141+
if ignore_whitespace_changes {
1142+
args.push("-w");
1143+
}
1144+
args.push("--");
1145+
args.push(if cfg!(windows) { "NUL" } else { "/dev/null" });
1146+
args.push(action_path);
1147+
} else {
1148+
if source == "staged" {
1149+
args.push("--cached");
1150+
}
1151+
args.push("--no-color");
1152+
args.push("-U0");
1153+
if ignore_whitespace_changes {
1154+
args.push("-w");
1155+
}
1156+
args.push("--");
1157+
args.push(action_path);
1158+
}
1159+
1160+
Ok(String::from_utf8_lossy(
1161+
&git_core::run_git_diff(&repo_root.to_path_buf(), &args).await?,
1162+
)
1163+
.to_string())
1164+
}
1165+
11141166
pub(super) async fn stage_git_selection_inner(
11151167
workspaces: &Mutex<HashMap<String, WorkspaceEntry>>,
11161168
workspace_id: String,
@@ -1131,9 +1183,9 @@ pub(super) async fn stage_git_selection_inner(
11311183
}
11321184
let action_path = action_paths[0].clone();
11331185

1134-
let (diff_args, reverse_apply): (&[&str], bool) = match (op.as_str(), source.as_str()) {
1135-
("stage", "unstaged") => (&["diff", "--no-color", "-U0", "--"], false),
1136-
("unstage", "staged") => (&["diff", "--cached", "--no-color", "-U0", "--"], true),
1186+
let reverse_apply = match (op.as_str(), source.as_str()) {
1187+
("stage", "unstaged") => false,
1188+
("unstage", "staged") => true,
11371189
("stage", "staged") => {
11381190
return Err("Staging selected lines requires source `unstaged`.".to_string());
11391191
}
@@ -1145,14 +1197,8 @@ pub(super) async fn stage_git_selection_inner(
11451197
}
11461198
};
11471199

1148-
let mut args = diff_args.to_vec();
1149-
args.push(action_path.as_str());
1150-
let source_patch = String::from_utf8_lossy(&git_core::run_git_diff(
1151-
&repo_root.to_path_buf(),
1152-
&args,
1153-
)
1154-
.await?)
1155-
.to_string();
1200+
let source_patch =
1201+
load_selection_source_patch(&repo_root, action_path.as_str(), &source, false).await?;
11561202
if source_patch.trim().is_empty() {
11571203
return Err("No changes available for the requested selection source.".to_string());
11581204
}
@@ -1279,21 +1325,13 @@ pub(super) async fn apply_git_display_hunk_inner(
12791325
_ => unreachable!(),
12801326
};
12811327

1282-
let mut args = vec!["diff"];
1283-
if source == "staged" {
1284-
args.push("--cached");
1285-
}
1286-
args.push("--no-color");
1287-
args.push("-U0");
1288-
if ignore_whitespace_changes {
1289-
args.push("-w");
1290-
}
1291-
args.push("--");
1292-
args.push(action_path.as_str());
1293-
let source_patch = String::from_utf8_lossy(
1294-
&git_core::run_git_diff(&repo_root.to_path_buf(), &args).await?,
1328+
let source_patch = load_selection_source_patch(
1329+
&repo_root,
1330+
action_path.as_str(),
1331+
source,
1332+
ignore_whitespace_changes,
12951333
)
1296-
.to_string();
1334+
.await?;
12971335
if source_patch.trim().is_empty() {
12981336
return Err("No changes available for the requested display hunk.".to_string());
12991337
}
@@ -2030,4 +2068,100 @@ mod tests {
20302068

20312069
fs::remove_dir_all(&repo_root).expect("failed to cleanup temp repo");
20322070
}
2071+
2072+
#[test]
2073+
fn parse_zero_context_patch_keeps_no_newline_markers() {
2074+
let diff_patch = concat!(
2075+
"diff --git a/example.txt b/example.txt\n",
2076+
"index 1111111..2222222 100644\n",
2077+
"--- a/example.txt\n",
2078+
"+++ b/example.txt\n",
2079+
"@@ -1 +1 @@\n",
2080+
"-before\n",
2081+
"\\ No newline at end of file\n",
2082+
"+after\n",
2083+
"\\ No newline at end of file\n"
2084+
);
2085+
2086+
let parsed = parse_zero_context_patch(diff_patch).expect("parse source patch");
2087+
2088+
assert_eq!(parsed.hunks.len(), 1);
2089+
assert_eq!(parsed.hunks[0].lines.len(), 2);
2090+
assert!(parsed.hunks[0].lines[0].no_newline_after);
2091+
assert!(parsed.hunks[0].lines[1].no_newline_after);
2092+
}
2093+
2094+
#[test]
2095+
fn parse_zero_context_patch_keeps_content_lines_starting_with_patch_header_prefixes() {
2096+
let diff_patch = concat!(
2097+
"diff --git a/example.txt b/example.txt\n",
2098+
"index 1111111..2222222 100644\n",
2099+
"--- a/example.txt\n",
2100+
"+++ b/example.txt\n",
2101+
"@@ -1,2 +1,2 @@\n",
2102+
"----title\n",
2103+
"-plain\n",
2104+
"++++title\n",
2105+
"+plain updated\n"
2106+
);
2107+
2108+
let parsed = parse_zero_context_patch(diff_patch).expect("parse source patch");
2109+
let texts: Vec<&str> = parsed.hunks[0].lines.iter().map(|line| line.text.as_str()).collect();
2110+
2111+
assert_eq!(texts, vec!["---title", "plain", "+++title", "plain updated"]);
2112+
}
2113+
2114+
#[test]
2115+
fn build_selected_patch_preserves_no_newline_markers_for_apply() {
2116+
let repo_root = create_temp_repo();
2117+
let file_path = repo_root.join("example.txt");
2118+
2119+
fs::write(&file_path, "before").expect("write baseline");
2120+
run_git(&repo_root, &["add", "--", "example.txt"]);
2121+
run_git(&repo_root, &["commit", "-m", "Initial baseline", "--quiet"]);
2122+
2123+
fs::write(&file_path, "after").expect("write changed file");
2124+
2125+
let source_patch = run_git(&repo_root, &["diff", "--no-color", "-U0", "--", "example.txt"]);
2126+
let parsed = parse_zero_context_patch(&source_patch).expect("failed to parse source patch");
2127+
let selected_lines: HashSet<SelectionLineKey> = parsed.hunks[0]
2128+
.lines
2129+
.iter()
2130+
.map(|line| SelectionLineKey {
2131+
line_type: line.line_type,
2132+
old_line: line.old_line,
2133+
new_line: line.new_line,
2134+
text: line.text.clone(),
2135+
})
2136+
.collect();
2137+
2138+
let file_context = SelectionSourceFileContext {
2139+
old_lines: vec!["before".to_string()],
2140+
new_lines: vec!["after".to_string()],
2141+
};
2142+
let (selected_patch, _) = build_selected_patch(&source_patch, &selected_lines, &file_context)
2143+
.expect("selection patch failed");
2144+
2145+
assert!(
2146+
selected_patch.contains("\\ No newline at end of file"),
2147+
"selection patch should preserve no-newline marker: {selected_patch}"
2148+
);
2149+
2150+
run_git_with_stdin(
2151+
&repo_root,
2152+
&["apply", "--cached", "--unidiff-zero", "--whitespace=nowarn", "-"],
2153+
&selected_patch,
2154+
);
2155+
2156+
let cached_patch = run_git(
2157+
&repo_root,
2158+
&["diff", "--cached", "--no-color", "-U0", "--", "example.txt"],
2159+
);
2160+
assert!(
2161+
cached_patch.contains("+after"),
2162+
"cached patch did not stage newline-less change: {cached_patch}"
2163+
);
2164+
2165+
fs::remove_dir_all(&repo_root).expect("failed to cleanup temp repo");
2166+
}
20332167
}

src-tauri/src/shared/git_ui_core/tests.rs

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,80 @@ fn get_git_diffs_populates_untracked_file_unstaged_diff_and_display_hunks() {
290290
assert_eq!(diff.display_hunks[0].action, "stage");
291291
}
292292

293+
#[test]
294+
fn apply_git_display_hunk_stages_untracked_file_hunks() {
295+
let (root, repo) = create_temp_repo();
296+
let tracked_path = root.join("tracked.txt");
297+
fs::write(&tracked_path, "tracked\n").expect("write tracked file");
298+
let mut index = repo.index().expect("repo index");
299+
index.add_path(Path::new("tracked.txt")).expect("add tracked path");
300+
index.write().expect("write index");
301+
let tree_id = index.write_tree().expect("write tree");
302+
let tree = repo.find_tree(tree_id).expect("find tree");
303+
let sig = git2::Signature::now("Test", "test@example.com").expect("signature");
304+
repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[])
305+
.expect("commit");
306+
307+
fs::write(root.join("new-file.txt"), "first line\nsecond line\n").expect("write new file");
308+
309+
let workspace = WorkspaceEntry {
310+
id: "w1".to_string(),
311+
name: "w1".to_string(),
312+
path: root.to_string_lossy().to_string(),
313+
kind: WorkspaceKind::Main,
314+
parent_id: None,
315+
worktree: None,
316+
settings: WorkspaceSettings::default(),
317+
};
318+
let mut entries = HashMap::new();
319+
entries.insert("w1".to_string(), workspace);
320+
let workspaces = Mutex::new(entries);
321+
let app_settings = Mutex::new(AppSettings::default());
322+
323+
let runtime = Runtime::new().expect("create tokio runtime");
324+
let diffs = runtime
325+
.block_on(diff::get_git_diffs_inner(
326+
&workspaces,
327+
&app_settings,
328+
"w1".to_string(),
329+
))
330+
.expect("get git diffs");
331+
let display_hunk_id = diffs
332+
.iter()
333+
.find(|diff| diff.path == "new-file.txt")
334+
.and_then(|diff| diff.display_hunks.first())
335+
.map(|hunk| hunk.id.clone())
336+
.expect("find untracked display hunk");
337+
338+
let result = runtime
339+
.block_on(apply_git_display_hunk_core(
340+
&workspaces,
341+
&app_settings,
342+
"w1".to_string(),
343+
"new-file.txt".to_string(),
344+
display_hunk_id,
345+
))
346+
.expect("apply display hunk");
347+
348+
assert!(result.applied, "display hunk should be applied");
349+
350+
let cached = Command::new("git")
351+
.args(["diff", "--cached", "--no-color", "-U0", "--", "new-file.txt"])
352+
.current_dir(&root)
353+
.output()
354+
.expect("run cached diff");
355+
assert!(
356+
cached.status.success(),
357+
"cached diff failed: {}",
358+
String::from_utf8_lossy(&cached.stderr)
359+
);
360+
let cached_diff = String::from_utf8_lossy(&cached.stdout);
361+
assert!(
362+
cached_diff.contains("+first line\n+second line"),
363+
"expected untracked file additions to be staged, got: {cached_diff}"
364+
);
365+
}
366+
293367
#[test]
294368
fn check_ignore_with_git_respects_negated_rule_for_specific_file() {
295369
let (root, repo) = create_temp_repo();

0 commit comments

Comments
 (0)