Last active
November 23, 2025 19:47
-
-
Save rolandboon/6f7a1180807411c309b36e5d50c714ba to your computer and use it in GitHub Desktop.
Repo Stats - A TUI for repository statistics
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
| #!/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