Skip to content

Instantly share code, notes, and snippets.

@markusfisch
Last active October 8, 2022 06:21
Show Gist options
  • Star 14 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save markusfisch/c43e32a50f833539ea0f7b8b6109320e to your computer and use it in GitHub Desktop.
Save markusfisch/c43e32a50f833539ea0f7b8b6109320e to your computer and use it in GitHub Desktop.
Generate an annual commit report

Generate an annual commit report

Prints something like this:

In 2018 you made 2488 commits in 134 projects.
The average length of a commit message was 62 characters.

Commits per weekday
    Monday     334 ******************************************
   Tuesday     440 *******************************************************
 Wednesday     464 ***********************************************************
  Thursday     405 ***************************************************
    Friday     364 **********************************************
  Saturday     201 *************************
    Sunday     280 ***********************************

Commits per month
       Jan     160 **********************************
       Feb     199 ******************************************
       Mar     272 **********************************************************
       Apr     260 *******************************************************
       May     274 ***********************************************************
       Jun     216 **********************************************
       Jul     168 ************************************
       Aug     192 *****************************************
       Sep     132 ****************************
       Oct     194 *****************************************
       Nov     243 ****************************************************
       Dec     178 **************************************

Lines added (+) and removed (-) per weekday
    Monday   29655 ++++++++++++++++++++++++
   Tuesday   71311 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 Wednesday   65859 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
  Thursday   46093 ++++++++++++++++++++++++++++++++++++++
    Friday   14724 ++++++++++++
  Saturday    7019 +++++
    Sunday   17739 ++++++++++++++

Lines added (+) and removed (-) per month
       Jan   11195 +++++++
       Feb    6722 ++++
       Mar    6240 +++
       Apr   11334 +++++++
       May   10263 ++++++
       Jun   22041 +++++++++++++
       Jul   24548 +++++++++++++++
       Aug   10521 ++++++
       Sep   39420 ++++++++++++++++++++++++
       Oct    7028 ++++
       Nov    9254 +++++
       Dec   93834 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Top 3 words in commit messages (ignoring words up to three letters)
      into     282 ***********************************************************
    Update     227 ***********************************************
    branch     198 *****************************************

Configuration

If you keep your sources in ~/src you're all set up. If you like it different, simply invoke the script with the directories your sources live in:

$ ./annual.sh path/to/my/projects another/path

If you commit with more than just one eMail address, set an AUTHORS environment variable before invoking the script:

$ AUTHORS='alice@example.com ali@gmail.com' ./annual.sh

If you like to generate the report for a past year, set up YEAR:

$ YEAR=2010 ./annual.sh

If you want to replace "you" in the first line, set WHO:

$ WHO=Markus ./annual.sh

To exclude certain files or folders from line counting, use LOC_EXTRA_ARGS:

$ LOC_EXTRA_ARGS='-- . :(exclude)*folder_to_exclude*' ./annual.sh

To make your setup permanent, simply use your own wrapper script:

#!/usr/bin/env bash
export AUTHORS='...'
export WHO='...'
export LOC_EXTRA_ARGS='...'
./annual.sh "$@"

Make a Google Chart

Pipe the output of annual.sh into a file, say 2018:

$ ./annual.sh > 2018

And you may visualize one or more of those reports with Google Charts using the enclosed chart.sh script. For example, if you also generated a report for 2017 you can do:

