Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
235 changes: 220 additions & 15 deletions diff-so-fancy
Original file line number Diff line number Diff line change
Expand Up @@ -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})?/;
Expand All @@ -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});
Expand Down Expand Up @@ -114,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,@_);
Expand Down Expand Up @@ -248,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 #
Expand Down Expand Up @@ -366,16 +389,18 @@ 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 #
########################################
} elsif (!$change_hunk_indicators && $line =~ /^${ansi_color_regex}(@@@* .+? @@@*)(.*)/) {
$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);

my $frag_color = $1;
my $hunk_header = $4;
Expand Down Expand Up @@ -406,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;
}
Expand All @@ -432,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 #
#####################################################
Expand All @@ -452,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 #
Expand Down Expand Up @@ -486,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
Expand All @@ -495,6 +535,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);
Expand All @@ -516,6 +559,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);
}

Expand All @@ -536,6 +585,153 @@ 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;
}

# 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();
Expand Down Expand Up @@ -1457,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;
Expand Down Expand Up @@ -1493,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";
}
}

Expand Down
22 changes: 22 additions & 0 deletions test/fixtures/log-with-stat.diff
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
commit aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
Author: A U Thor <author@example.com>
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 <author@example.com>
Date: Mon Aug 3 10:00:00 2026 +0200

second commit

two.txt | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
10 changes: 10 additions & 0 deletions test/fixtures/submodule-log.diff
Original file line number Diff line number Diff line change
@@ -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
Loading