Skip to content

Instantly share code, notes, and snippets.

@WomB0ComB0
Last active June 11, 2025 20:14
Show Gist options
  • Save WomB0ComB0/43acb03ecbd012d761cadb440698faa6 to your computer and use it in GitHub Desktop.
Save WomB0ComB0/43acb03ecbd012d761cadb440698faa6 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.
#!/bin/bash
# Still want to catch undefined variables, but don't exit on errors
set -u
# Ensure HOME is defined for systemd services, as it might be minimal.
# IMPORTANT: Adjust this path if your user's home directory is different.
HOME="/home/wombocombo"
# Script configuration defaults
DEFAULT_REPO_DIR="$HOME/github"
DEFAULT_AI_COMMAND="ask cm -m gemini-2.0-flash"
LOG_FILE="/tmp/git-auto-commit-$(date +%Y%m%d-%H%M%S).log"
MAX_RETRIES=3
TIMEOUT_SECONDS=30
DEFAULT_GITDIFF_EXCLUDE="$HOME/.config/git/gitdiff-exclude"
DEFAULT_GH_AUTH_EMAIL="" # Default authorized email (empty means use global git config)
DEFAULT_AUTO_PUSH=true # Whether to automatically push changes after commit
# Configuration file path
CONFIG_FILE="$HOME/.git-auto-commit.conf"
# Load configuration if the file exists
if [ -f "$CONFIG_FILE" ]; then
source "$CONFIG_FILE"
fi
# Use defaults or values from the configuration file
REPO_DIR="${REPO_DIR:-$DEFAULT_REPO_DIR}"
AI_COMMAND="${AI_COMMAND:-$DEFAULT_AI_COMMAND}"
GITDIFF_EXCLUDE="${GITDIFF_EXCLUDE:-$DEFAULT_GITDIFF_EXCLUDE}"
GH_AUTH_EMAIL="${GH_AUTH_EMAIL:-$DEFAULT_GH_AUTH_EMAIL}"
AUTO_PUSH="${AUTO_PUSH:-$DEFAULT_AUTO_PUSH}"
# Logging function: writes messages to stdout and the log file
log() {
local level="$1"
shift
local message="$*"
local timestamp
timestamp=$(date '+%Y-%m-%d %H:%M:%S')
echo "[$timestamp] [$level] $message" | tee -a "$LOG_FILE"
}
# Error handling function: logs errors but does not exit the script
handle_error() {
local exit_code=$?
log "ERROR" "An error occurred in line $1"
log "ERROR" "Exit code: $exit_code"
# Return the error code to allow the script to continue processing other repositories
return $exit_code
}
# Set up error trapping: call handle_error on any command failure
trap 'handle_error $LINENO' ERR
# Check for required external commands
check_dependencies() {
local missing_deps=()
# List of commands required for the script to function
local deps=("git" "grep" "timeout" "gh")
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[*]}"
return 1
fi
return 0
}
# Check if GitHub CLI is authenticated
check_gh_auth() {
if ! gh auth status &>/dev/null; then
log "ERROR" "GitHub CLI not authenticated. Please run 'gh auth login' first."
return 1
fi
return 0
}
# Get the authorized email for a given repository
get_authorized_email() {
local repo_path="$1"
local repo_name
repo_name=$(basename "$repo_path")
local remote_url
local repo_owner
local repo_name_only
local authorized_email
# Try to get the remote URL for the origin
remote_url=$(git -C "$repo_path" config --get remote.origin.url 2>/dev/null)
if [ -z "$remote_url" ]; then
log "WARNING" "Could not determine remote URL for $repo_name" >&2
return 1
fi
# Extract owner and repo name from GitHub URL
if [[ "$remote_url" =~ github\.com[:/]([^/]+)/([^/.]+) ]]; then
repo_owner="${BASH_REMATCH[1]}"
repo_name_only="${BASH_REMATCH[2]}"
log "INFO" "Checking authorized emails for $repo_owner/$repo_name_only" >&2
# 1. Prioritize the explicitly configured GH_AUTH_EMAIL
if [ -n "$GH_AUTH_EMAIL" ]; then
log "INFO" "Using configured authorized email: $GH_AUTH_EMAIL" >&2
echo "$GH_AUTH_EMAIL"
return 0
fi
# 2. Try to get email from GitHub CLI API (requires gh auth)
authorized_email=$(gh api user/emails 2>/dev/null | grep -o '"email":"[^"]*"' | head -1 | cut -d'"' -f4)
if [ -n "$authorized_email" ]; then
log "INFO" "Found authorized email via gh CLI: $authorized_email" >&2
echo "$authorized_email"
return 0
fi
# 3. Fallback to global git user email
authorized_email=$(git config --global user.email)
if [ -n "$authorized_email" ]; then
log "INFO" "Using global git email: $authorized_email" >&2
echo "$authorized_email"
return 0
fi
else
log "WARNING" "Could not parse GitHub repository information from URL: $remote_url" >&2
return 1
fi
log "WARNING" "Could not determine authorized email for $repo_name. Falling back to current git config." >&2
return 1
}
# Get the default branch for a repository
get_default_branch() {
local repo_path="$1"
local default_branch
# 1. Try to get the default branch from git's symbolic-ref (origin/HEAD)
default_branch=$(git -C "$repo_path" symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@')
# 2. If that fails, try to get it from GitHub CLI (requires gh auth)
if [ -z "$default_branch" ]; then
local remote_url
remote_url=$(git -C "$repo_path" config --get remote.origin.url 2>/dev/null)
if [[ "$remote_url" =~ github\.com[:/]([^/]+)/([^/.]+) ]]; then
local repo_owner="${BASH_REMATCH[1]}"
local repo_name="${BASH_REMATCH[2]}"
default_branch=$(gh api repos/"$repo_owner"/"$repo_name" 2>/dev/null | grep -o '"default_branch":"[^"]*"' | cut -d'"' -f4)
fi
fi
# 3. Fallback to common default branch names (main or master)
if [ -z "$default_branch" ]; then
if git -C "$repo_path" show-ref --verify --quiet refs/heads/main; then
default_branch="main"
elif git -C "$repo_path" show-ref --verify --quiet refs/heads/master; then
default_branch="master"
fi
fi
if [ -n "$default_branch" ]; then
echo "$default_branch"
return 0
else
log "WARNING" "Could not determine default branch for $repo_path" >&2
return 1
fi
}
# Validate that the configured repository directory exists
validate_repo_dir() {
if [ ! -d "$REPO_DIR" ]; then
log "ERROR" "Repository directory not found: $REPO_DIR"
return 1
fi
return 0
}
# Check if common .env variants are ignored in .gitignore
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")
local has_env_files=false
# Check for existence of any .env files in the repository
for variant in "${env_variants[@]}"; do
if [ -f "$repo_path/$variant" ]; then
has_env_files=true
# If a .env file exists and it's not ignored in .gitignore, log a warning
if [ ! -f "$gitignore_file" ] || ! grep -q "^$variant$" "$gitignore_file"; then
log "WARNING" "Found $variant but it's not properly ignored in $(basename "$repo_path")/.gitignore"
fi
fi
done
if [ "$has_env_files" = false ]; then
log "DEBUG" "No .env files found in $(basename "$repo_path")"
fi
# Always return success to allow the script to continue processing
return 0
}
# Generate a commit message using the AI command, with retry mechanism
generate_commit_message() {
local attempt=1
local commit_message=""
local exclude_args=""
local diff_file=""
local output_file=""
# Build exclude arguments from the GITDIFF_EXCLUDE file
if [ -f "$GITDIFF_EXCLUDE" ]; then
while IFS= read -r line || [ -n "$line" ]; do
# Ignore comments and empty lines
if [[ ! "$line" =~ ^# ]] && [ -n "$line" ]; then
# Add the exclude pattern if the file/path exists
if [ -e "$line" ]; then
exclude_args="$exclude_args :!$line"
fi
fi
done < <(grep -v '^#' "$GITDIFF_EXCLUDE" 2>/dev/null || echo "")
fi
while [ $attempt -le $MAX_RETRIES ]; do
log "INFO" "Generating commit message (attempt $attempt/$MAX_RETRIES)..." >&2
log "DEBUG" "Using gitdiff-exclude: $exclude_args" >&2
# Create temporary files for git diff and AI command output
diff_file=$(mktemp)
output_file=$(mktemp)
# Capture the git diff of staged changes to a temporary file
# --diff-filter=ACMRTUXB includes added, copied, deleted, modified, renamed, type-changed, updated, unmerged, broken
# Since we are already in the repository directory, no need for -C
git diff --cached --diff-filter=ACMRTUXB $exclude_args > "$diff_file" 2>/dev/null
# Check if the generated diff is empty
if [ ! -s "$diff_file" ]; then
log "WARNING" "Git diff is empty, no AI message needed." >&2
rm -f "$diff_file" "$output_file" # Clean up temp files
echo "Auto-commit: No changes to commit" # Return a default message indicating no changes
return 0
fi
local diff_size
diff_size=$(wc -l < "$diff_file")
log "DEBUG" "Git diff size: $diff_size lines" >&2
# Run AI command with a timeout, piping the diff as input
log "DEBUG" "Running AI command: $AI_COMMAND" >&2
if timeout $((TIMEOUT_SECONDS*2)) bash -c "cat \"$diff_file\" | $AI_COMMAND 'Could you write a concise, single-line commit message for this? Please only provide the commit message without any additional commentary or markdown formatting.'" > "$output_file" 2>/dev/null; then
commit_message=$(cat "$output_file")
log "DEBUG" "AI command raw output length: $(echo "$commit_message" | wc -c) bytes" >&2
# Clean up the output: remove common markdown code blocks or quotes
commit_message=$(echo "$commit_message" | sed -e 's/^```bash.*$//' -e 's/^```sh.*$//' -e 's/^```.*$//' -e 's/^```$//' -e 's/^"//' -e 's/"$//' | xargs echo -n) # xargs echo -n removes extra whitespace/newlines
# Ensure the message is not empty after cleaning
if [ -n "$commit_message" ]; then
log "DEBUG" "Cleaned commit message: $commit_message" >&2
rm -f "$diff_file" "$output_file" # Clean up temp files
echo "$commit_message"
return 0
fi
fi
log "WARNING" "Failed to generate commit message on attempt $attempt" >&2
((attempt++))
[ $attempt -le $MAX_RETRIES ] && sleep 2 # Wait before retrying
done
# Fallback to a timestamp-based commit message if AI generation fails
log "WARNING" "AI message generation failed after $MAX_RETRIES attempts. Using fallback message." >&2
rm -f "$diff_file" "$output_file" # Ensure temp files are cleaned up even on failure
echo "Auto-commit: Changes at $(date +%Y-%m-%d\ %H:%M:%S)"
return 0
}
# Push changes to the remote repository
push_changes() {
local repo_path="$1" # This function still needs the full path to operate on
local repo_name
repo_name=$(basename "$repo_path")
local current_branch
local default_branch
# Get the current branch
current_branch=$(git -C "$repo_path" symbolic-ref --short HEAD 2>/dev/null)
if [ -z "$current_branch" ]; then
log "ERROR" "Could not determine current branch for $repo_name"
return 1
fi
# Get the default branch, falling back to current if not found
default_branch=$(get_default_branch "$repo_path")
if [ -z "$default_branch" ]; then
log "WARNING" "Could not determine default branch for $repo_name, using current branch: $current_branch for push."
default_branch="$current_branch"
fi
log "INFO" "Pushing changes to origin/$current_branch in $repo_name"
# Attempt to push changes
if git -C "$repo_path" push origin "$current_branch" 2>/dev/null; then
log "SUCCESS" "Successfully pushed changes to $current_branch in $repo_name"
# Suggest creating a pull request if not on the default branch
if [ "$current_branch" != "$default_branch" ]; then
log "INFO" "You're on branch '$current_branch', not on default branch '$default_branch'."
# Extract owner/repo from remote URL for gh pr create command
local repo_slug
repo_slug=$(git -C "$repo_path" config --get remote.origin.url | sed -E 's/.*github.com[:/]([^/]+\/[^/.]+)(\.git)?/\1/')
if [ -n "$repo_slug" ]; then
log "INFO" "Consider creating a pull request with: gh pr create -R $repo_slug"
fi
fi
return 0
else
log "ERROR" "Failed to push changes to $current_branch in $repo_name"
return 1
fi
}
# Process a single Git repository
process_repository() {
local repo="$1"
local repo_name
repo_name=$(basename "$repo")
local original_email="" # Initialize to empty string
local authorized_email
local has_committed=false
log "INFO" "Processing repository: $repo_name"
# Verify it's a Git repository directory
if [ ! -d "$repo/.git" ]; then
log "WARNING" "Not a Git repository: $repo_name. Skipping."
return 0 # Continue to next repo
fi
# Attempt to change directory. Capture stderr for specific error checking.
local cd_error_message
# Using process substitution and `read` to capture stderr and check exit code
if ! { cd "$repo" 2>&1; } | read -r cd_error_message; then
if [[ "$cd_error_message" =~ "dubious ownership" ]]; then
log "ERROR" "Failed to change directory to $repo_name due to dubious ownership."
log "ERROR" "To fix this, run: git config --global --add safe.directory \"$repo\""
else
log "ERROR" "Failed to change directory to $repo_name: $cd_error_message. Skipping."
fi
return 0 # Skip this repo
fi
# If we reach here, cd was successful, and we are now in the repository directory.
# Check for .env files and their ignore status (always continues)
# Pass the current working directory to check_env_ignored
check_env_ignored "$(pwd)"
# Store original user.email configuration before potentially changing it
original_email=$(git config user.email 2>/dev/null)
# Attempt to get and set the authorized email for this repository
# Pass the current working directory to get_authorized_email
if authorized_email=$(get_authorized_email "$(pwd)"); then
log "INFO" "Setting commit email to: $authorized_email for $repo_name"
git config user.email "$authorized_email"
else
log "WARNING" "Using current git email configuration for $repo_name (could not determine specific authorized email)."
fi
# Check for any changes (staged or unstaged) more robustly
local git_status_output=""
local git_status_exit_code=0
# Run git status and capture both stdout and stderr. No need for -C here as we are in the directory.
git_status_output=$(git status --porcelain 2>&1) || git_status_exit_code=$?
if [ "$git_status_exit_code" -ne 0 ]; then
log "ERROR" "Git status failed in $repo_name (exit code $git_status_exit_code): $git_status_output. Skipping."
# Restore email before returning
if [ -n "$original_email" ]; then git config user.email "$original_email"; fi
return 0
fi
# Now check if there are actual changes based on the porcelain output
if ! echo "$git_status_output" | grep '^[A-Z]' > /dev/null; then
log "INFO" "No changes to commit in $repo_name."
# Restore original email if it was previously set
if [ -n "$original_email" ]; then git config user.email "$original_email"; fi
return 0 # No changes, successfully processed (nothing to do)
fi
log "INFO" "Changes detected in $repo_name."
# Stage all changes in the current directory. No need for -C.
git add .
# Generate the commit message
local commit_message
commit_message=$(generate_commit_message)
# Check if the AI generated an empty message (indicating failure or no relevant changes for AI)
if [ -z "$commit_message" ] || [[ "$commit_message" == "Auto-commit: No changes to commit" ]]; then
log "WARNING" "AI command failed to generate a meaningful commit message or detected no relevant changes. Using a generic fallback message."
commit_message="Auto-commit: Changes detected at $(date +%Y-%m-%d\ %H:%M:%S)"
fi
log "INFO" "Commit message for $repo_name: '$commit_message'"
# Commit changes with the generated message. No need for -C.
if git commit -m "$commit_message"; then
log "SUCCESS" "Changes committed in $repo_name."
has_committed=true
# Push changes if auto-push is enabled
if [ "$AUTO_PUSH" = true ]; then
# Pass the current working directory to push_changes
push_changes "$(pwd)"
fi
else
log "ERROR" "Failed to commit changes in $repo_name."
fi
# Always restore the original email configuration after processing the repository
if [ -n "$original_email" ]; then
git config user.email "$original_email"
fi
if [ "$has_committed" = true ]; then
return 0 # Successfully committed
else
return 1 # Failed to commit
fi
}
# Main function to orchestrate the auto-commit process
main() {
log "INFO" "Starting auto-commit process."
# Perform initial checks, but allow the script to continue even if they fail
# These checks log errors but do not stop the script from attempting to process repos.
check_dependencies || true
check_gh_auth || true
validate_repo_dir || true
local success_count=0
local fail_count=0
# Enable nullglob to handle cases where no files match the pattern (e.g., empty REPO_DIR)
shopt -s nullglob
# Iterate through each category directory (e.g., 'prs') in REPO_DIR
for category in "$REPO_DIR"/*; do
if [ -d "$category" ]; then
# Iterate through repositories within each category
for repo in "$category"/*; do
if [ -d "$repo" ]; then # Ensure it's a directory
if process_repository "$repo"; then
((success_count++))
else
((fail_count++))
fi
fi
done
fi
done
shopt -u nullglob # Disable nullglob
log "INFO" "Auto-commit process completed."
log "INFO" "Successful repositories: $success_count"
log "INFO" "Failed repositories: $fail_count"
# Exit successfully regardless of individual repository failures
return 0
}
# Run the main function
main
# General
*.log
*.lock
package-lock.json
yarn.lock
pnpm-lock.yaml
.DS_Store
Thumbs.db
# Node.js / TypeScript / JavaScript
node_modules/
dist/
build/
coverage/
*.min.js
*.map
.npm/
.nvmrc
# Python
__pycache__/
*.pyc
*.pyo
*.pyd
env/
venv/
.Python
mypy_cache/
pylint.d/
# Dart / Flutter
.dart_tool/
.buildlog/
pubspec.lock
*.iml
# Java / Kotlin
*.class
*.jar
*.war
*.ear
.gradle/
build/
out/
# Rust
target/
Cargo.lock
# Go
bin/
*.test
*.mod
# C / C++
*.o
*.obj
*.so
*.exe
*.dll
*.dylib
CMakeCache.txt
# Miscellaneous
.vscode/
.idea/
*.swp
*.swo
*.swn
*.bak
*.tmp
@WomB0ComB0
Copy link
Author

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment