Last active
October 2, 2025 16:25
-
-
Save WomB0ComB0/e6090cc36ea95ecfb1f7da743c3750fe to your computer and use it in GitHub Desktop.
Utilizing an AI-powered CLI to incrementally create AI-generated commit messages based on you git changes.
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 | |
| set -euo pipefail | |
| # Enhanced Git Auto-Commit Script | |
| # Automatically commits and pushes changes across multiple repositories with AI-generated messages | |
| # --- Configuration and Defaults --- | |
| readonly SCRIPT_NAME="$(basename "$0")" | |
| readonly SCRIPT_VERSION="2.0.1" | |
| readonly HOME="${HOME}" | |
| # IMPORTANT: AI_COMMAND must be a bash array | |
| declare -a DEFAULT_AI_COMMAND=("ask" "cm" "-m" "gemini-2.0-flash") | |
| readonly DEFAULT_REPO_DIR="$HOME/github" | |
| readonly LOG_FILE="/tmp/git-auto-commit-$(date +%Y%m%d-%H%M%S).log" | |
| readonly MAX_RETRIES=3 | |
| readonly TIMEOUT_SECONDS=30 | |
| readonly DEFAULT_GITDIFF_EXCLUDE="$HOME/.config/git/gitdiff-exclude" | |
| readonly DEFAULT_AUTO_PUSH=true | |
| readonly MAX_COMMIT_MSG_LENGTH=72 | |
| readonly MAX_BRANCH_NAME_LENGTH=30 | |
| # Initialize AI_COMMAND with default | |
| declare -a AI_COMMAND=("${DEFAULT_AI_COMMAND[@]}") | |
| readonly CONFIG_FILE="$HOME/.git-auto-commit.conf" | |
| # Load configuration if available | |
| load_config() { | |
| if [[ -f "$CONFIG_FILE" ]]; then | |
| log "DEBUG" "Loading configuration from $CONFIG_FILE" | |
| # Validate config file permissions for security | |
| local config_perms | |
| config_perms=$(stat -f %A "$CONFIG_FILE" 2>/dev/null || stat -c %a "$CONFIG_FILE" 2>/dev/null || echo "") | |
| if [[ -n "$config_perms" && "$config_perms" != "600" ]]; then | |
| log "WARNING" "Config file $CONFIG_FILE has insecure permissions. Should be 600." | |
| fi | |
| # Source config file safely | |
| set +e | |
| source "$CONFIG_FILE" | |
| local source_result=$? | |
| set -e | |
| if [[ $source_result -ne 0 ]]; then | |
| log "WARNING" "Failed to load configuration file" | |
| fi | |
| fi | |
| } | |
| # Set configuration variables with defaults | |
| REPO_DIR="${REPO_DIR:-$DEFAULT_REPO_DIR}" | |
| GITDIFF_EXCLUDE="${GITDIFF_EXCLUDE:-$DEFAULT_GITDIFF_EXCLUDE}" | |
| AUTO_PUSH="${AUTO_PUSH:-$DEFAULT_AUTO_PUSH}" | |
| # --- Utility Functions --- | |
| # Check if running as root (security concern) | |
| check_root() { | |
| if [[ $EUID -eq 0 ]]; then | |
| log "ERROR" "This script should not be run as root for security reasons" | |
| return 1 | |
| fi | |
| } | |
| # Validate configuration values | |
| validate_config() { | |
| if [[ ! "$AUTO_PUSH" =~ ^(true|false)$ ]]; then | |
| log "ERROR" "AUTO_PUSH must be 'true' or 'false'" | |
| return 1 | |
| fi | |
| if [[ ! "$TIMEOUT_SECONDS" =~ ^[0-9]+$ ]] || (( TIMEOUT_SECONDS < 5 || TIMEOUT_SECONDS > 300 )); then | |
| log "ERROR" "TIMEOUT_SECONDS must be between 5 and 300" | |
| return 1 | |
| fi | |
| if [[ ${#AI_COMMAND[@]} -eq 0 ]]; then | |
| log "ERROR" "AI_COMMAND cannot be empty" | |
| return 1 | |
| fi | |
| } | |
| # Enhanced logging with levels and colors | |
| log() { | |
| local level="$1" | |
| shift | |
| local message="$*" | |
| local timestamp | |
| timestamp=$(date '+%Y-%m-%d %H:%M:%S') | |
| local color="" | |
| local reset="\033[0m" | |
| case "$level" in | |
| "ERROR") color="\033[31m" ;; # Red | |
| "WARNING") color="\033[33m" ;; # Yellow | |
| "SUCCESS") color="\033[32m" ;; # Green | |
| "INFO") color="\033[36m" ;; # Cyan | |
| "DEBUG") color="\033[90m" ;; # Gray | |
| esac | |
| # Log to file without colors | |
| echo "[$timestamp] [$level] $message" >> "$LOG_FILE" || true | |
| # Output to stderr with colors if terminal supports it | |
| if [[ -t 2 ]]; then | |
| echo -e "${color}[$timestamp] [$level] $message${reset}" >&2 | |
| else | |
| echo "[$timestamp] [$level] $message" >&2 | |
| fi | |
| } | |
| # Cleanup function for temporary files | |
| cleanup() { | |
| local exit_code=$? | |
| if [[ -n "${temp_files:-}" ]]; then | |
| for temp_file in "${temp_files[@]}"; do | |
| [[ -f "$temp_file" ]] && rm -f "$temp_file" 2>/dev/null || true | |
| done | |
| fi | |
| exit $exit_code | |
| } | |
| # Enhanced error handling | |
| handle_error() { | |
| local exit_code=$? | |
| local line_number=${1:-"unknown"} | |
| log "ERROR" "Script failed at line $line_number with exit code $exit_code" | |
| cleanup | |
| } | |
| # Only set error trap if not in dry run mode | |
| if [[ "${DRY_RUN:-false}" != "true" ]]; then | |
| trap 'handle_error $LINENO' ERR | |
| fi | |
| trap 'cleanup' EXIT INT TERM | |
| # Track temporary files for cleanup | |
| declare -a temp_files=() | |
| create_temp_file() { | |
| local temp_file | |
| temp_file=$(mktemp) | |
| temp_files+=("$temp_file") | |
| echo "$temp_file" | |
| } | |
| # --- Dependency and Authentication Checks --- | |
| check_dependencies() { | |
| local missing_deps=() | |
| local deps=("git" "grep" "timeout") | |
| # Optional dependencies | |
| local optional_deps=("gh" "sed" "awk") | |
| for dep in "${deps[@]}"; do | |
| if ! command -v "$dep" >/dev/null 2>&1; then | |
| missing_deps+=("$dep") | |
| fi | |
| done | |
| if [[ ${#missing_deps[@]} -gt 0 ]]; then | |
| log "ERROR" "Required commands not found: ${missing_deps[*]}" | |
| log "INFO" "Please install missing dependencies and try again" | |
| return 1 | |
| fi | |
| # Check optional dependencies | |
| for dep in "${optional_deps[@]}"; do | |
| if ! command -v "$dep" >/dev/null 2>&1; then | |
| log "WARNING" "Optional command not found: $dep (some features may be limited)" | |
| fi | |
| done | |
| # Check git version | |
| local git_version | |
| git_version=$(git --version | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || echo "unknown") | |
| log "DEBUG" "Using Git version: $git_version" | |
| return 0 | |
| } | |
| check_gh_auth() { | |
| if ! command -v gh >/dev/null 2>&1; then | |
| log "WARNING" "GitHub CLI not found. Account switching features will be disabled" | |
| return 0 # Not a fatal error | |
| fi | |
| if ! gh auth status &>/dev/null; then | |
| log "WARNING" "GitHub CLI not authenticated. Account switching features will be disabled" | |
| return 0 # Not a fatal error | |
| fi | |
| # Test API access | |
| if ! gh api user --jq .login >/dev/null 2>&1; then | |
| log "WARNING" "GitHub CLI authentication appears invalid. Account switching features will be disabled" | |
| return 0 # Not a fatal error | |
| fi | |
| return 0 | |
| } | |
| # Enhanced GitHub user detection | |
| get_active_gh_user() { | |
| local user | |
| if ! command -v gh >/dev/null 2>&1; then | |
| return 1 | |
| fi | |
| user=$(gh api user --jq .login 2>/dev/null || echo "") | |
| if [[ -z "$user" ]]; then | |
| log "DEBUG" "Could not determine active GitHub user" | |
| return 1 | |
| fi | |
| echo "$user" | |
| } | |
| # Improved repository account detection | |
| get_repo_account() { | |
| local repo_path="$1" | |
| local remote_url | |
| remote_url=$(git -C "$repo_path" config --get remote.origin.url 2>/dev/null || echo "") | |
| if [[ -z "$remote_url" ]]; then | |
| log "DEBUG" "No remote origin URL found for $(basename "$repo_path")" | |
| return 1 | |
| fi | |
| # Handle both SSH and HTTPS URLs | |
| if [[ "$remote_url" =~ github\.com[:/]([^/]+)/([^/]+)(\.git)?$ ]]; then | |
| echo "${BASH_REMATCH[1]}" | |
| return 0 | |
| fi | |
| log "DEBUG" "Could not parse GitHub repository owner from: $remote_url" | |
| return 1 | |
| } | |
| # Enhanced account switching with better error handling | |
| switch_to_repo_account() { | |
| local repo_path="$1" | |
| local repo_name | |
| repo_name=$(basename "$repo_path") | |
| if ! command -v gh >/dev/null 2>&1; then | |
| log "DEBUG" "GitHub CLI not available, skipping account switching for $repo_name" | |
| return 0 | |
| fi | |
| local repo_owner | |
| if ! repo_owner=$(get_repo_account "$repo_path"); then | |
| log "DEBUG" "Could not determine repository owner for $repo_name" | |
| return 0 # Not a fatal error | |
| fi | |
| local current_user | |
| if ! current_user=$(get_active_gh_user); then | |
| log "DEBUG" "Could not determine current GitHub user" | |
| return 0 # Not a fatal error | |
| fi | |
| local target_account="$current_user" | |
| # Account mapping with validation | |
| declare -A account_mappings=( | |
| ["WomB0ComB0"]="WomB0ComB0" | |
| ) | |
| if [[ -n "${account_mappings[$repo_owner]:-}" ]]; then | |
| target_account="${account_mappings[$repo_owner]}" | |
| fi | |
| if [[ "$current_user" != "$target_account" ]]; then | |
| log "INFO" "Switching from '$current_user' to '$target_account' for $repo_name" | |
| if gh auth switch --user "$target_account" >/dev/null 2>&1; then | |
| configure_git_user "$target_account" | |
| log "INFO" "Successfully switched to $target_account and updated git config" | |
| else | |
| log "WARNING" "Failed to switch to $target_account for $repo_name, continuing with current user" | |
| return 0 # Not a fatal error | |
| fi | |
| else | |
| log "DEBUG" "Already using correct account ($current_user) for $repo_name" | |
| fi | |
| return 0 | |
| } | |
| # Separate function for git user configuration | |
| configure_git_user() { | |
| local account="$1" | |
| case "$account" in | |
| "WomB0ComB0") | |
| git config --global user.name "WomB0ComB0" | |
| git config --global user.email "[email protected]" | |
| ;; | |
| *) | |
| log "DEBUG" "No git configuration defined for account: $account" | |
| ;; | |
| esac | |
| } | |
| get_authorized_email() { | |
| local repo_path="$1" | |
| # Try to switch to repo account (non-fatal if it fails) | |
| switch_to_repo_account "$repo_path" | |
| local authorized_email | |
| authorized_email=$(git config --global user.email 2>/dev/null || echo "") | |
| if [[ -n "$authorized_email" ]]; then | |
| echo "$authorized_email" | |
| return 0 | |
| fi | |
| log "DEBUG" "Could not determine authorized email for $(basename "$repo_path")" | |
| return 1 | |
| } | |
| # Enhanced default branch detection | |
| get_default_branch() { | |
| local repo_path="$1" | |
| local default_branch | |
| # Try multiple methods to determine default branch | |
| default_branch=$(git -C "$repo_path" symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@' || echo "") | |
| if [[ -z "$default_branch" ]]; then | |
| # Try to determine from remote | |
| default_branch=$(git -C "$repo_path" ls-remote --symref origin HEAD 2>/dev/null | awk '/^ref:/ {print $2}' | sed 's@refs/heads/@@' || echo "") | |
| fi | |
| if [[ -z "$default_branch" ]]; then | |
| # Attempt to detect the default branch by checking for the existence of common branch names in order of preference. | |
| for candidate in main master develop trunk default; do | |
| if git -C "$repo_path" show-ref --verify --quiet "refs/heads/$candidate" 2>/dev/null; then | |
| default_branch="$candidate" | |
| break | |
| fi | |
| done | |
| fi | |
| if [[ -n "$default_branch" ]]; then | |
| echo "$default_branch" | |
| else | |
| log "DEBUG" "Could not determine default branch for $(basename "$repo_path")" | |
| return 1 | |
| fi | |
| } | |
| validate_repo_dir() { | |
| if [[ ! -d "$REPO_DIR" ]]; then | |
| log "ERROR" "Repository directory not found: $REPO_DIR" | |
| return 1 | |
| fi | |
| if [[ ! -r "$REPO_DIR" ]]; then | |
| log "ERROR" "Repository directory not readable: $REPO_DIR" | |
| return 1 | |
| fi | |
| return 0 | |
| } | |
| # Enhanced environment file checking | |
| check_env_ignored() { | |
| local repo_path="$1" | |
| local gitignore_file="$repo_path/.gitignore" | |
| local env_variants=(".env" ".env.local" ".env.development" ".env.production" ".env.test" ".env.example") | |
| local has_env_files=false | |
| local unignored_files=() | |
| for variant in "${env_variants[@]}"; do | |
| if [[ -f "$repo_path/$variant" ]]; then | |
| has_env_files=true | |
| if [[ "$variant" == ".env.example" ]]; then | |
| continue # .env.example should typically be committed | |
| fi | |
| if [[ ! -f "$gitignore_file" ]] || ! grep -q "^${variant//./\\.}$" "$gitignore_file" 2>/dev/null; then | |
| unignored_files+=("$variant") | |
| fi | |
| fi | |
| done | |
| if [[ ${#unignored_files[@]} -gt 0 ]]; then | |
| log "WARNING" "Found unignored environment files in $(basename "$repo_path"): ${unignored_files[*]}" | |
| log "INFO" "Consider adding these to .gitignore to prevent accidental commits of sensitive data" | |
| fi | |
| if [[ "$has_env_files" == false ]]; then | |
| log "DEBUG" "No .env files found in $(basename "$repo_path")" | |
| fi | |
| return 0 | |
| } | |
| # Enhanced AI interaction with better error handling | |
| call_ai_command() { | |
| local prompt="$1" | |
| local input_file="$2" | |
| local output_file | |
| output_file=$(create_temp_file) | |
| # Check if AI command is available | |
| if ! command -v "${AI_COMMAND[0]}" >/dev/null 2>&1; then | |
| log "WARNING" "AI command '${AI_COMMAND[0]}' not found. Using fallback messages" | |
| return 1 | |
| fi | |
| local attempt=1 | |
| local max_attempts=2 | |
| while (( attempt <= max_attempts )); do | |
| log "DEBUG" "Calling AI command (attempt $attempt/$max_attempts)" | |
| if timeout "$TIMEOUT_SECONDS" "${AI_COMMAND[@]}" "$prompt" < "$input_file" > "$output_file" 2>/dev/null; then | |
| local response | |
| response=$(<"$output_file" || echo "") | |
| if [[ -n "$response" ]]; then | |
| echo "$response" | |
| return 0 | |
| fi | |
| fi | |
| log "DEBUG" "AI command failed on attempt $attempt" | |
| ((attempt++)) | |
| [[ $attempt -le $max_attempts ]] && sleep 1 | |
| done | |
| return 1 | |
| } | |
| # Enhanced branch name generation with validation | |
| generate_branch_name() { | |
| local diff_file="$1" | |
| local branch_name="" | |
| local prompt="Based on this git diff, suggest a concise branch name (kebab-case, no spaces, max ${MAX_BRANCH_NAME_LENGTH} chars). Only return the branch name, nothing else." | |
| if branch_name=$(call_ai_command "$prompt" "$diff_file"); then | |
| # Clean and validate branch name | |
| branch_name=$(echo "$branch_name" | sed -e 's/^```.*$//' -e 's/^```$//' -e 's/^"//' -e 's/"$//' -e 's/[^a-zA-Z0-9-]//g' | head -1 | xargs echo -n) | |
| branch_name=$(echo "$branch_name" | sed 's/^-*//') | |
| # Validate branch name | |
| if [[ -n "$branch_name" && ${#branch_name} -le $MAX_BRANCH_NAME_LENGTH && "$branch_name" =~ ^[a-zA-Z0-9-]+$ ]]; then | |
| log "DEBUG" "Generated branch name: $branch_name" | |
| echo "$branch_name" | |
| return 0 | |
| fi | |
| fi | |
| local fallback_name="auto-commit-$(date +%Y%m%d-%H%M%S)" | |
| log "DEBUG" "Using fallback branch name: $fallback_name" | |
| echo "$fallback_name" | |
| return 0 | |
| } | |
| # Enhanced commit message generation | |
| generate_commit_message() { | |
| local repo_path="$1" | |
| local exclude_args="" | |
| if [[ -f "$GITDIFF_EXCLUDE" ]]; then | |
| while IFS= read -r line; do | |
| # Skip comments and empty lines | |
| if [[ ! "$line" =~ ^# && -n "$line" ]]; then | |
| # Expand tilde to home directory | |
| local expanded_path="${line/#\~/$HOME}" | |
| if [[ -e "$expanded_path" ]]; then | |
| exclude_args+=" :!$expanded_path" | |
| fi | |
| fi | |
| done < <(grep -v '^#' "$GITDIFF_EXCLUDE" 2>/dev/null || true) | |
| fi | |
| local diff_file | |
| diff_file=$(create_temp_file) | |
| git -C "$repo_path" diff --cached --diff-filter=ACMRTUXB $exclude_args > "$diff_file" 2>/dev/null || true | |
| if [[ ! -s "$diff_file" ]]; then | |
| log "DEBUG" "Git diff is empty, using fallback message" | |
| echo "Auto-commit: No changes to commit" | |
| return 0 | |
| fi | |
| local prompt="Write a concise, well-structured commit message for this git diff. Follow conventional commit format if applicable. Return only the commit message, no other text or formatting. Maximum ${MAX_COMMIT_MSG_LENGTH} characters." | |
| if commit_message=$(call_ai_command "$prompt" "$diff_file"); then | |
| # Clean and validate commit message | |
| commit_message=$(echo "$commit_message" | sed -e 's/^```.*$//' -e 's/^```$//' -e 's/^"//' -e 's/"$//' | head -1 | xargs) | |
| # Validate commit message | |
| if [[ -n "$commit_message" && ${#commit_message} -gt 5 && ${#commit_message} -le $MAX_COMMIT_MSG_LENGTH ]]; then | |
| log "DEBUG" "Generated commit message: $commit_message" | |
| echo "$commit_message" | |
| return 0 | |
| fi | |
| fi | |
| log "DEBUG" "AI message generation failed. Using fallback message" | |
| echo "Auto-commit: Changes at $(date +%Y-%m-%d\ %H:%M:%S)" | |
| return 0 | |
| } | |
| # Enhanced branch status checking | |
| fetch_and_check_status() { | |
| local repo_path="$1" | |
| local current_branch="$2" | |
| log "DEBUG" "Fetching latest changes for $current_branch" | |
| if ! git -C "$repo_path" fetch origin "$current_branch" 2>/dev/null; then | |
| log "DEBUG" "Failed to fetch from origin/$current_branch" | |
| return 1 | |
| fi | |
| local behind_count ahead_count | |
| behind_count=$(git -C "$repo_path" rev-list --count HEAD..origin/"$current_branch" 2>/dev/null || echo "0") | |
| ahead_count=$(git -C "$repo_path" rev-list --count origin/"$current_branch"..HEAD 2>/dev/null || echo "0") | |
| if (( behind_count > 0 )); then | |
| log "INFO" "Local branch is $behind_count commits behind origin/$current_branch" | |
| if (( ahead_count > 0 )); then | |
| log "INFO" "Local branch is also $ahead_count commits ahead (diverged)" | |
| fi | |
| return 1 | |
| fi | |
| if (( ahead_count > 0 )); then | |
| log "DEBUG" "Local branch is $ahead_count commits ahead of origin/$current_branch" | |
| fi | |
| return 0 | |
| } | |
| # Enhanced branch creation and pushing | |
| create_and_push_branch() { | |
| local repo_path="$1" | |
| local repo_name="$2" | |
| local diff_file="$3" | |
| local branch_name | |
| branch_name=$(generate_branch_name "$diff_file") | |
| # Check if branch already exists | |
| if git -C "$repo_path" show-ref --verify --quiet refs/heads/"$branch_name" 2>/dev/null; then | |
| log "WARNING" "Branch '$branch_name' already exists, appending timestamp" | |
| branch_name="${branch_name}-$(date +%H%M%S)" | |
| fi | |
| log "INFO" "Creating new branch '$branch_name' for $repo_name" | |
| if git -C "$repo_path" checkout -b "$branch_name" 2>/dev/null; then | |
| log "SUCCESS" "Created and switched to branch '$branch_name'" | |
| if git -C "$repo_path" push -u origin "$branch_name" 2>/dev/null; then | |
| log "SUCCESS" "Successfully pushed new branch '$branch_name' for $repo_name" | |
| local repo_slug | |
| repo_slug=$(git -C "$repo_path" config --get remote.origin.url | sed -E 's/.*github.com[:/]([^/]+\/[^/.]+)(\.git)?/\1/' || echo "") | |
| if [[ -n "$repo_slug" && -x "$(command -v gh)" ]]; then | |
| log "INFO" "Consider creating a pull request: gh pr create -R $repo_slug --head $branch_name" | |
| fi | |
| return 0 | |
| else | |
| log "ERROR" "Failed to push new branch '$branch_name' for $repo_name" | |
| # Cleanup failed branch | |
| git -C "$repo_path" checkout - >/dev/null 2>&1 || true | |
| git -C "$repo_path" branch -D "$branch_name" >/dev/null 2>&1 || true | |
| return 1 | |
| fi | |
| else | |
| log "ERROR" "Failed to create branch '$branch_name' for $repo_name" | |
| return 1 | |
| fi | |
| } | |
| # Enhanced push logic with better error handling | |
| push_changes() { | |
| local repo_path="$1" | |
| local repo_name | |
| repo_name=$(basename "$repo_path") | |
| local current_branch | |
| current_branch=$(git -C "$repo_path" symbolic-ref --short HEAD 2>/dev/null || echo "") | |
| if [[ -z "$current_branch" ]]; then | |
| log "ERROR" "Could not determine current branch for $repo_name" | |
| return 1 | |
| fi | |
| if fetch_and_check_status "$repo_path" "$current_branch"; then | |
| log "INFO" "Pushing changes to origin/$current_branch in $repo_name" | |
| if git -C "$repo_path" push origin "$current_branch" 2>/dev/null; then | |
| log "SUCCESS" "Successfully pushed changes to $current_branch in $repo_name" | |
| return 0 | |
| else | |
| log "WARNING" "Failed to push to $current_branch. Remote may have rejected it" | |
| fi | |
| else | |
| log "INFO" "Local branch is behind or diverged from origin/$current_branch" | |
| fi | |
| log "INFO" "Falling back to creating a new branch for changes in $repo_name" | |
| local diff_file | |
| diff_file=$(create_temp_file) | |
| git -C "$repo_path" diff --cached > "$diff_file" || true | |
| if create_and_push_branch "$repo_path" "$repo_name" "$diff_file"; then | |
| return 0 | |
| else | |
| log "ERROR" "Failed to create and push new branch for $repo_name" | |
| return 1 | |
| fi | |
| } | |
| # --- Main Repository Processing --- | |
| process_repository() { | |
| local repo="$1" | |
| local repo_name | |
| repo_name=$(basename "$repo") | |
| log "INFO" "Processing repository: $repo_name" | |
| if [[ ! -d "$repo/.git" ]]; then | |
| log "WARNING" "Not a Git repository: $repo_name. Skipping" | |
| return 1 | |
| fi | |
| # Use a subshell to isolate repository operations | |
| ( | |
| # Temporarily disable error exit for this subshell | |
| set +e | |
| cd "$repo" || { | |
| log "ERROR" "Failed to change directory to $repo_name. Skipping" | |
| return 1 | |
| } | |
| # Store original email for restoration | |
| local original_email | |
| original_email=$(git config user.email 2>/dev/null || echo "") | |
| # Set up cleanup for this repo | |
| repo_cleanup() { | |
| if [[ -n "$original_email" ]]; then | |
| git config user.email "$original_email" 2>/dev/null || true | |
| fi | |
| } | |
| trap repo_cleanup EXIT | |
| # Security checks | |
| check_env_ignored "$(pwd)" | |
| # Configure authentication | |
| local authorized_email | |
| if authorized_email=$(get_authorized_email "$(pwd)"); then | |
| log "INFO" "Using commit email: $authorized_email" | |
| git config user.email "$authorized_email" || log "WARNING" "Failed to set commit email" | |
| else | |
| log "DEBUG" "Could not determine authorized email. Using existing config for $repo_name" | |
| fi | |
| # Check for changes | |
| local status_output | |
| status_output=$(git status --porcelain 2>/dev/null || echo "") | |
| if [[ -z "$status_output" ]]; then | |
| log "INFO" "No changes to commit in $repo_name" | |
| return 1 | |
| fi | |
| log "INFO" "Changes detected in $repo_name" | |
| # Add all changes | |
| if ! git add . 2>/dev/null; then | |
| log "ERROR" "Failed to stage changes in $repo_name" | |
| return 1 | |
| fi | |
| # Check if there are actually staged changes | |
| if git diff --cached --quiet 2>/dev/null; then | |
| log "INFO" "No staged changes to commit in $repo_name after 'git add'" | |
| return 1 | |
| fi | |
| # Generate commit message | |
| local commit_message | |
| commit_message=$(generate_commit_message "$(pwd)") | |
| if [[ -z "$commit_message" || "$commit_message" == "Auto-commit: No changes to commit" ]]; then | |
| log "WARNING" "Commit message generation resulted in no message. Skipping commit" | |
| return 1 | |
| fi | |
| log "INFO" "Committing in $repo_name with message: '$commit_message'" | |
| # Attempt to commit | |
| if git commit -m "$commit_message" 2>/dev/null; then | |
| log "SUCCESS" "Changes committed in $repo_name" | |
| # Push changes if enabled | |
| if [[ "$AUTO_PUSH" == "true" ]]; then | |
| push_changes "$(pwd)" || log "WARNING" "Push failed for $repo_name" | |
| fi | |
| return 0 | |
| else | |
| log "ERROR" "Failed to commit changes in $repo_name" | |
| return 1 | |
| fi | |
| ) | |
| } | |
| # --- Main Function --- | |
| show_usage() { | |
| cat << EOF | |
| $SCRIPT_NAME v$SCRIPT_VERSION - Automated Git commit and push tool | |
| USAGE: | |
| $SCRIPT_NAME [OPTIONS] | |
| OPTIONS: | |
| -h, --help Show this help message | |
| -v, --version Show version information | |
| -d, --dry-run Show what would be done without making changes | |
| --no-push Disable automatic pushing | |
| --config FILE Use alternative configuration file | |
| CONFIGURATION: | |
| Configuration is loaded from: $CONFIG_FILE | |
| Example configuration: | |
| REPO_DIR="$HOME/projects" | |
| AUTO_PUSH=true | |
| AI_COMMAND=("ask" "cm" "-m" "gemini-2.0-flash") | |
| GITDIFF_EXCLUDE="$HOME/.config/git/gitdiff-exclude" | |
| ENVIRONMENT: | |
| LOG_FILE: $LOG_FILE | |
| For more information, see the documentation. | |
| EOF | |
| } | |
| show_version() { | |
| echo "$SCRIPT_NAME v$SCRIPT_VERSION" | |
| } | |
| parse_arguments() { | |
| while [[ $# -gt 0 ]]; do | |
| case $1 in | |
| -h|--help) | |
| show_usage | |
| exit 0 | |
| ;; | |
| -v|--version) | |
| show_version | |
| exit 0 | |
| ;; | |
| -d|--dry-run) | |
| DRY_RUN=true | |
| log "INFO" "Dry run mode enabled" | |
| shift | |
| ;; | |
| --no-push) | |
| AUTO_PUSH=false | |
| log "INFO" "Auto-push disabled" | |
| shift | |
| ;; | |
| --config) | |
| CONFIG_FILE="$2" | |
| shift 2 | |
| ;; | |
| *) | |
| log "ERROR" "Unknown option: $1" | |
| show_usage | |
| exit 1 | |
| ;; | |
| esac | |
| done | |
| } | |
| main() { | |
| # Parse arguments first to handle dry-run mode | |
| parse_arguments "$@" | |
| # --- FIX 1: Enable the error handler always --- | |
| # This ensures we get a line number if the script fails for any reason. | |
| trap 'handle_error $LINENO' ERR | |
| log "INFO" "Starting $SCRIPT_NAME v$SCRIPT_VERSION" | |
| log "DEBUG" "Configuration: REPO_DIR=$REPO_DIR, AUTO_PUSH=$AUTO_PUSH" | |
| # Load configuration | |
| load_config | |
| # Perform initial checks | |
| if ! check_root; then | |
| log "ERROR" "Initial checks failed. Aborting" | |
| return 1 | |
| fi | |
| if ! validate_config; then | |
| log "ERROR" "Configuration validation failed. Aborting" | |
| return 1 | |
| fi | |
| if ! check_dependencies; then | |
| log "ERROR" "Dependency checks failed. Aborting" | |
| return 1 | |
| fi | |
| # GitHub CLI auth is optional, so we don't fail on this | |
| check_gh_auth | |
| if ! validate_repo_dir; then | |
| log "ERROR" "Repository directory validation failed. Aborting" | |
| return 1 | |
| fi | |
| local success_count=0 | |
| local fail_count=0 | |
| local total_repos=0 | |
| # --- FIX 2: Use a more robust loop with `find` --- | |
| shopt -s nullglob | |
| log "DEBUG" "Starting to process categories in '$REPO_DIR'" | |
| for category in "$REPO_DIR"/*; do | |
| if [[ -d "$category" ]]; then | |
| local category_name | |
| category_name=$(basename "$category") | |
| log "INFO" "Processing category: $category_name" | |
| # Use find to get a clean list of subdirectories. This is safer than globbing. | |
| # -mindepth 1 and -maxdepth 1 ensure we only get immediate children. | |
| local repo_list=() | |
| while IFS= read -r d; do repo_list+=("$d"); done < <(find "$category" -mindepth 1 -maxdepth 1 -type d) | |
| if [[ ${#repo_list[@]} -eq 0 ]]; then | |
| log "DEBUG" "No repository directories found in category '$category_name'." | |
| continue | |
| fi | |
| for repo in "${repo_list[@]}"; do | |
| log "DEBUG" "Processing path: $repo" | |
| ((total_repos++)) | |
| if [[ "${DRY_RUN:-false}" == "true" ]]; then | |
| log "INFO" "[DRY RUN] Would process repository: $(basename "$repo")" | |
| continue | |
| fi | |
| # Process repository with error handling | |
| if process_repository "$repo"; then | |
| ((success_count++)) | |
| else | |
| ((fail_count++)) | |
| log "DEBUG" "Repository $(basename "$repo") processing failed or had no changes" | |
| fi | |
| done | |
| else | |
| log "DEBUG" "Skipping non-directory item: $category" | |
| fi | |
| done | |
| shopt -u nullglob | |
| # Summary | |
| log "INFO" "Auto-commit process completed" | |
| log "INFO" "Total repositories found: $total_repos" | |
| log "INFO" "Repositories with new commits: $success_count" | |
| log "INFO" "Repositories skipped or failed: $fail_count" | |
| if [[ "${DRY_RUN:-false}" == "true" ]]; then | |
| log "INFO" "This was a dry run - no actual changes were made" | |
| fi | |
| return 0 | |
| } | |
| # Only execute main if script is run directly | |
| if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then | |
| main "$@" | |
| fi |
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
| # ============================================================================= | |
| # COMPREHENSIVE .GITIGNORE FOR MULTI-LANGUAGE DEVELOPMENT | |
| # ============================================================================= | |
| # ============================================================================= | |
| # OPERATING SYSTEM FILES | |
| # ============================================================================= | |
| # macOS | |
| .DS_Store | |
| .DS_Store? | |
| ._* | |
| .Spotlight-V100 | |
| .Trashes | |
| ehthumbs.db | |
| Icon? | |
| # Windows | |
| Thumbs.db | |
| Thumbs.db:encryptable | |
| ehthumbs.db | |
| ehthumbs_vista.db | |
| *.stackdump | |
| [Dd]esktop.ini | |
| $RECYCLE.BIN/ | |
| *.cab | |
| *.msi | |
| *.msix | |
| *.msm | |
| *.msp | |
| *.lnk | |
| # Linux | |
| *~ | |
| .fuse_hidden* | |
| .directory | |
| .Trash-* | |
| .nfs* | |
| # ============================================================================= | |
| # GENERAL DEVELOPMENT FILES | |
| # ============================================================================= | |
| # Logs and temporary files | |
| *.log | |
| *.log.* | |
| *.tmp | |
| *.temp | |
| *.bak | |
| *.swp | |
| *.swo | |
| *.swn | |
| *~ | |
| # Archive files | |
| *.zip | |
| *.tar.gz | |
| *.rar | |
| *.7z | |
| # Lock files (general) | |
| *.lock | |
| *.lockb | |
| # ============================================================================= | |
| # NODE.JS / JAVASCRIPT / TYPESCRIPT | |
| # ============================================================================= | |
| # Dependencies | |
| node_modules/ | |
| npm-debug.log* | |
| yarn-debug.log* | |
| yarn-error.log* | |
| lerna-debug.log* | |
| .pnpm-debug.log* | |
| # Package managers | |
| package-lock.json | |
| yarn.lock | |
| pnpm-lock.yaml | |
| .npmrc | |
| .yarnrc | |
| .yarn/ | |
| .pnp.* | |
| # Build outputs | |
| dist/ | |
| build/ | |
| out/ | |
| .next/ | |
| .nuxt/ | |
| .vuepress/dist/ | |
| .serverless/ | |
| # Coverage and testing | |
| coverage/ | |
| .nyc_output/ | |
| .coverage/ | |
| *.lcov | |
| .mocha-tmp/ | |
| # Runtime and environment | |
| .npm/ | |
| .node_repl_history | |
| .nvmrc | |
| .env | |
| .env.local | |
| .env.development.local | |
| .env.test.local | |
| .env.production.local | |
| # Minified files and source maps | |
| *.min.js | |
| *.min.css | |
| *.map | |
| # TypeScript | |
| *.tsbuildinfo | |
| # ESLint and Prettier | |
| .eslintcache | |
| .prettiercache | |
| # Webpack | |
| .webpack/ | |
| # ============================================================================= | |
| # PYTHON | |
| # ============================================================================= | |
| # Byte-compiled / optimized / DLL files | |
| __pycache__/ | |
| *.py[cod] | |
| *$py.class | |
| *.pyc | |
| *.pyo | |
| *.pyd | |
| # Distribution / packaging | |
| .Python | |
| build/ | |
| develop-eggs/ | |
| dist/ | |
| downloads/ | |
| eggs/ | |
| .eggs/ | |
| lib/ | |
| lib64/ | |
| parts/ | |
| sdist/ | |
| var/ | |
| wheels/ | |
| pip-wheel-metadata/ | |
| share/python-wheels/ | |
| *.egg-info/ | |
| .installed.cfg | |
| *.egg | |
| MANIFEST | |
| # Virtual environments | |
| venv/ | |
| env/ | |
| ENV/ | |
| env.bak/ | |
| venv.bak/ | |
| .venv/ | |
| .ENV/ | |
| .env | |
| pyvenv.cfg | |
| # Testing and coverage | |
| .pytest_cache/ | |
| .coverage | |
| .coverage.* | |
| htmlcov/ | |
| .tox/ | |
| .nox/ | |
| coverage.xml | |
| *.cover | |
| *.py,cover | |
| .hypothesis/ | |
| # Jupyter Notebook | |
| .ipynb_checkpoints | |
| */.ipynb_checkpoints/* | |
| # IPython | |
| profile_default/ | |
| ipython_config.py | |
| # Type checking and linting | |
| mypy_cache/ | |
| .mypy_cache/ | |
| .dmypy.json | |
| dmypy.json | |
| pylint.d/ | |
| .pylintrc | |
| # Django | |
| *.log | |
| local_settings.py | |
| db.sqlite3 | |
| db.sqlite3-journal | |
| media/ | |
| # Flask | |
| instance/ | |
| .webassets-cache | |
| # Scrapy | |
| .scrapy | |
| # Sphinx documentation | |
| docs/_build/ | |
| # PyBuilder | |
| target/ | |
| # Celery | |
| celerybeat-schedule | |
| celerybeat.pid | |
| # SageMath parsed files | |
| *.sage.py | |
| # Spyder project settings | |
| .spyderproject | |
| .spyproject | |
| # Rope project settings | |
| .ropeproject | |
| # ============================================================================= | |
| # JAVA / KOTLIN / SCALA | |
| # ============================================================================= | |
| # Compiled class files | |
| *.class | |
| *.jar | |
| *.war | |
| *.ear | |
| *.nar | |
| # Log files | |
| hs_err_pid* | |
| # BlueJ files | |
| *.ctxt | |
| # Mobile Tools for Java (J2ME) | |
| .mtj.tmp/ | |
| # Package Files | |
| *.zip | |
| *.tar.gz | |
| *.rar | |
| # Maven | |
| target/ | |
| pom.xml.tag | |
| pom.xml.releaseBackup | |
| pom.xml.versionsBackup | |
| pom.xml.next | |
| release.properties | |
| dependency-reduced-pom.xml | |
| buildNumber.properties | |
| .mvn/timing.properties | |
| .mvn/wrapper/maven-wrapper.jar | |
| # Gradle | |
| .gradle/ | |
| build/ | |
| out/ | |
| gradle-app.setting | |
| !gradle-wrapper.jar | |
| !gradle-wrapper.properties | |
| !gradle/wrapper/gradle-wrapper.jar | |
| !gradle/wrapper/gradle-wrapper.properties | |
| # IntelliJ IDEA | |
| *.iml | |
| *.ipr | |
| *.iws | |
| .idea/ | |
| out/ | |
| # Eclipse | |
| .classpath | |
| .project | |
| .settings/ | |
| bin/ | |
| tmp/ | |
| *.tmp | |
| *.bak | |
| local.properties | |
| # NetBeans | |
| nbproject/private/ | |
| nbbuild/ | |
| nbdist/ | |
| .nb-gradle/ | |
| # Android | |
| *.apk | |
| *.ap_ | |
| *.aab | |
| *.dex | |
| bin/ | |
| gen/ | |
| proguard/ | |
| lint.xml | |
| # Kotlin | |
| *.kt.class | |
| # ============================================================================= | |
| # C / C++ | |
| # ============================================================================= | |
| # Prerequisites | |
| *.d | |
| # Object files | |
| *.o | |
| *.ko | |
| *.obj | |
| *.elf | |
| # Linker output | |
| *.ilk | |
| *.map | |
| *.exp | |
| # Precompiled Headers | |
| *.gch | |
| *.pch | |
| # Libraries | |
| *.lib | |
| *.a | |
| *.la | |
| *.lo | |
| # Shared objects (inc. Windows DLLs) | |
| *.dll | |
| *.so | |
| *.so.* | |
| *.dylib | |
| # Executables | |
| *.exe | |
| *.out | |
| *.app | |
| *.i*86 | |
| *.x86_64 | |
| *.hex | |
| # Debug files | |
| *.dSYM/ | |
| *.su | |
| *.idb | |
| *.pdb | |
| # Kernel Module Compile Results | |
| *.mod* | |
| *.cmd | |
| .tmp_versions/ | |
| modules.order | |
| Module.symvers | |
| Mkfile.old | |
| dkms.conf | |
| # CMake | |
| CMakeCache.txt | |
| CMakeFiles/ | |
| CMakeScripts/ | |
| Testing/ | |
| Makefile | |
| cmake_install.cmake | |
| install_manifest.txt | |
| compile_commands.json | |
| CTestTestfile.cmake | |
| _deps/ | |
| # Build directories | |
| build/ | |
| debug/ | |
| release/ | |
| # ============================================================================= | |
| # RUST | |
| # ============================================================================= | |
| # Generated by Cargo | |
| /target/ | |
| Cargo.lock | |
| # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries | |
| # These are backup files generated by rustfmt | |
| **/*.rs.bk | |
| # MSVC Windows builds of rustc generate these, which store debugging information | |
| *.pdb | |
| # ============================================================================= | |
| # GO | |
| # ============================================================================= | |
| # Binaries for programs and plugins | |
| *.exe | |
| *.exe~ | |
| *.dll | |
| *.so | |
| *.dylib | |
| # Test binary, built with `go test -c` | |
| *.test | |
| # Output of the go coverage tool | |
| *.out | |
| # Dependency directories | |
| vendor/ | |
| # Go workspace file | |
| go.work | |
| # Go modules | |
| go.mod | |
| go.sum | |
| # Air (hot reload tool) | |
| tmp/ | |
| # ============================================================================= | |
| # DART / FLUTTER | |
| # ============================================================================= | |
| # Build outputs | |
| /build/ | |
| /.dart_tool/ | |
| /.buildlog/ | |
| # Files and directories created by pub | |
| .dart_tool/ | |
| .packages | |
| build/ | |
| pubspec.lock | |
| # Web related | |
| lib/generated_plugin_registrant.dart | |
| # Symbolication related | |
| app.*.symbols | |
| # Obfuscation related | |
| app.*.map.json | |
| # Android related | |
| **/android/**/gradle-wrapper.jar | |
| **/android/.gradle | |
| **/android/captures/ | |
| **/android/gradlew | |
| **/android/gradlew.bat | |
| **/android/local.properties | |
| **/android/**/GeneratedPluginRegistrant.java | |
| # iOS/XCode related | |
| **/ios/**/*.mode1v3 | |
| **/ios/**/*.mode2v3 | |
| **/ios/**/*.moved-aside | |
| **/ios/**/*.pbxuser | |
| **/ios/**/*.perspectivev3 | |
| **/ios/**/*sync/ | |
| **/ios/**/.sconsign.dblite | |
| **/ios/**/.tags* | |
| **/ios/**/.vagrant/ | |
| **/ios/**/DerivedData/ | |
| **/ios/**/Icon? | |
| **/ios/**/Pods/ | |
| **/ios/**/.symlinks/ | |
| **/ios/**/profile | |
| **/ios/**/xcuserdata | |
| **/ios/.generated/ | |
| **/ios/Flutter/App.framework | |
| **/ios/Flutter/Flutter.framework | |
| **/ios/Flutter/Generated.xcconfig | |
| **/ios/Flutter/app.flx | |
| **/ios/Flutter/app.zip | |
| **/ios/Flutter/flutter_assets/ | |
| **/ios/ServiceDefinitions.json | |
| **/ios/Runner/GeneratedPluginRegistrant.* | |
| # Coverage | |
| coverage/ | |
| # Exceptions to above rules | |
| !**/ios/**/default.mode1v3 | |
| !**/ios/**/default.mode2v3 | |
| !**/ios/**/default.pbxuser | |
| !**/ios/**/default.perspectivev3 | |
| !/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages | |
| # ============================================================================= | |
| # DATABASE FILES | |
| # ============================================================================= | |
| *.db | |
| *.sqlite | |
| *.sqlite3 | |
| *.db-journal | |
| *.db-shm | |
| *.db-wal | |
| # ============================================================================= | |
| # IDE AND EDITOR FILES | |
| # ============================================================================= | |
| # Visual Studio Code | |
| .vscode/ | |
| !.vscode/settings.json | |
| !.vscode/tasks.json | |
| !.vscode/launch.json | |
| !.vscode/extensions.json | |
| *.code-workspace | |
| # IntelliJ IDEA / PhpStorm / WebStorm / etc. | |
| .idea/ | |
| *.iml | |
| *.ipr | |
| *.iws | |
| out/ | |
| # Sublime Text | |
| *.tmlanguage.cache | |
| *.tmPreferences.cache | |
| *.stTheme.cache | |
| *.sublime-workspace | |
| *.sublime-project | |
| # Atom | |
| .atom/ | |
| # Vim | |
| *.swp | |
| *.swo | |
| *.swn | |
| *~ | |
| .netrwhist | |
| tags | |
| # Emacs | |
| *~ | |
| \#*\# | |
| /.emacs.desktop | |
| /.emacs.desktop.lock | |
| *.elc | |
| auto-save-list | |
| tramp | |
| .\#* | |
| # ============================================================================= | |
| # SECURITY AND ENVIRONMENT | |
| # ============================================================================= | |
| # Environment variables | |
| .env | |
| .env.local | |
| .env.*.local | |
| .envrc | |
| # API keys and secrets | |
| secrets/ | |
| *.key | |
| *.pem | |
| *.p12 | |
| *.p8 | |
| *.mobileprovision | |
| # ============================================================================= | |
| # DOCKER | |
| # ============================================================================= | |
| # Docker | |
| .dockerignore | |
| docker-compose.override.yml | |
| # ============================================================================= | |
| # MISCELLANEOUS | |
| # ============================================================================= | |
| # Terraform | |
| *.tfstate | |
| *.tfstate.* | |
| .terraform/ | |
| .terraform.lock.hcl | |
| # Vagrant | |
| .vagrant/ | |
| # MacOS Finder | |
| .DS_Store | |
| # Windows image file caches | |
| Thumbs.db | |
| # Folder config file | |
| Desktop.ini | |
| # NPM error logs | |
| npm-debug.log* | |
| # Optional npm cache directory | |
| .npm | |
| # Optional REPL history | |
| .node_repl_history | |
| # Output of 'npm pack' | |
| *.tgz | |
| # Yarn Integrity file | |
| .yarn-integrity |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
~/.config/shell-ask/config.json{ ... }^ -> https://github.com/egoist/shell-ask/blob/main/docs/config.md