$ ./chart.sh 2017 2018 > charts.html
#!/usr/bin/env bash
# Print a bar chart from NAME VALUE pairs given on STDIN
chart() {
local TMP MIN=0 MAX=0 NAME VALUE
TMP=$(mktemp)
while read -r NAME VALUE
do
((VALUE > MAX)) && MAX=$VALUE
((VALUE < MIN)) && MIN=$VALUE
echo "$NAME" "$VALUE"
done > "$TMP"
local COLS=${COLS:-$(tput cols)}
((COLS -= 21))
local BLANK PBAR NBAR
BLANK=$(printf "%${COLS}s")
PBAR=${BLANK// /${POSITIVES:-'*'}}
NBAR=${BLANK// /${NEGATIVES:-'-'}}
((MIN < 0)) && {
((MAX -= MIN))
((MIN = -MIN))
}
local F=$(((MAX << 16) / COLS)) BAR FILL
local BASE=$(((MIN << 16) / F))
((F < 1)) && F=1
while read -r NAME VALUE
do
if ((VALUE > 0))
then
BAR=${BLANK:0:$BASE}
BAR=$BAR${PBAR:0:$(((VALUE << 16) / F))}
else
FILL=$(((-VALUE << 16) / F))
BAR=${BLANK:0:$((BASE - FILL))}
BAR=$BAR${NBAR:0:$FILL}
fi
printf '%10s %7s %s\n' "$NAME" "$VALUE" "$BAR"
done < "$TMP"
rm -rf "$TMP"
}
# Calculate average length of commit messages
commit_message_length() {
local MESSAGE='' MESSAGES=0 LENGTH=0
while read -r
do
[ -z "$MESSAGE" ] && {
if [[ "$REPLY" == "{{{"* ]]
then
REPLY=${REPLY#* }
else
continue
fi
}
MESSAGE=$MESSAGE${REPLY%'}}}'}
[[ "$REPLY" == *'}}}' ]] && {
((++MESSAGES))
((LENGTH += ${#MESSAGE}))
MESSAGE=
}
done
if ((MESSAGES > 0))
then
echo "The average length of a commit message was $((LENGTH / MESSAGES)) characters."
fi
}
# Filter commits into days of the week
commits_per_weeday() {
local MON=0 TUE=0 WED=0 THU=0 FRI=0 SAT=0 SUN=0
while read -r DAY REST
do
case "$DAY" in
Mon*) ((++MON));;
Tue*) ((++TUE));;
Wed*) ((++WED));;
Thu*) ((++THU));;
Fri*) ((++FRI));;
Sat*) ((++SAT));;
Sun*) ((++SUN));;
esac
done
cat << EOF
Monday $MON
Tuesday $TUE
Wednesday $WED
Thursday $THU
Friday $FRI
Saturday $SAT
Sunday $SUN
EOF
}
# Filter commits into months
commits_per_month() {
local JAN=0 FEB=0 MAR=0 APR=0 MAY=0 JUN=0
local JUL=0 AUG=0 SEP=0 OCT=0 NOV=0 DEC=0
while read -r _ _ MONTH _
do
case "$MONTH" in
Jan*) ((++JAN));;
Feb*) ((++FEB));;
Mar*) ((++MAR));;
Apr*) ((++APR));;
May*) ((++MAY));;
Jun*) ((++JUN));;
Jul*) ((++JUL));;
Aug*) ((++AUG));;
Sep*) ((++SEP));;
Oct*) ((++OCT));;
Nov*) ((++NOV));;
Dec*) ((++DEC));;
esac
done
cat << EOF
Jan $JAN
Feb $FEB
Mar $MAR
Apr $APR
May $MAY
Jun $JUN
Jul $JUL
Aug $AUG
Sep $SEP
Oct $OCT
Nov $NOV
Dec $DEC
EOF
}
# Filter lines of code into days of the week
loc_per_weeday() {
local INSERTS=() DELETIONS=() DAY=7
while read -r FIRST SECOND _
do
case "$FIRST" in
'-') ;;
[0-9]*)
((DAY > 6)) && continue
((FIRST > 0)) && ((INSERTS[DAY] += FIRST))
((SECOND > 0)) && ((DELETIONS[DAY] += SECOND))
;;
Mon*) DAY=0;;
Tue*) DAY=1;;
Wed*) DAY=2;;
Thu*) DAY=3;;
Fri*) DAY=4;;
Sat*) DAY=5;;
Sun*) DAY=6;;
esac
done
cat << EOF
Monday $((INSERTS[0] - DELETIONS[0]))
Tuesday $((INSERTS[1] - DELETIONS[1]))
Wednesday $((INSERTS[2] - DELETIONS[2]))
Thursday $((INSERTS[3] - DELETIONS[3]))
Friday $((INSERTS[4] - DELETIONS[4]))
Saturday $((INSERTS[5] - DELETIONS[5]))
Sunday $((INSERTS[6] - DELETIONS[6]))
EOF
}
# Filter lines of code into months
loc_per_month() {
local INSERTS=() DELETIONS=() MONTH=12
while read -r FIRST SECOND REST
do
case "$FIRST" in
'-') ;;
[0-9]*)
((MONTH > 11)) && continue
((FIRST > 0)) && ((INSERTS[MONTH] += FIRST))
((SECOND > 0)) && ((DELETIONS[MONTH] += SECOND))
;;
*)
case "$REST" in
Jan*) MONTH=0;;
Feb*) MONTH=1;;
Mar*) MONTH=2;;
Apr*) MONTH=3;;
May*) MONTH=4;;
Jun*) MONTH=5;;
Jul*) MONTH=6;;
Aug*) MONTH=7;;
Sep*) MONTH=8;;
Oct*) MONTH=9;;
Nov*) MONTH=10;;
Dec*) MONTH=11;;
esac
;;
esac
done
cat << EOF
Jan $((INSERTS[0] - DELETIONS[0]))
Feb $((INSERTS[1] - DELETIONS[1]))
Mar $((INSERTS[2] - DELETIONS[2]))
Apr $((INSERTS[3] - DELETIONS[3]))
May $((INSERTS[4] - DELETIONS[4]))
Jun $((INSERTS[5] - DELETIONS[5]))
Jul $((INSERTS[6] - DELETIONS[6]))
Aug $((INSERTS[7] - DELETIONS[7]))
Sep $((INSERTS[8] - DELETIONS[8]))
Oct $((INSERTS[9] - DELETIONS[9]))
Nov $((INSERTS[10] - DELETIONS[10]))
Dec $((INSERTS[11] - DELETIONS[11]))
EOF
}
# Most used words in commit messages; ignoring words up to three letters
#
# @param 1 - number of words (optional)
top_words() {
grep -Eoa '[a-zA-Z0-9]+' |
grep -FvE '(^[a-zA-Z]{1,3}$|^[0-9]+$)' |
sort |
uniq -c |
sort -rn |
head -n "${1:-3}" | while read -r COUNT WORD
do
echo "$WORD $COUNT"
done
}
# Browse all git repositories and create an annual activity report
#
# ... - source directories (optional)
annual_report() {
local SINCE UNTIL
YEAR=${YEAR:-$(date +%Y)}
SINCE="${YEAR}-01-01"
UNTIL="$((YEAR + 1))-01-01"
local AUTHOR AUTHOR_TAGS=''
for AUTHOR in ${AUTHORS:-$(git config user.email)}
do
AUTHOR_TAGS=$AUTHOR_TAGS${AUTHOR_TAGS:+' '}"--author=$AUTHOR"
done
local PROJECTS=0
local COMMITS=0
local CACHE_PREFIX="$PWD/${0##*/}-$$"
local CACHE_ALL="${CACHE_PREFIX}-all"
local CACHE_PROJECT="${CACHE_PREFIX}-project"
local CACHE_LOC="${CACHE_PREFIX}-loc"
local CACHE_MESSAGES="${CACHE_PREFIX}-messages"
rm_cache() {
rm -f "${CACHE_PREFIX}"*
}
rm_cache
local DIRECTORY
while read -r DIRECTORY
do
DIRECTORY=${DIRECTORY%/.git}
# $AUTHOR_TAGS and *_EXTRA_ARGS are expanded and cannot be quoted
# shellcheck disable=SC2086
(cd "$DIRECTORY" && [ -d '.git' ] &&
git log \
--branches \
--since="$SINCE" \
--until="$UNTIL" \
--format='tformat:%aD' \
$AUTHOR_TAGS \
$PROJECTS_EXTRA_ARGS > "$CACHE_PROJECT" &&
git log \
--numstat \
--branches \
--since="$SINCE" \
--until="$UNTIL" \
--format='tformat:%aD' \
$AUTHOR_TAGS \
$LOC_EXTRA_ARGS >> "$CACHE_LOC" &&
git log \
--branches \
--since="$SINCE" \
--until="$UNTIL" \
--format='tformat:{{{%B}}}' \
$AUTHOR_TAGS \
$MESSAGES_EXTRA_ARGS >> "$CACHE_MESSAGES"
) || {
echo "error: failed to process $DIRECTORY" >&2
continue
}
local N
N=$(wc -l < "$CACHE_PROJECT")
((N > 0)) && {
cat "$CACHE_PROJECT" >> "$CACHE_ALL"
((++PROJECTS))
((COMMITS += N))
}
done
echo "In $YEAR ${WHO:-you} made $COMMITS commits in $PROJECTS projects."
((COMMITS > 0)) && {
commit_message_length < "$CACHE_MESSAGES"
echo
echo 'Commits per weekday'
commits_per_weeday < "$CACHE_ALL" | chart
echo
echo 'Commits per month'
commits_per_month < "$CACHE_ALL" | chart
echo
echo 'Lines added (+) and removed (-) per weekday'
loc_per_weeday < "$CACHE_LOC" | POSITIVES='+' chart
echo
echo 'Lines added (+) and removed (-) per month'
loc_per_month < "$CACHE_LOC" | POSITIVES='+' chart
echo
echo 'Top 3 words in commit messages (ignoring words up to three letters)'
top_words 3 < "$CACHE_MESSAGES" | chart
echo
}
rm_cache
unset rm_cache
}
# Create annual reports
#
# @param ... - list file of directories or parent directory
process() {
local QUEUE="$PWD/${0##*/}-$$-queue"
for F
do
if [ -d "$F" ]
then
find "$F" -type d -name '.git' >> "$QUEUE"
else
cat "$F" >> "$QUEUE"
fi
done
annual_report < "$QUEUE"
rm -f "$QUEUE"
}
if [ "${BASH_SOURCE[0]}" == "$0" ]
then
process "$@"
fi
#!/usr/bin/env bash
readonly MONTHS='Jan,Feb,Mar,Apr,May,Jun,Jul,Aug,Sep,Oct,Nov,Dec'
readonly DAYS='Monday,Tuesday,Wednesday,Thursday,Friday,Saturday,Sunday'
# Extract commit totals from given annual reports
#
# @param ... - annual reports
extract_total() {
echo "['Name', 'Commits', 'Projects', 'Length of average commit message'],"
local F REPORTS=
for F
do
COMMITS=$(grep -o 'made [0-9]* commits' < "$F" | cut -d ' ' -f 2)
PROJECTS=$(grep -o 'in [0-9]* projects' < "$F" | cut -d ' ' -f 2)
AVERAGE=$(grep -o 'was [0-9]* characters.' < "$F" | cut -d ' ' -f 2)
echo "['${F##*/}', $((COMMITS)), $((PROJECTS)), $((AVERAGE))],"
done
}
# Extract a certain stat from given annual reports
#
# @param 1 - pattern
# @param 2 - comma separated list of names
# @param ... - annual reports
extract() {
local PATTERN=$1
shift
local NAMES=$1
shift
local F REPORTS=
for F in "$@"
do
[ "$(awk "/$PATTERN/" "$F")" ] || continue
REPORTS=$REPORTS${REPORTS:+', '}"'""${F##*/}""'"
local NAME COUNT
awk 'BEGIN { p = 0 };
/^$/{ p = 0 };
{ if (p) print $1, $2 };
/'"$PATTERN"'/{ p = 1 }' "$F" | while read -r NAME COUNT
do
echo "$COUNT" >> "$0_$$_$NAME"
done
done
echo "['Name', $REPORTS],"
local NAME
for NAME in ${NAMES//,/ }
do
local F="$0_$$_$NAME" COUNTS=
while read -r
do
COUNTS=$COUNTS${COUNTS:+', '}$REPLY
done < "$F"
echo "['$NAME', $COUNTS],"
rm -f "$F"
done
}
# Generate line charts for given annual reports
#
# @param ... - annual reports
generate_charts() {
cat << EOF
<!doctype html>
<html>
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0"/>
<style>
.Chart {
width: 100%;
height: 500px;
}
</style>
<script type="text/javascript" src="https://www.gstatic.com/charts/loader.js"></script>
<script type="text/javascript">
google.charts.load('current', {'packages':['corechart']})
google.charts.setOnLoadCallback(drawCharts)
function drawCharts() {
drawBarChart('commits_total', 'Total', [
$(extract_total "$@")
])
drawChart('commits_per_day', 'Commits per weekday', [
$(extract 'Commits.*per weekday' "$DAYS" "$@")
])
drawChart('commits_per_month', 'Commits per month', [
$(extract 'Commits.*per month' "$MONTHS" "$@")
])
drawChart('lines_per_day', 'Lines per weekday', [
$(extract 'Lines.*per weekday' "$DAYS" "$@")
])
drawChart('lines_per_month', 'Lines per month', [
$(extract 'Lines.*per month' "$MONTHS" "$@")
])
}
function drawBarChart(name, title, table) {
var data = google.visualization.arrayToDataTable(table)
var options = {
title: title,
legend: { position: 'bottom' }
}
var chart = new google.visualization.BarChart(
document.getElementById(name))
chart.draw(data, options)
}
function drawChart(name, title, table) {
var data = google.visualization.arrayToDataTable(table)
var options = {
title: title,
curveType: 'none',
legend: { position: 'bottom' }
}
var chart = new google.visualization.LineChart(
document.getElementById(name))
chart.draw(data, options)
}
</script>
</head>
<body>
<div id="commits_total" class="Chart"></div>
<div id="commits_per_day" class="Chart"></div>
<div id="commits_per_month" class="Chart"></div>
<div id="lines_per_day" class="Chart"></div>
<div id="lines_per_month" class="Chart"></div>
</body>
</html>
EOF
}
if [ "${BASH_SOURCE[0]}" == "$0" ]
then
generate_charts "$@"
fi
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment