Skip to content

Instantly share code, notes, and snippets.

@rolandboon
Last active November 23, 2025 19:47
Show Gist options
  • Select an option

  • Save rolandboon/6f7a1180807411c309b36e5d50c714ba to your computer and use it in GitHub Desktop.

Select an option

Save rolandboon/6f7a1180807411c309b36e5d50c714ba to your computer and use it in GitHub Desktop.
Repo Stats - A TUI for repository statistics
#!/bin/bash
#
# Repo Stats - A TUI for repository statistics
#
# Usage:
# curl -sL https://gist.github.com/rolandboon/6f7a1180807411c309b36e5d50c714ba/raw/repo_stats.sh | bash
#
# Dependencies:
# - gum (https://github.com/charmbracelet/gum)
# - git
#
if ! command -v gum &> /dev/null; then
echo "gum is not installed. Please install it to run this script."
exit 1
fi
CONFIG_FILE=".repo_stats_config"
PRIMARY="#7D56F4"
SECONDARY="#00D7D7"
ACCENT="#FF79C6"
TEXT="#FFFFFF"
MUTED="#6272A4"
# --- Functions ---
# detect_extensions
#
# Detects the project type based on the presence of configuration files
# (e.g., tsconfig.json, Gemfile) and returns a default list of file
# extensions to analyze.
#
# Returns:
# Comma-separated string of file extensions (e.g., "ts,tsx,js,jsx")
detect_extensions() {
if [ -f "tsconfig.json" ] || [ -f "package.json" ]; then
echo "ts,tsx,js,jsx"
elif [ -f "Gemfile" ]; then
echo "rb,erb"
elif [ -f "composer.json" ]; then
echo "php"
elif [ -f "go.mod" ]; then
echo "go"
elif [ -f "Cargo.toml" ]; then
echo "rs"
elif [ -f "requirements.txt" ] || [ -f "pyproject.toml" ]; then
echo "py"
else
echo "ts,tsx" # Default fallback
fi
}
# setup_wizard
#
# Runs an interactive setup wizard using 'gum' to configure the script.
# It prompts the user for file extensions and component definitions,
# then saves the configuration to .repo_stats_config.
setup_wizard() {
gum style --border double --margin "1 1" --padding "1 2" --border-foreground "$PRIMARY" "Welcome to Repo Stats Setup"
# 1. Extensions
DEFAULT_EXT=$(detect_extensions)
echo "Which file extensions should be included? (comma separated)"
EXTENSIONS=$(gum input --value "$DEFAULT_EXT" --placeholder "ts,tsx" < /dev/tty)
# 2. Components
COMPONENTS_JSON="[]"
while true; do
echo ""
gum style --foreground "$SECONDARY" "Add a Component (e.g., Backend, Frontend)"
NAME=$(gum input --placeholder "Component Name" < /dev/tty)
if [ -z "$NAME" ]; then
break
fi
echo "Source Directory for $NAME:"
SRC_DIR=$(gum input --placeholder "src/" --value "" < /dev/tty)
echo "Test Directory for $NAME (optional):"
TEST_DIR=$(gum input --placeholder "test/" --value "" < /dev/tty)
echo "$NAME|$SRC_DIR|$TEST_DIR" >> "$CONFIG_FILE.tmp"
if ! gum confirm "Add another component?" < /dev/tty; then
break
fi
done
# Save Config
echo "EXTENSIONS=$EXTENSIONS" > "$CONFIG_FILE"
cat "$CONFIG_FILE.tmp" >> "$CONFIG_FILE"
rm "$CONFIG_FILE.tmp"
gum style --foreground "$ACCENT" "Configuration saved to $CONFIG_FILE"
echo ""
}
# count_loc
#
# Counts the lines of code in a directory for specific file extensions.
#
# Arguments:
# $1 - Directory path to search
# $2 - Comma-separated list of file extensions (e.g., "ts,tsx")
#
# Returns:
# Number of lines of code (integer)
count_loc() {
local dir=$1
local exts=$2
if [ -z "$dir" ] || [ ! -d "$dir" ]; then
echo 0
return
fi
# Convert comma-separated extensions to pipe-separated for grep regex
# e.g., "ts,tsx" -> "ts|tsx"
local ext_regex
ext_regex=$(echo "$exts" | sed 's/,/|/g')
# List files respecting gitignore, filter by extension, cat and count lines
# We use git ls-files to respect .gitignore (both tracked and untracked files)
git ls-files --exclude-standard --cached --others "$dir" | \
grep -E "\.($ext_regex)$" | \
while IFS= read -r file; do cat "$file" 2>/dev/null; done | wc -l
}
# make_row
#
# Formats a row for the Lines of Code table using printf.
#
# Arguments:
# $1 - Label (Component Name)
# $2 - Source LOC
# $3 - Test LOC
# $4 - Total LOC
#
# Returns:
# Formatted string
make_row() {
local label=$1
local src=$2
local test=$3
local total=$4
printf "%-15s %-10s %-10s %-10s\n" "$label" "$src" "$test" "$total"
}
# --- Main Execution ---
# Check for config
if [ ! -f "$CONFIG_FILE" ]; then
setup_wizard
fi
# Load Config
# First line is EXTENSIONS=...
source <(head -n 1 "$CONFIG_FILE")
# Header
gum style \
--foreground "$PRIMARY" \
--border-foreground "$PRIMARY" \
--border double \
--align center \
--width 50 \
--margin "1 2" \
--padding "2 4" \
"Repository Statistics"
echo ""
gum style --foreground "$SECONDARY" --bold "Lines of Code ($EXTENSIONS)"
# Process Components
TABLE_ROWS=""
TOTAL_SRC=0
TOTAL_TEST=0
TOTAL_ALL=0
# Read components (lines 2 onwards)
while IFS="|" read -r name src_dir test_dir; do
# Skip empty lines
if [ -z "$name" ]; then continue; fi
SRC_COUNT=$(count_loc "$src_dir" "$EXTENSIONS")
TEST_COUNT=$(count_loc "$test_dir" "$EXTENSIONS")
TOTAL_COUNT=$((SRC_COUNT + TEST_COUNT))
# Update Totals
TOTAL_SRC=$((TOTAL_SRC + SRC_COUNT))
TOTAL_TEST=$((TOTAL_TEST + TEST_COUNT))
TOTAL_ALL=$((TOTAL_ALL + TOTAL_COUNT))
# Format Test Count for display (dash if 0 and dir was empty/missing)
DISPLAY_TEST="$TEST_COUNT"
if [ -z "$test_dir" ] || [ "$TEST_COUNT" -eq 0 ]; then
if [ -z "$test_dir" ]; then DISPLAY_TEST="-"; fi
fi
TABLE_ROWS+="$(make_row "$name" "$SRC_COUNT" "$DISPLAY_TEST" "$TOTAL_COUNT")"$'\n'
done < <(tail -n +2 "$CONFIG_FILE")
# Display LOC Table
gum style --border normal --padding "0 1" --border-foreground "$MUTED" -- \
"$(printf "%-15s %-10s %-10s %-10s" "Component" "Src" "Test" "Total")" \
"$(printf "%-15s %-10s %-10s %-10s" "---------------" "---" "----" "-----")" \
"$TABLE_ROWS" \
"$(printf "%-15s %-10s %-10s %-10s" "---------------" "---" "----" "-----")" \
"$(make_row "TOTAL" "$TOTAL_SRC" "$TOTAL_TEST" "$TOTAL_ALL")"
echo ""
# --- Git Statistics ---
#
# Analyzes the git log to calculate lines added and removed per contributor.
# Uses 'git log --numstat' to get raw numbers and 'awk' to aggregate them.
gum style --foreground "$SECONDARY" --bold "Git Statistics"
echo "Analyzing git history... (this might take a moment)"
STATS_FILE=$(mktemp)
# git log --numstat output format:
# AUTHOR:Name
# added removed filename
# ...
#
# We use awk to:
# 1. Capture the author name from lines starting with AUTHOR:
# 2. Sum up added ($1) and removed ($2) lines for the current author
# 3. Print the aggregated results
git log --format='AUTHOR:%aN' --numstat | awk '
BEGIN { FS="[ \t]+" }
/^AUTHOR:/ {
auth = substr($0, 8)
next
}
/^[0-9]+/ {
a[auth] += $1
r[auth] += $2
}
END {
for (auth in a) {
print auth "|" a[auth] "|" r[auth]
}
}
' | sort -t"|" -k2 -nr > "$STATS_FILE"
MAX_ADDED=$(awk -F"|" 'BEGIN{max=0} {if($2>max) max=$2} END{print max}' "$STATS_FILE")
if [ -z "$MAX_ADDED" ] || [ "$MAX_ADDED" -eq 0 ]; then MAX_ADDED=1; fi
# draw_bar
#
# Generates a text-based bar chart using block characters.
#
# Arguments:
# $1 - Value to represent
# $2 - Maximum value (for scaling)
#
# Returns:
# String of block characters
draw_bar() {
local val=$1
local max=$2
local width=30
local len=$(( val * width / max ))
if [ "$len" -eq 0 ] && [ "$val" -gt 0 ]; then len=1; fi
local bar=""
for ((i=0; i<len; i++)); do bar+="█"; done
echo "$bar"
}
echo ""
gum style --foreground "$MUTED" "Top Contributors (by lines added)"
echo ""
while IFS="|" read -r author added removed; do
if [ "$added" -eq 0 ] && [ "$removed" -eq 0 ]; then continue; fi
ADDED_BAR=$(draw_bar "$added" "$MAX_ADDED")
gum style --foreground "$ACCENT" --bold -- "$author"
gum join --horizontal " " "$(gum style --foreground "#50FA7B" --width 10 -- "+ $added")" "$(gum style --foreground "#50FA7B" "$ADDED_BAR")"
gum join --horizontal " " "$(gum style --foreground "#50FA7B" --width 10 -- "- $removed")"
echo ""
done < <(head -n 10 "$STATS_FILE")
rm "$STATS_FILE"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment