Skip to content

Instantly share code, notes, and snippets.

@syhw
Created April 8, 2026 23:21
Show Gist options
  • Select an option

  • Save syhw/2942ecdd04a631cf1d14459b9218ffe2 to your computer and use it in GitHub Desktop.

Select an option

Save syhw/2942ecdd04a631cf1d14459b9218ffe2 to your computer and use it in GitHub Desktop.
analyze a git repo for code and team signals
#!/usr/bin/env bash
# ============================================================================
# Codebase Analyzer — produces a self-contained HTML report from any git repo
# Usage: ./analyze.sh [path-to-repo] [--since "1 year ago"] [--output report.html]
# Requires: git, bash, sort, uniq, awk, sed, wc, date
# ============================================================================
set -uo pipefail
# ── Defaults ────────────────────────────────────────────────────────────────
REPO_PATH="."
SINCE="2 years ago"
OUTPUT="analysis.html"
TOP_N=25
# ── Arg parsing ─────────────────────────────────────────────────────────────
while [[ $# -gt 0 ]]; do
case "$1" in
--since) SINCE="$2"; shift 2 ;;
--output) OUTPUT="$2"; shift 2 ;;
--top) TOP_N="$2"; shift 2 ;;
-h|--help)
echo "Usage: $0 [repo-path] [--since \"1 year ago\"] [--output file.html] [--top N]"
exit 0 ;;
*) REPO_PATH="$1"; shift ;;
esac
done
cd "$REPO_PATH"
if ! git rev-parse --is-inside-work-tree &>/dev/null; then
echo "Error: $REPO_PATH is not a git repository" >&2
exit 1
fi
REPO_NAME=$(basename "$(git rev-parse --show-toplevel)")
TOTAL_COMMITS=$(git rev-list --count HEAD)
FIRST_COMMIT_DATE=$(git log --reverse --format='%ai' | head -1 | cut -d' ' -f1 || true)
LAST_COMMIT_DATE=$(git log -1 --format='%ai' | cut -d' ' -f1)
AUTHOR_COUNT=$(git log --format='%aN' | sort -u | wc -l | tr -d ' ')
BRANCH_COUNT=$(git branch -a 2>/dev/null | wc -l | tr -d ' ')
TRACKED_FILES=$(git ls-files | wc -l | tr -d ' ')
echo "🔍 Analyzing repository: $REPO_NAME"
echo " Commits: $TOTAL_COMMITS | Authors: $AUTHOR_COUNT | Files: $TRACKED_FILES"
echo " Period filter: since $SINCE"
echo ""
# ── Helper: escape strings for JSON ────────────────────────────────────────
json_escape() {
local s="$1"
s="${s//\\/\\\\}"
s="${s//\"/\\\"}"
s="${s//$'\n'/\\n}"
s="${s//$'\r'/}"
s="${s//$'\t'/\\t}"
printf '%s' "$s"
}
# ── 1. Commit activity by month ─────────────────────────────────────────────
echo " [1/12] Commit activity over time..."
COMMITS_BY_MONTH=$(git log --since="$SINCE" --format='%ad' --date=format:'%Y-%m' | sort | uniq -c | awk '{print $2","$1}')
COMMITS_MONTH_LABELS=""
COMMITS_MONTH_DATA=""
while IFS=',' read -r month count; do
[[ -z "$month" ]] && continue
COMMITS_MONTH_LABELS+="\"$month\","
COMMITS_MONTH_DATA+="$count,"
done <<< "$COMMITS_BY_MONTH"
# ── 2. Top contributors ─────────────────────────────────────────────────────
echo " [2/12] Contributor analysis..."
CONTRIBUTORS=$(git log --since="$SINCE" --no-merges --format='%aN' | sort | uniq -c | sort -nr | head -"$TOP_N")
CONTRIB_LABELS=""
CONTRIB_DATA=""
while IFS= read -r line; do
line=$(echo "$line" | sed 's/^ *//')
count="${line%% *}"
name="${line#* }"
[[ -z "$count" ]] && continue
CONTRIB_LABELS+="\"$(json_escape "$name")\","
CONTRIB_DATA+="$count,"
done <<< "$CONTRIBUTORS"
# ── 3. File churn (most modified files) ──────────────────────────────────────
echo " [3/12] File churn analysis..."
FILE_CHURN=$(git log --since="$SINCE" --format=format: --name-only | grep -v '^$' | sort | uniq -c | sort -nr | head -"$TOP_N")
CHURN_LABELS=""
CHURN_DATA=""
while read -r count filepath; do
[[ -z "$count" ]] && continue
CHURN_LABELS+="\"$(json_escape "$filepath")\","
CHURN_DATA+="$count,"
done <<< "$FILE_CHURN"
# ── 4. Bug hotspots ─────────────────────────────────────────────────────────
echo " [4/12] Bug hotspot detection..."
BUG_HOTSPOTS=$(git log --since="$SINCE" -i -E --grep="fix|bug|broken|crash|patch|issue|error" --name-only --format='' | grep -v '^$' | sort | uniq -c | sort -nr | head -"$TOP_N")
BUG_LABELS=""
BUG_DATA=""
while read -r count filepath; do
[[ -z "$count" ]] && continue
BUG_LABELS+="\"$(json_escape "$filepath")\","
BUG_DATA+="$count,"
done <<< "$BUG_HOTSPOTS"
# ── 5. Activity by day of week ──────────────────────────────────────────────
echo " [5/12] Activity patterns (day of week)..."
DOW_DATA_RAW=$(git log --since="$SINCE" --format='%ad' --date=format:'%u' | sort | uniq -c | sort -k2)
DOW_LABELS='"Mon","Tue","Wed","Thu","Fri","Sat","Sun"'
DOW_COUNTS="0,0,0,0,0,0,0"
declare -a DOW_ARR=(0 0 0 0 0 0 0)
while read -r count day; do
[[ -z "$day" ]] && continue
idx=$((day - 1))
DOW_ARR[$idx]=$count
done <<< "$DOW_DATA_RAW"
DOW_COUNTS="${DOW_ARR[0]},${DOW_ARR[1]},${DOW_ARR[2]},${DOW_ARR[3]},${DOW_ARR[4]},${DOW_ARR[5]},${DOW_ARR[6]}"
# ── 6. Activity by hour ─────────────────────────────────────────────────────
echo " [6/12] Activity patterns (hour of day)..."
HOUR_DATA_RAW=$(git log --since="$SINCE" --format='%ad' --date=format:'%H' | sort | uniq -c | sort -k2)
declare -a HOUR_ARR=()
for i in $(seq 0 23); do HOUR_ARR[$i]=0; done
while read -r count hour; do
[[ -z "$hour" ]] && continue
h=$((10#$hour))
HOUR_ARR[$h]=$count
done <<< "$HOUR_DATA_RAW"
HOUR_LABELS=""
HOUR_DATA=""
for i in $(seq 0 23); do
HOUR_LABELS+="\"$(printf '%02d:00' "$i")\","
HOUR_DATA+="${HOUR_ARR[$i]},"
done
# ── 7. File type distribution ───────────────────────────────────────────────
echo " [7/12] File type distribution..."
FILETYPE_DATA=$(git ls-files | sed 's/.*\.//' | grep -v '/' | sort | uniq -c | sort -nr | head -15)
FT_LABELS=""
FT_DATA=""
while read -r count ext; do
[[ -z "$count" ]] && continue
FT_LABELS+="\".$ext\","
FT_DATA+="$count,"
done <<< "$FILETYPE_DATA"
# ── 8. Code ownership / bus factor ──────────────────────────────────────────
echo " [8/12] Code ownership analysis..."
# For each of the top-churn files, find dominant author
OWNERSHIP_JSON="["
OWNERSHIP_COUNT=0
while read -r _count filepath; do
[[ -z "$filepath" ]] && continue
# Check file still exists
git ls-files --error-unmatch "$filepath" &>/dev/null 2>&1 || continue
top_author=$(git log --since="$SINCE" --format='%aN' -- "$filepath" 2>/dev/null | sort | uniq -c | sort -nr | head -1 | sed 's/^ *[0-9]* *//')
total=$(git log --since="$SINCE" --oneline -- "$filepath" 2>/dev/null | wc -l | tr -d ' ')
dominant=$(git log --since="$SINCE" --format='%aN' -- "$filepath" 2>/dev/null | sort | uniq -c | sort -nr | head -1 | awk '{print $1}')
[[ -z "$total" || "$total" -eq 0 ]] && continue
pct=$(( (dominant * 100) / total ))
OWNERSHIP_JSON+="{\"file\":\"$(json_escape "$filepath")\",\"author\":\"$(json_escape "$top_author")\",\"pct\":$pct,\"commits\":$total},"
OWNERSHIP_COUNT=$((OWNERSHIP_COUNT + 1))
[[ $OWNERSHIP_COUNT -ge 15 ]] && break
done <<< "$FILE_CHURN"
OWNERSHIP_JSON="${OWNERSHIP_JSON%,}]"
# ── 9. Firefighting signals ─────────────────────────────────────────────────
echo " [9/12] Firefighting pattern detection..."
REVERTS=$(git log --oneline --since="$SINCE" | grep -ciE 'revert' || true)
HOTFIXES=$(git log --oneline --since="$SINCE" | grep -ciE 'hotfix|emergency|urgent' || true)
FIXUPS=$(git log --oneline --since="$SINCE" | grep -ciE 'fixup|quick.?fix|band.?aid' || true)
ROLLBACKS=$(git log --oneline --since="$SINCE" | grep -ciE 'rollback|roll.back' || true)
PERIOD_COMMITS=$(git log --oneline --since="$SINCE" | wc -l | tr -d ' ')
# ── 10. Lines added/removed over time ───────────────────────────────────────
echo " [10/12] Code growth trends..."
GROWTH_DATA=$(git log --since="$SINCE" --format='%ad' --date=format:'%Y-%m' --numstat | \
awk '/^[0-9]/ {added+=$1; removed+=$2} /^20[0-9][0-9]-/ {if(month!="") print month","added","removed; month=$0; added=0; removed=0} END {if(month!="") print month","added","removed}')
GROWTH_LABELS=""
GROWTH_ADDED=""
GROWTH_REMOVED=""
while IFS=',' read -r month added removed; do
[[ -z "$month" ]] && continue
GROWTH_LABELS+="\"$month\","
GROWTH_ADDED+="${added:-0},"
GROWTH_REMOVED+="${removed:-0},"
done <<< "$GROWTH_DATA"
# ── 11. File coupling (files that change together) ──────────────────────────
echo " [11/12] File coupling analysis..."
# Use awk to do all pair counting (avoids bash 4 associative arrays)
COUPLING_JSON="["
COUPLING_RAW=$(git log --since="$SINCE" --name-only --pretty=format:'---COMMIT---' | awk '
/^---COMMIT---$/ {
if (n >= 2 && n <= 6) {
for (i = 0; i < n; i++)
for (j = i+1; j < n; j++) {
a = files[i]; b = files[j]
if (a > b) { t = a; a = b; b = t }
pairs[a"|||"b]++
}
}
n = 0; next
}
/^$/ { next }
{ files[n++] = $0 }
END {
for (p in pairs) print pairs[p] " " p
}' | sort -nr | head -20)
while IFS= read -r line; do
[[ -z "$line" ]] && continue
count="${line%% *}"
pair="${line#* }"
file_a="${pair%%|||*}"
file_b="${pair##*|||}"
COUPLING_JSON+="{\"a\":\"$(json_escape "$file_a")\",\"b\":\"$(json_escape "$file_b")\",\"count\":$count},"
done <<< "$COUPLING_RAW"
COUPLING_JSON="${COUPLING_JSON%,}]"
# ── 12. Largest files currently tracked ──────────────────────────────────────
echo " [12/12] Identifying largest files..."
LARGE_FILES_JSON="["
LARGE_COUNT=0
while IFS= read -r filepath; do
[[ -z "$filepath" ]] && continue
size=$(wc -c < "$filepath" 2>/dev/null || echo 0)
lines=$(wc -l < "$filepath" 2>/dev/null || echo 0)
size=$(echo "$size" | tr -d ' ')
lines=$(echo "$lines" | tr -d ' ')
LARGE_FILES_JSON+="{\"file\":\"$(json_escape "$filepath")\",\"bytes\":$size,\"lines\":$lines},"
LARGE_COUNT=$((LARGE_COUNT + 1))
done < <(git ls-files -z | xargs -0 -I{} sh -c 'sz=$(wc -c < "{}" 2>/dev/null) && echo "$sz {}"' 2>/dev/null | sort -nr | head -20 | sed 's/^ *[0-9]* *//')
LARGE_FILES_JSON="${LARGE_FILES_JSON%,}]"
# ── 13. Commit message length distribution ───────────────────────────────────
echo " [bonus] Commit message analysis..."
MSG_LENGTHS=$(git log --since="$SINCE" --format='%s' | awk '{print length($0)}')
MSG_SHORT=$(echo "$MSG_LENGTHS" | awk '$1 < 20' | wc -l | tr -d ' ')
MSG_MEDIUM=$(echo "$MSG_LENGTHS" | awk '$1 >= 20 && $1 < 50' | wc -l | tr -d ' ')
MSG_GOOD=$(echo "$MSG_LENGTHS" | awk '$1 >= 50 && $1 < 72' | wc -l | tr -d ' ')
MSG_LONG=$(echo "$MSG_LENGTHS" | awk '$1 >= 72' | wc -l | tr -d ' ')
# Common words in commit messages (excluding stop words)
TOP_WORDS=$(git log --since="$SINCE" --format='%s' | tr '[:upper:]' '[:lower:]' | tr -cs '[:alpha:]' '\n' | \
grep -vE '^(the|a|an|and|or|but|in|on|at|to|for|of|with|by|from|is|it|as|be|was|are|that|this|into|not|has|have|had|will|can|do|did|no|so|if|up|out|its|all|been|more|also|than|just|when|about|which|only|then|them|they|were|would|each|make|like|our|over|such|after|most|any|these|may|new|could|should|what|some|other|how|very|even|much|get|got|set|see|use|used|we|he|she|his|her|now|one|two|add|added|per|pre|re|src|de|le|la|les|des|un|une|en|et|du|au|ce|que|qui|se|ne|est|pas|pour|sur|par|avec|dans|son|une|fait|pas|ete|tout|elle)$' | \
sort | uniq -c | sort -nr | head -30)
WORDS_LABELS=""
WORDS_DATA=""
WORDS_COUNT=0
while read -r count word; do
[[ -z "$word" || ${#word} -lt 3 ]] && continue
WORDS_LABELS+="\"$word\","
WORDS_DATA+="$count,"
WORDS_COUNT=$((WORDS_COUNT + 1))
[[ $WORDS_COUNT -ge 20 ]] && break
done <<< "$TOP_WORDS"
echo ""
echo "📊 Generating report..."
# ════════════════════════════════════════════════════════════════════════════
# Generate HTML
# ════════════════════════════════════════════════════════════════════════════
cat > "$OUTPUT" << 'HTMLEOF'
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Codebase Analysis</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
<style>
:root {
--bg: #0d1117; --surface: #161b22; --border: #30363d;
--text: #e6edf3; --text2: #8b949e; --accent: #58a6ff;
--green: #3fb950; --red: #f85149; --orange: #d29922;
--purple: #bc8cff; --pink: #f778ba;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: var(--bg); color: var(--text); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; line-height: 1.5; }
.container { max-width: 1400px; margin: 0 auto; padding: 24px; }
h1 { font-size: 28px; margin-bottom: 4px; }
.subtitle { color: var(--text2); margin-bottom: 32px; font-size: 14px; }
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; margin-bottom: 32px; }
.stat-card { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 16px; text-align: center; }
.stat-card .value { font-size: 32px; font-weight: 700; color: var(--accent); }
.stat-card .label { font-size: 12px; color: var(--text2); text-transform: uppercase; letter-spacing: 0.5px; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(580px, 1fr)); gap: 24px; margin-bottom: 24px; }
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 20px; }
.card h2 { font-size: 16px; margin-bottom: 4px; display: flex; align-items: center; gap: 8px; }
.card .desc { font-size: 12px; color: var(--text2); margin-bottom: 16px; }
.card canvas { max-height: 350px; }
.wide { grid-column: 1 / -1; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th, td { padding: 8px 12px; text-align: left; border-bottom: 1px solid var(--border); }
th { color: var(--text2); font-weight: 600; text-transform: uppercase; font-size: 11px; letter-spacing: 0.5px; }
td:first-child { max-width: 400px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bar-inline { display: inline-block; height: 14px; border-radius: 3px; vertical-align: middle; }
.badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 11px; font-weight: 600; }
.badge-red { background: rgba(248,81,73,0.15); color: var(--red); }
.badge-orange { background: rgba(210,153,34,0.15); color: var(--orange); }
.badge-green { background: rgba(63,185,80,0.15); color: var(--green); }
.signal-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px,1fr)); gap: 12px; }
.signal { text-align: center; padding: 16px; border-radius: 8px; background: rgba(255,255,255,0.03); }
.signal .num { font-size: 28px; font-weight: 700; }
.signal .lbl { font-size: 11px; color: var(--text2); text-transform: uppercase; }
.tab-nav { display: flex; gap: 0; margin-bottom: 16px; border-bottom: 1px solid var(--border); }
.tab-btn { background: none; border: none; color: var(--text2); padding: 8px 16px; cursor: pointer; font-size: 13px; border-bottom: 2px solid transparent; }
.tab-btn.active { color: var(--accent); border-bottom-color: var(--accent); }
.tab-content { display: none; }
.tab-content.active { display: block; }
.health-bar { display: flex; gap: 4px; margin-top: 8px; }
.health-segment { height: 8px; border-radius: 4px; }
footer { text-align: center; color: var(--text2); font-size: 12px; padding: 32px 0; border-top: 1px solid var(--border); margin-top: 32px; }
@media (max-width: 640px) { .grid { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<div class="container">
HTMLEOF
# Inject header and summary stats
cat >> "$OUTPUT" << EOF
<h1>$REPO_NAME — Codebase Analysis</h1>
<p class="subtitle">Analysis period: since $SINCE · Generated on $(date '+%Y-%m-%d %H:%M') · Commits analyzed: $PERIOD_COMMITS</p>
<div class="stats-grid">
<div class="stat-card"><div class="value">$TOTAL_COMMITS</div><div class="label">Total Commits</div></div>
<div class="stat-card"><div class="value">$AUTHOR_COUNT</div><div class="label">Contributors</div></div>
<div class="stat-card"><div class="value">$TRACKED_FILES</div><div class="label">Tracked Files</div></div>
<div class="stat-card"><div class="value">$BRANCH_COUNT</div><div class="label">Branches</div></div>
<div class="stat-card"><div class="value">$FIRST_COMMIT_DATE</div><div class="label">First Commit</div></div>
<div class="stat-card"><div class="value">$LAST_COMMIT_DATE</div><div class="label">Last Commit</div></div>
</div>
EOF
# ── Charts ───────────────────────────────────────────────────────────────────
cat >> "$OUTPUT" << EOF
<div class="grid">
<!-- Commit Activity -->
<div class="card wide">
<h2>📈 Commit Activity</h2>
<p class="desc">Monthly commit volume — look for trends, drops, or seasonal patterns</p>
<canvas id="commitActivity"></canvas>
</div>
<!-- Code Growth -->
<div class="card wide">
<h2>📊 Code Growth</h2>
<p class="desc">Lines added vs removed each month — sustained red may signal large refactors or deletions</p>
<canvas id="codeGrowth"></canvas>
</div>
<!-- Contributors -->
<div class="card">
<h2>👥 Top Contributors</h2>
<p class="desc">Commit count by author — high concentration = bus factor risk</p>
<canvas id="contributors"></canvas>
</div>
<!-- Day of Week -->
<div class="card">
<h2>📅 Activity by Day</h2>
<p class="desc">When does development happen? Weekend work may signal crunch</p>
<canvas id="dayOfWeek"></canvas>
</div>
<!-- Hour of Day -->
<div class="card">
<h2>🕐 Activity by Hour</h2>
<p class="desc">Commit distribution across hours (local timezone)</p>
<canvas id="hourOfDay"></canvas>
</div>
<!-- File Types -->
<div class="card">
<h2>📁 File Type Distribution</h2>
<p class="desc">Breakdown of tracked files by extension</p>
<canvas id="fileTypes"></canvas>
</div>
<!-- File Churn -->
<div class="card wide">
<h2>🔥 Most Modified Files (Churn)</h2>
<p class="desc">Frequently changed files often indicate instability, active features, or config drift</p>
<canvas id="fileChurn"></canvas>
</div>
<!-- Bug Hotspots -->
<div class="card wide">
<h2>🐛 Bug Hotspots</h2>
<p class="desc">Files most often touched in commits mentioning "fix", "bug", "crash", etc.</p>
<canvas id="bugHotspots"></canvas>
</div>
<!-- Commit Message Words -->
<div class="card">
<h2>💬 Commit Vocabulary</h2>
<p class="desc">Most common words in commit messages — reveals team focus areas</p>
<canvas id="commitWords"></canvas>
</div>
<!-- Commit Message Quality -->
<div class="card">
<h2>📝 Commit Message Quality</h2>
<p class="desc">Distribution of commit message lengths — short messages often lack context</p>
<canvas id="msgQuality"></canvas>
</div>
</div><!-- /grid -->
<!-- Firefighting Signals -->
<div class="grid">
<div class="card wide">
<h2>🚨 Firefighting Signals</h2>
<p class="desc">Reverts, hotfixes, and emergency commits — high counts suggest instability or weak CI</p>
<div class="signal-grid">
<div class="signal"><div class="num" style="color:var(--red)">$REVERTS</div><div class="lbl">Reverts</div></div>
<div class="signal"><div class="num" style="color:var(--orange)">$HOTFIXES</div><div class="lbl">Hotfixes / Urgent</div></div>
<div class="signal"><div class="num" style="color:var(--orange)">$FIXUPS</div><div class="lbl">Quick fixes</div></div>
<div class="signal"><div class="num" style="color:var(--red)">$ROLLBACKS</div><div class="lbl">Rollbacks</div></div>
<div class="signal"><div class="num" style="color:var(--text2)">$PERIOD_COMMITS</div><div class="lbl">Total Commits</div></div>
</div>
</div>
</div>
<!-- Tables Section -->
<div class="grid">
<!-- Code Ownership -->
<div class="card">
<h2>🏠 Code Ownership</h2>
<p class="desc">Dominant author per high-churn file — 90%+ ownership = single point of failure</p>
<div id="ownershipTable"></div>
</div>
<!-- File Coupling -->
<div class="card">
<h2>🔗 File Coupling</h2>
<p class="desc">Files that change together — expected for related code, suspicious across modules</p>
<div id="couplingTable"></div>
</div>
<!-- Largest Files -->
<div class="card wide">
<h2>📦 Largest Files</h2>
<p class="desc">Biggest files in the repo — oversized files may be candidates for splitting</p>
<div id="largeFilesTable"></div>
</div>
</div><!-- /grid -->
<footer>
Generated by <strong>Codebase Analyzer</strong> · Powered by git + bash · $(date '+%Y-%m-%d %H:%M:%S')
</footer>
</div><!-- /container -->
<script>
// ── Data ────────────────────────────────────────────────────────────────────
const DATA = {
commitMonths: { labels: [${COMMITS_MONTH_LABELS%,}], data: [${COMMITS_MONTH_DATA%,}] },
contributors: { labels: [${CONTRIB_LABELS%,}], data: [${CONTRIB_DATA%,}] },
fileChurn: { labels: [${CHURN_LABELS%,}], data: [${CHURN_DATA%,}] },
bugHotspots: { labels: [${BUG_LABELS%,}], data: [${BUG_DATA%,}] },
dayOfWeek: { labels: [${DOW_LABELS}], data: [${DOW_COUNTS}] },
hourOfDay: { labels: [${HOUR_LABELS%,}], data: [${HOUR_DATA%,}] },
fileTypes: { labels: [${FT_LABELS%,}], data: [${FT_DATA%,}] },
growth: { labels: [${GROWTH_LABELS%,}], added: [${GROWTH_ADDED%,}], removed: [${GROWTH_REMOVED%,}] },
commitWords: { labels: [${WORDS_LABELS%,}], data: [${WORDS_DATA%,}] },
msgQuality: { short: $MSG_SHORT, medium: $MSG_MEDIUM, good: $MSG_GOOD, long: $MSG_LONG },
ownership: ${OWNERSHIP_JSON},
coupling: ${COUPLING_JSON},
largeFiles: ${LARGE_FILES_JSON}
};
// ── Chart defaults ──────────────────────────────────────────────────────────
Chart.defaults.color = '#8b949e';
Chart.defaults.borderColor = '#30363d';
Chart.defaults.font.family = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif";
Chart.defaults.plugins.legend.display = false;
const COLORS = ['#58a6ff','#3fb950','#d29922','#f85149','#bc8cff','#f778ba','#79c0ff','#56d364','#e3b341','#ff7b72','#d2a8ff','#ff9bce','#a5d6ff','#7ee787','#f0c844'];
function colorAlpha(hex, a) {
const r = parseInt(hex.slice(1,3),16), g = parseInt(hex.slice(3,5),16), b = parseInt(hex.slice(5,7),16);
return 'rgba('+r+','+g+','+b+','+a+')';
}
function truncLabel(s, n) { return s.length > n ? '…'+s.slice(-(n-1)) : s; }
// ── Commit Activity ─────────────────────────────────────────────────────────
new Chart(document.getElementById('commitActivity'), {
type: 'line',
data: {
labels: DATA.commitMonths.labels,
datasets: [{
data: DATA.commitMonths.data,
borderColor: '#58a6ff', backgroundColor: colorAlpha('#58a6ff', 0.1),
fill: true, tension: 0.3, pointRadius: 3, borderWidth: 2
}]
},
options: { scales: { y: { beginAtZero: true } }, plugins: { tooltip: { mode: 'index' } } }
});
// ── Code Growth ─────────────────────────────────────────────────────────────
new Chart(document.getElementById('codeGrowth'), {
type: 'bar',
data: {
labels: DATA.growth.labels,
datasets: [
{ label: 'Added', data: DATA.growth.added, backgroundColor: colorAlpha('#3fb950', 0.7) },
{ label: 'Removed', data: DATA.growth.removed.map(v => -v), backgroundColor: colorAlpha('#f85149', 0.7) }
]
},
options: { scales: { x: { stacked: true }, y: { stacked: true } }, plugins: { legend: { display: true } } }
});
// ── Contributors ────────────────────────────────────────────────────────────
new Chart(document.getElementById('contributors'), {
type: 'bar',
data: {
labels: DATA.contributors.labels.map(l => truncLabel(l, 20)),
datasets: [{ data: DATA.contributors.data, backgroundColor: COLORS.map(c => colorAlpha(c, 0.7)), borderColor: COLORS, borderWidth: 1 }]
},
options: { indexAxis: 'y', scales: { x: { beginAtZero: true } } }
});
// ── Day of Week ─────────────────────────────────────────────────────────────
new Chart(document.getElementById('dayOfWeek'), {
type: 'bar',
data: {
labels: DATA.dayOfWeek.labels,
datasets: [{ data: DATA.dayOfWeek.data, backgroundColor: DATA.dayOfWeek.data.map((v,i) => i>=5 ? colorAlpha('#f85149',0.6) : colorAlpha('#58a6ff',0.6)) }]
},
options: { scales: { y: { beginAtZero: true } } }
});
// ── Hour of Day ─────────────────────────────────────────────────────────────
new Chart(document.getElementById('hourOfDay'), {
type: 'bar',
data: {
labels: DATA.hourOfDay.labels,
datasets: [{ data: DATA.hourOfDay.data, backgroundColor: colorAlpha('#bc8cff', 0.6), borderColor: '#bc8cff', borderWidth: 1 }]
},
options: { scales: { y: { beginAtZero: true } } }
});
// ── File Types ──────────────────────────────────────────────────────────────
new Chart(document.getElementById('fileTypes'), {
type: 'doughnut',
data: {
labels: DATA.fileTypes.labels,
datasets: [{ data: DATA.fileTypes.data, backgroundColor: COLORS, borderWidth: 0 }]
},
options: { plugins: { legend: { display: true, position: 'right', labels: { boxWidth: 12 } } } }
});
// ── File Churn ──────────────────────────────────────────────────────────────
new Chart(document.getElementById('fileChurn'), {
type: 'bar',
data: {
labels: DATA.fileChurn.labels.map(l => truncLabel(l, 40)),
datasets: [{ data: DATA.fileChurn.data, backgroundColor: colorAlpha('#d29922', 0.6), borderColor: '#d29922', borderWidth: 1 }]
},
options: { indexAxis: 'y', scales: { x: { beginAtZero: true } } }
});
// ── Bug Hotspots ────────────────────────────────────────────────────────────
new Chart(document.getElementById('bugHotspots'), {
type: 'bar',
data: {
labels: DATA.bugHotspots.labels.map(l => truncLabel(l, 40)),
datasets: [{ data: DATA.bugHotspots.data, backgroundColor: colorAlpha('#f85149', 0.6), borderColor: '#f85149', borderWidth: 1 }]
},
options: { indexAxis: 'y', scales: { x: { beginAtZero: true } } }
});
// ── Commit Words ────────────────────────────────────────────────────────────
new Chart(document.getElementById('commitWords'), {
type: 'bar',
data: {
labels: DATA.commitWords.labels,
datasets: [{ data: DATA.commitWords.data, backgroundColor: colorAlpha('#f778ba', 0.6), borderColor: '#f778ba', borderWidth: 1 }]
},
options: { indexAxis: 'y', scales: { x: { beginAtZero: true } } }
});
// ── Message Quality ─────────────────────────────────────────────────────────
new Chart(document.getElementById('msgQuality'), {
type: 'doughnut',
data: {
labels: ['Too short (<20)', 'Brief (20-49)', 'Good (50-71)', 'Verbose (72+)'],
datasets: [{ data: [DATA.msgQuality.short, DATA.msgQuality.medium, DATA.msgQuality.good, DATA.msgQuality.long],
backgroundColor: [colorAlpha('#f85149',0.7), colorAlpha('#d29922',0.7), colorAlpha('#3fb950',0.7), colorAlpha('#58a6ff',0.7)], borderWidth: 0 }]
},
options: { plugins: { legend: { display: true, position: 'bottom', labels: { boxWidth: 12 } } } }
});
// ── Tables ──────────────────────────────────────────────────────────────────
function renderOwnership(data) {
if (!data.length) return '<p style="color:var(--text2)">No data</p>';
let h = '<table><tr><th>File</th><th>Dominant Author</th><th>Ownership</th><th>Commits</th></tr>';
data.forEach(d => {
const badge = d.pct >= 90 ? 'badge-red' : d.pct >= 70 ? 'badge-orange' : 'badge-green';
h += '<tr><td title="'+d.file+'">'+truncLabel(d.file,50)+'</td><td>'+d.author+'</td><td><span class="badge '+badge+'">'+d.pct+'%</span></td><td>'+d.commits+'</td></tr>';
});
return h+'</table>';
}
document.getElementById('ownershipTable').innerHTML = renderOwnership(DATA.ownership);
function renderCoupling(data) {
if (!data.length) return '<p style="color:var(--text2)">No coupling data</p>';
let h = '<table><tr><th>File A</th><th>File B</th><th>Co-changes</th></tr>';
data.forEach(d => {
h += '<tr><td title="'+d.a+'">'+truncLabel(d.a,35)+'</td><td title="'+d.b+'">'+truncLabel(d.b,35)+'</td><td>'+d.count+'</td></tr>';
});
return h+'</table>';
}
document.getElementById('couplingTable').innerHTML = renderCoupling(DATA.coupling);
function renderLargeFiles(data) {
if (!data.length) return '<p style="color:var(--text2)">No data</p>';
const maxBytes = Math.max(...data.map(d => d.bytes));
let h = '<table><tr><th>File</th><th>Size</th><th>Lines</th><th></th></tr>';
data.forEach(d => {
const kb = (d.bytes/1024).toFixed(1);
const pct = (d.bytes/maxBytes*100).toFixed(0);
h += '<tr><td title="'+d.file+'">'+truncLabel(d.file,60)+'</td><td>'+kb+' KB</td><td>'+d.lines.toLocaleString()+'</td><td><div class="bar-inline" style="width:'+pct+'%;background:var(--accent);min-width:4px"></div></td></tr>';
});
return h+'</table>';
}
document.getElementById('largeFilesTable').innerHTML = renderLargeFiles(DATA.largeFiles);
</script>
</body>
</html>
EOF
echo ""
echo "✅ Report generated: $OUTPUT"
echo " Open it in a browser: open $OUTPUT"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment