Skip to content

Commit d5ea5ea

Browse files
committed
conflicts: allow missing newlines at end of file
A missing newline would cause the following conflict markers to become invalid when materializing the conflict, so we add a newline to prevent this from happening. When parsing, if we parsed a conflict at the end of the file which ended in a newline, we can check whether the file actually did end in a newline, and then remove the newline if it didn't.
1 parent 0dbcdf7 commit d5ea5ea

File tree

3 files changed

+324
-24
lines changed

3 files changed

+324
-24
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,10 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
103103
* `jj resolve` no longer removes the executable bit on resolved files when using
104104
an external merge tool.
105105

106+
* Conflicts at the end of files which don't end with a newline character are
107+
now materialized in a way that can be parsed correctly.
108+
[#3968](https://github.com/jj-vcs/jj/issues/3968)
109+
106110
## [0.24.0] - 2024-12-04
107111

108112
### Release highlights

lib/src/conflicts.rs

Lines changed: 86 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -58,24 +58,33 @@ pub const MIN_CONFLICT_MARKER_LEN: usize = 7;
5858
/// existing markers.
5959
const CONFLICT_MARKER_LEN_INCREMENT: usize = 4;
6060

61+
/// Marker for missing terminating newline in a term of a conflict.
62+
const NO_EOL_MARKER: &str = " [noeol]";
63+
64+
/// Marker for missing terminating newline in the "add" side of a diff.
65+
const ADD_NO_EOL_MARKER: &str = " [+noeol]";
66+
67+
/// Marker for missing terminating newline in the "remove" side of a diff.
68+
const REMOVE_NO_EOL_MARKER: &str = " [-noeol]";
69+
6170
fn write_diff_hunks(hunks: &[DiffHunk], file: &mut dyn Write) -> io::Result<()> {
6271
for hunk in hunks {
6372
match hunk.kind {
6473
DiffHunkKind::Matching => {
6574
debug_assert!(hunk.contents.iter().all_equal());
6675
for line in hunk.contents[0].lines_with_terminator() {
6776
file.write_all(b" ")?;
68-
file.write_all(line)?;
77+
write_and_ensure_newline(file, line)?;
6978
}
7079
}
7180
DiffHunkKind::Different => {
7281
for line in hunk.contents[0].lines_with_terminator() {
7382
file.write_all(b"-")?;
74-
file.write_all(line)?;
83+
write_and_ensure_newline(file, line)?;
7584
}
7685
for line in hunk.contents[1].lines_with_terminator() {
7786
file.write_all(b"+")?;
78-
file.write_all(line)?;
87+
write_and_ensure_newline(file, line)?;
7988
}
8089
}
8190
}
@@ -460,29 +469,35 @@ fn materialize_git_style_conflict(
460469
output,
461470
ConflictMarkerLineChar::ConflictStart,
462471
conflict_marker_len,
463-
&format!("Side #1 ({conflict_info})"),
472+
&format!("Side #1{} ({conflict_info})", maybe_no_eol_marker(left)),
464473
)?;
465-
output.write_all(left)?;
474+
write_and_ensure_newline(output, left)?;
475+
466476
write_conflict_marker(
467477
output,
468478
ConflictMarkerLineChar::GitAncestor,
469479
conflict_marker_len,
470-
"Base",
480+
&format!("Base{}", maybe_no_eol_marker(base)),
471481
)?;
472-
output.write_all(base)?;
482+
write_and_ensure_newline(output, base)?;
483+
473484
// VS Code doesn't seem to support any trailing text on the separator line
474485
write_conflict_marker(
475486
output,
476487
ConflictMarkerLineChar::GitSeparator,
477488
conflict_marker_len,
478489
"",
479490
)?;
480-
output.write_all(right)?;
491+
492+
write_and_ensure_newline(output, right)?;
481493
write_conflict_marker(
482494
output,
483495
ConflictMarkerLineChar::ConflictEnd,
484496
conflict_marker_len,
485-
&format!("Side #2 ({conflict_info} ends)"),
497+
&format!(
498+
"Side #2{} ({conflict_info} ends)",
499+
maybe_no_eol_marker(right)
500+
),
486501
)?;
487502

488503
Ok(())
@@ -501,9 +516,13 @@ fn materialize_jj_style_conflict(
501516
output,
502517
ConflictMarkerLineChar::Add,
503518
conflict_marker_len,
504-
&format!("Contents of side #{}", add_index + 1),
519+
&format!(
520+
"Contents of side #{}{}",
521+
add_index + 1,
522+
maybe_no_eol_marker(data)
523+
),
505524
)?;
506-
output.write_all(data)
525+
write_and_ensure_newline(output, data)
507526
};
508527

509528
// Write a negative snapshot (base) of a conflict
@@ -512,19 +531,34 @@ fn materialize_jj_style_conflict(
512531
output,
513532
ConflictMarkerLineChar::Remove,
514533
conflict_marker_len,
515-
&format!("Contents of {base_str}"),
534+
&format!("Contents of {base_str}{}", maybe_no_eol_marker(data)),
516535
)?;
517-
output.write_all(data)
536+
write_and_ensure_newline(output, data)
518537
};
519538

520539
// Write a diff from a negative term to a positive term
521540
let write_diff =
522541
|base_str: &str, add_index: usize, diff: &[DiffHunk], output: &mut dyn Write| {
542+
let no_eol_remove = diff
543+
.last()
544+
.is_some_and(|diff_hunk| has_no_eol(diff_hunk.contents[0]));
545+
let no_eol_add = diff
546+
.last()
547+
.is_some_and(|diff_hunk| has_no_eol(diff_hunk.contents[1]));
548+
let no_eol_marker = match (no_eol_remove, no_eol_add) {
549+
(true, true) => NO_EOL_MARKER,
550+
(true, _) => REMOVE_NO_EOL_MARKER,
551+
(_, true) => ADD_NO_EOL_MARKER,
552+
_ => "",
553+
};
523554
write_conflict_marker(
524555
output,
525556
ConflictMarkerLineChar::Diff,
526557
conflict_marker_len,
527-
&format!("Changes from {base_str} to side #{}", add_index + 1),
558+
&format!(
559+
"Changes from {base_str} to side #{}{no_eol_marker}",
560+
add_index + 1
561+
),
528562
)?;
529563
write_diff_hunks(diff, output)
530564
};
@@ -593,6 +627,29 @@ fn materialize_jj_style_conflict(
593627
Ok(())
594628
}
595629

630+
fn maybe_no_eol_marker(slice: &[u8]) -> &'static str {
631+
if has_no_eol(slice) {
632+
NO_EOL_MARKER
633+
} else {
634+
""
635+
}
636+
}
637+
638+
// Write a chunk of data, ensuring that it doesn't end with a line which is
639+
// missing its terminating newline.
640+
fn write_and_ensure_newline(output: &mut dyn Write, data: &[u8]) -> io::Result<()> {
641+
output.write_all(data)?;
642+
if has_no_eol(data) {
643+
writeln!(output)?;
644+
}
645+
Ok(())
646+
}
647+
648+
// Check whether a slice is missing its terminating newline character.
649+
fn has_no_eol(slice: &[u8]) -> bool {
650+
slice.last().is_some_and(|&last| last != b'\n')
651+
}
652+
596653
fn diff_size(hunks: &[DiffHunk]) -> usize {
597654
hunks
598655
.iter()
@@ -873,7 +930,7 @@ pub async fn update_from_content(
873930
// conflicts initially. If unsuccessful, attempt to parse conflicts from with
874931
// the arity of the unsimplified conflicts since such a conflict may be
875932
// present in the working copy if written by an earlier version of jj.
876-
let (used_file_ids, hunks) = 'hunks: {
933+
let (used_file_ids, mut hunks) = 'hunks: {
877934
if let Some(hunks) = parse_conflict(
878935
content,
879936
simplified_file_ids.num_sides(),
@@ -892,6 +949,20 @@ pub async fn update_from_content(
892949
return Ok(Merge::normal(file_id));
893950
};
894951

952+
// If there is a conflict at the end of the file and a term ends with a newline,
953+
// check whether the original term ended with a newline. If it didn't, then
954+
// remove the newline since it was added automatically when materializing.
955+
if let Some(last_hunk) = hunks.last_mut().filter(|hunk| !hunk.is_resolved()) {
956+
for (used_file_id, term) in zip(used_file_ids.iter(), last_hunk.iter_mut()) {
957+
if term.last() == Some(&b'\n') {
958+
let used_file_content = get_file_contents(store, path, used_file_id).block_on()?;
959+
if has_no_eol(&used_file_content) {
960+
term.pop();
961+
}
962+
}
963+
}
964+
}
965+
895966
let mut contents = used_file_ids.map(|_| vec![]);
896967
for hunk in hunks {
897968
if let Some(slice) = hunk.as_resolved() {

0 commit comments

Comments
 (0)