Files
dotfiles/local/bin/git-dirty

1083 lines
31 KiB
Bash
Executable File

#!/usr/bin/env bash
# Get git repository status for all subdirectories
# recursively in specified dir.
#
# Check the default dir:
# `git-dirty.sh`
# Check specific dir:
# `git-dirty.sh ~/Projects`
# Override default dir with env:
# `GIT_DIRTY_DIR=$HOME/Projects git-dirty.sh`
#
# If you want to skip directory from checks, just add `.ignore` file next
# to the `.git` folder. ProTip: Add `.ignore` to your global `.gitignore`.
#
# The script automatically skips folders:
# node_modules, vendor
#
# Copyright Ismo Vuorinen (2023)
# Licensed under the MIT License
# More robust error handling
set -o pipefail # Fail if any command in a pipe fails
# SET Defaults:
# Default dir to check, can be overridden in env (.bashrc, .zshrc, ...)
: "${GIT_DIRTY_DIR:=$HOME/Code}"
: "${VERBOSE:=0}" # Enable verbosity with VERBOSE=1
: "${PARALLEL:=0}" # Enable parallel processing with PARALLEL=1
: "${GIT_DIRTY_DEPTH:=5}" # How deep to show non-git directories
: "${GIT_DIRTY_MAXDEPTH:=15}" # Maximum recursion depth
: "${GIT_DIRTY_CHECK_STASH:=0}" # Whether to check if there are stashed changes
: "${GIT_DIRTY_CHECK_UNTRACKED:=1}" # Whether to report untracked files
: "${GIT_DIRTY_EXCLUDE:=node_modules vendor .cache build dist .tests .test}" # Directories to exclude
: "${GIT_DIRTY_COLOR:=1}" # Enable colorized output (0 to disable)
: "${GIT_DIRTY_TRUNCATE:=1}" # Truncate paths to be relative to base dir
: "${GIT_DIRTY_SHOW_BRANCH:=1}" # Show current branch name after directory
: "${GIT_DIRTY_MAIN_BRANCHES:=main master trunk}" # List of main branches, don't show names
# Load configuration from file
config_file="${XDG_CONFIG_HOME:-$HOME/.config}/git-dirty/config"
if [[ -f $config_file ]]; then
# shellcheck source=/dev/null
source "$config_file"
fi
# Check if colors should be enabled
use_color()
{
[[ $GIT_DIRTY_COLOR -eq 1 && -t 1 ]]
}
# Color printing function
print_color()
{
local color text
color="$1"
text="$2"
if use_color; then
printf "\033[%sm%s\033[0m" "$color" "$text"
else
printf "%s" "$text"
fi
}
# Define colored icons
if use_color; then
GIT_CLEAN=$(print_color "32" "✅") # Green check mark
GIT_DIRTY=$(print_color "31" "❌") # Red X
NOT_GIT=$(print_color "33" "⚠️") # Yellow warning
BRANCH_COLOR="35" # Purple for branch names
STATUS_COLOR="33" # Yellow for status codes
else
GIT_CLEAN="✅"
GIT_DIRTY="❌"
NOT_GIT="⚠️"
BRANCH_COLOR=""
STATUS_COLOR=""
fi
# Global base directory (used for path truncation)
BASE_DIR=""
# Temporary file for storing results when not using parallel mode
RESULTS_FILE=""
# Keep track of directories that contain git repos
declare -A DIR_HAS_REPOS
# Record start time
START_TIME=$(date +%s)
# Logging functions
log_error()
{
print_color "31" "ERROR:" >&2
echo " $*" >&2
}
log_info()
{
if [[ $VERBOSE -eq 1 ]]; then
print_color "36" "INFO:"
echo " $*"
fi
}
log_warn()
{
print_color "33" "WARNING:" >&2
echo " $*" >&2
}
# Truncate a path to be relative to base directory
truncate_path()
{
local path
path="$1"
if [[ $GIT_DIRTY_TRUNCATE -eq 1 && -n $BASE_DIR && $path == "$BASE_DIR"* ]]; then
# First show the base dir only for the root
if [[ $path == "$BASE_DIR" ]]; then
echo "$path"
else
# For other paths, make them relative to base dir
echo "${path#"$BASE_DIR"/}"
fi
else
# If truncation is disabled, show full paths
echo "$path"
fi
}
# Check if a branch is a main branch
is_main_branch()
{
local branch
branch="$1"
for main in $GIT_DIRTY_MAIN_BRANCHES; do
if [[ $branch == "$main" ]]; then
return 0 # It's a main branch (exit code 0 means true in bash)
fi
done
return 1 # Not a main branch (exit code 1 means false in bash)
}
# Error handling function
handle_error()
{
local exit_code line_no
exit_code=$1
line_no=$2
case $exit_code in
1) log_error "General error at line $line_no" ;;
126) log_error "Command not executable at line $line_no" ;;
127) log_error "Command not found at line $line_no" ;;
128) log_error "Invalid argument to exit at line $line_no" ;;
130) log_info "Script terminated by Ctrl+C" ;;
139 | 11) log_error "Segmentation fault occurred at line $line_no" ;;
*) log_error "Unknown error $exit_code at line $line_no" ;;
esac
}
# Get current branch name
get_branch_name()
{
git symbolic-ref --short HEAD 2> /dev/null \
|| git describe --tags --exact-match 2> /dev/null \
|| git rev-parse --short HEAD 2> /dev/null \
|| echo "detached"
}
# More comprehensive git status check
check_git_status()
{
local repo_path status branch
repo_path="$1"
status=""
# Check for dirty files (modified/staged)
git -C "$repo_path" diff --no-ext-diff --quiet 2> /dev/null || status+="M"
git -C "$repo_path" diff --no-ext-diff --cached --quiet 2> /dev/null || status+="S"
# Check for untracked files if enabled
if [[ $GIT_DIRTY_CHECK_UNTRACKED -eq 1 ]]; then
[[ -n $(git -C "$repo_path" ls-files --others --exclude-standard 2> /dev/null) ]] && status+="?"
fi
# Check for stashed changes if enabled
if [[ $GIT_DIRTY_CHECK_STASH -eq 1 ]]; then
[[ -f "$(git -C "$repo_path" rev-parse --git-dir 2> /dev/null)/refs/stash" ]] && status+="$"
fi
# Check for unpushed commits
branch=$(git -C "$repo_path" symbolic-ref --short HEAD 2> /dev/null)
if [[ -n $branch ]]; then
git -C "$repo_path" rev-list "@{u}".. --count 2> /dev/null \
| grep -q '^0$' 2> /dev/null \
|| status+="↑"
fi
# Return the status
echo "$status"
}
# Function to check if a directory should be excluded
should_exclude()
{
local dir basename
dir="$1"
basename="${dir##*/}"
# Quick path for node_modules and vendor which are common
[[ $basename == "node_modules" || $basename == "vendor" ]] && return 0
# Check explicit exclude list
for exclude in $GIT_DIRTY_EXCLUDE; do
[[ $basename == "$exclude" ]] && return 0
done
# Check for ignore file - only if we're not already checking exclude patterns
[[ -e "$dir/.ignore" ]] && return 0
return 1
}
# Format a path for sorting to maintain tree structure
# Add a special suffix to directory entries to ensure they appear first
format_path_for_sorting()
{
local path is_dir normalized
path="$1"
is_dir="$2" # 1 for directory, 0 for file/git repo
# Normalize path to use / separator and remove trailing slashes
normalized=$(echo "$path" | tr -s '/' | sed 's:/*$::')
# Add a suffix to control sort order - directories first
if [[ $is_dir -eq 1 ]]; then
echo "${normalized}/0" # Make directories come first in sort by adding '/0'
else
echo "${normalized}/1" # Regular git repos get '/1' suffix
fi
}
# Extract the basename of a path
get_basename()
{
local path
path="$1"
basename "$path"
}
# Format time duration in a human-readable way
format_duration()
{
local seconds minutes remaining_seconds
seconds=$1
minutes=$((seconds / 60))
remaining_seconds=$((seconds % 60))
if [[ $minutes -gt 0 ]]; then
echo "${minutes}m ${remaining_seconds}s"
else
echo "${seconds}s"
fi
}
# Mark a directory and all its parents as containing git repos
mark_directory_with_repos()
{
local dir path
dir="$1"
path="$dir"
while [[ $path != "$BASE_DIR" && $path != "/" && -n $path ]]; do
DIR_HAS_REPOS["$path"]=1
path=$(dirname "$path")
done
# Mark the base directory too
DIR_HAS_REPOS["$BASE_DIR"]=1
}
# Better output formatting with tree-like structure and saving to file
print_result()
{
local depth path icon status branch indent \
is_dir display_path basename_path sort_path \
output_line status_display
depth=$1
path=$2
icon=$3
status=$4
branch=$5
indent=$(printf "%*s" $((depth * 2)) "")
is_dir=0
# Only continue if this is a git repository or a container directory
if [[ $icon == "$NOT_GIT" && $VERBOSE -eq 0 && -z ${DIR_HAS_REPOS["$path"]} ]]; then
return
fi
# Mark as directory if it's not a git repo
if [[ $icon == "$NOT_GIT" ]]; then
is_dir=1
fi
# Truncate path if enabled
display_path=$(truncate_path "$path")
# For display, we only want the basename of the path
basename_path=$(get_basename "$display_path")
# For root node, use the full path
if [[ $path == "$BASE_DIR" ]]; then
basename_path="$display_path"
fi
# Format path for sorting - directories first
sort_path=$(format_path_for_sorting "$display_path" "$is_dir")
# Format output with status, branch name if available and enabled
if [[ $icon == "$NOT_GIT" ]]; then
# Non-git directory
output_line=$(printf "%s %s %s" "$indent" "$icon" "$basename_path")
elif [[ -z $status ]]; then
# Clean git repo
if [[ $GIT_DIRTY_SHOW_BRANCH -eq 1 && -n $branch ]]; then
# Only show branch if it's not a main branch
if is_main_branch "$branch"; then
output_line=$(printf "%s %s %s" "$indent" "$icon" "$basename_path")
else
output_line=$(printf "%s %s %s %s" "$indent" "$icon" "$basename_path" "$(print_color "$BRANCH_COLOR" "($branch)")")
fi
else
output_line=$(printf "%s %s %s" "$indent" "$icon" "$basename_path")
fi
else
# Dirty git repo
status_display="$(print_color "$STATUS_COLOR" "[$status]")"
if [[ $GIT_DIRTY_SHOW_BRANCH -eq 1 && -n $branch ]]; then
# Only show branch if it's not a main branch
if is_main_branch "$branch"; then
output_line=$(printf "%s %s %s %s" "$indent" "$icon" "$basename_path" "$status_display")
else
output_line=$(printf "%s %s %s %s %s" "$indent" "$icon" "$basename_path" "$status_display" "$(print_color "$BRANCH_COLOR" "($branch)")")
fi
else
output_line=$(printf "%s %s %s %s" "$indent" "$icon" "$basename_path" "$status_display")
fi
fi
# If we're collecting results for sorting, add to file with a path-based sorting key
if [[ -n $RESULTS_FILE ]]; then
echo "$sort_path:$output_line" >> "$RESULTS_FILE"
else
# Direct output
echo "$output_line"
fi
}
# Display sorted results from the results file
display_sorted_results()
{
if [[ -n $RESULTS_FILE && -f $RESULTS_FILE ]]; then
# Sort by path for proper tree hierarchy, with our special suffix ensuring directories come first
LC_ALL=C sort "$RESULTS_FILE" | cut -d: -f2- | sed 's/^://'
rm -f "$RESULTS_FILE"
fi
}
# Function to check the git status of a directory
# $1 - directory (string)
# $2 - depth (number, optional)
git_dirty()
{
local d depth orig_dir status_text branch_name icon elapsed_time rate has_git_repos
d="$1"
depth="${2:-0}"
orig_dir=$(pwd)
# Initialize a global counter for git repos if at root level
if [[ $depth -eq 0 ]]; then
GIT_REPO_COUNT=0
echo "Finding git repositories..."
# Create a temporary file for results if needed
if [[ -z $RESULTS_FILE ]]; then
RESULTS_FILE=$(mktemp)
fi
# Reset the directory tracking
declare -g -A DIR_HAS_REPOS=()
fi
if [[ ! -d $d ]]; then
return
fi
# Check if directory should be excluded
if should_exclude "$d"; then
log_info "Skipping excluded directory: $d"
return
fi
# Check depth limit
if [[ $depth -gt $GIT_DIRTY_MAXDEPTH ]]; then
log_info "Max depth reached at $d"
return
fi
cd "$d" 2> /dev/null || {
log_warn "Cannot access $d"
return
}
# If we have `.git` folder, check it
if [[ -d ".git" ]]; then
GIT_REPO_COUNT=$((GIT_REPO_COUNT + 1))
# Mark this directory and all parent directories as containing repos
mark_directory_with_repos "$d"
# Get status and branch information
status_text=$(check_git_status "$d")
branch_name=$(get_branch_name)
# Determine icon based on status
if [[ -z $status_text ]]; then
icon="$GIT_CLEAN"
else
icon="$GIT_DIRTY"
fi
# Add to results
print_result "$depth" "$(pwd)" "$icon" "$status_text" "$branch_name"
# Show count when the repo count changes
if [[ $depth -eq 0 || $((GIT_REPO_COUNT % 5)) -eq 0 ]]; then
elapsed_time=$(($(date +%s) - START_TIME))
rate=$((GIT_REPO_COUNT > 0 && elapsed_time > 0 ? GIT_REPO_COUNT / elapsed_time : 0))
echo -ne "Found $GIT_REPO_COUNT git repositories... (${rate} repos/sec)\r"
fi
else
# Process all subdirectories recursively (sorted)
local subdirs=()
while IFS= read -r subdir; do
if [[ -d $subdir && ! -L $subdir ]]; then # Not a symlink
subdirs+=("$subdir")
fi
done < <(find . -mindepth 1 -maxdepth 1 -type d 2> /dev/null | LC_ALL=C sort)
# Process each subdirectory
has_git_repos=0
for subdir in "${subdirs[@]}"; do
local full_path
full_path="$d/${subdir#./}"
git_dirty "$full_path" $((depth + 1))
# Check if this subdirectory contains git repos
if [[ -n ${DIR_HAS_REPOS["$full_path"]} ]]; then
has_git_repos=1
fi
done
# After processing subdirectories, check if this directory contains repos
if [[ $has_git_repos -eq 1 || -n ${DIR_HAS_REPOS["$d"]} ]]; then
# This is a non-git directory but contains git repos - print it
print_result "$depth" "$d" "$NOT_GIT" "" ""
fi
fi
# Print final count when done with root scan
if [[ $depth -eq 0 ]]; then
echo -ne " \r" # Clear the progress line
# Display sorted results
display_sorted_results
local end_time elapsed_time formatted_time
# Calculate and display elapsed time
end_time=$(date +%s)
elapsed_time=$((end_time - START_TIME))
formatted_time=$(format_duration $elapsed_time)
# Show summary
echo -e "\nFound $GIT_REPO_COUNT git repositories in $formatted_time"
fi
cd "$orig_dir" || {
log_error "Failed to return to original directory"
exit 1
}
}
# Function to process directories in parallel
process_in_parallel()
{
local base_dir search_start_time
base_dir="$1"
search_start_time=$(date +%s)
echo "Finding git repositories..."
# Find all git repositories efficiently
local repos=()
declare -A repo_parents=()
while IFS= read -r repo; do
if [[ -d $repo ]]; then
repos+=("$repo")
# Track the parent directories for each repo
local parent
parent=$(dirname "$repo")
while [[ $parent != "$base_dir" && $parent != "/" ]]; do
repo_parents["$parent"]=1
parent=$(dirname "$parent")
done
fi
done < <(find "$base_dir" -type d -name ".git" -prune 2> /dev/null | sed 's/\/\.git$//')
local total search_time
total=${#repos[@]}
search_time=$(($(date +%s) - search_start_time))
echo "Found $total git repositories in $(format_duration $search_time)"
if [[ $total -eq 0 ]]; then
echo "No git repositories found in $base_dir"
return
fi
# Check if we have GNU parallel specifically
local have_gnu_parallel
have_gnu_parallel=0
if command -v parallel &> /dev/null; then
if parallel --version 2> /dev/null | grep -q "GNU parallel"; then
have_gnu_parallel=1
echo "Processing $total repositories using GNU parallel..."
else
echo "Non-GNU parallel detected (moreutils). Using manual parallelization..."
fi
else
echo "Parallel not found. Using manual parallelization..."
fi
local cores max_jobs processing_start_time
# Use max CPU cores but not more than 8
cores=$(sysctl -n hw.ncpu 2> /dev/null || nproc 2> /dev/null || echo 4)
max_jobs=$((cores > 8 ? 8 : cores))
processing_start_time=$(date +%s)
if [[ $have_gnu_parallel -eq 1 ]]; then
# GNU parallel approach
export -f check_git_status truncate_path print_color \
get_branch_name format_path_for_sorting get_basename is_main_branch
export GIT_DIRTY_TRUNCATE BASE_DIR GIT_CLEAN GIT_DIRTY \
GIT_DIRTY_CHECK_STASH GIT_DIRTY_CHECK_UNTRACKED \
GIT_DIRTY_SHOW_BRANCH GIT_DIRTY_MAIN_BRANCHES BRANCH_COLOR STATUS_COLOR
# Create a temporary file for results
local parallel_results
parallel_results=$(mktemp)
# Create a shell function for parallel to use
process_repo()
{
local repo
repo=$1
cd "$repo" 2> /dev/null || return
local status_text display_path basename_path branch rel_path depth indent
status_text=$(check_git_status "$repo")
display_path=$(truncate_path "$repo")
basename_path=$(get_basename "$display_path")
branch=$(get_branch_name)
# Calculate depth for proper indentation
rel_path="${repo#"$BASE_DIR"/}"
depth=$(echo "$rel_path" | tr -cd '/' | wc -c)
indent=$(printf "%*s" $((depth * 2)) "")
# Determine icon based on status
local icon
if [[ -z $status_text ]]; then
icon="$GIT_CLEAN"
else
icon="$GIT_DIRTY"
fi
# Format output with status and branch name if needed
local output
if [[ -z $status_text ]]; then
# Clean git repo
if [[ $GIT_DIRTY_SHOW_BRANCH -eq 1 && -n $branch ]]; then
# Only show branch if it's not a main branch
if is_main_branch "$branch"; then
output="$indent $icon $basename_path"
else
output="$indent $icon $basename_path $(print_color "$BRANCH_COLOR" "($branch)")"
fi
else
output="$indent $icon $basename_path"
fi
else
# Dirty git repo
local status_display
status_display="$(print_color "$STATUS_COLOR" "[$status_text]")"
if [[ $GIT_DIRTY_SHOW_BRANCH -eq 1 && -n $branch ]]; then
# Only show branch if it's not a main branch
if is_main_branch "$branch"; then
output="$indent $icon $basename_path $status_display"
else
output="$indent $icon $basename_path $status_display $(print_color "$BRANCH_COLOR" "($branch)")"
fi
else
output="$indent $icon $basename_path $status_display"
fi
fi
# Path-based sorting key for proper tree structure - append '/1' to make git repos come after directories
echo "$rel_path/1:$output"
}
export -f process_repo
parallel --bar -j "$max_jobs" process_repo ::: "${repos[@]}" > "$parallel_results"
# Process parent directories to ensure tree structure
for parent in "${!repo_parents[@]}"; do
local rel_path depth basename indent
rel_path="${parent#"$BASE_DIR"/}"
depth=$(echo "$rel_path" | tr -cd '/' | wc -c)
basename=$(get_basename "$parent")
indent=$(printf "%*s" $((depth * 2)) "")
# Append '/0' to make directories come before their contents
echo "$rel_path/0:$indent $NOT_GIT $basename" >> "$parallel_results"
done
# Also add the root directory (with empty sort key to put it first)
echo "/0:$NOT_GIT $BASE_DIR" >> "$parallel_results"
# Sort and display results - path-based sort for proper tree structure
LC_ALL=C sort "$parallel_results" | cut -d: -f2- | sed 's/^://'
rm -f "$parallel_results"
else
local temp_output progress_file running_file temp_files job_count
# Manual parallelization with progress bar
echo "Using $max_jobs parallel jobs..."
# Use temp files to collect results and track progress
temp_output=$(mktemp)
progress_file=$(mktemp)
echo "0" > "$progress_file" # Initialize progress counter
# To track if we're running or should stop
running_file=$(mktemp)
echo "1" > "$running_file" # 1 means running, 0 means stop
# Store temp files for cleanup in trap
temp_files=("$temp_output" "$progress_file" "$running_file")
# Cleanup function
cleanup()
{
echo -e "\nCleaning up..."
echo "0" > "$running_file" # Signal progress bar to stop
# Kill all background processes in our process group
# We use SIGTERM to allow processes to clean up
pkill -P $$ TERM 2> /dev/null || true
# Clean up temp files
for file in "${temp_files[@]}"; do
[[ -f $file ]] && rm -f "$file"
done
# Clear the line and restore cursor
echo -ne "\r\033[K"
exit 1
}
# Set up trap to catch CTRL+C and other termination signals
trap cleanup INT TERM HUP
# Progress bar function
update_progress()
{
local current total width percent completed elapsed rate eta
current=$1
total=$2
width=50 # width of the progress bar
percent=$((current * 100 / total))
completed=$((width * current / total))
elapsed=$(($(date +%s) - processing_start_time))
rate=$((current > 0 && elapsed > 0 ? current / elapsed : 0))
eta=$((rate > 0 ? (total - current) / rate : 0))
# Create the progress bar
local bar="["
for ((i = 0; i < completed; i++)); do bar+="="; done
if [[ $completed -lt $width ]]; then bar+=">" && ((completed++)); fi
for ((i = completed; i < width; i++)); do bar+=" "; done
bar+="]"
# Print the progress bar with ETA
printf "\r%s %d/%d (%d%%) %d repos/s ETA: %s" \
"$bar" "$current" "$total" "$percent" "$rate" "$(format_duration $eta)"
}
# Process repositories with progress tracking
job_count=0
# Start a background process to update the progress bar
(
while true; do
sleep 0.2 # Update every 0.2 seconds
# Check if we should continue running
if [[ -f $running_file && "$(cat "$running_file")" == "1" ]]; then
if [[ -f $progress_file ]]; then
local progress
progress=$(cat "$progress_file")
update_progress "$progress" "$total"
# Exit when we reach the total
if [[ $progress -ge $total ]]; then
break
fi
fi
else
# Stop if signaled to do so
break
fi
done
# Clear the line when done
echo -ne "\r\033[K"
# Show completion message only if we finished normally
if [[ -f $progress_file ]]; then
local final_progress
final_progress=$(cat "$progress_file")
if [[ $final_progress -ge $total ]]; then
echo "Processing complete!"
fi
fi
) &
local progress_pid
progress_pid=$!
# Process repositories
for repo in "${repos[@]}"; do
# Check if we should continue
if [[ "$(cat "$running_file")" != "1" ]]; then
break
fi
(
cd "$repo" 2> /dev/null || exit
local status_text branch icon display_path depth indent basename_path
# Get status and branch information
status_text=""
# Check for modified files
git diff --quiet 2> /dev/null || status_text+="M"
# Check for staged changes
git diff --cached --quiet 2> /dev/null || status_text+="S"
# Check for untracked files
if [[ $GIT_DIRTY_CHECK_UNTRACKED -eq 1 ]]; then
[[ -n $(git ls-files --others --exclude-standard 2> /dev/null | head -1) ]] && status_text+="?"
fi
# Check for stashed changes
if [[ $GIT_DIRTY_CHECK_STASH -eq 1 ]]; then
[[ -f "$(git rev-parse --git-dir 2> /dev/null)/refs/stash" ]] && status_text+="$"
fi
# Check for unpushed commits
eval "git rev-list @{u}.. --count" 2> /dev/null \
| grep -q '^0$' 2> /dev/null || status_text+="↑"
# Get branch name
branch=$(get_branch_name)
# Determine icon based on status
if [[ -z $status_text ]]; then
icon="$GIT_CLEAN"
else
icon="$GIT_DIRTY"
fi
# Get path for display
if [[ $GIT_DIRTY_TRUNCATE -eq 1 ]]; then
display_path="${repo#"$BASE_DIR"/}"
else
display_path="$repo"
fi
# Calculate depth for indentation
depth=$(echo "$display_path" | tr -cd '/' | wc -c)
indent=$(printf "%*s" $((depth * 2)) "")
# Get basename for display
basename_path=$(get_basename "$display_path")
# Generate output with branch info if enabled
if [[ -z $status_text ]]; then
# Clean repo
if [[ $GIT_DIRTY_SHOW_BRANCH -eq 1 && -n $branch ]]; then
# Only show branch if it's not a main branch
if is_main_branch "$branch"; then
echo "$display_path/1:$indent $icon $basename_path" >> "$temp_output"
else
echo "$display_path/1:$indent $icon $basename_path $(print_color "$BRANCH_COLOR" "($branch)")" >> "$temp_output"
fi
else
echo "$display_path/1:$indent $icon $basename_path" >> "$temp_output"
fi
else
# Dirty repo with status
local status_display
status_display="$(print_color "$STATUS_COLOR" "[$status_text]")"
if [[ $GIT_DIRTY_SHOW_BRANCH -eq 1 && -n $branch ]]; then
# Only show branch if it's not a main branch
if is_main_branch "$branch"; then
echo "$display_path/1:$indent $icon $basename_path $status_display" \
>> "$temp_output"
else
local b
b=$(print_color "$BRANCH_COLOR" "($branch)")
echo "$display_path/1:$indent $icon $basename_path $status_display $b" \
>> "$temp_output"
fi
else
echo "$display_path/1:$indent $icon $basename_path $status_display" \
>> "$temp_output"
fi
fi
# Update progress atomically
local current_progress
current_progress=$(cat "$progress_file")
echo $((current_progress + 1)) > "$progress_file"
) &
# Control the number of parallel jobs
((job_count++))
if ((job_count >= max_jobs)); then
wait -n
((job_count--))
fi
done
# Wait for all remaining jobs to finish
wait
# Signal progress bar to stop and wait for it to finish
echo "0" > "$running_file"
wait $progress_pid 2> /dev/null
# Add parent directories to the output for tree structure
for parent in "${!repo_parents[@]}"; do
local rel_path depth indent basename_path
rel_path="${parent#"$BASE_DIR"/}"
depth=$(echo "$rel_path" | tr -cd '/' | wc -c)
indent=$(printf "%*s" $((depth * 2)) "")
basename_path=$(get_basename "$rel_path")
echo "$rel_path/0:$indent $NOT_GIT $basename_path" >> "$temp_output"
done
# Add root directory (with special path to ensure it comes first)
echo "/0:$NOT_GIT $BASE_DIR" >> "$temp_output"
# Display the results in sorted order
if [[ -f $temp_output ]]; then
LC_ALL=C sort "$temp_output" | cut -d: -f2-
fi
# Clean up temp files
for file in "${temp_files[@]}"; do
[[ -f $file ]] && rm -f "$file"
done
# Remove the trap since we're done
trap - INT TERM HUP
fi
# Calculate and show total processing time
local total_time processing_time dur runtime
total_time=$(($(date +%s) - START_TIME))
processing_time=$(($(date +%s) - processing_start_time))
dur=$(format_duration $processing_time)
runtime=$(format_duration $total_time)
echo -e "\nProcessed $total repositories in $dur (Total runtime: $runtime)"
}
check_directory_with_progress()
{
local dir
dir="$1"
# Simple forward to git_dirty which will count as it goes
git_dirty "$dir" 0
}
# Cleanup function to remove temporary files
cleanup_temp_files()
{
if [[ -n $RESULTS_FILE && -f $RESULTS_FILE ]]; then
rm -f "$RESULTS_FILE"
fi
}
# Show help
show_help()
{
local bin
bin=$(basename "$0")
cat << EOF
Usage: $bin [OPTIONS] [DIRECTORY]
Recursively check git repository status
Options:
-h Show this help message and exit
-d NUM Set maximum depth for showing non-git directories (default: $GIT_DIRTY_DEPTH)
-p Process directories in parallel (requires 'parallel' command)
-v Enable verbose output
-a Show all status details (stash, untracked, etc.)
-e PATTERNS Additional patterns to exclude (comma separated)
-m NUM Set maximum recursion depth (default: $GIT_DIRTY_MAXDEPTH)
-c Toggle colorized output (currently: $(use_color && echo "on" || echo "off"))
-t Toggle path truncation (currently: $([[ $GIT_DIRTY_TRUNCATE -eq 1 ]] && echo "on" || echo "off"))
-b Toggle branch name display (currently: $([[ $GIT_DIRTY_SHOW_BRANCH -eq 1 ]] && echo "on" || echo "off"))
Status indicators:
M = Modified files
S = Staged changes
? = Untracked files (with -a)
$ = Stashed changes (with -a)
↑ = Unpushed commits
Example:
$bin ~/Projects
$bin -d 3 -e 'build,dist' ~/Code
EOF
}
# Main function
main()
{
# Set up trap for error handling
trap 'handle_error $? $LINENO' ERR
trap 'cleanup_temp_files' EXIT
# Parse command line options - cross-platform way
while getopts "hvpacbd:e:m:t" opt; do
case $opt in
h)
show_help
exit 0
;;
v)
VERBOSE=1
;;
p)
PARALLEL=1
;;
a)
GIT_DIRTY_CHECK_STASH=1
GIT_DIRTY_CHECK_UNTRACKED=1
;;
c)
# Toggle color
[[ $GIT_DIRTY_COLOR -eq 1 ]] && GIT_DIRTY_COLOR=0 || GIT_DIRTY_COLOR=1
;;
b)
# Toggle branch display
[[ $GIT_DIRTY_SHOW_BRANCH -eq 1 ]] && GIT_DIRTY_SHOW_BRANCH=0 || GIT_DIRTY_SHOW_BRANCH=1
;;
t)
# Toggle truncation
[[ $GIT_DIRTY_TRUNCATE -eq 1 ]] && GIT_DIRTY_TRUNCATE=0 || GIT_DIRTY_TRUNCATE=1
;;
d)
GIT_DIRTY_DEPTH="$OPTARG"
;;
m)
GIT_DIRTY_MAXDEPTH="$OPTARG"
;;
e)
IFS=',' read -ra EXCLUDE_ARRAY <<< "$OPTARG"
for item in "${EXCLUDE_ARRAY[@]}"; do
GIT_DIRTY_EXCLUDE="$GIT_DIRTY_EXCLUDE $item"
done
;;
\?)
log_error "Invalid option: -$OPTARG"
show_help
exit 1
;;
:)
log_error "Option -$OPTARG requires an argument."
show_help
exit 1
;;
esac
done
# Remove processed options
shift $((OPTIND - 1))
# If user has provided folder as a first argument, use it
if [[ -n $1 ]]; then
GIT_DIRTY_DIR="$1"
fi
if [[ $GIT_DIRTY_DIR == "." ]]; then
GIT_DIRTY_DIR="$(pwd)"
fi
# Expand path if it contains tilde
GIT_DIRTY_DIR="${GIT_DIRTY_DIR/#\~/$HOME}"
# Set base directory for path truncation
BASE_DIR="$(cd "$GIT_DIRTY_DIR" && pwd)"
# Verify the directory exists
if [[ ! -d $GIT_DIRTY_DIR ]]; then
log_error "Directory does not exist: $GIT_DIRTY_DIR"
exit 1
fi
# Update color variables after possible toggle
if use_color; then
GIT_CLEAN=$(print_color "32" "✅")
GIT_DIRTY=$(print_color "31" "❌")
NOT_GIT=$(print_color "33" "⚠️")
BRANCH_COLOR="35"
STATUS_COLOR="33"
else
GIT_CLEAN="✅"
GIT_DIRTY="❌"
NOT_GIT="⚠️"
BRANCH_COLOR=""
STATUS_COLOR=""
fi
echo "Checking repositories in: $GIT_DIRTY_DIR"
echo "Legend: $GIT_CLEAN Clean repo | $GIT_DIRTY Dirty repo"
if [[ $GIT_DIRTY_SHOW_BRANCH -eq 1 ]]; then
echo "Showing non-standard branches (hiding ${GIT_DIRTY_MAIN_BRANCHES})"
fi
echo "---------------------------------------------------------"
# Use parallel processing if enabled and available
if [[ $PARALLEL -eq 1 ]]; then
process_in_parallel "$GIT_DIRTY_DIR"
else
# Use progress indicator for large directories
check_directory_with_progress "$GIT_DIRTY_DIR"
fi
}
# Run the main function
main "$@"