Subject: [PATCH] git-gui: Implement "Stage/Unstage Line"

From: Johannes Sixt <[email-masked]>
Date: Fri, 27 Jun 2008 09:22:01 +0200

From: Johannes Sixt <[email-masked]>

This adds a context menu entry below "Stage/Unstage Hunk" that stages or
unstages just the line under the mouse pointer.

This is by itself useful, for example, if there are unrelated changes in
the same hunk and the hunk cannot be split by reducing the context.

The feature can also be used to split a hunk by staging a number of
additions (or unstaging a number of removals) until there are enough
context lines that the hunk gets split.

The implementation reads the complete hunk that the line lives in, and
constructs a new hunk by picking existing context lines, removing unneeded
change lines and transforming other change lines to context lines. The
resulting hunk is fed through 'git apply' just like in the "Stage/Unstage
Hunk" case.

Signed-off-by: Johannes Sixt <[email-masked]>
---
	Disclaimer: I'm Tcl/Tk illiterate. Feel free to munge the patch
	to your taste.

	The 'do_rescan' is probably a bit heavy-weight. But editing the
	diff window like we do in "Stage Hunk" would be a bit complex, and
	just redisplaying the diff is easier.

	Furthermore, I don't know why I have to do the loop until
	"end - 1 chars". If it goes until "end", then the hunk contains
	an extra line, so that the patch in general does not apply.
	Is there an extra newline in the diff view that is not in the
	git diff output?

	-- Hannes

 git-gui.sh   |    8 +++++
 lib/diff.tcl |   87 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 95 insertions(+), 0 deletions(-)

diff --git a/git-gui.sh b/git-gui.sh
index 1bbae15..d89f156 100755
--- a/git-gui.sh
+++ b/git-gui.sh
@@ -2666,6 +2666,11 @@ $ctxm add command \
 	-command {apply_hunk $cursorX $cursorY}
 set ui_diff_applyhunk [$ctxm index last]
 lappend diff_actions [list $ctxm entryconf $ui_diff_applyhunk -state]
+$ctxm add command \
+	-label [mc "Apply/Reverse Line"] \
+	-command {apply_line $cursorX $cursorY; do_rescan}
+set ui_diff_applyline [$ctxm index last]
+lappend diff_actions [list $ctxm entryconf $ui_diff_applyline -state]
 $ctxm add separator
 $ctxm add command \
 	-label [mc "Show Less Context"] \
@@ -2714,8 +2719,10 @@ proc popup_diff_menu {ctxm x y X Y} {
 	set ::cursorY $y
 	if {$::ui_index eq $::current_diff_side} {
 		set l [mc "Unstage Hunk From Commit"]
+		set t [mc "Unstage Line From Commit"]
 	} else {
 		set l [mc "Stage Hunk For Commit"]
+		set t [mc "Stage Line For Commit"]
 	}
 	if {$::is_3way_diff
 		|| $current_diff_path eq {}
@@ -2726,6 +2733,7 @@ proc popup_diff_menu {ctxm x y X Y} {
 		set s normal
 	}
 	$ctxm entryconf $::ui_diff_applyhunk -state $s -label $l
+	$ctxm entryconf $::ui_diff_applyline -state $s -label $t
 	tk_popup $ctxm $X $Y
 }
 bind_button3 $ui_diff [list popup_diff_menu $ctxm %x %y %X %Y]
diff --git a/lib/diff.tcl b/lib/diff.tcl
index d04f6db..96ba949 100644
--- a/lib/diff.tcl
+++ b/lib/diff.tcl
@@ -362,3 +362,90 @@ proc apply_hunk {x y} {
 		set current_diff_path $current_diff_path
 	}
 }
+
+proc apply_line {x y} {
+	global current_diff_path current_diff_header current_diff_side
+	global ui_diff ui_index file_states
+
+	if {$current_diff_path eq {} || $current_diff_header eq {}} return
+	if {![lock_index apply_hunk]} return
+
+	set apply_cmd {apply --cached --whitespace=nowarn}
+	set mi [lindex $file_states($current_diff_path) 0]
+	if {$current_diff_side eq $ui_index} {
+		set failed_msg [mc "Failed to unstage selected line."]
+		set to_context {+}
+		lappend apply_cmd --reverse
+		if {[string index $mi 0] ne {M}} {
+			unlock_index
+			return
+		}
+	} else {
+		set failed_msg [mc "Failed to stage selected line."]
+		set to_context {-}
+		if {[string index $mi 1] ne {M}} {
+			unlock_index
+			return
+		}
+	}
+
+	set the_l [$ui_diff index @$x,$y]
+
+	# operate only on change lines
+	set c1 [$ui_diff get "$the_l linestart"]
+	if {$c1 ne {+} && $c1 ne {-}} {
+		unlock_index
+		return
+	}
+	set sign $c1
+
+	set i_l [$ui_diff search -backwards -regexp ^@@ $the_l 0.0]
+	if {$i_l eq {}} {
+		unlock_index
+		return
+	}
+	# $i_l is now at the beginning of a line
+
+	# pick start line number from hunk header
+	set hh [$ui_diff get $i_l "$i_l + 1 lines"]
+	set hh [lindex [split $hh ,] 0]
+	set hln [lindex [split $hh -] 1]
+
+	set n 0
+	set i_l [$ui_diff index "$i_l + 1 lines"]
+	set patch {}
+	while {[$ui_diff compare $i_l < "end - 1 chars"] &&
+	       [$ui_diff get $i_l "$i_l + 2 chars"] ne {@@}} {
+		set next_l [$ui_diff index "$i_l + 1 lines"]
+		set c1 [$ui_diff get $i_l]
+		if {[$ui_diff compare $i_l <= $the_l] &&
+		    [$ui_diff compare $the_l < $next_l]} {
+			# the line to stage/unstage
+			set ln [$ui_diff get $i_l $next_l]
+			set patch "$patch$ln"
+		} elseif {$c1 ne {-} && $c1 ne {+}} {
+			# context line
+			set ln [$ui_diff get $i_l $next_l]
+			set patch "$patch$ln"
+			set n [expr $n+1]
+		} elseif {$c1 eq $to_context} {
+			# turn change line into context line
+			set ln [$ui_diff get "$i_l + 1 chars" $next_l]
+			set patch "$patch $ln"
+			set n [expr $n+1]
+		}
+		set i_l $next_l
+	}
+	set patch "@@ -$hln,$n +$hln,[eval expr $n $sign 1] @@\n$patch"
+
+	if {[catch {
+		set p [eval git_write $apply_cmd]
+		fconfigure $p -translation binary -encoding binary
+		puts -nonewline $p $current_diff_header
+		puts -nonewline $p $patch
+		close $p} err]} {
+		error_popup [append $failed_msg "\n\n$err"]
+	}
+
+	unlock_index
+}
-- 
1.5.6.918.g8a69