Subject: [PATCH 6/6] Make cg-log use optparse, and add features

From: Dan Holmsand <[email-masked]>
Date: Thu, 09 Jun 2005 13:29:14 +0200

This is more or less a rewrite of cg-log, that adds a bunch
of new features and gives a substantial speedup.

cg-log now lets git-rev-list and git-diff-tree do as much
of the heavy lifting as possible. When possible, cg-log
pretty much reduces to a sed script, that does some fixup
and adds colorization. Otherwise, cg-log uses
--pretty=[raw|medium|short] output, that's augmented as
necessary.

New features:

  - New, human friendlier output format. cg-log now uses
    the same format as git-rev-list --pretty. It also
    doesn't show tree, committer and parents by default.
    These are all shown with "-v or --verbose", however.

  - Slightly more readable summary format. sha1s are hidden
    unless -v is given. Date format is condenced to HH:MM
    for the last day, and Month Day otherwise. (+year is
    shown for really old stuff).

    summary format can also be combined with other options,
    like "-f" and "-d" (diffstat).

  - "-u" (or --search=) now searches the entire commit
    message. This is more flexible, as you can still do e.g.
    "cg-log -u 'author Petr Baudis'". Search is now also
    a lot faster, done with a single grep invocation.

  - Output can be limited by date or count.

  - New format for "-f". This is pretty much the same
    output as cg-status gives, and allows for the
    rename/copy detection things to work. New and
    deleted files are optionally color coded.

    The old format is still available as -F.

  - Merge commits are hidden by default when displaying
    differences between commits, or when displaying disjoint
    sets of commits. "-a" makes them show again. So
    "cg-log -caF" gives you pretty much the same output
    as the old "cg-log -c -f" would.

  - Shows diffs between commits with the "-p" options.
    The diffs use the same colorization rules as cg-diff,
    and can be skipped through with "n" in less if "-c"
    is given.

  - "--stdin" reads sha1s from stdin instead of from
    git-rev-list, for some extra flexibility.

New features in common with cg-diff:

  - diffstat support (or rather git-apply --stat). The
    "-d" option outputs (optionally colorized) diff stats
    before the diff.

  - support for more git-diff-[tree/cache] options:
    -B, -R, -M, -C are now all passed on.

  - The COGITO_AUTO_COLOR environment variable makes output
    automatically colorized, if set and if we're on a color
    capable terminal.

We also use the new optparse function from cg-Xlib, to allow
for e.g. "cg-log -sfa" and stuff.

Reuse colorization logic from cg-Xlib. All the colors are
added in a single sed invocation.

And use LESS to make "less" search for chunks and diff --git
markers. This allows you to "n" your way through a series
of diffs, and gives a nice visual separation of patches.

Signed-off-by: Dan Holmsand <[email-masked]>
---
 cg-log |  545 +++++++++++++++++++++++++++++++++++++++-------------------------
 1 files changed, 331 insertions(+), 214 deletions(-)

diff --git a/cg-log b/cg-log
--- a/cg-log
+++ b/cg-log
@@ -3,6 +3,7 @@
 # Make a log of changes in a GIT branch.
 # Copyright (c) Petr Baudis, 2005.
 # Copyright (c) David Woodhouse, 2005.
+# Copyright (c) Dan Holmsand, 2005.
 #
 # Display log information for files or a range of commits. The output
 # will automatically be displayed in a pager unless it is piped to
@@ -13,7 +14,7 @@
 # Arguments not interpreted as options will be interpreted as filenames;
 # cg-log then displays only changes in those files.
 #
-# -c::
+# -c, --color::
 #	Colorize to the output. The used colors are listed below together
 #	with information about which log output (summary, full or both)
 #	they apply to:
@@ -26,8 +27,15 @@
 #		- `date`:	'green'		(summary)
 #		- `trim_mark`:	'magenta'	(summary)
 #
+# -d, --diffstat::
+#	Show `diffstat' for every commit.
+#
 # -f::
-#	List affected files. (No effect when passed along `-s`.)
+#	Show list of files modified in each commit.
+#
+# -F::
+#	Show ChangeLog-style list of modified files, instead of the
+#	default listing.
 #
 # -r FROM_ID[:TO_ID]::
 #	Limit the log information to a set of revisions using either
