#!/bin/bash # whatsdirty.sh - Check git repository status in subdirectories # Shows which repositories have uncommitted changes and when they were last committed # Configuration CONFIG_FILE="$HOME/.config/whatsdirty.conf" RED='\033[0;31m' NC='\033[0m' # No Color # === Configuration Management Functions === save_directory() { local dir="$1" mkdir -p "$(dirname "$CONFIG_FILE")" echo "$dir" > "$CONFIG_FILE" } load_directory() { if [ -f "$CONFIG_FILE" ]; then cat "$CONFIG_FILE" fi } validate_directory() { local dir="$1" # Use subshell to avoid changing current directory (cd "$dir" 2>/dev/null && pwd) } handle_directory_argument() { local arg="$1" if [ -z "$arg" ]; then # No argument - try to load from config local saved_dir saved_dir=$(load_directory) if [ -z "$saved_dir" ]; then echo "Error: No directory specified and no saved directory found." >&2 echo "Usage: $0 " >&2 echo "The specified directory will be saved for future use." >&2 exit 1 fi echo "$saved_dir" else # Argument provided - validate and save local canonical_dir canonical_dir=$(validate_directory "$arg") if [ -z "$canonical_dir" ]; then echo "Error: Directory '$arg' not found" >&2 exit 1 fi save_directory "$canonical_dir" echo "$canonical_dir" fi } # === Git Repository Functions === get_last_commit_hours() { local last_commit_timestamp last_commit_timestamp=$(git log -1 --format="%ct" 2>/dev/null) if [ -n "$last_commit_timestamp" ]; then local current_timestamp current_timestamp=$(date +%s) local diff_seconds=$((current_timestamp - last_commit_timestamp)) # Convert to hours with decimal precision using awk (more portable than bc) local hours_ago hours_ago=$(awk "BEGIN {printf \"%.1f\", $diff_seconds / 3600}") # Ensure leading zero for values less than 1 if [[ $hours_ago =~ ^\. ]]; then hours_ago="0$hours_ago" fi echo "$hours_ago" else echo "" fi } count_git_changes() { local staged staged=$(git diff --cached --numstat 2>/dev/null | wc -l) local unstaged unstaged=$(git diff --numstat 2>/dev/null | wc -l) local untracked untracked=$(git ls-files --others --exclude-standard 2>/dev/null | wc -l) echo $((staged + unstaged + untracked)) } get_changed_files_list() { local changed_files="" # Get staged files local staged_files staged_files=$(git diff --cached --name-only 2>/dev/null) if [ -n "$staged_files" ]; then while IFS= read -r file; do changed_files="${changed_files} [staged] $file\n" done <<< "$staged_files" fi # Get unstaged modified files local unstaged_files unstaged_files=$(git diff --name-only 2>/dev/null) if [ -n "$unstaged_files" ]; then while IFS= read -r file; do changed_files="${changed_files} [modified] $file\n" done <<< "$unstaged_files" fi # Get untracked files local untracked untracked=$(git ls-files --others --exclude-standard 2>/dev/null) if [ -n "$untracked" ]; then while IFS= read -r file; do changed_files="${changed_files} [untracked] $file\n" done <<< "$untracked" fi # Remove trailing newline if [ -n "$changed_files" ]; then echo -e "$changed_files" | sed '$ d' fi } analyze_repository() { local repo_path="$1" local repo_name repo_name=$(basename "$repo_path") cd "$repo_path" || return 1 # Get commit info local hours_ago hours_ago=$(get_last_commit_hours) local hours_num local hours_label local hours_formatted if [ -n "$hours_ago" ]; then hours_num="$hours_ago" hours_label="hours" hours_formatted=$(printf "%8s %-6s" "$hours_num" "$hours_label") else hours_num="999999" # Large number for sorting hours_label="commits" hours_formatted=$(printf "%8s %-7s" "No" "$hours_label") fi # Count changes local total_changes total_changes=$(count_git_changes) # Determine status and collect data if [ "$total_changes" -eq 0 ]; then echo "C|${hours_num}|${total_changes}|${repo_name}|${hours_formatted}" else local changed_files changed_files=$(get_changed_files_list) echo "D|${hours_num}|${total_changes}|${repo_name}|${hours_formatted}|||${changed_files}" fi } # === Output Formatting Functions === print_header() { printf "%-40s %15s %-10s %-10s\n" "Repository" "Last Commit" "Changes" "Status" printf "%-40s %15s %-10s %-10s\n" "----------" "(hours)" "-------" "------" } print_dirty_repositories() { local temp_file="$1" if grep -q "^D|" "$temp_file" 2>/dev/null; then printf "\n=== DIRTY REPOSITORIES ===\n\n" grep "^D|" "$temp_file" | sort -t'|' -k3,3nr | while IFS='|' read -r _ _ changes repo hours_fmt _ file_list; do printf "%-40s %15s ${RED}%-10s${NC} ${RED}%-10s${NC}\n" "$repo" "$hours_fmt" "$changes" "Dirty" if [ -n "$file_list" ]; then echo -e "$file_list" fi done fi } print_clean_repositories() { local temp_file="$1" if grep -q "^C|" "$temp_file" 2>/dev/null; then printf "\n=== CLEAN REPOSITORIES ===\n\n" grep "^C|" "$temp_file" | sort -t'|' -k2,2n | while IFS='|' read -r _ _ changes repo hours_fmt; do printf "%-40s %15s %-10s %-10s\n" "$repo" "$hours_fmt" "$changes" "Clean" done fi } # === Main Function === scan_repositories() { local scan_dir="$1" local temp_file="$2" for folder in "$scan_dir"/*; do [ ! -d "$folder" ] && continue if [ -d "$folder/.git" ]; then local repo_data repo_data=$(analyze_repository "$folder") if [ -n "$repo_data" ]; then echo "$repo_data" >> "$temp_file" fi cd "$scan_dir" || exit 1 fi done } main() { # Handle directory argument local DIR DIR=$(handle_directory_argument "$1") # Validate directory still exists (for loaded configs) local validated_dir validated_dir=$(validate_directory "$DIR") if [ -z "$validated_dir" ]; then echo "Error: Saved directory '$DIR' no longer exists" >&2 rm -f "$CONFIG_FILE" exit 1 fi DIR="$validated_dir" # Create temp file for results local TEMP_FILE TEMP_FILE=$(mktemp) trap 'rm -f "$TEMP_FILE"' EXIT # Scan repositories scan_repositories "$DIR" "$TEMP_FILE" # Display results print_header print_dirty_repositories "$TEMP_FILE" print_clean_repositories "$TEMP_FILE" } # === Autocomplete Function === handle_autocomplete() { local cur_word="${COMP_WORDS[COMP_CWORD]}" # If we're completing the first argument after 'whatsdirty' if [ ${COMP_CWORD} -eq 1 ]; then # Suggest common directories and saved directory local saved_dir saved_dir=$(load_directory) local suggestions="" # Add saved directory if it exists if [ -n "$saved_dir" ] && [ -d "$saved_dir" ]; then suggestions="$saved_dir" fi # Add common development directories for dir in ~/code ~/projects ~/dev ~/src ~/workspace /home/*/code /home/*/projects; do if [ -d "$dir" ]; then # Only add if it contains git repositories if find "$dir" -maxdepth 2 -name ".git" -type d 2>/dev/null | head -1 | grep -q .; then suggestions="$suggestions $dir" fi fi done # Directory completion for current word if [ -n "$cur_word" ]; then # Use mapfile for directory completion local dir_completions mapfile -t dir_completions < <(compgen -d -- "$cur_word") # Add our suggestions that match the current word local matching_suggestions mapfile -t matching_suggestions < <(echo "$suggestions" | tr ' ' '\n' | grep "^$cur_word" | sort -u) # Combine and deduplicate mapfile -t COMPREPLY < <(printf '%s\n' "${dir_completions[@]}" "${matching_suggestions[@]}" | sort -u) else # No current word, suggest all our directories plus general directory completion local dir_completions mapfile -t dir_completions < <(compgen -d) local all_suggestions mapfile -t all_suggestions < <(echo "$suggestions" | tr ' ' '\n' | sort -u) # Combine and deduplicate, but limit to reasonable number mapfile -t COMPREPLY < <(printf '%s\n' "${all_suggestions[@]}" "${dir_completions[@]}" | sort -u | head -20) fi fi } # Check if we're being called for autocomplete if [ "$1" = "autocomplete" ]; then # This is called when the tool is being used for bash completion # The arguments after 'autocomplete' are the completion context shift # Set up COMP_WORDS and COMP_CWORD from arguments COMP_WORDS=("$@") COMP_CWORD=$(($# - 1)) # Generate completions handle_autocomplete # Print completions, one per line printf '%s\n' "${COMPREPLY[@]}" exit 0 fi # Check if we're being called to show version if [ "$1" = "version" ] || [ "$1" = "--version" ] || [ "$1" = "-v" ] || [ "$1" = "v" ]; then echo "1.0.1" exit 0 fi # Execute main function with all arguments main "$@"