From 4146844401a4ee6aa9b9288b9934365f958aa93c Mon Sep 17 00:00:00 2001 From: stk Date: Sat, 20 Jun 2026 11:42:32 +0200 Subject: [PATCH 1/3] Emit per-line diff metadata as an OSC sequence for host applications A host that renders diff-so-fancy's output (e.g. lazygit) needs to map a rendered diff row back to its patch-space identity -- file, line type, and new/old line numbers -- to act on the line the user points at. diff-so-fancy strips the +/- markers and conveys the side by color, so that identity cannot be recovered from the painted text; the pager, which still has it at render time, has to state it. Gate the emission on the OSC1717 environment variable so output is byte-for-byte unchanged outside such a host -- harmless in a raw terminal, less, or tmux. The host advertises the protocol versions it understands and diff-so-fancy emits the highest mutually-understood one (just V1 today). See the protocol in diff-line-metadata-osc-spec.md; this is the third reference implementation after delta and difftastic. The emitter tracks its own old/new line counters, seeded from each hunk header, and classifies every content line by its leading +/- indicator -- read before the existing code strips that indicator and rewrites the line. sanitize_display strips OSC sequences, so the record is prepended to the already-sanitized line rather than embedded in it. The path prefers the new-file side, falling back to the old side for a deletion (whose new side is /dev/null), which also recovers the path for a noprefix deletion that never reaches $last_file_seen. Co-Authored-By: Claude Opus 4.8 (1M context) --- diff-so-fancy | 142 +++++++++++++++++++++++++++++++++++++++++ test/osc-metadata.bats | 98 ++++++++++++++++++++++++++++ 2 files changed, 240 insertions(+) create mode 100644 test/osc-metadata.bats diff --git a/diff-so-fancy b/diff-so-fancy index 329c68e..f5958ed 100755 --- a/diff-so-fancy +++ b/diff-so-fancy @@ -36,6 +36,7 @@ my $ruler_width = git_config("diff-so-fancy.rulerWidth", undef); my $git_strip_prefix = git_config_boolean("diff.noprefix","false"); my $do_sem_stuff = git_config_boolean("diff-so-fancy.semIntegration","false"); my $has_stdin = has_stdin(); +my $osc_metadata_version = negotiated_osc_version(); my $ansi_regex = qr/\e\[([0-9]{0,3}(;[0-9]{1,3}){0,10})[mK]/; my $ansi_color_regex = qr/(${ansi_regex})?/; @@ -57,6 +58,20 @@ my $i = 0; my $in_hunk = 0; my $columns_to_remove = 0; my $is_mercurial = 0; +# OSC 1717 state: the new-/old-file line counters and the file path, all seeded +# at each hunk header (osc_seed_hunk) and advanced per content line +# (osc_for_content_line), plus a flag for hunks we don't annotate. The +# remaining counters hold how many old-/new-file lines of the hunk are still +# to come (from the @@ counts, which are exact); they bound the records to the +# hunk, so that later lines that merely look like content -- submodule log +# lines, diffstats and indented commit messages in `git log -p` output -- are +# not tagged with the last hunk's (by then stale) identity. +my $osc_old_line = 0; +my $osc_new_line = 0; +my $osc_old_remaining = 0; +my $osc_new_remaining = 0; +my $osc_file = ""; +my $osc_skip_hunk = 1; if ($args->{rulerWidth}) { $ruler_width = int($args->{rulerWidth}); @@ -372,10 +387,12 @@ sub do_dsf_stuff { ######################################## } elsif (!$change_hunk_indicators && $line =~ /^${ansi_color_regex}(@@@* .+? @@@*)(.*)/) { $in_hunk = 1; + osc_seed_hunk($line); print $line; } elsif ($change_hunk_indicators && $line =~ /^${ansi_color_regex}(@@@* .+? @@@*)(.*)/) { $in_hunk = 1; + osc_seed_hunk($line); my $frag_color = $1; my $hunk_header = $4; @@ -495,6 +512,9 @@ sub do_dsf_stuff { # Just a regular line, print it out # ##################################### } else { + # Save the line before we muck around with it + my $orig = $line; + # Mark empty line with a red/green box indicating addition/removal if ($mark_empty_lines) { $line = mark_empty_line($line); @@ -516,6 +536,12 @@ sub do_dsf_stuff { } } + # Classify the line for OSC 1717 metadata while its leading +/- + # indicator is still present, before we rewrite the line below. + if ($osc_metadata_version) { + print osc_for_content_line($orig); + } + print sanitize_display($line); } @@ -536,6 +562,122 @@ sub parse_hunk_header { return ($o_ofs, $o_cnt, $n_ofs, $n_cnt); } +# Negotiate the OSC 1717 diff-line-metadata protocol version against the host's +# advertised list in OSC1717 (e.g. "V1" or "V1,V2"). Returns the highest version +# we both understand, or undef when no host is asking (the variable is unset), +# in which case nothing is emitted and the output is byte-for-byte unchanged. +# See diff-line-metadata-osc-spec.md for the protocol. +sub negotiated_osc_version { + my $supported = 1; # Highest version this build knows how to emit + my $advertised = $ENV{OSC1717} || ""; + + if (!$advertised) { + return undef; + } + + # Loop through all the OSC params looking for 'V1' and pull out + # the highest number + my $best = undef; + foreach my $v (split(/,/, $advertised)) { + $v = trim($v); + if ($v !~ /^V(\d+)$/) { next; } + + my $num = int($1); + if ($num > $supported) { next; } + + if (!defined($best) || $num > $best) { + $best = $num; + } + } + + return $best; +} + +# Seed the OSC 1717 line counters at a hunk header. Combined/merge diffs (@@@) +# carry multiple old-file sides and a different line-number model, so we don't +# annotate them (matching the delta/difftastic prototypes); the same goes for an +# unparseable header. +sub osc_seed_hunk { + my ($line) = @_; + + if (!$osc_metadata_version) { + return undef; + } + + my $bleached = bleach_text($line); + if ($bleached =~ /^@@@/) { + $osc_skip_hunk = 1; + return undef; + } + + my ($o_ofs, $o_cnt, $n_ofs, $n_cnt) = parse_hunk_header($bleached); + if (!defined($o_ofs) || !defined($n_ofs)) { + $osc_skip_hunk = 1; + return undef; + } + + # The record carries the new-file path, falling back to the old path for a + # deletion (where the new side is /dev/null) -- the same preference the host + # applies. $file_1/$file_2 are this file's old/new paths from its ---/+++ + # lines; preferring them over $last_file_seen keeps the path for a noprefix + # deletion, which never reaches $last_file_seen. + my $path = $file_1; + if (defined($file_2) && $file_2 ne "" && $file_2 ne "/dev/null") { + $path = $file_2; + } + + $osc_old_line = $o_ofs; + $osc_new_line = $n_ofs; + $osc_old_remaining = $o_cnt; + $osc_new_remaining = $n_cnt; + $osc_file = sanitize_display(defined($path) ? $path : ""); + $osc_skip_hunk = 0; +} + +# Build the OSC 1717 record for a content line and advance the line counters. +# Returns the escape sequence to print before the line, or "" for non-content +# lines (e.g. "\ No newline at end of file") and un-annotated hunks. Must be +# called before the leading +/- indicator is stripped, as that is how the line +# is classified; the indicator survives DiffHighlight, after any leading ANSI. +sub osc_for_content_line { + my ($line) = @_; + + if (!$osc_metadata_version || $osc_skip_hunk) { return ''; } + if ($line !~ /^${ansi_color_regex}([ +-])/) { return ''; } + my $indicator = $4; + + # The hunk's @@ counts are exact, so a line whose side(s) the hunk has + # already used up is not part of the hunk -- it only looks like content + # (a " > subject" submodule log line, a diffstat or an indented commit + # message in `git log -p` output). Such a line carries no record; tagging + # it would name the last hunk's file and line numbers, which it has + # nothing to do with. + my ($type, $new_line, $old_field); + if ($indicator eq "+") { + # Addition: advances the new-file side only. + if ($osc_new_remaining <= 0) { return ''; } + $osc_new_remaining--; + ($type, $new_line, $old_field) = ("a", $osc_new_line++, ""); + } elsif ($indicator eq "-") { + # Deletion: sits at the current new-file position (which does not + # advance) and carries its own old-file line; advances the old side. + if ($osc_old_remaining <= 0) { return ''; } + $osc_old_remaining--; + ($type, $new_line, $old_field) = ("d", $osc_new_line, $osc_old_line++); + } else { + # Context: advances both sides. + if ($osc_old_remaining <= 0 || $osc_new_remaining <= 0) { return ''; } + $osc_old_remaining--; + $osc_new_remaining--; + ($type, $new_line, $old_field) = ("c", $osc_new_line++, ""); + $osc_old_line++; + } + + my $ret = "\e]1717;${osc_metadata_version};${type};${new_line};${old_field};${osc_file}\e\\"; + + return $ret; +} + # Mark the first char of an empty line sub mark_empty_line { my $line = shift(); diff --git a/test/osc-metadata.bats b/test/osc-metadata.bats new file mode 100644 index 0000000..3d76182 --- /dev/null +++ b/test/osc-metadata.bats @@ -0,0 +1,98 @@ +#!/usr/bin/env bats + +# Tests for the OSC 1717 diff-line-metadata protocol (see +# diff-line-metadata-osc-spec.md). diff-so-fancy strips the +/- markers and +# conveys the side by color, so a host that renders its output cannot recover a +# row's patch identity by parsing it -- the pager states it inline instead. The +# protocol is gated on the OSC1717 handshake and is strictly additive: with the +# variable unset, output is byte-for-byte unchanged. + +__load_imports__() { + load 'test_helper/bats-support/load' + load 'test_helper/bats-assert/load' + load 'test_helper/util' +} + +setup() { + __load_imports__ + set_env + setup_default_dsf_git_config +} + +teardown() { + teardown_default_dsf_git_config +} + +# Extract the OSC 1717 payloads (everything between "ESC ] 1717 ;" and the +# string terminator) from stdin, one record per line, for line-wise assertions. +osc_records() { + perl -ne 'while (/\e\]1717;([^\e\a]*)(?:\e\\|\a)/g) { print "$1\n"; }' +} + +# Render a fixture with the host handshake set to V1, returning just the records. +records_for() { + load_fixture "$1" | OSC1717=V1 "$diff_so_fancy" | osc_records +} + +@test "no metadata is emitted without the handshake" { + output=$( load_fixture "ls-function" | $diff_so_fancy | osc_records ) + assert_output "" +} + +@test "the handshake negotiates the protocol version" { + # A version we don't emit -> silence (the advertised set is disjoint). + output=$( load_fixture "add_file_with_content" | OSC1717=V2 "$diff_so_fancy" | osc_records ) + assert_output "" + + # Junk -> silence. + output=$( load_fixture "add_file_with_content" | OSC1717=nonsense "$diff_so_fancy" | osc_records ) + assert_output "" + + # A list that includes V1 -> V1 records (we emit the highest we both know). + output=$( load_fixture "add_file_with_content" | OSC1717=V0,V1,V2 "$diff_so_fancy" | osc_records ) + run printf "%s" "$output" + assert_line --index 0 "1;a;1;;newfile.txt" +} + +@test "added lines carry the new-file line and an empty old-file field" { + output=$( records_for "add_file_with_content" ) + run printf "%s" "$output" + assert_line --index 0 "1;a;1;;newfile.txt" + assert_line --index 1 "1;a;2;;newfile.txt" + assert_line --index 2 "1;a;3;;newfile.txt" +} + +@test "deleted lines carry both line numbers; a whole-file delete sits at new-line 0" { + output=$( records_for "delete_file_with_content" ) + run printf "%s" "$output" + assert_line --index 0 "1;d;0;1;oldfile.txt" + assert_line --index 1 "1;d;0;2;oldfile.txt" + assert_line --index 2 "1;d;0;3;oldfile.txt" +} + +@test "the path falls back to the old side for a noprefix deletion" { + # This fixture's "diff --git" line has no a/ b/ prefix and the +++ side is + # /dev/null, so the path is recoverable only from the --- (old) side. + output=$( records_for "single-line-remove" ) + assert_output "1;d;0;1;test/data/readywaitasset.js" +} + +@test "context, deletion and addition interleave; the no-newline marker is skipped" { + # one/two are context; "three" is modified (delete then add at new-line 3). + # The trailing "\ No newline at end of file" carries no record and does not + # advance the counters. + output=$( records_for "remove_slashn_eof" ) + run printf "%s" "$output" + assert_line --index 0 "1;c;1;;test.txt" + assert_line --index 1 "1;c;2;;test.txt" + assert_line --index 2 "1;d;3;3;test.txt" + assert_line --index 3 "1;a;3;;test.txt" + refute_line "1;c;4;;test.txt" +} + +@test "combined (merge) diffs are not annotated" { + # A combined diff (@@@ ...) has multiple old-file sides and a different + # line-number model, so no records are emitted for it. + output=$( records_for "complex-hunks" ) + assert_output "" +} From 2ce3adc6ecf614f4ddcb180a7c3c57a03b854d2d Mon Sep 17 00:00:00 2001 From: stk Date: Sat, 20 Jun 2026 14:57:55 +0200 Subject: [PATCH 2/3] Emit a version-only metadata handshake as the first output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A host can already learn diff-so-fancy's per-line diff metadata, but only from the records emitted before content lines — so a diff with no content (a binary file, or the empty diff a host would use to probe) emits nothing, and "speaks the protocol" is indistinguishable from "unsupported pager". Emit a version-only OSC 1717 record (no further fields) once, before processing, whenever a version is negotiated. It's content-independent, so a host can probe diff-so-fancy on an empty diff and get a conclusive answer. The osc_records test helper now skips this handshake record so the per-line assertions are unchanged; two new tests cover the handshake (first, and present on an empty diff). See diff-line-metadata-osc-spec.md §4.4. Co-Authored-By: Claude Opus 4.8 (1M context) --- diff-so-fancy | 8 ++++++++ test/osc-metadata.bats | 24 +++++++++++++++++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/diff-so-fancy b/diff-so-fancy index f5958ed..1590c92 100755 --- a/diff-so-fancy +++ b/diff-so-fancy @@ -129,6 +129,14 @@ if ($args->{debug}) { debug_log("Ruler width : $ruler_width"); } +# Announce protocol support as the first output: a version-only OSC 1717 record +# (the handshake). It lets a host probe diff-so-fancy on an empty diff — which emits +# no per-line records — and tell "speaks the protocol" apart from "unsupported +# pager". See diff-line-metadata-osc-spec.md §4.4. +if ($osc_metadata_version) { + print "\e]1717;${osc_metadata_version}\e\\"; +} + my @lines; local $DiffHighlight::line_cb = sub { push(@lines,@_); diff --git a/test/osc-metadata.bats b/test/osc-metadata.bats index 3d76182..f3c5a8b 100644 --- a/test/osc-metadata.bats +++ b/test/osc-metadata.bats @@ -25,10 +25,16 @@ teardown() { # Extract the OSC 1717 payloads (everything between "ESC ] 1717 ;" and the # string terminator) from stdin, one record per line, for line-wise assertions. -osc_records() { +all_osc_records() { perl -ne 'while (/\e\]1717;([^\e\a]*)(?:\e\\|\a)/g) { print "$1\n"; }' } +# As all_osc_records, but skips the version-only handshake record (no fields; see +# the dedicated handshake tests), so per-line assertions stay focused on content. +osc_records() { + all_osc_records | perl -ne 'print if /;/;' +} + # Render a fixture with the host handshake set to V1, returning just the records. records_for() { load_fixture "$1" | OSC1717=V1 "$diff_so_fancy" | osc_records @@ -54,6 +60,22 @@ records_for() { assert_line --index 0 "1;a;1;;newfile.txt" } +@test "a version-only handshake is emitted first, before any per-line record" { + # The handshake (just the version, no further fields) announces protocol support; + # it precedes the per-line records so a host sees it up front. + output=$( load_fixture "add_file_with_content" | OSC1717=V1 "$diff_so_fancy" | all_osc_records ) + run printf "%s" "$output" + assert_line --index 0 "1" + assert_line --index 1 "1;a;1;;newfile.txt" +} + +@test "the handshake is emitted even for an empty diff, so a host can probe" { + # An empty diff has no content lines and so no per-line records; the handshake is + # still emitted, letting a host probe diff-so-fancy with empty input. + output=$( printf "" | OSC1717=V1 "$diff_so_fancy" | all_osc_records ) + assert_output "1" +} + @test "added lines carry the new-file line and an empty old-file field" { output=$( records_for "add_file_with_content" ) run printf "%s" "$output" From 968fc01aadfae05fb66b9e7688710dfa4e422602 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 16 Jul 2026 15:41:51 +0200 Subject: [PATCH 3/3] Emit `f` and `h` records on file and hunk header rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Annotate the structural rows too, so a host can anchor file and hunk navigation on the headers themselves and can see files that emit no content records at all: every row of a ruled file-header block carries the file's `f` record (spec §6.4), and the row announcing a hunk carries that hunk's `h` record with the hunk's first new-file line -- straight from the freshly seeded counters, so it is the @@ new start, not the first changed line the rendered header displays. `f` never carries line numbers (the spec fixes each header type's payload; a streaming renderer does not know the first hunk's line when it draws the file header). That is what makes headers available for files with no content lines, which now stay visible to the host: - a pure rename or copy tags its ruled block, - a binary file tags its "(binary)" block, - a mode-only change tags its single announcement row -- gated on a lookahead so that a mode change on a file that also has content does not produce a second, differing header block (the ruled header that follows is that file's header), - a combined/merge diff still has no annotated content (its multiple old sides do not fit the line-number model), but its file header now carries `f`, keeping conflicted files reachable. With shortHeaders on, no hunk rows are rendered at all, so there is no row for an `h` to tag; hosts fall back to content records. With OSC1717 unset, output remains byte-for-byte identical. Co-Authored-By: Claude Opus 4.8 (1M context) --- diff-so-fancy | 85 ++++++++++++++++++++++++----- test/fixtures/log-with-stat.diff | 22 ++++++++ test/fixtures/submodule-log.diff | 10 ++++ test/osc-metadata.bats | 94 +++++++++++++++++++++++++++++--- 4 files changed, 187 insertions(+), 24 deletions(-) create mode 100644 test/fixtures/log-with-stat.diff create mode 100644 test/fixtures/submodule-log.diff diff --git a/diff-so-fancy b/diff-so-fancy index 1590c92..d27b489 100755 --- a/diff-so-fancy +++ b/diff-so-fancy @@ -271,7 +271,7 @@ sub do_dsf_stuff { if ($file_1 && $file_2) { my $str = $meta_color . file_change_string($file_1,$file_2) . "\n"; - draw_ruler($str, $ruler_shape, $meta_color); + draw_ruler($str, $ruler_shape, $meta_color, osc_file_header_record($file_1, $file_2)); } ######################### # Look for the filename # @@ -389,7 +389,7 @@ sub do_dsf_stuff { } } - draw_ruler($str, $ruler_shape, $meta_color); + draw_ruler($str, $ruler_shape, $meta_color, osc_file_header_record($file_1, $file_2)); ######################################## # Check for "@@ -3,41 +3,63 @@" syntax # ######################################## @@ -397,7 +397,7 @@ sub do_dsf_stuff { $in_hunk = 1; osc_seed_hunk($line); - print $line; + print osc_hunk_header_record() . $line; } elsif ($change_hunk_indicators && $line =~ /^${ansi_color_regex}(@@@* .+? @@@*)(.*)/) { $in_hunk = 1; osc_seed_hunk($line); @@ -431,7 +431,10 @@ sub do_dsf_stuff { } if (!$short_headers) { - my $str = "$frag_color@ $last_file_seen:$start_line \@${reset_color}${last_function_color}${remain}${reset_color}\n"; + # The row announcing the hunk carries the hunk's `h` record. + # (With shortHeaders on, no hunk rows are rendered at all, so + # there is nothing to tag.) + my $str = osc_hunk_header_record() . "$frag_color@ $last_file_seen:$start_line \@${reset_color}${last_function_color}${remain}${reset_color}\n"; print $str; } @@ -457,10 +460,11 @@ sub do_dsf_stuff { # Look for binary file changes # ################################ } elsif ($line =~ /^Binary files (\w\/)?(.+?) and (\w\/)?(.+?) differ/) { - my $change = file_change_string($2,$4); + my ($old_file, $new_file) = ($2, $4); + my $change = file_change_string($old_file,$new_file); my $str = "$meta_color$change (binary)\n"; - draw_ruler($str, $ruler_shape, $meta_color); + draw_ruler($str, $ruler_shape, $meta_color, osc_file_header_record($old_file, $new_file)); ##################################################### # Check if we're changing the permissions of a file # ##################################################### @@ -477,7 +481,18 @@ sub do_dsf_stuff { if ($patch_mode) { print "\n"; } - print "$last_file_seen changed file mode from $old_mode to $new_mode\n"; + + # For a mode-only change this line is the only row announcing the + # file, so it carries the file's `f` record -- that keeps files with + # no content lines visible to a host (spec §5.5). When the file also + # has content (an index/similarity line follows), the ruled file + # header drawn later is the file's header instead. + my $osc = ""; + my $peek = $input->[0] || ""; + if ($peek !~ /^${ansi_color_regex}(index |similarity index )/) { + $osc = osc_file_header_record($last_file_seen, $last_file_seen); + } + print $osc . "$last_file_seen changed file mode from $old_mode to $new_mode\n"; ############### # File rename # @@ -511,7 +526,7 @@ sub do_dsf_stuff { my $str = $meta_color . $change . "\n"; - draw_ruler($str, $ruler_shape, $meta_color); + draw_ruler($str, $ruler_shape, $meta_color, osc_file_header_record($file1, $file2)); } $i += 3; # We've consumed three lines @@ -686,6 +701,37 @@ sub osc_for_content_line { return $ret; } +# Build the OSC 1717 `f` (file-header) record for a file-header block, or "" +# when no host negotiated the protocol. The path prefers the new-file side, +# falling back to the old side for a deletion -- the same preference +# osc_seed_hunk applies, so the header groups with its file's content records. +# An `f` record never carries line numbers (spec §5.5): a file header needs +# none, and a streaming renderer doesn't know the first hunk's line yet when +# it draws the header. +sub osc_file_header_record { + my ($old_path, $new_path) = @_; + + if (!$osc_metadata_version) { return ''; } + + my $path = $old_path; + if (defined($new_path) && $new_path ne "" && $new_path ne "/dev/null") { + $path = $new_path; + } + $path = sanitize_display(defined($path) ? $path : ""); + + return "\e]1717;${osc_metadata_version};f;;;${path}\e\\"; +} + +# Build the OSC 1717 `h` (hunk-header) record for the hunk osc_seed_hunk has +# just seeded, or "" for un-annotated hunks. Its new-line is the hunk's first +# new-file line (spec §5.2), which is exactly what the freshly seeded counter +# holds. Must be called after osc_seed_hunk and before any content line. +sub osc_hunk_header_record { + if (!$osc_metadata_version || $osc_skip_hunk) { return ''; } + + return "\e]1717;${osc_metadata_version};h;${osc_new_line};;${osc_file}\e\\"; +} + # Mark the first char of an empty line sub mark_empty_line { my $line = shift(); @@ -1607,11 +1653,14 @@ sub get_sem_info { } sub draw_ruler { - my ($str, $type, $color) = @_; + my ($str, $type, $color, $osc) = @_; $str = trim($str); $type ||= 'ruler'; $color ||= ""; + # An OSC 1717 record to attach to the block: every row of a multi-row + # header carries the same record, like a wrapped content line (spec §6.4). + $osc ||= ""; my @lines = split(/\n/, $str); my $max = 0; @@ -1643,20 +1692,26 @@ sub draw_ruler { my $width = get_terminal_width(); my $line = $color . $dash x $width . $reset_color; - print $line . "\n"; - print $color . $str . $reset_color . "\n"; - print $line . "\n"; + # $str may span several rows (e.g. semantic info below the file name); + # each gets the record. + if ($osc) { + $str =~ s/\n/\n$osc/g; + } + + print $osc . $line . "\n"; + print $osc . $color . $str . $reset_color . "\n"; + print $osc . $line . "\n"; } elsif ($type eq 'box') { my $len = $max; - print $color . ($dash x $len) . $top_r . $reset_color . "\n"; + print $osc . $color . ($dash x $len) . $top_r . $reset_color . "\n"; foreach my $line (@lines) { my $padding = $len - length(bleach_text($line)); $line .= " " x $padding; - print $color . $line . $color . $right . $reset_color . "\n"; + print $osc . $color . $line . $color . $right . $reset_color . "\n"; } - print $color . ($dash x $len) . $bot_r . $reset_color . "\n"; + print $osc . $color . ($dash x $len) . $bot_r . $reset_color . "\n"; } } diff --git a/test/fixtures/log-with-stat.diff b/test/fixtures/log-with-stat.diff new file mode 100644 index 0000000..d591452 --- /dev/null +++ b/test/fixtures/log-with-stat.diff @@ -0,0 +1,22 @@ +commit aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +Author: A U Thor +Date: Mon Aug 3 09:00:00 2026 +0200 + + first commit + +diff --git a/one.txt b/one.txt +index 257cc56..5716ca5 100644 +--- a/one.txt ++++ b/one.txt +@@ -1 +1 @@ +-foo ++bar + +commit bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb +Author: A U Thor +Date: Mon Aug 3 10:00:00 2026 +0200 + + second commit + + two.txt | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/fixtures/submodule-log.diff b/test/fixtures/submodule-log.diff new file mode 100644 index 0000000..38a9b57 --- /dev/null +++ b/test/fixtures/submodule-log.diff @@ -0,0 +1,10 @@ +diff --git a/one.txt b/one.txt +index 257cc56..5716ca5 100644 +--- a/one.txt ++++ b/one.txt +@@ -1 +1 @@ +-foo ++bar +Submodule sub/mod 1234567..89abcde: + > Add a feature + > Fix a bug diff --git a/test/osc-metadata.bats b/test/osc-metadata.bats index f3c5a8b..8f7cbc9 100644 --- a/test/osc-metadata.bats +++ b/test/osc-metadata.bats @@ -40,6 +40,13 @@ records_for() { load_fixture "$1" | OSC1717=V1 "$diff_so_fancy" | osc_records } +# As records_for, but only the content-line records (c/a/d), skipping the f/h +# header records, so tests about content-line semantics stay independent of how +# many rows the rendered header blocks span. +content_records_for() { + records_for "$1" | perl -ne 'print if /^\d+;[cad];/;' +} + @test "no metadata is emitted without the handshake" { output=$( load_fixture "ls-function" | $diff_so_fancy | osc_records ) assert_output "" @@ -57,7 +64,7 @@ records_for() { # A list that includes V1 -> V1 records (we emit the highest we both know). output=$( load_fixture "add_file_with_content" | OSC1717=V0,V1,V2 "$diff_so_fancy" | osc_records ) run printf "%s" "$output" - assert_line --index 0 "1;a;1;;newfile.txt" + assert_line --index 0 "1;f;;;newfile.txt" } @test "a version-only handshake is emitted first, before any per-line record" { @@ -66,7 +73,7 @@ records_for() { output=$( load_fixture "add_file_with_content" | OSC1717=V1 "$diff_so_fancy" | all_osc_records ) run printf "%s" "$output" assert_line --index 0 "1" - assert_line --index 1 "1;a;1;;newfile.txt" + assert_line --index 1 "1;f;;;newfile.txt" } @test "the handshake is emitted even for an empty diff, so a host can probe" { @@ -77,7 +84,7 @@ records_for() { } @test "added lines carry the new-file line and an empty old-file field" { - output=$( records_for "add_file_with_content" ) + output=$( content_records_for "add_file_with_content" ) run printf "%s" "$output" assert_line --index 0 "1;a;1;;newfile.txt" assert_line --index 1 "1;a;2;;newfile.txt" @@ -85,7 +92,7 @@ records_for() { } @test "deleted lines carry both line numbers; a whole-file delete sits at new-line 0" { - output=$( records_for "delete_file_with_content" ) + output=$( content_records_for "delete_file_with_content" ) run printf "%s" "$output" assert_line --index 0 "1;d;0;1;oldfile.txt" assert_line --index 1 "1;d;0;2;oldfile.txt" @@ -95,7 +102,7 @@ records_for() { @test "the path falls back to the old side for a noprefix deletion" { # This fixture's "diff --git" line has no a/ b/ prefix and the +++ side is # /dev/null, so the path is recoverable only from the --- (old) side. - output=$( records_for "single-line-remove" ) + output=$( content_records_for "single-line-remove" ) assert_output "1;d;0;1;test/data/readywaitasset.js" } @@ -103,7 +110,7 @@ records_for() { # one/two are context; "three" is modified (delete then add at new-line 3). # The trailing "\ No newline at end of file" carries no record and does not # advance the counters. - output=$( records_for "remove_slashn_eof" ) + output=$( content_records_for "remove_slashn_eof" ) run printf "%s" "$output" assert_line --index 0 "1;c;1;;test.txt" assert_line --index 1 "1;c;2;;test.txt" @@ -112,9 +119,78 @@ records_for() { refute_line "1;c;4;;test.txt" } -@test "combined (merge) diffs are not annotated" { +@test "combined (merge) diffs carry only their file header's f record" { # A combined diff (@@@ ...) has multiple old-file sides and a different - # line-number model, so no records are emitted for it. + # line-number model, so its hunks and content lines are not annotated. The + # file header still carries its `f` record (one per row of the ruled + # block), so the file stays visible to a host's file list/navigation. output=$( records_for "complex-hunks" ) - assert_output "" + run printf "%s" "$output" + assert_output "1;f;;;libs/header_clean/header_clean.pl +1;f;;;libs/header_clean/header_clean.pl +1;f;;;libs/header_clean/header_clean.pl" +} + +@test "file headers carry f records on every row; hunk headers carry h with the hunk's first line" { + # file-rename.diff renames Changes.new (no content change) and then + # modifies dist.ini in two hunks (@@ -1,4 +1,4 @@ and @@ -9,6 +9,7 @@). + output=$( records_for "file-rename" ) + run printf "%s" "$output" + + # The pure rename emits only its `f` records -- one per row of the ruled + # header block, no line numbers (spec §5.5) -- keeping a file with no + # content lines visible to the host. + assert_line --index 0 "1;f;;;bin/Changes.new" + assert_line --index 1 "1;f;;;bin/Changes.new" + assert_line --index 2 "1;f;;;bin/Changes.new" + + # The modified file: its own `f` block, then each hunk's row carries `h` + # with the hunk's first new-file line (the @@ new start, not the first + # changed line the rendered header displays). + assert_line --index 3 "1;f;;;dist.ini" + assert_line --index 6 "1;h;1;;dist.ini" + assert_line --index 7 "1;d;1;1;dist.ini" + assert_line "1;h;9;;dist.ini" +} + +@test "a mode-only change carries an f record on its single announcement row" { + # circle.yml only changes its mode; "circle.yml changed file mode ..." is + # the only row announcing it, so that row carries the `f`. foo.json is a + # regular added file whose header follows as its own f block. + output=$( records_for "file-perms" ) + run printf "%s" "$output" + assert_line --index 0 "1;f;;;circle.yml" + assert_line --index 1 "1;f;;;foo.json" + assert_line --index 4 "1;h;1;;foo.json" + assert_line --index 5 "1;a;1;;foo.json" +} + +@test "binary files carry an f record" { + # A binary file emits no content records; its `f` keeps it visible. + output=$( records_for "binary-modified" ) + run printf "%s" "$output" + assert_line --index 0 "1;f;;;cancel.png" +} + +@test "content records stop at the hunk's counted extent" { + # The @@ counts are exact, so the records must end with the hunk. The + # submodule log lines that follow (" > subject") begin with a space and + # would otherwise be classified as context lines carrying the previous + # hunk's (stale) file and line numbers. + output=$( content_records_for "submodule-log" ) + run printf "%s" "$output" + assert_line --index 0 "1;d;1;1;one.txt" + assert_line --index 1 "1;a;1;;one.txt" + refute_line --partial ";c;" +} + +@test "diffstat and commit-message lines after a hunk carry no records" { + # In `git log -p` output, the next commit's indented message body and its + # diffstat lines also begin with a space; none of them are content lines + # of the preceding hunk. + output=$( content_records_for "log-with-stat" ) + run printf "%s" "$output" + assert_line --index 0 "1;d;1;1;one.txt" + assert_line --index 1 "1;a;1;;one.txt" + refute_line --partial ";c;" }