mirror of
https://github.com/ivuorinen/dotfiles.git
synced 2026-01-26 11:14:08 +00:00
1083 lines
31 KiB
Bash
Executable File
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 "$@"
|