#!/bin/sh # Add-then-remove churn in a commit range: the range's net diff against the # sum of the individual commits' diffs, and the gap between them. With # --origins, also which earlier commit wrote the lines each commit removes, by # path; a path row counts its blank lines, which belong to the block they # separate, and which files an in-range commit adds that the tip lacks. # --lines also prints the removed lines an in-range origin wrote, # under that origin, and each commit's additions by path. A after # either flag restricts that table to the one commit. # # Usage: git-churn.sh [--origins | --lines] [] .. [-- ...] # is the commit below the range set -eu usage() { echo "usage: git-churn.sh [--origins | --lines] [] .. [-- ...]"; } origins= lines= carrier= case ${1-} in --origins) origins=1 shift ;; --lines) origins=1 lines=1 shift ;; esac if [ -n "$origins" ]; then case ${1-} in '' | -- | -h | --help | *..*) ;; *) carrier=$(git rev-parse --verify --quiet "$1^{commit}") || { echo "git-churn.sh: not a commit: $1" >&2; exit 2; } shift ;; esac fi case ${1-} in --help | -h) usage exit 0 ;; *..*) range=$1 ;; *) usage >&2 exit 2 ;; esac shift [ "${1-}" != -- ] || shift base=${range%%..*} tip=${range##*..} tab=$(printf '\t') for end in "$base" "$tip"; do git rev-parse --verify --quiet "$end^{commit}" >/dev/null || { echo "git-churn.sh: not a commit: $end" >&2; exit 2; } done if [ -n "$carrier" ] && ! git rev-list "$range" | grep -qx "$carrier"; then echo "git-churn.sh: $carrier is not in $range" >&2 exit 2 fi total() { awk '{i += $1; d += $2} END {print i + d + 0}'; } subject() { git log -1 --format=%s "$1" | sed 's/^[Bb]ug [0-9]* - //; s/ r[?=].*//' | awk 'length > 56 { $0 = substr($0, 1, 57); sub(/ [^ ]*$/, "") } 1'; } net=$(git diff --numstat "$base" "$tip" -- "$@" | total) sum=$(git log --no-merges --numstat --format= "$range" -- "$@" | total) gap=$((sum - net)) [ "$sum" -gt 0 ] || { echo "git-churn.sh: nothing measured; a pathspec is relative to the current directory, so run from the repository root or write :(top)" >&2; exit 2; } printf 'range %s\n' "$range" printf 'fold NET %s\n' "$net" printf 'per-commit SUM %s\n' "$sum" printf 'gap %s\n' "$gap" for c in $(git rev-list --reverse "$range"); do printf ' %s %6s %s\n' \ "$(git rev-parse --short=12 "$c")" \ "$(git show --numstat --format= "$c" -- "$@" | total)" \ "$(subject "$c")" done [ -n "$origins" ] || exit 0 # Blame each removed line at the commit's parent. A line from inside the range # is churn; a line from below it is the commit's own work. printf '\nremoved lines by the commit that wrote them%s\n' "${lines:+, and added lines by path}" all_in=0 for c in $(git rev-list --reverse "$range"); do [ -z "$carrier" ] || [ "$c" = "$carrier" ] || continue printf ' %s %s\n' "$(git rev-parse --short=12 "$c")" "$(subject "$c")" # "originpathlineno) text" for every removed line blamed=$(git show -U0 --src-prefix=a/ --dst-prefix=b/ --format= "$c" -- "$@" | awk '/^--- a\// { f = substr($2, 3) } /^@@/ { split(substr($2, 2), o, ","); n = (o[2] == "" ? 1 : o[2]); if (n > 0) print f, o[1], o[1] + n - 1 }' | while read -r f lo hi; do git blame --line-porcelain -L "$lo,$hi" "$c^" -- "$f" | awk -v f="$f" -v tab="$tab" ' $1 ~ /^[0-9a-f]+$/ && length($1) == 40 { sha = substr($1, 1, 12); n = $3 } /^\t/ { print sha tab f tab n ") " substr($0, 2) }' done) by_path=$([ -z "$blamed" ] || printf '%s\n' "$blamed" | cut -f1,2 | sort | uniq -c) classified=$(printf '%s\n' "$by_path" | awk 'NF { n[$2] += $1 } END { for (o in n) print n[o], o }' | sort -rn | while read -r n o; do if git merge-base --is-ancestor "$o" "$base"; then where=below; else where=in; fi printf '%s %s %s %s\n' "$n" "$o" "$where" "$(subject "$o")" done) # Each origin lists its paths; an in-range origin's are the partition an absorb needs. [ -z "$classified" ] || printf '%s\n' "$classified" | while read -r n o where rest; do printf ' %5d from %s %s (%s the range)\n' "$n" "$o" "$rest" "$where" printf '%s\n' "$by_path" | awk -v o="$o" '$2 == o { print $1, $3 }' | while read -r n f; do blank=$(printf '%s\n' "$blamed" | awk -F"$tab" -v o="$o" -v f="$f" '$1 == o && $2 == f && $3 ~ /^[0-9]+\) $/ { b++ } END { print b + 0 }') if [ "$blank" -gt 0 ]; then printf ' %5d %s (%d blank)\n' "$n" "$f" "$blank" else printf ' %5d %s\n' "$n" "$f" fi [ -z "$lines" ] || [ "$where" != in ] || printf '%s\n' "$blamed" | awk -F"$tab" -v o="$o" -v f="$f" '$1 == o && $2 == f { print " " $3 }' done done in=$(printf '%s\n' "$classified" | awk '$3 == "in" { s += $1 } END { print s + 0 }') printf ' %5d removed from inside the range\n' "$in" all_in=$((all_in + in)) [ -z "$lines" ] || git show --numstat --format= "$c" -- "$@" | awk -F"$tab" '$1 > 0 { printf " %5d added %s\n", $1, $3 }' done [ -n "$carrier" ] || printf ' %5d removed from inside the range in all\n' "$all_in" # A file an in-range commit adds and the tip lacks was born and died inside the range. dead=$(git log --reverse --diff-filter=A --format='commit %h' --name-only "$range" -- "$@" | while IFS= read -r line; do case $line in '') ;; 'commit '*) c=${line#commit } ;; *) git cat-file -e "$tip:$line" 2>/dev/null || printf ' %s %s\n' "$c" "$line" ;; esac done) [ -z "$dead" ] || printf '\nfiles born and removed inside the range, by the commit that added them\n%s\n' "$dead"