1#!/usr/bin/env bash
2#
3# Compares multiple branches against a reference and shows which ones contain
4# each commit, and the level of backports since the origin or its own ancestors.
5#
6# Copyright (c) 2016 Willy Tarreau <w@1wt.eu>
7#
8# The purpose is to make it easy to visualize what backports might be missing
9# in a maintenance branch, and to easily spot the ones that are needed and the
10# ones that are not. It solely relies on the "cherry-picked from" tags in the
11# commit messages to find what commit is available where, and can even find a
12# reference commit's ancestor in another branch's commit ancestors as well to
13# detect that the patch is present. When done with the proper references and
14# a correct ordering of the branches, it can be used to quickly apply a set of
15# fixes to a branch since it dumps suggested commands at the end. When doing
16# so it is a good idea to use "HEAD" as the last branch to avoid doing mistakes.
17#
18# Examples :
19# - find what's in master and not in current branch :
20#   show-backports -q -m -r master HEAD
21# - find what's in 1.6/master and in hapee-maint-1.5r2 but not in current branch :
22#   show-backports -q -m -r 1.6/master hapee-maint-1.5r2 HEAD | grep ' [a-f0-9]\{8\}[-+][0-9] '
23# - check that no recent fix from master is missing in any maintenance branch :
24#   show-backports -r master hapee-maint-1.5r2 aloha-7.5 hapee-maint-1.5r1 aloha-7.0
25# - see what was recently merged into 1.6 and has no equivalent in local master :
26#   show-backports -q -m -r 1.6/master -b "1.6/master@{1 week ago}" master
27# - check what extra backports are present in hapee-r2 compared to hapee-r1 :
28#   show-backports -q -m -r hapee-r2 hapee-r1
29
30
31USAGE="Usage: ${0##*/} [-q] [-H] [-m] [-u] [-r reference] [-l logexpr] [-s subject] [-b base] {branch|range} [...] [-- file*]"
32BASES=( )
33BRANCHES=( )
34REF=master
35BASE=
36QUIET=
37LOGEXPR=
38SUBJECT=
39MISSING=
40UPSTREAM=
41BODYHASH=
42
43die() {
44	[ "$#" -eq 0 ] || echo "$*" >&2
45	exit 1
46}
47
48err() {
49	echo "$*" >&2
50}
51
52quit() {
53	[ "$#" -eq 0 ] || echo "$*"
54	exit 0
55}
56
57short() {
58	# git rev-parse --short $1
59	echo "${1::8}"
60}
61
62dump_commit_matrix() {
63	title=":$REF:"
64	for branch in "${BRANCHES[@]}"; do
65		#echo -n " $branch"
66		title="$title :${branch}:"
67	done
68	title="$title |"
69
70	count=0
71	# now look up commits
72	while read ref subject; do
73		if [ -n "$MISSING" -a "${subject:0:9}" = "[RELEASE]" ]; then
74			continue
75		fi
76
77		upstream="none"
78		missing=0
79		refbhash=""
80		line=""
81		for branch in "${BRANCHES[@]}"; do
82			set -- $(grep -m 1 $ref "$WORK/${branch//\//_}")
83			newhash=$1 ; shift
84			bhash=""
85			# count the number of cherry-picks after this one. Since we shift,
86			# the result is in "$#"
87			while [ -n "$1" -a "$1" != "$ref" ]; do
88				shift
89			done
90			if [ -n "$newhash" ]; then
91				line="${line} $(short $newhash)-$#"
92			else
93				# before giving up we can check if our current commit was
94				# itself cherry-picked and check this again. In order not
95				# to have to do it all the time, we can cache the result
96				# for the current line. If a match is found we report it
97				# with the '+' delimiter instead of '-'.
98				if [ "$upstream" = "none" ]; then
99					upstream=( $(git log -1 --pretty --format=%B "$ref" | \
100						sed -n 's/^commit \([^)]*\) upstream\.$/\1/p;s/^(cherry picked from commit \([^)]*\))/\1/p') )
101				fi
102				newhash=""
103				for h in ${upstream[@]}; do
104					set -- $(grep -m 1 $h "$WORK/${branch//\//_}")
105					newhash=$1 ; shift
106					while [ -n "$1" -a "$1" != "$h" ]; do
107						shift
108					done
109					if [ -n "$newhash" ]; then
110						line="${line} $(short $newhash)+$#"
111						break
112					fi
113				done
114				if [ -z "$newhash" -a -n "$BODYHASH" ]; then
115					if [ -z "$refbhash" ]; then
116						refbhash=$(git log -1 --pretty="%an|%ae|%at|%B" "$ref" | sed -n '/^\(Signed-off-by\|(cherry picked\)/q;p' | md5sum)
117					fi
118
119
120					set -- $(grep -m 1 "H$refbhash\$" "$WORK/${branch//\//_}")
121					newhash=$1 ; shift
122					if [ -n "$newhash" ]; then
123						line="${line} $(short $newhash)+?"
124						break
125					fi
126				fi
127				if [ -z "$newhash" ]; then
128					line="${line} -"
129					missing=1
130				fi
131			fi
132		done
133		line="${line} |"
134		if [ -z "$MISSING" -o $missing -gt 0 ]; then
135			[ $((count++)) -gt 0 ] || echo "$title"
136			[ "$QUIET" != "" -o $count -lt 20 ] || count=0
137			if [ -z "$UPSTREAM" -o "$upstream" = "none" -o -z "$upstream" ]; then
138				echo "$(short $ref) $line"
139			else
140				echo "$(short $upstream) $line"
141			fi
142		fi
143	done < "$WORK/${REF//\//_}"
144}
145
146while [ -n "$1" -a -z "${1##-*}" ]; do
147	case "$1" in
148		-b)        BASE="$2"      ; shift 2 ;;
149		-r)        REF="$2"       ; shift 2 ;;
150		-l)        LOGEXPR="$2"   ; shift 2 ;;
151		-s)        SUBJECT="$2"   ; shift 2 ;;
152		-q)        QUIET=1        ; shift   ;;
153		-m)        MISSING=1      ; shift   ;;
154		-u)        UPSTREAM=1     ; shift   ;;
155		-H)        BODYHASH=1     ; shift   ;;
156		-h|--help) quit "$USAGE" ;;
157		*)         die  "$USAGE" ;;
158	esac
159done
160
161# branches may also appear as id1..id2 to limit the history instead of looking
162# back to the common base. The field is left empty if not set.
163BRANCHES=( )
164BASES=( )
165while [ $# -gt 0 ]; do
166        if [ "$1" = "--" ]; then
167                shift
168                break
169        fi
170	branch="${1##*..}"
171	if [ "$branch" == "$1" ]; then
172		base=""
173	else
174		base="${1%%..*}"
175	fi
176	BASES[${#BRANCHES[@]}]="$base"
177	BRANCHES[${#BRANCHES[@]}]="$branch"
178	shift
179done
180
181# args left for git-log
182ARGS=( "$@" )
183
184if [ ${#BRANCHES[@]} = 0 ]; then
185	die "$USAGE"
186fi
187
188for branch in "$REF" "${BRANCHES[@]}"; do
189	if ! git rev-parse --verify -q "$branch" >/dev/null; then
190		die "Failed to check git branch $branch."
191	fi
192done
193
194if [ -z "$BASE" ]; then
195	err "Warning! No base specified, looking for common ancestor."
196	BASE=$(git merge-base --all "$REF" "${BRANCHES[@]}")
197	if [ -z "$BASE" ]; then
198		die "Couldn't find a common ancestor between these branches"
199	fi
200fi
201
202# we want to go to the git root dir
203DIR="$PWD"
204cd $(git rev-parse --show-toplevel)
205
206mkdir -p .git/.show-backports #|| die "Can't create .git/.show-backports"
207WORK=.git/.show-backports
208
209rm -f "$WORK/${REF//\//_}"
210git log --reverse ${LOGEXPR:+--grep $LOGEXPR} --pretty="%H %s" "$BASE".."$REF" -- "${ARGS[@]}" | grep "${SUBJECT}" > "$WORK/${REF//\//_}"
211
212# for each branch, enumerate all commits and their ancestry
213
214branch_num=0;
215while [ $branch_num -lt "${#BRANCHES[@]}" ]; do
216	branch="${BRANCHES[$branch_num]}"
217	base="${BASES[$branch_num]}"
218	base="${base:-$BASE}"
219	rm -f "$WORK/${branch//\//_}"
220	git log --reverse --pretty="%H %s" "$base".."$branch" -- "${ARGS[@]}" | grep "${SUBJECT}" | while read h subject; do
221		echo -n "$h" $(git log -1 --pretty --format=%B "$h" | \
222			sed -n 's/^commit \([^)]*\) upstream\.$/\1/p;s/^(cherry picked from commit \([^)]*\))/\1/p')
223		if [ -n "$BODYHASH" ]; then
224			echo " H$(git log -1 --pretty="%an|%ae|%at|%B" "$h" | sed -n '/^\(Signed-off-by\|(cherry picked\)/q;p' | md5sum)"
225		else
226			echo
227		fi
228	done > "$WORK/${branch//\//_}"
229	(( branch_num++ ))
230done
231
232count=0
233dump_commit_matrix | column -t | \
234(
235	left_commits=( )
236	right_commits=( )
237	while read line; do
238		# append the subject at the end of the line
239		set -- $line
240		echo -n "$line "
241		if [ "${line::1}" = ":" ]; then
242			echo "---- Subject ----"
243		else
244			# doing it this way prevents git from abusing the terminal
245			echo "$(git log -1 --pretty="%s" "$1")"
246			left_commits[${#left_commits[@]}]="$1"
247			comm=""
248			while [ -n "$1" -a "$1" != "-" -a "$1" != "|" ]; do
249				comm="${1%-*}"
250				shift
251			done
252			right_commits[${#right_commits[@]}]="$comm"
253		fi
254	done
255	if [ -n "$MISSING" -a ${#left_commits[@]} -eq 0 ]; then
256		echo "No missing commit to apply."
257	elif [ -n "$MISSING" ]; then
258		echo
259		echo
260		echo "In order to show and/or apply all leftmost commits to current branch :"
261		echo "   git show --pretty=format:'%C(yellow)commit  %H%C(normal)%nAuthor: %an <%ae>%nDate:   %aD%n%n%C(green)%C(bold)git cherry-pick -sx %h%n%n%w(72,4,4)%B%N' ${left_commits[@]}"
262		echo
263		echo "   git cherry-pick -sx ${left_commits[@]}"
264		echo
265		if [ "${left_commits[*]}" != "${right_commits[*]}" ]; then
266			echo "In order to show and/or apply all rightmost commits to current branch :"
267			echo "   git show --pretty=format:'%C(yellow)commit  %H%C(normal)%nAuthor: %an <%ae>%nDate:   %aD%n%n%C(green)%C(bold)git cherry-pick -sx %h%n%n%w(72,4,4)%B%N' ${right_commits[@]}"
268			echo
269			echo "   git cherry-pick -sx ${right_commits[@]}"
270			echo
271		fi
272	fi
273)
274