@@ -37,29 +45,61 @@
 #	to the initial commit is shown. If no revisions is specified,
 #	the log information starting from 'HEAD' will be shown.
 #
-# -m::
-#	End the log listing at the merge base of the -r arguments
-#	(defaulting to master and origin).
-#
-# -s::
+# -s, --summary::
 #	Show a one line summary for each log entry. The summary contains
 #	information about the commit date, the author, the first line
 #	of the commit log and the commit ID. Long author names and commit
 #	IDs are trimmed and marked with an ending tilde (~).
 #
-# -uUSERNAME::
-#	List only commits where author or committer contains 'USERNAME'.
-#	The search for 'USERNAME' is case-insensitive.
+# -p, --patches::
+#	Show diffs for files modified by each commit.
+#
+# -v, --verbose::
+#	Show committer, tree and parents for each commit.
+#
+# -a, --all::
+#	Show merge commits as well.
+#
+# -m::
+#	End the log listing at the merge base of the -r arguments
+#	(defaulting to master and origin).
+#
+# -u, --search=TEXT::
+#	List only commits where author, committer or commit message
+#	contains 'TEXT'. The search for 'TEXT' is case-insensitive.
+#
+# -S, --match=TEXT::
+#	Look for commits that introduces TEXT in a file.
+#
+# -M::
+#	Detect renames.
+#
+# -C::
+#	Detect copies (as well as renames).
+#
+# -B::
+#	Detect rewrites.
+#
+# -y, --max-age=DATE::
+#	Limit output to commits younger than DATE.
+#
+# -o, --min-age=DATE::
+#	Limit output to commits older than DATE.
+#
+# --max-count=N::
+#	Show at most N commits.
+#
+# --stdin::
+#	Show commits for sha1s read from stdin.
 #
 # ENVIRONMENT VARIABLES
 # ---------------------
 # PAGER::
 #	The pager to display log information in, defaults to `less`.
 #
-# PAGER_FLAGS::
-#	Flags to pass to the pager. By default `R` and `S` is added to the
-#	`LESS` environment variable to allow displaying of colorized output
-#	and to avoid long lines from wrapping when using `-s`.
+# COGITO_AUTO_COLOR::
+#	If set, colorized output is used automatically on color-capable
+#	terminals.
 #
 # EXAMPLE USAGE
 # -------------
@@ -75,228 +115,305 @@ USAGE="cg-log [-c] [-f] [-m] [-s] [-uUSE
 # at least somewhere it does. Bash is broken.
 trap exit SIGPIPE
 
-[ "$COLUMNS" ] || COLUMNS="$(tput cols)"
+# speed bash up on utf-8 locales
+LANG=C
 
-colheader=
-colauthor=
-colcommitter=
-colfiles=
-colsignoff=
-colcommit=
-coldate=
-coltrim=
-coldefault=
-
-list_files=
-log_start=
-log_end=
-summary=
-user=
-mergebase=
-files=()
-
-while [ "$1" ]; do
-	case "$1" in
-	-c)
-		# See terminfo(5), "Color Handling"
-		colheader="$(tput setaf 2)"    # Green
-		colauthor="$(tput setaf 6)"    # Cyan
-		colcommitter="$(tput setaf 5)" # Magenta
-		colfiles="$(tput setaf 4)"     # Blue
-		colsignoff="$(tput setaf 3)"   # Yellow
-
-		colcommit="$(tput setaf 4)"
-		coldate="$(tput setaf 2)"
-		coltrim="$(tput setaf 5)"
-
-		coldefault="$(tput op)"        # Restore default
-		;;
-	-f)
-		list_files=1
-		;;
-	-u*)
-		user="${1#-u}"
-		;;
-	-r)
-		shift
-		if echo "$1" | grep -q ':'; then
-			log_end=$(echo "$1" | cut -d : -f 2)
-			[ "$log_end" ] || log_end="HEAD"
-			log_start=$(echo "$1" | cut -d : -f 1)
-		elif [ -z "$log_start" ]; then
-			log_start="$1"
-		else
-			log_end="$1"
-		fi
-		;;
-	-m)
-		mergebase=1
-		;;
-	-s)
-		summary=1
-		;;
-	*)
-		files=("$@")
-		break
-		;;
-	esac
-	shift
-done
+unset id1 id2 user verbose filelist patches sedprog incoming pretty maxn
+unset diffstat stdin opt_color renames dtmode quiet summary longsummary files
+unset needparse
+filemode=-s
+dtargs=() rlargs=()
+
+revlist() {
+	if [ "$stdin" ]; then
+		# read sha1s from stdin. remove leading stuff from
+		# git-rev-tree output.
+		sed 's/^[0-9]* \([a-f0-9]\{40\}\)/\1/'
+	elif [ "$user" ]; then
+		git-rev-list --header "${rlargs[@]}" |
+		LANG=C grep -iz "$user" | tr '\n\0' '\t\n' | cut -f1
+	else
+		git-rev-list "${rlargs[@]}" "$@"
+	fi 
+}
 
-list_commit_files()
-{
-	tree1="$1"
-	tree2="$2"
-	line=
-	sep="    * $colfiles"
-	# List all files for for the initial commit
-	if [ -z $tree2 ]; then
-		list_cmd="git-ls-tree $tree1"
+dolog() {
+	if [ "$dtmode" = diff-tree ]; then
+		revlist | 
+		git-diff-tree --stdin -v -r $filemode "${dtargs[@]}" \
+			-- "${ARGS[@]}"
 	else
-		list_cmd="git-diff-tree -r $tree1 $tree2"
+		revlist --pretty${pretty+=}$pretty
 	fi
+}
+
+showstat() {
+	git-diff-tree -r -p "${dtargs[@]}" $1 $2 -- "${ARGS[@]}" | 
+		git-apply --stat 2>/dev/null
 	echo
-	$list_cmd | cut -f 2- | while read file; do
-		echo -n "$sep"
-		sep=", "
-		line="$line$sep$file"
-		if [ ${#line} -le 74 ]; then
-			echo -n "$file"
+}
+
+showfiles() {
+	local p=6 sep="    * $colfiles" IFS=$'\n' file end=":$coldefault"
+	[ "$summary" ] && { end="$coldefault"$'\n'; }
+	for i in $(git-diff-tree -m -r $1 $2); do
+		[[ "$i" == :* ]] || continue
+		i=${i##*$'\t'}
+		if (( (p += 2 + ${#i}) < 75 )); then
+			echo -n "$sep$i"
 		else
-			line="      $file"
-			echo "$coldefault"
-			echo -n "      $colfiles$file"
+			(( p = 6 + ${#i} ))
+			echo ",$coldefault"
+			echo -n "      $colfiles$i"
 		fi
+		sep=", "
 	done
-	echo "$coldefault:"
+	echo "$end"
 }
 
-process_commit_line()
-{
-	if [ "$key" = "%" ] || [ "$key" = "%$colsignoff" ]; then
-		# The fast common case
-		[ "$summary" ] || [ "$skip_commit" ] || echo "    $rest"
-		return
+toepoch() {
+	expr "$1" : "[0-9]*$" >/dev/null && echo "$1" ||
+	date -ud "$1" +%s || die "invalid date $1"
+}
+
+showsummary() {
+	local commit=$1 author=$2 date=$3 text=$4 da
+	author=${author#Author: }
+	author=${author% <*}
+	[ ${#author} -gt 14 ] && author=${author:0:13}$coltrim~
+	if [ -z "$longsummary" ]; then
+		commit=
+	elif [ "${COLUMNS:-0}" -le 90 ]; then
+		commit="$colcommit${commit:0:12}$coltrim~ "
+	else
+		commit="$colcommit$commit "
 	fi
-	case "$key" in
-	"commit")
-		[ "$summary" ] || [ "$skip_commit" ] || { [ "$commit" ] && echo; }
-		commit="$rest"
-		parents=()
-		skip_commit=
-		;;
-	"tree")
-		tree="$rest"
-		;;
-	"parent")
-		parents[${#parents[@]}]="$rest"
-		;;
-	"committer")
-		committer="$rest"
-		;;
-	"author")
-		author="$rest"
-		;;
-	"")
-		if [ ! "$commit" ]; then
-			# Next commit is coming
-			[ "$summary" ] || echo
-			return
-		fi
+	# find suitable short date format, assumes date in "std linus format"
+	da=(${date#Date:})
+	local dyear=${da[4]}
+	if [ "$year" -ne "$dyear" ]; then
+		# year month day
+		date="${da[1]} ${da[2]} $dyear"
+	elif [ "$month" != "${da[1]}" -o "$day" -ne "${da[2]}" ]; then
+		# month day
+		date="${da[1]} ${da[2]}"
+	else
+		# time
+		date=${da[3]%:*}
+	fi
+	line=${line#    }
+	printf "%s$colauthor%-14s $coldate%-6s $coldefault%s\n" \
+		"$commit" "$author" "$date" "$line"
+}
 
-		if [ "$user" ]; then
-			if ! echo -e "author $author\ncommitter $committer" \
-			     | grep -qi "$user"; then
-				skip_commit=1
-				return
-			fi
-		fi
-		if [ "$files" ]; then
-			parent="${parents[0]}"
-			diff_ops=
-			[ "$parent" ] || diff_ops=--root
-			if ! [ "$(git-diff-tree -r $diff_ops $commit $parent "${files[@]}")" ]; then
-				skip_commit=1
-				return
-			fi
-		fi
-		if [ "$summary" ]; then
-			# Print summary
-			commit="${commit%:*}"
-			author="${author% <*}"
-			date=(${committer#*> })
-			date="$(showdate $date '+%F %H:%M')"
-			read title
-			if [ "${#author}" -gt 15 ]; then
-				author="${author:0:14}$coltrim~"
-			fi
-			if [ "${COLUMNS:-0}" -le 90 ]; then
-				commit="${commit:0:12}$coltrim~"
+while optparse; do
+	if optparse -c || optparse --color; then
+		opt_color=1
+	elif optparse -f; then
+		[ "$patches" ] && optconflict
+		files=1
+		filemode=
+	elif optparse -F; then
+		filelist=1
+		dtmode=diff-tree
+	elif optparse -p || optparse --patches; then
+		[ "$files" ] && optconflict
+		patches=1
+		filemode=-p
+	elif optparse -d || optparse --diffstat; then
+		diffstat=1
+		dtmode=diff-tree
+	elif optparse -u= || optparse --search= 2; then
+		user=$OPTARG
+		dtmode=diff-tree
+	elif optparse -q || optparse --short; then
+		[ "$verbose$summary" ] && optconflict
+		quiet=1
+	elif optparse -v || optparse --verbose; then
+		[ "$quiet" ] && optconflict
+		verbose=1
+	elif optparse -s || optparse --summary 2; then
+		[ "$quiet" ] && optconflict
+		summary=1
+	elif optparse --stdin 2; then
+		stdin=1
+		dtmode=diff-tree
+	elif optparse -y= || optparse --max-age= 5; then
+		rlargs[${#rlargs[@]}]=--max-age=$(toepoch "$OPTARG") || exit 1
+	elif optparse -o= || optparse --min-age= 2; then
+		rlargs[${#rlargs[@]}]=--min-age=$(toepoch "$OPTARG") || exit 1
+	elif optparse --max-count= 5; then
+		maxn=$OPTARG
+	elif optparse -a || optparse --all; then
+		dtargs[${#dtargs[@]}]="-m"
+		dtargs[${#dtargs[@]}]="--root"
+	elif optparse -m || optparse --mergebase 2; then
+		incoming=1
+	elif optparse -S= || optparse --match= 3; then
+		dtargs[${#dtargs[@]}]="-S$OPTARG"
+	elif optparse -M; then
+		[ "$renames" ] && optconflict
+		[ "$patches" ] || filemode=
+		dtargs[${#dtargs[@]}]="-M"
+		renames=1
+	elif optparse -C; then
+		[ "$renames" ] && optconflict
+		[ "$patches" ] || filemode=
+		dtargs[${#dtargs[@]}]="-C"
+		renames=1
+	elif optparse -B; then
+		[ "$patches" ] || filemode=
+		dtargs[${#dtargs[@]}]="-B"
+	elif optparse -r= || optparse --revision=; then
+		if [ -z "${id1+set}" ]; then
+			id1=$OPTARG
+			if [[ "$id1" == *:* ]]; then
+				id2=${id1#*:}
+				id1=${id1%:*}
 			fi
-
-			printf "$colcommit%s $colauthor%-15s $coldate%s $coldefault%s\n" \
-				"${commit%:*}" "$author" "$date" "${title:2}"
-			commit=
-			return
+		else
+			[ -z "${id2+set}" ] || die "too many revisions"
+			id2=$OPTARG
 		fi
+	else
+		optfail
+	fi
+done
 
-		echo ${colheader}commit ${commit%:*} $coldefault
-		echo ${colheader}tree $tree $coldefault
+[ -n "$COGITO_AUTO_COLOR" -a -t 1 ] && [ "$(tput setaf 1 2>/dev/null)" ] && 
+	opt_color=1
 
-		for parent in "${parents[@]}"; do
-			echo ${colheader}parent $parent $coldefault
-		done
+LESS="-S $LESS"
+[ "$COLUMNS" ] || COLUMNS="$(tput cols)"
 
-		date=(${author#*> })
-		pdate="$(showdate $date)"
-		[ "$pdate" ] && author="${author%> *}> $pdate"
-		echo ${colauthor}author $author $coldefault
-
-		date=(${committer#*> })
-		pdate="$(showdate $date)"
-		[ "$pdate" ] && committer="${committer%> *}> $pdate"
-		echo ${colcommitter}committer $committer $coldefault
+if [ "$incoming" ]; then
+	id1="$(commit-id "${id1:-origin}")" || exit 1
+	id2="$(commit-id "${id2:-HEAD}")" || exit 1
+	id2="$(git-merge-base "$id1" "$id2")" || exit 1
+fi
 
-		if [ -n "$list_files" ]; then
-			list_commit_files "$tree" "${parents[0]}"
-		fi
-		echo
-		commit=
-		;;
-	esac
-}
+id1=$(commit-id "$id1") || exit 1
+[ -z "${id2+set}" ] || id2=$(commit-id "$id2") || exit 1
+rlargs=( "${rlargs[@]}" $id1 ${id2+^}$id2 )
 
-print_commit_log()
-{
-	commit=
-	author=
-	committer=
-	tree=
-
-	sed -e '
-		s/^    \(.*\)/% \1/
-		/^% *[Ss]igned-[Oo]ff-[Bb]y:.*/ s/^% \(.*\)/% '$colsignoff'\1'$coldefault'/
-		/^% *[Aa]cked-[Bb]y:.*/ s/^% \(.*\)/% '$colsignoff'\1'$coldefault'/
-	' | while read key rest; do
-		trap exit SIGPIPE
-		process_commit_line
-	done
-}
+if [ -n "${dtargs[*]}${ARGS[*]}" -o "$filemode" != -s -o "$dtmode" ]; then
+	dtmode=diff-tree
+else
+	dtmode=commit
+	[ "$verbose" -a -z "$summary" ] && pretty=raw
+	[ "$quiet" ] && { pretty=short; quiet=; } 
+fi
 
-if [ "$mergebase" ]; then
-	[ "$log_start" ] || log_start="master"
-	[ "$log_end" ] || log_end="origin"
-	log_start=$(git-merge-base $(commit-id "$log_start") $(commit-id "$log_end"))
+if [ "$summary" ]; then
+	year=$(date +%Y)
+	month=$(LANG=C date +%b)
+	day=$(date +%d)
+	[ "$verbose" ] && { longsummary=1; verbose=; }
 fi
 
-id1="$(commit-id "$log_start")" || exit 1
-if [ "$log_end" ]; then
-	id2="$(commit-id "$log_end")" || exit 1
-	revls="git-rev-list --pretty=raw $id2 ^$id1"
+if [ "$filelist$diffstat$summary" -o "$pretty" = raw -o $dtmode = diff-tree ]
+then
+	needparse=1
 else
-	revls="git-rev-list --pretty=raw $id1"
+	[ "$maxn" ] && rlargs=(--max-count="$maxn" "${rlargs[@]}")
+fi
+
+if [ "$opt_color" ]; then
+	setup_colors
+
+	if [ ! "$summary" ]; then
+		LESS=$'+/\013^Commit:.[a-f0-9]*|^diff.--git..*$'" $LESS"
+		sedprog="
+s,^\\(Commit: [^ ]*\\)\\(.*\\),$colheader\\1$coldefault\\2,
+s,^Author:.*,$colauthor&$coldefault,
+s,^Date:.*,$colauthor&$coldefault,
+s,^Tree:.*,$colheader&$coldefault,
+s,^Parent:.*,$colheader&$coldefault,
+s,^Committer:.*,$colcommitter&$coldefault,
+s,^Commitdate:.*,$colcommitter&$coldefault,
+s,^    Signed-[Oo]ff[- ][Bb]y:.*,$colsignoff&$coldefault,
+s,^    Acked[- ][Bb]y:.*,$colsignoff&$coldefault,"
+	fi
+
+	[ "$patches" ] && sedprog="$sedprog;$color_rules"
+
+	[ "$diffstat" ] && sedprog="$sedprog
+s,^\\( [^ ].*\\)\\( |  *[0-9][0-9]* \\),$colfiles\\1$coldefault\\2,"
+fi
+
+if [ $dtmode = diff-tree -a -z "$filemode" ]; then
+	filediffstart=$'s,^:[0-9]* [^ ]* [^ ]* [^ ]* \('
+	filediffend=$'[^\t]*\)\t\(.*\)'
+	sedprog="$sedprog
+${filediffstart}[NCR]$filediffend,$coldiffadd\\1 \\2$coldefault,
+${filediffstart}D$filediffend,$coldiffrem\\1 \\2$coldefault,
+${filediffstart}.$filediffend,$colfiles\\1 \\2$coldefault,"
 fi
 
-# LESS="S" will prevent less to wrap too long titles to multiple lines;
-# you can scroll horizontally.
-$revls | print_commit_log | LESS="S$LESS" pager
+if [ "$needparse" ]; then
+	# Slow version, parsing output from diff-tree or rev-list
+	unset commit showds from lineno line
+	n=0
+	dolog | ( trap exit SIGPIPE
+	while [ "$line" ] || IFS='' read -r line; do 
+	case $line in 
+		$dtmode\ *)
+		[ "$maxn" ] && (( ++n > maxn )) && exit
+		[ "$showds" ] && showstat $commit $from
+		line=${line#$dtmode } f="%s\n" showds=$diffstat lineno=0
+		commit=${line% (*} from=${line#*(from }; from=${from%)}
+		[ "$summary" ] && f= || echo "Commit: $line" ;;
+
+		Author:*) printf "$f" "$line"; author=$line ;;
+
+		Date:*) printf "$f" "$line"; date=$line ;;
+
+		author\ *)
+		line=${line#author }; author=${line% [0-9]*}; date=${line##*> }
+		if [ "$f" ]; then
+			printf "$f" "Author: $author"
+			[ "$quiet" ] || printf "$f" "Date:   $(showdate $date)" 
+		elif [ "$summary" ]; then
+			date=$(showdate $date)
+		fi ;;
+
+		committer\ *)
+		if [ "$verbose" ]; then
+			line=${line#committer }
+			echo "Committer:  ${line% [0-9]*}"
+			echo "Commitdate: $(showdate ${line##*> })"
+		fi ;;
+
+		tree\ *) [ "$verbose" ] && echo "Tree: ${line#tree }" ;;
+
+		parent\ *) [ "$verbose" ] && echo "Parent: ${line#parent }" ;;
+
+		\ \ \ \ * | '')
+		if (( ++lineno == 2 )); then
+			[ "$summary" ] && 
+			showsummary $commit "$author" "$date" "$line"
+			[ "$filelist" ] && showfiles $commit $from
+			printf "$f" "$line"
+			[ "$quiet" ] && { f=; echo; }
+		else
+			printf "$f" "$line"
+		fi ;;
+
+		*)
+		[ "$showds" ] && { showstat $commit $from; showds=; }
+		printf "%s\n" "$line"
+		while IFS='' read -r line; do
+			case $line in $dtmode\ *) break ;; esac
+			printf "%s\n" "$line"
+		done
+		continue ;;
+	esac
+	line=
+	done
+	[ "$showds" ] && showstat $commit $from
+	) | LANG=C sed -e "$sedprog" | pager
+else
+	# Fast version, using sed only.
+	sedprog="s,^$dtmode ,Commit: ,;$sedprog"
+	dolog | LANG=C sed -e "$sedprog" | pager
+fi