Last active
June 11, 2025 20:14
-
-
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.
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 | |
# 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 |
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
# 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 |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
see https://cli.github.com/