Created
April 8, 2026 23:21
-
-
Save syhw/2942ecdd04a631cf1d14459b9218ffe2 to your computer and use it in GitHub Desktop.
analyze a git repo for code and team signals
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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