From b9d0d8012da4e9617a0b84aecccb07a12fe986a5 Mon Sep 17 00:00:00 2001 From: Oliver Steele Date: Sat, 15 Feb 2025 12:45:05 +0900 Subject: [PATCH 1/9] refactor: improve fixup commit formatting, ordering, and rebase handling - Update the git-create-fixups script to use "fixup!" in commit messages and fix exit codes for help display. - Update regex to match both "f" and "fixup!" style prefixes. - Revamp commit reordering to place each fixup immediately after its target (ordered from oldest to newest) and set the command based on the squash flag. - Restrict commit message output to the first line for non-colored display to prevent multi-line issues. - Remove the --autosquash flag from the rebase command and adjust base commit determination for --root cases. --- git-apply-fixups | 118 +++++++++++++++++++++++------------------------ git-stage-fixups | 5 +- 2 files changed, 60 insertions(+), 63 deletions(-) diff --git a/git-apply-fixups b/git-apply-fixups index 728c7fa..f672b15 100755 --- a/git-apply-fixups +++ b/git-apply-fixups @@ -8,14 +8,14 @@ # ] # /// +import os import re import shlex +import tempfile from dataclasses import dataclass from datetime import datetime from pathlib import Path from typing import Iterator, Optional -import tempfile -import os import click import git @@ -49,12 +49,17 @@ class Commit: return dt.strftime("[%Y-%m-%d %H:%M:%S]") def format_line(self, with_color: bool = True) -> str: - """Format the commit line for display or rebase todo list.""" - line = f"{self.command} {self.hash} {self.formatted_date} ({self.author}) {self.message}" + """Format the commit line for display or rebase todo list. + For non-colored output (used in the rebase todo), only the first line of + the commit message is used to avoid multi-line issues.""" if with_color: + first_line = self.message.splitlines()[0].strip() if self.message else "" + line = f"{self.command} {self.hash} {self.formatted_date} ({self.author}) {first_line}" style = "fixup" if self.is_fixup else "commit" return f"[{style}]{line}[/{style}]" - return line + else: + first_line = self.message.splitlines()[0].strip() if self.message else "" + return f"{self.command} {self.hash} {self.formatted_date} ({self.author}) {first_line}" def __str__(self) -> str: return self.format_line(with_color=True) @@ -62,21 +67,21 @@ class Commit: def parse_commits(repo_path: Path = Path("."), max_count: Optional[int] = None, since: Optional[str] = None) -> Iterator[Commit]: """Parse git log and yield Commit objects.""" repo = git.Repo(repo_path) - + # Build the options for iter_commits kwargs = {} if max_count is not None: kwargs['max_count'] = max_count if since is not None: kwargs['since'] = since - + for commit in repo.iter_commits(**kwargs): message = commit.message.strip() target_hash = None - - if match := re.match(r'^f \[([a-f0-9]+)\]', message): + + if match := re.match(r'^f(?:ixup)?! \[([a-f0-9]+)\]', message): target_hash = match.group(1) - + yield Commit( hash=commit.hexsha[:7], timestamp=commit.committed_date, @@ -86,38 +91,28 @@ def parse_commits(repo_path: Path = Path("."), max_count: Optional[int] = None, ) def reorder_commits(commits: list[Commit], use_squash: bool = False) -> list[Commit]: - """Reorder commits so fixups come before their targets.""" - # First pass: collect all fixups and their targets - fixups = [c for c in commits if c.is_fixup] - fixup_hashes = {c.hash for c in fixups} - - # Second pass: build the new order + """Reorder commits so that each fixup commit comes after its target commit. + The commits are ordered from oldest to newest, and for each non-fixup commit, + its associated fixup commits (if any) are appended immediately after.""" + # Reverse the commits to get ascending order (oldest to newest) + sorted_commits = commits[::-1] + + # Build a mapping from target commit hash to list of fixup commits targeting that commit + fixups_by_target = {} + for commit in sorted_commits: + if commit.is_fixup and commit.target_hash: + fixups_by_target.setdefault(commit.target_hash, []).append(commit) + result = [] - processed = set() - - # Process commits in reverse order (newest to oldest) - for commit in commits: - # Skip if we've already processed this commit - if commit.hash in processed: - continue - - # If this is a regular commit (not a fixup) - if commit.hash not in fixup_hashes: - # Check if any fixups target this commit - matching_fixups = [f for f in fixups if f.target_hash == commit.hash] - - # Add any fixups that target this commit - for fixup in matching_fixups: - if fixup.hash not in processed: - if use_squash: - fixup.command = "squash" - result.append(fixup) - processed.add(fixup.hash) - - # Add the commit itself + for commit in sorted_commits: + if not commit.is_fixup: + # Add the target commit first result.append(commit) - processed.add(commit.hash) - + # Then append any fixup commits targeting it + if commit.hash in fixups_by_target: + for fixup in fixups_by_target[commit.hash]: + fixup.command = "squash" if use_squash else "fixup" + result.append(fixup) return result class DateTimeParamType(click.ParamType): @@ -137,7 +132,7 @@ def handle_rebase_error(error: git.exc.GitCommandError, repo: git.Repo) -> None: except git.exc.GitCommandError: # If abort fails, just continue with the error pass - + # Check for common error types if "CONFLICT" in error.stderr: console.print("[error]Rebase failed due to conflicts:[/error]") @@ -154,28 +149,28 @@ def handle_rebase_error(error: git.exc.GitCommandError, repo: git.Repo) -> None: @click.option("--dry-run", "-n", is_flag=True, help="Show proposed reordering without executing") @click.option("--squash", "-s", is_flag=True, help="Use 'squash' instead of 'pick' for fixup commits") @click.option("--max-count", "-n", type=int, help="Limit the number of commits to process") -@click.option("--since", type=DateTimeParamType(), +@click.option("--since", type=DateTimeParamType(), help='Show commits more recent than a specific date (e.g. "2 days ago" or "2024-01-01")') def main(dry_run: bool, squash: bool, max_count: Optional[int], since: Optional[str]) -> None: """Reorder commits so that fixup commits are placed before their targets. - This script is part of a workflow with git-stage-fixups: - + This script is part of a workflow with git-create-fixups: + 1. Make changes to multiple files 2. Run git-stage-fixups to create separate commits for each file 3. Run this script (git-apply-fixups) to automatically reorder the commits so that each fixup commit is placed before its target commit - + The --squash option will mark fixup commits with 'squash' instead of 'pick', causing them to be combined with their target commits during the rebase. - + Examples: # Show proposed reordering without executing git-apply-fixups --dry-run - + # Reorder and mark fixups for squashing git-apply-fixups --squash - + # Only process recent commits git-apply-fixups --since="2 days ago" git-apply-fixups -n 10 @@ -186,9 +181,9 @@ def main(dry_run: bool, squash: bool, max_count: Optional[int], since: Optional[ if not commits: console.print("[yellow]No commits found in the specified range[/yellow]") return - + reordered = reorder_commits(commits, use_squash=squash) - + if dry_run: console.print("Proposed commit order:") for commit in reordered: @@ -196,27 +191,28 @@ def main(dry_run: bool, squash: bool, max_count: Optional[int], since: Optional[ else: # Generate the rebase todo list using the same format as dry-run, but without colors todo_list = "\n".join(commit.format_line(with_color=False) for commit in reordered) - + # Start interactive rebase repo = git.Repo(".") - + # Build rebase command - cmd = ["git", "rebase", "-i", "--autosquash", "--autostash"] - + cmd = ["git", "rebase", "-i", "--autostash"] + # Find the base commit for rebase if max_count: # For max_count, we can use HEAD~N base_commit = f"HEAD~{max_count}" else: - # For since/until, find the oldest commit in our range and use its parent - oldest_commit = commits[-1].hash - try: - base_commit = f"{oldest_commit}^" - except git.exc.GitCommandError: - # If the oldest commit is the root commit, use --root + # For since/until, find the oldest commit in our range and determine its parent via git.Repo + oldest_commit_obj = commits[-1] + oldest_git_commit = repo.commit(oldest_commit_obj.hash) + if not oldest_git_commit.parents: + # This is the root commit, so we use --root so that it appears in the todo list cmd.append("--root") base_commit = None - + else: + base_commit = f"{oldest_git_commit.hexsha}^" + if base_commit: cmd.append(base_commit) diff --git a/git-stage-fixups b/git-stage-fixups index 105b88c..67328a1 100755 --- a/git-stage-fixups +++ b/git-stage-fixups @@ -22,7 +22,6 @@ usage() { echo >&2 echo "Options:" >&2 echo " -n, --dry-run Show what would be done" >&2 - exit 1 } # Change to git root directory for the duration of the script @@ -38,9 +37,11 @@ while [[ $# -gt 0 ]]; do ;; -h|--help) usage + exit 0 ;; *) usage + exit 1 ;; esac done @@ -74,7 +75,7 @@ done # Process each commit group for commit_hash in "${!files_by_commit[@]}"; do - message="f [$commit_hash] ${messages_by_commit[$commit_hash]}" + message="fixup! [$commit_hash] ${messages_by_commit[$commit_hash]}" if [ "$dry_run" = true ]; then echo "Would commit: $message" echo "${files_by_commit[$commit_hash]}" | sed 's/^/- /' From 1dae0d7cf3f223210ced2f74fcd0a8700772f234 Mon Sep 17 00:00:00 2001 From: Oliver Steele Date: Sat, 15 Feb 2025 13:22:23 +0900 Subject: [PATCH 2/9] feat: add custom prompt option for commit rewording Introduce a new -p/--prompt flag to allow users to supply custom instructions for modifying the existing commit message. The flag enables tailoring the commit message generation process by incorporating a user-defined prompt along with the diff details, while preserving the conventional commit format. --- README.md | 5 ++++- git-ai-rewrite-commit | 52 ++++++++++++++++++++++++++++++++++--------- 2 files changed, 45 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index b9cbc35..4e19955 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,10 @@ git-ai-rewrite-commit git-ai-rewrite-commit -n # Use a specific LLM model -git-ai-rewrite-commit --model gpt-4 +git-ai-reword-message --model gpt-4 + +# Modify the message according to specific instructions +git-ai-reword-message --prompt "Make the message more concise" ``` #### `git-squash-commit-messages` diff --git a/git-ai-rewrite-commit b/git-ai-rewrite-commit index 36119f0..e0883eb 100755 --- a/git-ai-rewrite-commit +++ b/git-ai-rewrite-commit @@ -15,12 +15,13 @@ Generate a new commit message for a specified commit based on its changes. If no commit is specified, uses HEAD. Options: - -m, --model MODEL Specify the LLM model to use (default: deepseek-coder or o1-mini) - Examples: gpt-4, claude-3-opus, mistral-7b, ... - -n, --dry-run Show the new message without applying it - -f, --force Force rewrite even if commit has been pushed to remote - -l, --list List available models - -h, --help Show this help + -m, --model MODEL Specify the LLM model to use (default: deepseek-coder or o1-mini) + Examples: gpt-4, claude-3-opus, mistral-7b, ... + -n, --dry-run Show the new message without applying it + -f, --force Force rewrite even if commit has been pushed to remote + -l, --list List available models + -p, --prompt PROMPT Provide instructions to modify the commit message + -h, --help Show this help EOF exit 1 } @@ -48,6 +49,7 @@ model=$(select_default_model) dry_run=false force=false commit="HEAD" +custom_prompt="" # Parse arguments while [[ $# -gt 0 ]]; do @@ -69,6 +71,10 @@ while [[ $# -gt 0 ]]; do llm models exit 0 ;; + -p|--prompt) + custom_prompt="$2" + shift 2 + ;; -h|--help) usage ;; @@ -107,9 +113,28 @@ echo -n "Generating commit message... " >&2 # Get the original commit message original_msg=$(git log -1 --pretty=%B "$commit") -commit_msg=$(llm \ - --model "$model" \ - "Analyze these changes and create a new commit message: +if [[ -n "$custom_prompt" ]]; then + prompt_text="Analyze these changes and modify the existing commit message according to the following instructions: + +The original commit message was: +\`\`\` +$original_msg +\`\`\` + +Changes: +\`\`\` +$(git show --patch "$commit") +\`\`\` + +Modification instructions: +\`\`\` +$custom_prompt +\`\`\` + +Format the response as a conventional commit message with a brief title line followed by a more detailed description if needed. +Don't include any other text in the response, just the commit message." +else + prompt_text="Analyze these changes and create a new commit message: \`\`\` $(git show --patch "$commit") @@ -121,8 +146,13 @@ $original_msg \`\`\` Format the response as a conventional commit message with a brief title line followed by a more detailed description if needed. -Don't include any other text in the response, just the commit message. -") +Don't include any other text in the response, just the commit message." +fi + +commit_msg=$(llm \ + --model "$model" \ + "$prompt_text" +) printf "\r\033[K" >&2 # Clear progress message From 7bc1353a3646ef48cefae7d1e4cd990a49ddb434 Mon Sep 17 00:00:00 2001 From: Oliver Steele Date: Sun, 16 Feb 2025 23:02:53 +0900 Subject: [PATCH 3/9] feat: update CLI options and pre-commit hook handling - Enhance usage message to include new options: --author, --cleanup, --edit/--no-edit, and --signoff/--no-signoff. - Replace -n/--dry-run with -d/--dry-run for clarity. - Introduce -n/--no-verify flag to optionally skip pre-commit hooks (passes --no-verify to git commit). - Run pre-commit hook conditionally unless skipping verification. - Adjust argument filtering for dry-run mode to reflect renamed option. --- git-ai-commit | 48 ++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 38 insertions(+), 10 deletions(-) diff --git a/git-ai-commit b/git-ai-commit index f29db95..c81ed47 100755 --- a/git-ai-commit +++ b/git-ai-commit @@ -14,11 +14,18 @@ Usage: $(basename "$0") [options] Generate and commit changes using AI messages. Options: - -m, --model MODEL Specify the LLM model to use (default: deepseek-coder or o1-mini) - Examples: gpt-4, claude-3-opus, mistral-7b, ... - -n, --dry-run Show changes without committing - -l, --list List available models - -h, --help Show this help + -m, --model MODEL Specify the LLM model to use (default: deepseek-coder or o1-mini) + Examples: gpt-4, claude-3-opus, mistral-7b, ... + -d, --dry-run Show changes without committing + -n, --no-verify Skip pre-commit hooks (and pass --no-verify to git commit) + --author= Set commit author + -s, --signoff, --no-signoff + Add or remove signoff + --cleanup= Set commit cleanup mode + -e, --edit, --no-edit + Edit or skip editing commit message + -l, --list List available models + -h, --help Show this help EOF exit 1 } @@ -43,12 +50,14 @@ select_default_model() { # Default values model=$(select_default_model) -dry_run=false script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # Capture original arguments before parsing original_args=("$@") + dry_run=false +no_verify=false +extra_commit_args=() # Parse arguments while [[ $# -gt 0 ]]; do @@ -57,10 +66,15 @@ while [[ $# -gt 0 ]]; do model="$2" shift 2 ;; - -n|--dry-run) + -d|--dry-run) dry_run=true shift ;; + -n|--no-verify) + no_verify=true + extra_commit_args+=("--no-verify") + shift + ;; -l|--list) echo "Available models:" llm models @@ -69,6 +83,10 @@ while [[ $# -gt 0 ]]; do -h|--help) usage ;; + --author=*|--cleanup=*|-e|--edit|--no-edit|-s|--signoff|--no-signoff) + extra_commit_args+=("$1") + shift + ;; *) echo "Error: Unknown option: $1" >&2 usage @@ -82,6 +100,16 @@ if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then exit 1 fi +# Run pre-commit hook unless --no-verify was specified +if [ "$no_verify" = false ]; then + git_dir=$(git rev-parse --git-dir) + pre_commit_hook="$git_dir/hooks/pre-commit" + if [ -x "$pre_commit_hook" ]; then + echo "Running pre-commit hook..." + "$pre_commit_hook" + fi +fi + # Check for any changes (staged, unstaged, or untracked) if [ -z "$(git status --porcelain)" ]; then echo "Error: No changes to commit" >&2 @@ -101,7 +129,7 @@ commit_msg=$(llm \ "Create a commit message for the following changes: \`\`\` -$(git diff; git ls-files --others --exclude-standard | xargs -I{} echo "A {}") +$(git diff; git ls-files --others --exclude-standard | xargs -I{} echo \"A {}\") \`\`\` Format the response as a conventional commit message with a brief title line followed by a more detailed description if needed. @@ -122,7 +150,7 @@ if [ "$dry_run" = true ]; then suggestion="$(basename "$0")" filtered_args=() for arg in "${original_args[@]}"; do - [[ "$arg" != "-n" && "$arg" != "--dry-run" ]] && filtered_args+=("$arg") + [[ "$arg" != "-d" && "$arg" != "--dry-run" ]] && filtered_args+=("$arg") done if [ ${#filtered_args[@]} -gt 0 ]; then suggestion+=" ${filtered_args[*]}" @@ -133,5 +161,5 @@ else # Stage and commit changes echo -e "\n== Committing changes... ==" >&2 git add -A - git commit -m "$commit_msg" >/dev/null && echo "Changes committed successfully!" + git commit -m "$commit_msg" ${extra_commit_args[@]:-} >/dev/null && echo "Changes committed successfully!" fi From db85a1020d637aefa7d0c96e3bc5e51e979c07b3 Mon Sep 17 00:00:00 2001 From: Oliver Steele Date: Tue, 4 Mar 2025 23:53:22 +0900 Subject: [PATCH 4/9] feat: add git-ai-release-notes command Introduce a new release notes generation feature that creates well-structured, markdown-formatted blog posts announcing new releases based on git commits. This tool leverages an LLM to categorize changes and supports various tonal styles (technical, casual, pirate, etc.), date filtering, and customizable output. Updated README.md with usage examples and added script permissions for executable use. --- README.md | 367 +++++++++++++++++++++++++++++++++++++++- git-ai-release-notes | 391 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 757 insertions(+), 1 deletion(-) create mode 100755 git-ai-release-notes diff --git a/README.md b/README.md index 4e19955..6a1c8cd 100644 --- a/README.md +++ b/README.md @@ -93,5 +93,370 @@ git-squash-commit-messages HEAD~3..HEAD git-squash-commit-messages abc123..def456 # Use a specific model -git-squash-commit-messages -m gpt-4 HEAD~3..HEAD +git-ai-squash-commit-messages -m gpt-4 HEAD~3..HEAD +``` +#### `git-ai-release-notes` +Generates a blog post announcing a new version based on git commits. Uses a LLM +to create a well-structured announcement that categorizes and highlights the +most important changes. + +```bash +# Generate a post from all commits +git-ai-release-notes + +# Generate a post from the last 5 commits +git-ai-release-notes HEAD~5..HEAD + +# Generate a post from commits between two tags +git-ai-release-notes v1.0..v1.1 + +# Generate a post from commits in the last day +git-ai-release-notes --since="1 day ago" + +# Save the output to a file +git-ai-release-notes -o announcement.md + +# Output raw markdown without rich formatting +git-ai-release-notes --raw + +# Use a different tone/style +git-ai-release-notes --tone=technical # More technical details +git-ai-release-notes --tone=casual # Conversational style +git-ai-release-notes --tone=enthusiastic # Excited and energetic +git-ai-release-notes --tone=minimal # Just the facts +git-ai-release-notes --tone=pirate # Arr, matey! Pirate speak +git-ai-release-notes --tone=nerd # For the technically obsessed +git-ai-release-notes --tone=apologetic # Sorry for the update... +``` + +The script automatically determines the project name from any project files, or +failing that the remote repository name or the local directory name, but you can +also specify it with the `--project-name` option. + +## Media & File Processing + +### Audio/Video Processing + +#### `audiocat` +Transcodes and concatenates audio files into a single output file. This script is helpful for batch processing or merging audio files for production. + +```bash +audiocat [-o output.m4a] [FILES...] # Specify output file and input files +audiocat # Process all audio files in current directory +``` + +#### `describe-image` +Uses the OpenAI API to analyze images. + +```bash +describe-image + +``` + +#### `frames-to-video` +Converts a sequence of numbered image frames into a video file using ffmpeg. Automatically detects frame numbering format and supports customizable output settings. + +```bash +frames-to-video [options] [output.mp4] + +Options: + -f, --fps N Set framerate (default: 60) + -r, --resolution WxH Set output resolution (default: 1920x1080) + -q, --quality N Set quality (0-51, lower is better, default: 25) +``` + +#### `imgcat` +Displays images directly in the terminal, with support for `tmux`. This is useful for quick image previews or terminal-based visualization. + +```bash +imgcat filename... # Display specified images +cat image.png | imgcat # Display image from stdin +``` + +#### `srt-dedup-lines` +Removes duplicate subtitle entries from SRT files by merging overlapping segments with identical text. Creates a backup of the original file and renumbers remaining segments. + +```bash +srt-dedup-lines # Processes file in place, creates .bak backup +``` + +#### `trim-silence` +Remove silence from the beginning and end of audio files. Supports various audio formats and quality settings. + +```bash +# Basic usage (creates input_trimmed.m4a from input.m4a) +audiotrim input.m4a + +# Convert to OGG with quality setting +audiotrim --format ogg --quality 3 input.m4a + +# Convert to MP3 with specific bitrate +audiotrim --format mp3 --bitrate 192k input.m4a + +# Adjust silence detection threshold (higher number = more aggressive) +audiotrim --threshold -30 input.m4a + +# Show debug information +audiotrim --debug input.m4a + +# Suppress output (except errors) +audiotrim --quiet input.m4a + +# Isolate voice before processing +audiotrim --isolate input.m4a # Uses Eleven Labs API if ELEVENLABS_API_KEY is set, otherwise uses Demucs locally + +# Example output: +Isolating voice using Eleven Labs API... +✓ Voice isolation complete +✓ Successfully processed audio: + • Original duration: 1:09:21.79 + • New duration: 1:09:19.18 + • Removed from start: 0:00.25 + • Removed from end: 0:02.36 + • Total silence removed: 0:02.61 + • Original size: 32.9MB + • New size: 31.2MB + • Size change: 1.7MB (-5.2%) + +Dependencies: +- Required: pydub, typer, rich +- Optional: + - For local voice isolation: demucs + - For cloud voice isolation: requests (and ELEVENLABS_API_KEY environment variable) + +Format-specific settings: +- **OGG**: Quality -1 (lowest) to 10 (highest), default ~3 +- **MP3**: Quality 0 (best) to 9 (worst), default 4 +- **M4A**: Quality 0 (worst) to 100 (best), default 80 + +Common bitrates: +- MP3: 32k-320k (common: 128k, 192k, 256k, 320k) +- AAC/M4A: 32k-400k (common: 128k, 256k) +- OGG: 45k-500k (common: 128k, 192k, 256k) + +The script will append " trimmed" to filenames that contain spaces, and "_trimmed" to filenames without spaces. + +#### `srt2paragraphs` +Converts SRT subtitle files into paragraphed text, using timing information and punctuation to intelligently break paragraphs. Removes `` tags and joins related lines. + +```bash +# Print processed text to stdout +srt2paragraphs input.srt + +# Write to output file +srt2paragraphs input.srt -o output.txt + +# Preview output without writing +srt2paragraphs input.srt --dry-run +``` + +### File Management + +#### `fix-file-dates` +Standardizes or adjusts file dates and supports a dry-run option to preview changes. Useful for fixing inconsistent file timestamps, often in file management or archiving tasks. + +```bash +fix-file-dates [-n|--dry-run] [-h|--help] FILES... # Preview or perform date fixes +``` + +#### `localize_cloud_files.sh` +Forces a local cache of all files in a directory by reading all bytes from the cloud storage. This can be useful for ensuring all files are downloaded locally, potentially improving performance for subsequent accesses. + +```bash +localize_cloud_files.sh [DIR] # Defaults to current directory if not specified +``` + +## Development Tools + +### File Watching + +#### `rerun` +Runs a given command every time filesystem changes are detected. This is useful for running commands to regenerate visual output every time you hit [save] in your editor. + +```bash +rerun [OPTIONS] COMMAND +``` + +Source: https://gist.github.com/rubencaro/633cd90065d399d5fe1b56e46440d2bb + +### Build Tools + +#### `clean-builds` +A utility script that helps clean up build artifacts and cache directories in development projects. It recursively finds project directories (containing .git, package.json, etc.) and cleans their build and cache directories. + +```bash +clean-builds [OPTIONS] [DIRECTORIES...] + +Options: + --dry-run Preview what would be deleted + --show-size Show sizes of directories being removed +``` + +### Python Environment Management + +#### `find-installed-python-environments` +Searches for installed Python environments and checks if they are in use. Useful for system administrators managing multiple Python versions and virtual environments. + +```bash +find-installed-python-environments # Lists all Python installations with status +``` + +#### `list-python-environments` +Lists available Python installations, including virtual environments and Homebrew installations. Helps in identifying Python versions across different environments on a system. + +```bash +list-python-environments +``` + +### Development Environment Tools + +#### `docker-machine-rename` +Renames Docker machine instances, allowing for better organization of Docker environments. + +```bash +docker-machine-rename OLD_NAME NEW_NAME +``` + +Adapted from https://gist.github.com/alexproca/2324c60c86380b59001f w/ comments from eurythmia + +#### `jupyter-agent` +Launches Jupyter Notebook from a specified directory. Useful for setting up a consistent working environment for Jupyter Notebooks in a designated directory. + +```bash +jupyter-agent +``` + +#### `pboard-sync` +Uses `rshell` to sync files to a pyboard device. This script facilitates transferring code or data to hardware running on a microcontroller. + +This just wraps the `rshell` command, since I keep forgetting the syntax. + +```bash +pboard-sync [DIR] # Defaults to current directory if not specified +``` + +#### `run-manim` +Runs the Manim (Mathematical Animation Engine) through Docker. This script simplifies running Manim without needing local installations or configurations. + +```bash +run-manim source.py [options] # Renders animation from source file +``` + +#### `sync_gists.py` +Synchronizes local script files with GitHub Gists. Supports interactive mode, dry-run, and diff viewing. Uses `.gists.toml` for mapping local files to gist IDs. + +```bash +sync_gists.py [OPTIONS] [FILES...] + +Options: + --dry-run Preview changes without making them + --interactive Prompt for each file without a gist mapping + --show-diffs Show diffs between local files and gists +``` + +## System Administration + +### Network & Security + +#### `disable-wsdl` +Disables `awdl0` on macOS to troubleshoot network issues. This is useful for resolving specific network conflicts related to Apple Wireless Direct Link (AWDL). + +```bash +disable-wsdl +``` + +#### `uninstall-juniper-connect` +Uninstalls the Juniper Network Connect software, removing related files and configurations from the system. + +```bash +uninstall-juniper-connect +``` + +#### `whitelist_rabbitmq` +Whitelists RabbitMQ in the macOS firewall by adding it to the Application Firewall settings. Essential for configuring firewall rules to allow RabbitMQ traffic. + +```bash +whitelist_rabbitmq +``` + +### Application Management + +#### `check-for-electron-apps.sh` +A companion script to `list-electron-apps.sh` that checks for the presence of Electron-based applications in common installation directories. + +```bash +check-for-electron-apps.sh +``` + +#### `dropbox-pause-unpause.sh` +Pauses or resumes Dropbox using signals on macOS. Particularly useful for users looking to control Dropbox activity without closing the application. + + +```bash +dropbox-pause-unpause.sh # Show current status +dropbox-pause-unpause.sh --pause # Pause Dropbox +dropbox-pause-unpause.sh --resume # Resume Dropbox +``` + +By Timothy J. Luoma. + +#### `list-electron-apps.sh` +Lists applications built on the Electron framework. Looks in common locations for Electron apps. + +```bash +list-electron-apps.sh +``` + +#### `remove-mackups` +Removes symlinks created by the Mackup utility in the Preferences directory. This script aids in clearing out unwanted or outdated backup links. + +```bash +remove-mackups +``` + +#### `uninstall-arq` +Uninstalls Arq backup software, removing all related files and configurations from the system. + +```bash +uninstall-arq +``` + +## Browser & Data Tools + +### Browser Management + +#### `chrome-tabs-to-md.sh` +Exports currently open Chrome tabs as Markdown links. This script is useful for quickly saving session data in a shareable format. + +```bash +chrome-tabs-to-md.sh # Outputs markdown-formatted links for all open tabs +``` + +#### `list-browser-urls.sh` +Uses AppleScript to retrieve URLs and titles of open Chrome tabs. Particularly useful for quick snapshots of browsing sessions or documentation of open resources. + +```bash +list-browser-urls.sh +``` + +### Data Analysis & Processing + +#### `analyze-apple-healthkit-export.py` +Parses and processes data from an Apple Health XML export file. This script can be customized for specific data analysis tasks related to health metrics. + +```bash +analyze-apple-healthkit-export.py +``` + +#### `google_to_hugo.py` +Converts Google data (potentially Google Docs or Sheets) to a Hugo-compatible format for website generation. Helpful for automating content migration to Hugo sites. + +```bash +google_to_hugo.py +``` +#### `vote-counter.py` +Interacts with Google Sheets to retrieve and count votes from a spreadsheet. It can be useful for basic polling or tabulation tasks in a Google Sheets-based workflow. + +```bash +vote-counter.py ``` diff --git a/git-ai-release-notes b/git-ai-release-notes new file mode 100755 index 0000000..61d0c20 --- /dev/null +++ b/git-ai-release-notes @@ -0,0 +1,391 @@ +#!/usr/bin/env -S uv --quiet run --script +# /// script +# requires-python = ">=3.13" +# dependencies = [ +# "gitpython", +# "click", +# "pydantic-ai", +# "rich", +# "tomli", +# ] +# /// + +import os +import sys +from pathlib import Path +from typing import List, Optional +from enum import Enum + +import click +import git +from pydantic_ai import Agent +from rich.console import Console +from rich.markdown import Markdown +from rich.panel import Panel +from rich.theme import Theme +from rich.progress import Progress, SpinnerColumn, TextColumn + +# Set up rich console with custom theme +custom_theme = Theme({ + "heading": "bold blue", + "info": "green", + "error": "bold red", + "warning": "yellow", +}) +console = Console(theme=custom_theme) + +class ToneStyle(str, Enum): + STANDARD = "standard" + TECHNICAL = "technical" + CASUAL = "casual" + ENTHUSIASTIC = "enthusiastic" + MINIMAL = "minimal" + PIRATE = "pirate" + NERD = "nerd" + APOLOGETIC = "apologetic" + +class BlogPostGenerator: + """Generate a blog post announcing a new version based on git commits.""" + + model_name = "anthropic/claude-3-7-sonnet-latest" + + def generate_blog_post(self, project_name: str, commits: List[str], tone: ToneStyle = ToneStyle.STANDARD) -> str: + """ + Generate a blog post announcing a new version based on git commits. + + Args: + project_name: The name of the project + commits: A list of commit messages + tone: The tone/style to use for the blog post + + Returns: + A markdown-formatted blog post announcing the new version + """ + # Base prompt structure + base_prompt = f""" + You are a technical writer for {project_name}. Create a blog post announcing a new version + of the software based on the following git commit messages. The blog post should: + + 1. Have a catchy title that mentions the project name + 2. Include a brief introduction about the new release + 3. Organize the changes into logical categories (features, bug fixes, etc.) + 4. Highlight the most important changes + 5. End with a call to action for users to update and provide feedback + + Here are the commit messages: + + {commits} + """ + + # Add tone-specific instructions + tone_instructions = { + ToneStyle.STANDARD: """ + Format your response in Markdown. Be concise but informative. Focus on the user benefits + of each change rather than technical implementation details. + """, + + ToneStyle.TECHNICAL: """ + Format your response in Markdown. Use a technical, detailed tone that emphasizes + implementation details and technical specifications. Include code examples or technical + concepts where relevant. This is for a technical audience who wants to understand the + engineering behind the changes. + """, + + ToneStyle.CASUAL: """ + Format your response in Markdown. Use a casual, conversational tone as if you're + chatting with the user. Be friendly and approachable, using simple language and + occasional humor. Avoid technical jargon unless absolutely necessary. + """, + + ToneStyle.ENTHUSIASTIC: """ + Format your response in Markdown. Be extremely enthusiastic and excited about the + changes. Use superlatives, exclamation points, and convey a sense of excitement + throughout the post. Make the reader feel like this is the most exciting update ever. + """, + + ToneStyle.MINIMAL: """ + Format your response in Markdown. Be extremely concise and to-the-point. Use bullet + points extensively and minimize prose. Focus only on what changed and why it matters, + with no marketing language or fluff. + """, + + ToneStyle.PIRATE: """ + Format your response in Markdown. Write the entire blog post in pirate speak, using + pirate slang, nautical references, and a swashbuckling tone. Say "arr" and "matey" + occasionally, refer to the software as "treasure" or "booty", and use other pirate + terminology throughout. Keep it fun but still make sure the information is clear. + """, + + ToneStyle.NERD: """ + Format your response in Markdown. Write the blog post in an extremely computer-nerdy tone. + Focus on technical programming details, algorithms, data structures, and system architecture. + Use programming jargon, reference computer science concepts, and show excitement about + implementation details like performance optimizations and elegant code patterns. Include + references to programming languages, tools, and computer science principles. Make technical + jokes and puns that would appeal to software developers and computer scientists. However, + stay grounded in the actual changes in the commit messages. + """, + + ToneStyle.APOLOGETIC: """ + Format your response in Markdown. Write the blog post in an apologetic tone, as if + the team is constantly apologizing for the changes or for taking so long to make them. + Express excessive gratitude for users' patience, apologize for any inconvenience, and + be overly humble about the achievements. Still describe all the changes accurately, + but frame them as if the team is nervous about how they'll be received. + """ + } + + prompt = base_prompt + tone_instructions[tone] + + agent = Agent(model=self.model_name) + + with Progress( + SpinnerColumn(), + TextColumn(f"[info]Generating {tone.value} blog post...[/info]"), + console=console, + transient=True, + ) as progress: + progress.add_task("generate", total=None) + result = agent.run_sync(prompt) + + return result.data + +def get_commit_messages(repo_path: Path, rev_range: Optional[str] = None, since_date: Optional[str] = None) -> List[str]: + """Get commit messages from the specified git repository and revision range.""" + try: + repo = git.Repo(repo_path) + + # Create a git command object + git_cmd = repo.git + + # Build the arguments for git log + kwargs = { + 'pretty': 'format:%h %s%n%b' # Use kwargs for format to avoid quoting issues + } + + # Add since date if provided + if since_date: + kwargs['since'] = since_date + + # Add revision range if provided + args = [] + if rev_range: + args.append(rev_range) + + # Execute the git log command using the git.cmd_override method + # This properly handles the arguments without shell quoting issues + result = git_cmd.log(*args, **kwargs) + + # Split the result into individual commit messages + commits = [] + current_commit = "" + + for line in result.split('\n'): + if line and line[0].isalnum() and len(line) >= 7 and line[7] == ' ': + # This is a new commit (starts with hash) + if current_commit: + commits.append(current_commit.strip()) + current_commit = line + else: + # This is part of the current commit message + current_commit += f"\n{line}" + + # Add the last commit + if current_commit: + commits.append(current_commit.strip()) + + return commits + + except git.exc.GitCommandError as e: + console.print(f"[error]Git error: {e}[/error]") + sys.exit(1) + +def get_project_name(repo_path: Path) -> str: + """Try to determine the project name from the git repository or project files.""" + try: + # Check for common project configuration files + # 1. Check pyproject.toml (Python) + pyproject_path = repo_path / "pyproject.toml" + if pyproject_path.exists(): + import tomli + with open(pyproject_path, "rb") as f: + pyproject_data = tomli.load(f) + if "project" in pyproject_data and "name" in pyproject_data["project"]: + return pyproject_data["project"]["name"] + elif "tool" in pyproject_data and "poetry" in pyproject_data["tool"] and "name" in pyproject_data["tool"]["poetry"]: + return pyproject_data["tool"]["poetry"]["name"] + + # 2. Check setup.py (Python) + setup_py_path = repo_path / "setup.py" + if setup_py_path.exists(): + # Try to extract name from setup.py using a simple regex + import re + setup_content = setup_py_path.read_text() + if match := re.search(r'name\s*=\s*[\'"]([^\'"]+)[\'"]', setup_content): + return match.group(1) + + # 3. Check package.json (Node.js) + package_json_path = repo_path / "package.json" + if package_json_path.exists(): + import json + with open(package_json_path, "r") as f: + package_data = json.load(f) + if "name" in package_data: + return package_data["name"] + + # 4. Check Cargo.toml (Rust) + cargo_toml_path = repo_path / "Cargo.toml" + if cargo_toml_path.exists(): + import tomli + with open(cargo_toml_path, "rb") as f: + cargo_data = tomli.load(f) + if "package" in cargo_data and "name" in cargo_data["package"]: + return cargo_data["package"]["name"] + + # 5. Check go.mod (Go) + go_mod_path = repo_path / "go.mod" + if go_mod_path.exists(): + import re + go_mod_content = go_mod_path.read_text() + if match := re.search(r'^module\s+([^\s]+)', go_mod_content, re.MULTILINE): + # Extract the last part of the module path as the name + module_path = match.group(1) + return module_path.split('/')[-1] + + # 6. Check composer.json (PHP) + composer_json_path = repo_path / "composer.json" + if composer_json_path.exists(): + import json + with open(composer_json_path, "r") as f: + composer_data = json.load(f) + if "name" in composer_data: + # Composer uses vendor/package format, extract just the package name + return composer_data["name"].split('/')[-1] + + # 7. Check build.gradle or settings.gradle (Java/Kotlin - Gradle) + gradle_files = [repo_path / "settings.gradle", repo_path / "settings.gradle.kts", + repo_path / "build.gradle", repo_path / "build.gradle.kts"] + for gradle_file in gradle_files: + if gradle_file.exists(): + import re + content = gradle_file.read_text() + # Try to find rootProject.name or project name + if match := re.search(r'rootProject\.name\s*=\s*[\'"]([^\'"]+)[\'"]', content): + return match.group(1) + elif match := re.search(r'project\([\'"]([^\'"]+)[\'"]\)', content): + return match.group(1) + + # 8. Check pom.xml (Java - Maven) + pom_xml_path = repo_path / "pom.xml" + if pom_xml_path.exists(): + import re + pom_content = pom_xml_path.read_text() + if match := re.search(r'([^<]+)', pom_content): + return match.group(1) + + # 9. Check gemspec files (Ruby) + import glob + gemspec_files = list(repo_path.glob("*.gemspec")) + if gemspec_files: + # Use the filename without extension as the project name + return gemspec_files[0].stem + + # 10. Check pubspec.yaml (Dart/Flutter) + pubspec_path = repo_path / "pubspec.yaml" + if pubspec_path.exists(): + import re + pubspec_content = pubspec_path.read_text() + if match := re.search(r'^name:\s*([^\s]+)', pubspec_content, re.MULTILINE): + return match.group(1) + + # 11. Try to get the name from the remote URL + repo = git.Repo(repo_path) + if repo.remotes: + url = repo.remotes[0].url + # Extract project name from URL (works for GitHub, GitLab, etc.) + if '/' in url: + name = url.split('/')[-1] + # Remove .git suffix if present + if name.endswith('.git'): + name = name[:-4] + return name + + # 12. Fallback: use the directory name + return repo_path.name + except Exception as e: + console.print(f"[warning]Error determining project name: {e}[/warning]", file=sys.stderr) + # If all else fails, use a generic name + return "Project" + +@click.command() +@click.argument('rev_range', required=False) +@click.option('--repo-path', '-r', type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path), + default=Path('.'), help='Path to the git repository') +@click.option('--project-name', '-p', help='Name of the project (defaults to repo name)') +@click.option('--output', '-o', type=click.Path(path_type=Path), + help='Save the blog post to this file instead of printing to console') +@click.option('--raw', is_flag=True, help='Output raw markdown without rich formatting') +@click.option('--since', help='Show commits more recent than a specific date (e.g. "1 day ago")') +@click.option('--tone', '-t', type=click.Choice([t.value for t in ToneStyle]), + default=ToneStyle.STANDARD.value, + help='Tone/style of the blog post') +def main(rev_range: Optional[str], repo_path: Path, project_name: Optional[str], + output: Optional[Path], raw: bool, since: Optional[str], tone: str): + """ + Generate a blog post announcing a new version based on git commits. + + REV_RANGE is an optional git revision range (e.g., 'v1.0..v1.1' or 'HEAD~10..HEAD'). + If not provided, all commits will be included. + + Examples: + git-ai-release-notes # Use all commits + git-ai-release-notes HEAD~5..HEAD # Use the last 5 commits + git-ai-release-notes v1.0..v1.1 # Use commits between tags v1.0 and v1.1 + git-ai-release-notes --since="1 day ago" # Use commits from the last day + git-ai-release-notes -o blog-post.md # Save output to a file + git-ai-release-notes --tone=technical # Use a technical tone + """ + # Get commit messages + if since and not rev_range: + # Pass the since parameter directly to get_commit_messages + commits = get_commit_messages(repo_path, since_date=since) + else: + commits = get_commit_messages(repo_path, rev_range=rev_range) + + if not commits: + console.print("[warning]No commits found in the specified range[/warning]") + return + + # Determine project name if not provided + if not project_name: + project_name = get_project_name(repo_path) + + # Convert tone string to enum - with error handling + try: + tone_enum = ToneStyle(tone) + except ValueError: + # This should not happen with Click's type checking, but just in case + available_tones = ", ".join([f"'{t.value}'" for t in ToneStyle]) + console.print(f"[error]Error: Invalid tone '{tone}'[/error]") + console.print(f"[info]Available tones: {available_tones}[/info]") + sys.exit(1) + + # Generate blog post + console.print(f"[info]Generating {tone_enum.value} blog post for [bold]{project_name}[/bold] based on {len(commits)} commits...[/info]") + + generator = BlogPostGenerator() + blog_post = generator.generate_blog_post(project_name, commits, tone_enum) + + # Output the blog post + if output: + output.write_text(blog_post) + console.print(f"[info]Blog post saved to [bold]{output}[/bold][/info]") + elif raw: + print(blog_post) + else: + md = Markdown(blog_post) + console.print(Panel(md, title=f"[bold]{project_name}[/bold] Release Announcement ({tone_enum.value})", + border_style="blue", padding=(1, 2))) + +if __name__ == "__main__": + main() From 092a04a62d7095a7944714b6201ca3befaa06410 Mon Sep 17 00:00:00 2001 From: Oliver Steele Date: Tue, 4 Mar 2025 18:27:47 +0900 Subject: [PATCH 5/9] refactor: improve model selection logic in git AI commands Update model selection in git-ai-commit, git-ai-reword-message, and git-ai-squash-messages with a more robust approach: - Add error handling for when no models are available - Create a prioritized list of preferred models to try in order - Use consistent model preferences across all three scripts - Add newer models like claude-3.7-sonnet and gemini-2.0-flash to preferences - Use the first available model as fallback only when no preferred models found --- git-ai-commit | 36 +++++++++++++++++++++++++----------- git-ai-rewrite-commit | 36 +++++++++++++++++++++++++----------- git-squash-commit-messages | 31 +++++++++++++++++++++++++------ 3 files changed, 75 insertions(+), 28 deletions(-) diff --git a/git-ai-commit b/git-ai-commit index c81ed47..0e6678c 100755 --- a/git-ai-commit +++ b/git-ai-commit @@ -34,18 +34,32 @@ EOF select_default_model() { local models models=$(llm models) - if echo "$models" | grep -q 'o3-mini'; then - echo "o3-mini" - elif echo "$models" | grep -q 'deepseek-coder'; then - echo "deepseek-coder" - elif echo "$models" | grep -q 'claude-3-5-sonnet'; then - echo "claude-3-5-sonnet" - elif echo "$models" | grep -q 'gpt-4o'; then - echo "gpt-4o" - else - # pick the alphabetically first model - echo "$models" | head -n 1 + + # Check if we got any models + if [ -z "$models" ]; then + echo "Error: No LLM models found. Please install at least one model." >&2 + exit 1 fi + + # Define preferred models in order of preference + local preferred_models=( + "claude-3.7-sonnet" + "gemini-2.0-flash" + "o3-mini" + "deepseek-coder" + "gpt-4o-mini" + ) + + # Try each preferred model in order + for model in "${preferred_models[@]}"; do + if echo "$models" | grep -q "$model"; then + echo "$model" + return 0 + fi + done + + # If no preferred model is found, use the first available model + echo "$models" | head -n 1 } # Default values diff --git a/git-ai-rewrite-commit b/git-ai-rewrite-commit index e0883eb..e890969 100755 --- a/git-ai-rewrite-commit +++ b/git-ai-rewrite-commit @@ -30,18 +30,32 @@ EOF select_default_model() { local models models=$(llm models) - if echo "$models" | grep -q 'o3-mini'; then - echo "o3-mini" - elif echo "$models" | grep -q 'deepseek-coder'; then - echo "deepseek-coder" - elif echo "$models" | grep -q 'gpt-4o'; then - echo "gpt-4o" - elif echo "$models" | grep -q 'claude-3-5-sonnet'; then - echo "claude-3-5-sonnet" - else - # pick the alphabetically first model - echo "$models" | head -n 1 + + # Check if we got any models + if [ -z "$models" ]; then + echo "Error: No LLM models found. Please install at least one model." >&2 + exit 1 fi + + # Define preferred models in order of preference + local preferred_models=( + "claude-3.7-sonnet" + "gemini-2.0-flash" + "o3-mini" + "deepseek-coder" + "gpt-4o-mini" + ) + + # Try each preferred model in order + for model in "${preferred_models[@]}"; do + if echo "$models" | grep -q "$model"; then + echo "$model" + return 0 + fi + done + + # If no preferred model is found, use the first available model + echo "$models" | head -n 1 } # Default values diff --git a/git-squash-commit-messages b/git-squash-commit-messages index d7a0db5..941e4bb 100755 --- a/git-squash-commit-messages +++ b/git-squash-commit-messages @@ -30,13 +30,32 @@ EOF select_default_model() { local models models=$(llm models) - if echo "$models" | grep -q 'deepseek-coder'; then - echo "deepseek-coder" - elif echo "$models" | grep -q 'o1-mini'; then - echo "o1-mini" - else - echo "gpt-4" # Fallback + + # Check if we got any models + if [ -z "$models" ]; then + echo "Error: No LLM models found. Please install at least one model." >&2 + exit 1 fi + + # Define preferred models in order of preference + local preferred_models=( + "claude-3.7-sonnet" + "gemini-2.0-flash" + "o3-mini" + "deepseek-coder" + "gpt-4o-mini" + ) + + # Try each preferred model in order + for model in "${preferred_models[@]}"; do + if echo "$models" | grep -q "$model"; then + echo "$model" + return 0 + fi + done + + # If no preferred model is found, use the first available model + echo "$models" | head -n 1 } model=$(select_default_model) From 393ff8a3e62a6ebbccdceebbc6feb850880659c5 Mon Sep 17 00:00:00 2001 From: Oliver Steele Date: Sun, 7 Sep 2025 13:38:11 +0800 Subject: [PATCH 6/9] Extracted from gh:osteele/scripts --- LICENSE | 24 +++ README.md | 208 ++++++++++++++++++ gh-repo-set-metadata | 398 +++++++++++++++++++++++++++++++++++ git-ai-commit | 194 +++++++++++++++++ git-ai-release-notes | 391 ++++++++++++++++++++++++++++++++++ git-ai-reword-commit-message | 250 ++++++++++++++++++++++ git-ai-reword-message | 237 +++++++++++++++++++++ git-ai-squash-messages | 126 +++++++++++ git-ai-write-release-notes | 391 ++++++++++++++++++++++++++++++++++ git-apply-fixups | 253 ++++++++++++++++++++++ git-create-fixups | 90 ++++++++ git-rank-contributors | 60 ++++++ git-show-large-objects | 33 +++ git-show-merges | 49 +++++ git-wtf | 364 ++++++++++++++++++++++++++++++++ 15 files changed, 3068 insertions(+) create mode 100644 LICENSE create mode 100644 README.md create mode 100755 gh-repo-set-metadata create mode 100755 git-ai-commit create mode 100755 git-ai-release-notes create mode 100755 git-ai-reword-commit-message create mode 100755 git-ai-reword-message create mode 100755 git-ai-squash-messages create mode 100755 git-ai-write-release-notes create mode 100755 git-apply-fixups create mode 100755 git-create-fixups create mode 100755 git-rank-contributors create mode 100755 git-show-large-objects create mode 100755 git-show-merges create mode 100755 git-wtf diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..236fbe0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,24 @@ +MIT License + +Copyright (c) 2025 Oliver Steele + +Note: Some scripts in this repository were contributed by other authors and are +clearly attributed in both the README.md file and in the script source code. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ea906bb --- /dev/null +++ b/README.md @@ -0,0 +1,208 @@ +# Git Scripts + +A collection of utility scripts for Git repositories, including AI-powered commit message generation and repository management tools. + +Note: Some scripts in this repository were contributed by other authors and are clearly attributed in the individual script descriptions below and in the script source code. + +For Jujutsu (jj) version control scripts, see: https://github.com/osteele/jj-scripts + +For more development tools, see: https://osteele.com/software/development-tools + +## Installation + +These script names begin with `git-`, so that they can be used as git subcommands, e.g. `git ai-commit` as an alternative to `git-ai-commit`. + +Clone this repository and add it to your PATH, or copy the scripts to a directory already in your PATH. + +## Repository Management Tools + +### `git-wtf` +Displays a readable summary of a repository's state, including branch relationships to remote repositories and other branches. Useful for working with multiple branches in complex repositories. + +Copyright 2008--2009 William Morgan. + +```bash +git-wtf +``` + +### `git-show-merges` +Lists branches that have been merged into the current branch and those that have not. Useful for tracking the status of branches and their relationships within a repository. + +```bash +git-show-merges [BRANCHES...] # Shows merge status of specified or all branches +``` + +### `git-show-large-objects` +Displays the largest objects in a Git repository's pack files, helpful for identifying and potentially cleaning up large files in a repository. + +```bash +git-show-large-objects +``` + +Written by Antony Stubbs, as `show-large-git-objects`. + +### `git-rank-contributors` +Ranks contributors based on the size of diffs they've made in the repository. This script can be valuable for creating contributor credit lists, although manual adjustments may be needed if contributors commit from multiple email addresses. + +```bash +git-rank-contributors [-v] [-o] [-h] # -v for verbose, -o to obfuscate emails +``` + +### `git-create-fixups` +Creates separate commits for each modified file, with commit messages that reference their previous commits. Part of a workflow with `git-apply-fixups` for efficiently managing related changes across multiple files. + +Usage: +```bash +git-create-fixups [-n|--dry-run] +``` + +### `git-apply-fixups` +Automatically reorders and optionally squashes fixup commits created by `git-create-fixups`. This provides an automated alternative to manually reordering commits in an interactive rebase. + +**Workflow**: +1. Make changes to multiple files +2. Run `git-create-fixups` to create separate commits for each file +3. Run `git-apply-fixups` to automatically reorder (and optionally squash) the commits + +**Usage**: +```bash +# Show proposed reordering without executing +git-apply-fixups --dry-run + +# Reorder and mark fixups for squashing +git-apply-fixups --squash + +# Only process recent commits +git-apply-fixups --since="2 days ago" +git-apply-fixups -n 10 +``` + +**Options**: +- `--dry-run, -n`: Show proposed reordering without executing +- `--squash, -s`: Use 'squash' instead of 'pick' for fixup commits +- `--max-count, -n N`: Limit to processing the last N commits +- `--since DATE`: Process commits more recent than DATE (e.g. "2 days ago") + +## AI-Assisted Git Tools + +These AI-powered tools use the `llm` command-line tool to generate and modify commit messages. You can configure a custom model for all commit message generation by setting an alias: + +```bash +# Set a preferred model for commit messages +llm aliases set ai-commit-message "openrouter/google/gemini-2.5-flash-lite" +# Or use any other available model +llm aliases set ai-commit-message "claude-3.5-sonnet" +llm aliases set ai-commit-message "gpt-4" + +# Check your current aliases +llm aliases + +# Remove the alias to use the default model selection +llm aliases remove ai-commit-message +``` + +### `git-ai-commit` +Automatically generates and commits changes using AI-generated commit messages. Commits both staged and unstaged changes to tracked files (like `git commit -a`), and by default also stages and commits untracked files. + +```bash +# Generate and commit all changes +git-ai-commit + +# Preview changes without committing (dry run) +git-ai-commit -d + +# Skip pre-commit hooks and untracked files +git-ai-commit -n + +# Use a specific LLM model +git-ai-commit -m gpt-4 + +# List available models +git-ai-commit -l +``` + +### `git-ai-reword-message` +Generates a new commit message for a specified commit based on analyzing its changes. Uses LLM to create a descriptive and accurate commit message that reflects the actual changes in the commit. + +```bash +# Rewrite message for the most recent commit +git-ai-reword-message + +# Rewrite message for a specific commit +git-ai-reword-message + +# Preview the new message without applying it +git-ai-reword-message -n + +# Use a specific LLM model +git-ai-reword-message --model gpt-4 + +# Modify the message according to specific instructions +git-ai-reword-message --prompt "Make the message more concise" +``` + +### `git-ai-squash-messages` +Generates commit messages based on changes using AI assistance. Designed to streamline commit message creation and ensure consistent descriptions. + +```bash +git-ai-squash-messages # Analyzes messages and proposes a combined commit message +``` + +### `git-ai-squash-commit-messages` +Uses an AI language model to combine multiple git commit messages into a single, comprehensive message. Useful when squashing commits or preparing pull request descriptions. + +```bash +# Combine the last 3 commit messages +git-ai-squash-commit-messages HEAD~3..HEAD + +# Combine messages between specific commits +git-ai-squash-commit-messages abc123..def456 + +# Use a specific model +git-ai-squash-commit-messages -m gpt-4 HEAD~3..HEAD +``` + +### `git-ai-release-notes` +Generates a blog post announcing a new version based on git commits. Uses a LLM to create a well-structured announcement that categorizes and highlights the most important changes. + +```bash +# Generate a post from all commits +git-ai-release-notes + +# Generate a post from the last 5 commits +git-ai-release-notes HEAD~5..HEAD + +# Generate a post from commits between two tags +git-ai-release-notes v1.0..v1.1 + +# Generate a post from commits in the last day +git-ai-release-notes --since="1 day ago" + +# Save the output to a file +git-ai-release-notes -o announcement.md + +# Output raw markdown without rich formatting +git-ai-release-notes --raw + +# Use a different tone/style +git-ai-release-notes --tone=technical # More technical details +git-ai-release-notes --tone=casual # Conversational style +git-ai-release-notes --tone=enthusiastic # Excited and energetic +git-ai-release-notes --tone=minimal # Just the facts +git-ai-release-notes --tone=pirate # Arr, matey! Pirate speak +git-ai-release-notes --tone=nerd # For the technically obsessed +git-ai-release-notes --tone=apologetic # Sorry for the update... +``` + +The script automatically determines the project name from any project files, or failing that the remote repository name or the local directory name, but you can also specify it with the `--project-name` option. + +### `gh-repo-set-metadata` +Sets GitHub repository metadata from local files. Updates repository description and topics from package.json or other project configuration files. + +```bash +gh-repo-set-metadata +``` + +## License + +MIT License diff --git a/gh-repo-set-metadata b/gh-repo-set-metadata new file mode 100755 index 0000000..d2bcf89 --- /dev/null +++ b/gh-repo-set-metadata @@ -0,0 +1,398 @@ +#!/usr/bin/env -S uv --quiet run --script + +# /// script +# requires-python = ">=3.12" +# dependencies = [ +# "click", +# "pydantic-ai", +# "rich", +# ] +# /// + +import json +import os +import re +import subprocess +import sys +from pathlib import Path +from typing import Annotated, List, Optional, Tuple + +import click +from pydantic import BaseModel, Field +from pydantic_ai import Agent +from rich.console import Console +from rich.prompt import Confirm, Prompt + + +class RepoDescriptionResult(BaseModel): + description: str = Field(description="Description of the repository") + keywords: List[str] = Field(description="Keywords/topics of the repository") + + +MODEL_NAME = "anthropic:claude-3-7-sonnet-latest" + +console = Console() + + +def is_github_url(repo_path: str) -> bool: + """Check if the provided string is a GitHub URL.""" + return repo_path.startswith(("https://github.com/", "github.com/")) + + +def parse_github_url(url: str) -> str: + """Extract owner/repo from a GitHub URL.""" + # Handle URLs with or without https:// prefix + if url.startswith("https://github.com/"): + path = url[19:] # Remove "https://github.com/" + elif url.startswith("github.com/"): + path = url[11:] # Remove "github.com/" + else: + raise ValueError(f"Not a valid GitHub URL: {url}") + + # Remove .git suffix if present + if path.endswith(".git"): + path = path[:-4] + + # Remove trailing slashes + path = path.rstrip("/") + + # Validate format (owner/repo) + if not re.match(r"^[^/]+/[^/]+$", path): + raise ValueError(f"Invalid GitHub repository path: {path}") + + return path + + +def get_repo_path(repo_path: Optional[str] = None) -> Tuple[Path, Optional[str]]: + """ + Get the repository path and remote repo name if applicable. + + Returns: + Tuple[Path, Optional[str]]: (local_path, remote_repo_name) + If repo_path is a GitHub URL, remote_repo_name will be set. + """ + remote_repo_name = None + + if repo_path: + if is_github_url(repo_path): + remote_repo_name = parse_github_url(repo_path) + path = Path.cwd() # Use current directory for operations + else: + path = Path(repo_path).resolve() + else: + path = Path.cwd() + + return path, remote_repo_name + + +def get_repo_name(repo_path: Path, remote_repo_name: Optional[str] = None) -> str: + """Get the repository name from the git remote or use provided remote name.""" + if remote_repo_name: + return remote_repo_name + + try: + # Change to the repository directory + original_dir = os.getcwd() + os.chdir(repo_path) + + # Get the remote URL + result = subprocess.run( + ["git", "remote", "get-url", "origin"], + capture_output=True, + text=True, + check=True, + ) + remote_url = result.stdout.strip() + + # Extract owner/repo from the URL + if remote_url.startswith("git@github.com:"): + repo_name = remote_url.split("git@github.com:")[1].split(".git")[0] + elif remote_url.startswith("https://github.com/"): + repo_name = remote_url.split("https://github.com/")[1].split(".git")[0] + else: + raise ValueError(f"Unsupported remote URL format: {remote_url}") + + os.chdir(original_dir) + return repo_name + except subprocess.CalledProcessError: + if "original_dir" in locals(): + os.chdir(original_dir) + raise ValueError( + f"Not a git repository or no remote named 'origin': {repo_path}" + ) + except Exception as e: + if "original_dir" in locals(): + os.chdir(original_dir) + raise e + + +def read_file_if_exists(path: Path) -> Optional[str]: + """Read a file if it exists, return None otherwise.""" + if path.exists() and path.is_file(): + return path.read_text(encoding="utf-8") + return None + + +def get_remote_readme(repo_name: str) -> Optional[str]: + """Fetch README content from a GitHub repository using gh CLI.""" + try: + # Try to get the README using gh api + result = subprocess.run( + ["gh", "api", f"repos/{repo_name}/readme", "--jq", ".content"], + capture_output=True, + text=True, + check=True, + ) + + # The content is base64 encoded, decode it + import base64 + + readme_content = base64.b64decode(result.stdout.strip()).decode("utf-8") + return readme_content + except subprocess.CalledProcessError as e: + console.print( + f"[yellow]Warning: Could not fetch README from {repo_name}: {e.stderr}[/yellow]" + ) + return None + + +def get_repo_files( + repo_path: Path, remote_repo_name: Optional[str] = None +) -> dict[str, str]: + """ + Get the content of relevant files from the repository. + + If remote_repo_name is provided, only fetch the README from the remote repository. + """ + files = {} + + # If we have a remote repo name, only fetch the README + if remote_repo_name: + readme_content = get_remote_readme(remote_repo_name) + if readme_content: + files["README"] = readme_content + return files + + # Otherwise, look for local files + # README file (various extensions) + for readme_name in ["README.md", "README.rst", "README.txt", "README"]: + readme_path = repo_path / readme_name + content = read_file_if_exists(readme_path) + if content: + files["README"] = content + break + + # Package files + package_files = [ + "package.json", + "pyproject.toml", + "Cargo.toml", + "go.mod", + "composer.json", + "gemspec", + ] + + for file_name in package_files: + file_path = repo_path / file_name + content = read_file_if_exists(file_path) + if content: + files[file_name] = content + + return files + + +def analyze_repo_files(files: dict[str, str]) -> RepoDescriptionResult: + """ + Use an LLM to analyze repository files and generate a description and keywords. + + Returns: + RepoDescriptionResult: The description and keywords + """ + agent = Agent( + MODEL_NAME, + system_prompt="You are a helpful assistant that analyzes repository files and generates a description and keywords.", + ) + + # Prepare the prompt + prompt = """ + I need to create a concise GitHub repository description and relevant keywords/topics based on the following files from a repository. + + Please analyze these files and provide: + 1. A short description (max 100 characters) that clearly explains what this repository is about + 2. A list of 3-7 relevant keywords/topics that would help categorize this repository + + Format your response as a JSON object with two fields: + - "description": the short description + - "keywords": an array of keyword strings + + Here are the repository files: + """ + + for file_name, content in files.items(): + prompt += f"\n\n## {file_name}\n" + + # Truncate very large files + if len(content) > 10000: + prompt += content[:10000] + "...[truncated]" + else: + prompt += content + + result = agent.run_sync(prompt, result_type=RepoDescriptionResult) + return result.data + + +def update_repo_metadata( + repo_name: str, description: str, keywords: list[str], dry_run: bool = False +) -> None: + """Update the repository description and topics using GitHub CLI.""" + if dry_run: + console.print( + "[bold yellow]DRY RUN: Would set the following metadata:[/bold yellow]" + ) + console.print(f"Repository: [bold]{repo_name}[/bold]") + console.print(f"Description: [bold]{description}[/bold]") + console.print(f"Topics: [bold]{', '.join(keywords)}[/bold]") + return + + # Update description + if description: + try: + subprocess.run( + ["gh", "repo", "edit", repo_name, "--description", description], + check=True, + capture_output=True, + text=True, + ) + console.print( + f"[bold green]✓[/bold green] Updated description: {description}" + ) + except subprocess.CalledProcessError as e: + console.print(f"[bold red]Error setting description:[/bold red] {e.stderr}") + + # Update topics + if keywords: + try: + for keyword in keywords: + subprocess.run( + ["gh", "repo", "edit", repo_name, "--add-topic", keyword], + check=True, + capture_output=True, + text=True, + ) + console.print( + f"[bold green]✓[/bold green] Added topics: {', '.join(keywords)}" + ) + except subprocess.CalledProcessError as e: + console.print(f"[bold red]Error setting topics:[/bold red] {e.stderr}") + + +@click.command() +@click.argument("repo_paths", nargs=-1) +@click.option( + "--dry-run", is_flag=True, help="Show what would be set without making changes" +) +@click.option("--description", help="Repository description to set") +@click.option("--topics", help="Comma-separated list of topics to set") +@click.option( + "--auto", + is_flag=True, + help="Automatically generate description and topics using AI", +) +def main( + repo_paths: tuple[str, ...], + dry_run: bool, + description: Optional[str], + topics: Optional[str], + auto: bool, +) -> None: + """ + Set GitHub repository description and topics. + + If no REPO_PATHS are provided, the current directory is used. + REPO_PATHS can be local directories or GitHub URLs (e.g., https://github.com/owner/repo). + Multiple repositories can be specified to update them all with the same metadata. + + If --description and --topics are not provided, you will be prompted for them + unless --auto is specified, which will use AI to generate them. + """ + # If no repo paths provided, use current directory + if not repo_paths: + repo_paths = (".",) + + # Process each repository + for repo_path in repo_paths: + try: + console.print(f"\n[bold]Processing repository: {repo_path}[/bold]") + + # Get repository path and check if it's a remote repo + path, remote_repo_name = get_repo_path(repo_path) + + if remote_repo_name: + console.print( + f"Target repository: [bold]{remote_repo_name}[/bold] (remote)" + ) + else: + console.print(f"Analyzing repository: [bold]{path}[/bold]") + + # Get repository name + repo_name = get_repo_name(path, remote_repo_name) + if not remote_repo_name: + console.print(f"Repository name: [bold]{repo_name}[/bold]") + + # Get repository files if needed for auto-generation + files = {} + if auto and not (description and topics): + files = get_repo_files(path, remote_repo_name) + if not files: + console.print( + "[bold red]Error: No relevant files found in the repository[/bold red]" + ) + continue # Skip to next repository instead of exiting + console.print(f"Found {len(files)} files to analyze") + + # Get description and topics + final_description = description + final_topics = [] + + if topics: + final_topics = [ + topic.strip() for topic in topics.split(",") if topic.strip() + ] + + # If description or topics are missing, prompt or auto-generate + if not final_description or not final_topics: + if auto: + result = analyze_repo_files(files) + if not final_description: + final_description = result.description + if not final_topics: + final_topics = result.keywords + else: + # Only prompt for what's missing + if not final_description: + final_description = Prompt.ask( + f"[bold]Enter description for {repo_name}[/bold]" + ) + if not final_topics: + topics_str = Prompt.ask( + f"[bold]Enter topics for {repo_name}[/bold] (comma-separated)" + ) + final_topics = [ + topic.strip() + for topic in topics_str.split(",") + if topic.strip() + ] + + # Update repository metadata + update_repo_metadata(repo_name, final_description, final_topics, dry_run) + + except Exception as e: + console.print( + f"[bold red]Error processing {repo_path}:[/bold red] {str(e)}" + ) + # Continue with next repository instead of exiting + continue + + +if __name__ == "__main__": + main() diff --git a/git-ai-commit b/git-ai-commit new file mode 100755 index 0000000..c72ecc0 --- /dev/null +++ b/git-ai-commit @@ -0,0 +1,194 @@ +#!/bin/bash +set -euo pipefail + +# Check for llm installation +if ! command -v llm >/dev/null 2>&1; then + echo "Error: llm command not found. Install from https://llm.datasette.io/en/stable/setup.html" >&2 + exit 1 +fi + +usage() { + cat << EOF +Usage: $(basename "$0") [options] + +Generate and commit changes using AI messages. +Commits both staged and unstaged changes to tracked files (like git commit -a). +By default, also stages and commits untracked files (skip with --no-verify). + +Options: + -m, --model MODEL Specify the LLM model to use (default: deepseek-coder or o1-mini) + Examples: gpt-4, claude-3-opus, mistral-7b, ... + -d, --dry-run Show changes without committing + -n, --no-verify Skip pre-commit hooks, skip adding untracked files, and pass --no-verify to git commit + --author= Set commit author + -s, --signoff, --no-signoff + Add or remove signoff + --cleanup= Set commit cleanup mode + -e, --edit, --no-edit + Edit or skip editing commit message + -l, --list List available models + -h, --help Show this help +EOF + exit 1 +} + +# Default model selection +select_default_model() { + local models + models=$(llm models) + + # Check if we got any models + if [ -z "$models" ]; then + echo "Error: No LLM models found. Please install at least one model." >&2 + exit 1 + fi + + # Define preferred models in order of preference + local preferred_models=( + "ai-commit-message" + "claude-3.5-sonnet" + "gemini-2.0-flash-latest" + "deepseek-coder" + "gpt-4o-mini" + ) + + # Try each preferred model in order + for model in "${preferred_models[@]}"; do + if echo "$models" | grep -q "$model"; then + echo "$model" + return 0 + fi + done + + # If no preferred model is found, use the first available model + echo "$models" | head -n 1 +} + +# Default values +model=$(select_default_model) +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Capture original arguments before parsing +original_args=("$@") + +dry_run=false +no_verify=false +extra_commit_args=() + +# Parse arguments +while [[ $# -gt 0 ]]; do + case "$1" in + -m|--model) + model="$2" + shift 2 + ;; + -d|--dry-run) + dry_run=true + shift + ;; + -n|--no-verify) + no_verify=true + extra_commit_args+=("--no-verify") + shift + ;; + -l|--list) + echo "Available models:" + llm models + exit 0 + ;; + -h|--help) + usage + ;; + --author=*|--cleanup=*|-e|--edit|--no-edit|-s|--signoff|--no-signoff) + extra_commit_args+=("$1") + shift + ;; + *) + echo "Error: Unknown option: $1" >&2 + usage + ;; + esac +done + +# Check if we're in a git repository +if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "Error: Not in a git repository" >&2 + exit 1 +fi + +# Run pre-commit hook unless --no-verify was specified +if [ "$no_verify" = false ]; then + git_dir=$(git rev-parse --git-dir) + pre_commit_hook="$git_dir/hooks/pre-commit" + if [ -x "$pre_commit_hook" ]; then + echo "Running pre-commit hook..." + "$pre_commit_hook" + fi +fi + +# Check for any changes (staged, unstaged, or untracked) +if [ -z "$(git status --porcelain)" ]; then + echo "Error: No changes to commit" >&2 + exit 1 +fi + +if [ "$dry_run" = true ]; then + echo "=== Changes to be committed ===" + git status --short +fi + +# Generate commit message with progress indicator +# Check if the model is an alias and resolve it +model_display="$model" +if [[ "$model" == "ai-commit-message" ]]; then + resolved_model=$(llm aliases list 2>/dev/null | grep "^ai-commit-message" | sed 's/^ai-commit-message[[:space:]]*:[[:space:]]*//') + if [[ -n "$resolved_model" ]]; then + model_display="ai-commit-message: $resolved_model" + fi +fi + +echo -e "\n== Commit Message (via ${model_display}) ==" +echo -n "Generating commit message... " >&2 +commit_msg=$(llm \ + --model "$model" \ + "Create a commit message for the following changes: + +\`\`\` +$(git diff HEAD; [ "$no_verify" = false ] && git ls-files --others --exclude-standard | xargs -I{} echo \"A {}\") +\`\`\` + +Format the response as a conventional commit message with a brief title line followed by a more detailed description if needed. +Don't include any other text in the response, just the commit message. +") +printf "\r\033[K" >&2 # Clear progress message + +# Strip markdown code fences if present +if [[ "$commit_msg" =~ ^\`\`\`.* ]] && [[ "$commit_msg" =~ \`\`\`$ ]]; then + commit_msg=$(echo "$commit_msg" | sed -e '1s/^```.*//' -e '$s/```$//' | sed '/^$/d') +fi + +# Display the message +echo "$commit_msg" + +if [ "$dry_run" = true ]; then + # Build suggestion command safely + suggestion="$(basename "$0")" + filtered_args=() + for arg in "${original_args[@]}"; do + [[ "$arg" != "-d" && "$arg" != "--dry-run" ]] && filtered_args+=("$arg") + done + if [ ${#filtered_args[@]} -gt 0 ]; then + suggestion+=" ${filtered_args[*]}" + fi + + echo -e "\n== Next Steps ==\nTo use a message like this, run:\n $suggestion" +else + # Stage and commit changes + echo -e "\n== Committing changes... ==" >&2 + if [ "$no_verify" = false ]; then + git add -A # Add all changes including untracked files + else + git add -u # Only add changes to tracked files + fi + git commit -m "$commit_msg" ${extra_commit_args[@]:-} >/dev/null && echo "Changes committed successfully!" +fi diff --git a/git-ai-release-notes b/git-ai-release-notes new file mode 100755 index 0000000..61d0c20 --- /dev/null +++ b/git-ai-release-notes @@ -0,0 +1,391 @@ +#!/usr/bin/env -S uv --quiet run --script +# /// script +# requires-python = ">=3.13" +# dependencies = [ +# "gitpython", +# "click", +# "pydantic-ai", +# "rich", +# "tomli", +# ] +# /// + +import os +import sys +from pathlib import Path +from typing import List, Optional +from enum import Enum + +import click +import git +from pydantic_ai import Agent +from rich.console import Console +from rich.markdown import Markdown +from rich.panel import Panel +from rich.theme import Theme +from rich.progress import Progress, SpinnerColumn, TextColumn + +# Set up rich console with custom theme +custom_theme = Theme({ + "heading": "bold blue", + "info": "green", + "error": "bold red", + "warning": "yellow", +}) +console = Console(theme=custom_theme) + +class ToneStyle(str, Enum): + STANDARD = "standard" + TECHNICAL = "technical" + CASUAL = "casual" + ENTHUSIASTIC = "enthusiastic" + MINIMAL = "minimal" + PIRATE = "pirate" + NERD = "nerd" + APOLOGETIC = "apologetic" + +class BlogPostGenerator: + """Generate a blog post announcing a new version based on git commits.""" + + model_name = "anthropic/claude-3-7-sonnet-latest" + + def generate_blog_post(self, project_name: str, commits: List[str], tone: ToneStyle = ToneStyle.STANDARD) -> str: + """ + Generate a blog post announcing a new version based on git commits. + + Args: + project_name: The name of the project + commits: A list of commit messages + tone: The tone/style to use for the blog post + + Returns: + A markdown-formatted blog post announcing the new version + """ + # Base prompt structure + base_prompt = f""" + You are a technical writer for {project_name}. Create a blog post announcing a new version + of the software based on the following git commit messages. The blog post should: + + 1. Have a catchy title that mentions the project name + 2. Include a brief introduction about the new release + 3. Organize the changes into logical categories (features, bug fixes, etc.) + 4. Highlight the most important changes + 5. End with a call to action for users to update and provide feedback + + Here are the commit messages: + + {commits} + """ + + # Add tone-specific instructions + tone_instructions = { + ToneStyle.STANDARD: """ + Format your response in Markdown. Be concise but informative. Focus on the user benefits + of each change rather than technical implementation details. + """, + + ToneStyle.TECHNICAL: """ + Format your response in Markdown. Use a technical, detailed tone that emphasizes + implementation details and technical specifications. Include code examples or technical + concepts where relevant. This is for a technical audience who wants to understand the + engineering behind the changes. + """, + + ToneStyle.CASUAL: """ + Format your response in Markdown. Use a casual, conversational tone as if you're + chatting with the user. Be friendly and approachable, using simple language and + occasional humor. Avoid technical jargon unless absolutely necessary. + """, + + ToneStyle.ENTHUSIASTIC: """ + Format your response in Markdown. Be extremely enthusiastic and excited about the + changes. Use superlatives, exclamation points, and convey a sense of excitement + throughout the post. Make the reader feel like this is the most exciting update ever. + """, + + ToneStyle.MINIMAL: """ + Format your response in Markdown. Be extremely concise and to-the-point. Use bullet + points extensively and minimize prose. Focus only on what changed and why it matters, + with no marketing language or fluff. + """, + + ToneStyle.PIRATE: """ + Format your response in Markdown. Write the entire blog post in pirate speak, using + pirate slang, nautical references, and a swashbuckling tone. Say "arr" and "matey" + occasionally, refer to the software as "treasure" or "booty", and use other pirate + terminology throughout. Keep it fun but still make sure the information is clear. + """, + + ToneStyle.NERD: """ + Format your response in Markdown. Write the blog post in an extremely computer-nerdy tone. + Focus on technical programming details, algorithms, data structures, and system architecture. + Use programming jargon, reference computer science concepts, and show excitement about + implementation details like performance optimizations and elegant code patterns. Include + references to programming languages, tools, and computer science principles. Make technical + jokes and puns that would appeal to software developers and computer scientists. However, + stay grounded in the actual changes in the commit messages. + """, + + ToneStyle.APOLOGETIC: """ + Format your response in Markdown. Write the blog post in an apologetic tone, as if + the team is constantly apologizing for the changes or for taking so long to make them. + Express excessive gratitude for users' patience, apologize for any inconvenience, and + be overly humble about the achievements. Still describe all the changes accurately, + but frame them as if the team is nervous about how they'll be received. + """ + } + + prompt = base_prompt + tone_instructions[tone] + + agent = Agent(model=self.model_name) + + with Progress( + SpinnerColumn(), + TextColumn(f"[info]Generating {tone.value} blog post...[/info]"), + console=console, + transient=True, + ) as progress: + progress.add_task("generate", total=None) + result = agent.run_sync(prompt) + + return result.data + +def get_commit_messages(repo_path: Path, rev_range: Optional[str] = None, since_date: Optional[str] = None) -> List[str]: + """Get commit messages from the specified git repository and revision range.""" + try: + repo = git.Repo(repo_path) + + # Create a git command object + git_cmd = repo.git + + # Build the arguments for git log + kwargs = { + 'pretty': 'format:%h %s%n%b' # Use kwargs for format to avoid quoting issues + } + + # Add since date if provided + if since_date: + kwargs['since'] = since_date + + # Add revision range if provided + args = [] + if rev_range: + args.append(rev_range) + + # Execute the git log command using the git.cmd_override method + # This properly handles the arguments without shell quoting issues + result = git_cmd.log(*args, **kwargs) + + # Split the result into individual commit messages + commits = [] + current_commit = "" + + for line in result.split('\n'): + if line and line[0].isalnum() and len(line) >= 7 and line[7] == ' ': + # This is a new commit (starts with hash) + if current_commit: + commits.append(current_commit.strip()) + current_commit = line + else: + # This is part of the current commit message + current_commit += f"\n{line}" + + # Add the last commit + if current_commit: + commits.append(current_commit.strip()) + + return commits + + except git.exc.GitCommandError as e: + console.print(f"[error]Git error: {e}[/error]") + sys.exit(1) + +def get_project_name(repo_path: Path) -> str: + """Try to determine the project name from the git repository or project files.""" + try: + # Check for common project configuration files + # 1. Check pyproject.toml (Python) + pyproject_path = repo_path / "pyproject.toml" + if pyproject_path.exists(): + import tomli + with open(pyproject_path, "rb") as f: + pyproject_data = tomli.load(f) + if "project" in pyproject_data and "name" in pyproject_data["project"]: + return pyproject_data["project"]["name"] + elif "tool" in pyproject_data and "poetry" in pyproject_data["tool"] and "name" in pyproject_data["tool"]["poetry"]: + return pyproject_data["tool"]["poetry"]["name"] + + # 2. Check setup.py (Python) + setup_py_path = repo_path / "setup.py" + if setup_py_path.exists(): + # Try to extract name from setup.py using a simple regex + import re + setup_content = setup_py_path.read_text() + if match := re.search(r'name\s*=\s*[\'"]([^\'"]+)[\'"]', setup_content): + return match.group(1) + + # 3. Check package.json (Node.js) + package_json_path = repo_path / "package.json" + if package_json_path.exists(): + import json + with open(package_json_path, "r") as f: + package_data = json.load(f) + if "name" in package_data: + return package_data["name"] + + # 4. Check Cargo.toml (Rust) + cargo_toml_path = repo_path / "Cargo.toml" + if cargo_toml_path.exists(): + import tomli + with open(cargo_toml_path, "rb") as f: + cargo_data = tomli.load(f) + if "package" in cargo_data and "name" in cargo_data["package"]: + return cargo_data["package"]["name"] + + # 5. Check go.mod (Go) + go_mod_path = repo_path / "go.mod" + if go_mod_path.exists(): + import re + go_mod_content = go_mod_path.read_text() + if match := re.search(r'^module\s+([^\s]+)', go_mod_content, re.MULTILINE): + # Extract the last part of the module path as the name + module_path = match.group(1) + return module_path.split('/')[-1] + + # 6. Check composer.json (PHP) + composer_json_path = repo_path / "composer.json" + if composer_json_path.exists(): + import json + with open(composer_json_path, "r") as f: + composer_data = json.load(f) + if "name" in composer_data: + # Composer uses vendor/package format, extract just the package name + return composer_data["name"].split('/')[-1] + + # 7. Check build.gradle or settings.gradle (Java/Kotlin - Gradle) + gradle_files = [repo_path / "settings.gradle", repo_path / "settings.gradle.kts", + repo_path / "build.gradle", repo_path / "build.gradle.kts"] + for gradle_file in gradle_files: + if gradle_file.exists(): + import re + content = gradle_file.read_text() + # Try to find rootProject.name or project name + if match := re.search(r'rootProject\.name\s*=\s*[\'"]([^\'"]+)[\'"]', content): + return match.group(1) + elif match := re.search(r'project\([\'"]([^\'"]+)[\'"]\)', content): + return match.group(1) + + # 8. Check pom.xml (Java - Maven) + pom_xml_path = repo_path / "pom.xml" + if pom_xml_path.exists(): + import re + pom_content = pom_xml_path.read_text() + if match := re.search(r'([^<]+)', pom_content): + return match.group(1) + + # 9. Check gemspec files (Ruby) + import glob + gemspec_files = list(repo_path.glob("*.gemspec")) + if gemspec_files: + # Use the filename without extension as the project name + return gemspec_files[0].stem + + # 10. Check pubspec.yaml (Dart/Flutter) + pubspec_path = repo_path / "pubspec.yaml" + if pubspec_path.exists(): + import re + pubspec_content = pubspec_path.read_text() + if match := re.search(r'^name:\s*([^\s]+)', pubspec_content, re.MULTILINE): + return match.group(1) + + # 11. Try to get the name from the remote URL + repo = git.Repo(repo_path) + if repo.remotes: + url = repo.remotes[0].url + # Extract project name from URL (works for GitHub, GitLab, etc.) + if '/' in url: + name = url.split('/')[-1] + # Remove .git suffix if present + if name.endswith('.git'): + name = name[:-4] + return name + + # 12. Fallback: use the directory name + return repo_path.name + except Exception as e: + console.print(f"[warning]Error determining project name: {e}[/warning]", file=sys.stderr) + # If all else fails, use a generic name + return "Project" + +@click.command() +@click.argument('rev_range', required=False) +@click.option('--repo-path', '-r', type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path), + default=Path('.'), help='Path to the git repository') +@click.option('--project-name', '-p', help='Name of the project (defaults to repo name)') +@click.option('--output', '-o', type=click.Path(path_type=Path), + help='Save the blog post to this file instead of printing to console') +@click.option('--raw', is_flag=True, help='Output raw markdown without rich formatting') +@click.option('--since', help='Show commits more recent than a specific date (e.g. "1 day ago")') +@click.option('--tone', '-t', type=click.Choice([t.value for t in ToneStyle]), + default=ToneStyle.STANDARD.value, + help='Tone/style of the blog post') +def main(rev_range: Optional[str], repo_path: Path, project_name: Optional[str], + output: Optional[Path], raw: bool, since: Optional[str], tone: str): + """ + Generate a blog post announcing a new version based on git commits. + + REV_RANGE is an optional git revision range (e.g., 'v1.0..v1.1' or 'HEAD~10..HEAD'). + If not provided, all commits will be included. + + Examples: + git-ai-release-notes # Use all commits + git-ai-release-notes HEAD~5..HEAD # Use the last 5 commits + git-ai-release-notes v1.0..v1.1 # Use commits between tags v1.0 and v1.1 + git-ai-release-notes --since="1 day ago" # Use commits from the last day + git-ai-release-notes -o blog-post.md # Save output to a file + git-ai-release-notes --tone=technical # Use a technical tone + """ + # Get commit messages + if since and not rev_range: + # Pass the since parameter directly to get_commit_messages + commits = get_commit_messages(repo_path, since_date=since) + else: + commits = get_commit_messages(repo_path, rev_range=rev_range) + + if not commits: + console.print("[warning]No commits found in the specified range[/warning]") + return + + # Determine project name if not provided + if not project_name: + project_name = get_project_name(repo_path) + + # Convert tone string to enum - with error handling + try: + tone_enum = ToneStyle(tone) + except ValueError: + # This should not happen with Click's type checking, but just in case + available_tones = ", ".join([f"'{t.value}'" for t in ToneStyle]) + console.print(f"[error]Error: Invalid tone '{tone}'[/error]") + console.print(f"[info]Available tones: {available_tones}[/info]") + sys.exit(1) + + # Generate blog post + console.print(f"[info]Generating {tone_enum.value} blog post for [bold]{project_name}[/bold] based on {len(commits)} commits...[/info]") + + generator = BlogPostGenerator() + blog_post = generator.generate_blog_post(project_name, commits, tone_enum) + + # Output the blog post + if output: + output.write_text(blog_post) + console.print(f"[info]Blog post saved to [bold]{output}[/bold][/info]") + elif raw: + print(blog_post) + else: + md = Markdown(blog_post) + console.print(Panel(md, title=f"[bold]{project_name}[/bold] Release Announcement ({tone_enum.value})", + border_style="blue", padding=(1, 2))) + +if __name__ == "__main__": + main() diff --git a/git-ai-reword-commit-message b/git-ai-reword-commit-message new file mode 100755 index 0000000..3d005a8 --- /dev/null +++ b/git-ai-reword-commit-message @@ -0,0 +1,250 @@ +#!/bin/bash +set -euo pipefail + +# Check for llm installation +if ! command -v llm >/dev/null 2>&1; then + echo "Error: llm command not found. Install from https://llm.datasette.io/en/stable/setup.html" >&2 + exit 1 +fi + +usage() { + cat << EOF +Usage: $(basename "$0") [options] [commit] + +Generate a new commit message for a specified commit based on its changes. +If no commit is specified, uses HEAD. + +Options: + -m, --model MODEL Specify the LLM model to use (default: deepseek-coder or o1-mini) + Examples: gpt-4, claude-3-opus, mistral-7b, ... + -n, --dry-run Show the new message without applying it + -f, --force Force rewrite even if commit has been pushed to remote + -l, --list List available models + -p, --prompt PROMPT Provide instructions to modify the commit message + -h, --help Show this help +EOF + exit 1 +} + +# Default model selection +select_default_model() { + local models + models=$(llm models) + + # Check if we got any models + if [ -z "$models" ]; then + echo "Error: No LLM models found. Please install at least one model." >&2 + exit 1 + fi + + # Define preferred models in order of preference + local preferred_models=( + "ai-commit-message" + "claude-3.5-sonnet" + "gemini-2.0-flash-latest" + "o3-mini" + "deepseek-coder" + "gpt-4o-mini" + ) + + # Try each preferred model in order + for model in "${preferred_models[@]}"; do + if echo "$models" | grep -q "$model"; then + echo "$model" + return 0 + fi + done + + # If no preferred model is found, use the first available model + echo "$models" | head -n 1 +} + +# Default values +model=$(select_default_model) +dry_run=false +force=false +commit="HEAD" +custom_prompt="" + +# Parse arguments +while [[ $# -gt 0 ]]; do + case "$1" in + -m|--model) + model="$2" + shift 2 + ;; + -n|--dry-run) + dry_run=true + shift + ;; + -f|--force) + force=true + shift + ;; + -l|--list) + echo "Available models:" + llm models + exit 0 + ;; + -p|--prompt) + custom_prompt="$2" + shift 2 + ;; + -h|--help) + usage + ;; + *) + if [[ $1 == -* ]]; then + echo "Error: Unknown option: $1" >&2 + usage + else + commit="$1" + shift + fi + ;; + esac +done + +# Check if we're in a git repository +if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "Error: Not in a git repository" >&2 + exit 1 +fi + +# Validate the commit reference +if ! git rev-parse --verify "$commit^{commit}" >/dev/null 2>&1; then + echo "Error: Invalid commit reference: $commit" >&2 + exit 1 +fi + +# Get the changes for the specified commit +echo -e "\n== Changes in commit $commit ==" +git --no-pager show --name-only --format=fuller "$commit" + +# Generate new commit message with progress indicator +# Check if the model is an alias and resolve it +model_display="$model" +if [[ "$model" == "ai-commit-message" ]]; then + resolved_model=$(llm aliases list 2>/dev/null | grep "^ai-commit-message" | sed 's/^ai-commit-message[[:space:]]*:[[:space:]]*//') + if [[ -n "$resolved_model" ]]; then + model_display="ai-commit-message: $resolved_model" + fi +fi + +echo -e "\n== New Commit Message (via ${model_display}) ==" +echo -n "Generating commit message... " >&2 + +# Get the original commit message +original_msg=$(git log -1 --pretty=%B "$commit") + +if [[ -n "$custom_prompt" ]]; then + prompt_text="Analyze these changes and modify the existing commit message according to the following instructions: + +The original commit message was: +\`\`\` +$original_msg +\`\`\` + +Changes: +\`\`\` +$(git show --patch "$commit") +\`\`\` + +Modification instructions: +\`\`\` +$custom_prompt +\`\`\` + +Format the response as a conventional commit message with a brief title line followed by a more detailed description if needed. +Don't include any other text in the response, just the commit message." +else + prompt_text="Analyze these changes and create a new commit message: + +\`\`\` +$(git show --patch "$commit") +\`\`\` + +The original commit message was: +\`\`\` +$original_msg +\`\`\` + +Format the response as a conventional commit message with a brief title line followed by a more detailed description if needed. +Don't include any other text in the response, just the commit message." +fi + +commit_msg=$(llm \ + --model "$model" \ + "$prompt_text" +) + +printf "\r\033[K" >&2 # Clear progress message + +# Strip markdown code fences if present +if [[ "$commit_msg" =~ ^\`\`\`.* ]] && [[ "$commit_msg" =~ \`\`\`$ ]]; then + commit_msg=$(echo "$commit_msg" | sed -e '1s/^```.*//' -e '$s/```$//' | sed '/^$/d') +fi + +# Display the message +echo "$commit_msg" + +if [ "$dry_run" = true ]; then + echo -e "\n== Next Steps ==" + echo "To apply this message, run: $(basename "$0") $commit" +else + echo -e "\n== Applying new commit message ==" >&2 + + # Check if commit has been pushed to any remote + commit_hash=$(git rev-parse "$commit") + is_pushed=false + for remote in $(git remote); do + if git branch -r --contains "$commit_hash" | grep -q "^[[:space:]]*$remote/"; then + is_pushed=true + break + fi + done + + if [ "$is_pushed" = true ] && [ "$force" = false ]; then + echo "Error: This commit has been pushed to a remote branch." >&2 + echo "Use -f or --force to rewrite the commit message anyway." >&2 + exit 1 + fi + + # Apply the new commit message + + if [ "$commit" = "HEAD" ]; then + # For HEAD, we can use --amend directly + git commit --amend --no-verify -m "$commit_msg" && echo "done!" + else + # For other commits, we need to use rebase + commit_hash=$(git rev-parse "$commit") + parent_hash=$(git rev-parse "$commit^") + + # Create a temporary script for the rebase + temp_script=$(mktemp) + trap 'rm -f "$temp_script"' EXIT + + # Write the rebase plan to the temp script with all commits + git rev-list --reverse "$parent_hash..HEAD" | while read -r rev; do + if [ "$rev" = "$commit_hash" ]; then + echo "edit $rev" >> "$temp_script" + else + echo "pick $rev" >> "$temp_script" + fi + done + + # Store the commit message in a temporary file + msg_file=$(mktemp) + echo "$commit_msg" > "$msg_file" + trap 'rm -f "$temp_script" "$msg_file"' EXIT + + # Set up our custom editor to use the prepared message + EDITOR="cat $msg_file >" git -c sequence.editor="cat $temp_script >" rebase -i "$parent_hash" --keep-empty + + # Apply the new commit message + git commit --amend -F "$msg_file" --no-edit --no-verify + + # Continue the rebase + git rebase --continue && echo "done!" + fi +fi diff --git a/git-ai-reword-message b/git-ai-reword-message new file mode 100755 index 0000000..1d83888 --- /dev/null +++ b/git-ai-reword-message @@ -0,0 +1,237 @@ +#!/bin/bash +set -euo pipefail + +# Check for llm installation +if ! command -v llm >/dev/null 2>&1; then + echo "Error: llm command not found. Install from https://llm.datasette.io/en/stable/setup.html" >&2 + exit 1 +fi + +usage() { + cat << EOF +Usage: $(basename "$0") [options] [commit] + +Generate a new commit message for a specified commit based on its changes. +If no commit is specified, uses HEAD. + +Options: + -m, --model MODEL Specify the LLM model to use (default: deepseek-coder or o1-mini) + Examples: gpt-4, claude-3-opus, mistral-7b, ... + -n, --dry-run Show the new message without applying it + -f, --force Force rewrite even if commit has been pushed to remote + -l, --list List available models + -p, --prompt PROMPT Provide instructions to modify the commit message + -h, --help Show this help +EOF + exit 1 +} + +# Default model selection +select_default_model() { + local models + models=$(llm models) + + # Check if we got any models + if [ -z "$models" ]; then + echo "Error: No LLM models found. Please install at least one model." >&2 + exit 1 + fi + + # Define preferred models in order of preference + local preferred_models=( + "ai-commit-message" + "claude-3.5-sonnet" + "gemini-2.0-flash-latest" + "o3-mini" + "deepseek-coder" + "gpt-4o-mini" + ) + + # Try each preferred model in order + for model in "${preferred_models[@]}"; do + if echo "$models" | grep -q "$model"; then + echo "$model" + return 0 + fi + done + + # If no preferred model is found, use the first available model + echo "$models" | head -n 1 +} + +# Default values +model=$(select_default_model) +dry_run=false +force=false +commit="HEAD" +custom_prompt="" + +# Parse arguments +while [[ $# -gt 0 ]]; do + case "$1" in + -m|--model) + model="$2" + shift 2 + ;; + -n|--dry-run) + dry_run=true + shift + ;; + -f|--force) + force=true + shift + ;; + -l|--list) + echo "Available models:" + llm models + exit 0 + ;; + -p|--prompt) + custom_prompt="$2" + shift 2 + ;; + -h|--help) + usage + ;; + *) + if [[ $1 == -* ]]; then + echo "Error: Unknown option: $1" >&2 + usage + else + commit="$1" + shift + fi + ;; + esac +done + +# Check if we're in a git repository +if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "Error: Not in a git repository" >&2 + exit 1 +fi + +# Validate the commit reference +if ! git rev-parse --verify "$commit^{commit}" >/dev/null 2>&1; then + echo "Error: Invalid commit reference: $commit" >&2 + exit 1 +fi + +# Get the changes for the specified commit +echo -e "\n== Changes in commit $commit ==" +git --no-pager show --name-only --format=fuller "$commit" + +# Generate new commit message with progress indicator +# Check if the model is an alias and resolve it +model_display="$model" +if [[ "$model" == "ai-commit-message" ]]; then + resolved_model=$(llm aliases list 2>/dev/null | grep "^ai-commit-message" | sed 's/^ai-commit-message[[:space:]]*:[[:space:]]*//') + if [[ -n "$resolved_model" ]]; then + model_display="ai-commit-message: $resolved_model" + fi +fi + +echo -e "\n== New Commit Message (via ${model_display}) ==" +echo -n "Generating commit message... " >&2 + +# Get the original commit message +original_msg=$(git log -1 --pretty=%B "$commit") + +if [[ -n "$custom_prompt" ]]; then + prompt_text="Analyze these changes and modify the existing commit message according to the following instructions: + +The original commit message was: +\`\`\` +$original_msg +\`\`\` + +Changes: +\`\`\` +$(git show --patch "$commit") +\`\`\` + +Modification instructions: +\`\`\` +$custom_prompt +\`\`\` + +Format the response as a conventional commit message with a brief title line followed by a more detailed description if needed. +Don't include any other text in the response, just the commit message." +else + prompt_text="Analyze these changes and create a new commit message: + +\`\`\` +$(git show --patch "$commit") +\`\`\` + +The original commit message was: +\`\`\` +$original_msg +\`\`\` + +Format the response as a conventional commit message with a brief title line followed by a more detailed description if needed. +Don't include any other text in the response, just the commit message." +fi + +commit_msg=$(llm \ + --model "$model" \ + "$prompt_text" +) + +printf "\r\033[K" >&2 # Clear progress message + +# Strip markdown code fences if present +if [[ "$commit_msg" =~ ^\`\`\`.* ]] && [[ "$commit_msg" =~ \`\`\`$ ]]; then + commit_msg=$(echo "$commit_msg" | sed -e '1s/^```.*//' -e '$s/```$//' | sed '/^$/d') +fi + +# Display the message +echo "$commit_msg" + +if [ "$dry_run" = true ]; then + echo -e "\n== Next Steps ==" + echo "To apply this message, run: $(basename "$0") $commit" +else + echo -e "\n== Applying new commit message ==" >&2 + + # Check if commit has been pushed to any remote + commit_hash=$(git rev-parse "$commit") + is_pushed=false + for remote in $(git remote); do + if git branch -r --contains "$commit_hash" | grep -q "^[[:space:]]*$remote/"; then + is_pushed=true + break + fi + done + + if [ "$is_pushed" = true ] && [ "$force" = false ]; then + echo "Error: This commit has been pushed to a remote branch." >&2 + echo "Use -f or --force to rewrite the commit message anyway." >&2 + exit 1 + fi + + # Apply the new commit message + + if [ "$commit" = "HEAD" ]; then + # For HEAD, we can use --amend directly + git commit --amend --no-verify -m "$commit_msg" && echo "done!" + else + # For other commits, we need to use rebase + commit_hash=$(git rev-parse "$commit") + parent_hash=$(git rev-parse "$commit^") + + # Create a temporary script for the rebase + temp_script=$(mktemp) + trap 'rm -f "$temp_script"' EXIT + + # Write the rebase plan to the temp script + echo "reword $(git rev-parse "$commit")" > "$temp_script" + + # Set up the commit message + git rev-parse "$commit" > .git/rebase-merge/stopped-sha + echo "$commit_msg" > .git/COMMIT_EDITMSG + + # Perform the rebase with our custom editor script + GIT_SEQUENCE_EDITOR="cat $temp_script >" EDITOR="true" git rebase -i "$parent_hash" && echo "done!" + fi +fi diff --git a/git-ai-squash-messages b/git-ai-squash-messages new file mode 100755 index 0000000..2683200 --- /dev/null +++ b/git-ai-squash-messages @@ -0,0 +1,126 @@ +#!/bin/bash +set -euo pipefail + +# Check for llm installation +if ! command -v llm >/dev/null 2>&1; then + echo "Error: llm command not found. Install from https://llm.datasette.io/en/stable/setup.html" >&2 + exit 1 +fi + +usage() { + cat << EOF +Usage: $(basename "$0") + +Combine git commit messages in a range using AI. + +Options: + -m, --model MODEL Specify the LLM model to use (default: deepseek-coder or o1-mini) + Examples: gpt-4, claude-3-opus, mistral-7b, ... + -l, --list List available models + -h, --help Show this help + +Example: + $(basename "$0") HEAD~3..HEAD # Combine last 3 commit messages + $(basename "$0") abc123..def456 # Combine messages between two commits +EOF + exit 1 +} + +# Default model selection +select_default_model() { + local models + models=$(llm models) + + # Check if we got any models + if [ -z "$models" ]; then + echo "Error: No LLM models found. Please install at least one model." >&2 + exit 1 + fi + + # Define preferred models in order of preference + local preferred_models=( + "ai-commit-message" + "claude-3.7-sonnet" + "gemini-2.0-flash" + "o3-mini" + "deepseek-coder" + "gpt-4o-mini" + ) + + # Try each preferred model in order + for model in "${preferred_models[@]}"; do + if echo "$models" | grep -q "$model"; then + echo "$model" + return 0 + fi + done + + # If no preferred model is found, use the first available model + echo "$models" | head -n 1 +} + +model=$(select_default_model) + +# Parse arguments +while [[ $# -gt 0 ]]; do + case "$1" in + -m|--model) + model="$2" + shift 2 + ;; + -l|--list) + echo "Available models:" + llm models + exit 0 + ;; + -h|--help) + usage + ;; + *) + commit_range="$1" + shift + ;; + esac +done + +# Check if we're in a git repository +if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "Error: Not in a git repository" >&2 + exit 1 +fi + +# Check if commit range is provided +if [ -z "${commit_range:-}" ]; then + echo "Error: No commit range provided" >&2 + usage +fi + +# Get commit messages +echo "Fetching commit messages..." >&2 +commit_messages=$(git log --format="%B" "$commit_range") + +if [ -z "$commit_messages" ]; then + echo "Error: No commits found in range $commit_range" >&2 + exit 1 +fi + +# Generate combined message +# Check if the model is an alias and resolve it +model_display="$model" +if [[ "$model" == "ai-commit-message" ]]; then + resolved_model=$(llm aliases list 2>/dev/null | grep "^ai-commit-message" | sed 's/^ai-commit-message[[:space:]]*:[[:space:]]*//') + if [[ -n "$resolved_model" ]]; then + model_display="ai-commit-message: $resolved_model" + fi +fi + +echo "Combining messages using $model_display..." >&2 +llm --model "$model" "Combine these git commit messages into a single, comprehensive commit message that summarizes all the changes: + +$commit_messages + +The combined message should: +1. Start with a clear, concise summary line +2. Include relevant details from individual commits +3. Use the conventional commit message format +4. Not include redundant information" | sed 's/\*\*//g' diff --git a/git-ai-write-release-notes b/git-ai-write-release-notes new file mode 100755 index 0000000..61d0c20 --- /dev/null +++ b/git-ai-write-release-notes @@ -0,0 +1,391 @@ +#!/usr/bin/env -S uv --quiet run --script +# /// script +# requires-python = ">=3.13" +# dependencies = [ +# "gitpython", +# "click", +# "pydantic-ai", +# "rich", +# "tomli", +# ] +# /// + +import os +import sys +from pathlib import Path +from typing import List, Optional +from enum import Enum + +import click +import git +from pydantic_ai import Agent +from rich.console import Console +from rich.markdown import Markdown +from rich.panel import Panel +from rich.theme import Theme +from rich.progress import Progress, SpinnerColumn, TextColumn + +# Set up rich console with custom theme +custom_theme = Theme({ + "heading": "bold blue", + "info": "green", + "error": "bold red", + "warning": "yellow", +}) +console = Console(theme=custom_theme) + +class ToneStyle(str, Enum): + STANDARD = "standard" + TECHNICAL = "technical" + CASUAL = "casual" + ENTHUSIASTIC = "enthusiastic" + MINIMAL = "minimal" + PIRATE = "pirate" + NERD = "nerd" + APOLOGETIC = "apologetic" + +class BlogPostGenerator: + """Generate a blog post announcing a new version based on git commits.""" + + model_name = "anthropic/claude-3-7-sonnet-latest" + + def generate_blog_post(self, project_name: str, commits: List[str], tone: ToneStyle = ToneStyle.STANDARD) -> str: + """ + Generate a blog post announcing a new version based on git commits. + + Args: + project_name: The name of the project + commits: A list of commit messages + tone: The tone/style to use for the blog post + + Returns: + A markdown-formatted blog post announcing the new version + """ + # Base prompt structure + base_prompt = f""" + You are a technical writer for {project_name}. Create a blog post announcing a new version + of the software based on the following git commit messages. The blog post should: + + 1. Have a catchy title that mentions the project name + 2. Include a brief introduction about the new release + 3. Organize the changes into logical categories (features, bug fixes, etc.) + 4. Highlight the most important changes + 5. End with a call to action for users to update and provide feedback + + Here are the commit messages: + + {commits} + """ + + # Add tone-specific instructions + tone_instructions = { + ToneStyle.STANDARD: """ + Format your response in Markdown. Be concise but informative. Focus on the user benefits + of each change rather than technical implementation details. + """, + + ToneStyle.TECHNICAL: """ + Format your response in Markdown. Use a technical, detailed tone that emphasizes + implementation details and technical specifications. Include code examples or technical + concepts where relevant. This is for a technical audience who wants to understand the + engineering behind the changes. + """, + + ToneStyle.CASUAL: """ + Format your response in Markdown. Use a casual, conversational tone as if you're + chatting with the user. Be friendly and approachable, using simple language and + occasional humor. Avoid technical jargon unless absolutely necessary. + """, + + ToneStyle.ENTHUSIASTIC: """ + Format your response in Markdown. Be extremely enthusiastic and excited about the + changes. Use superlatives, exclamation points, and convey a sense of excitement + throughout the post. Make the reader feel like this is the most exciting update ever. + """, + + ToneStyle.MINIMAL: """ + Format your response in Markdown. Be extremely concise and to-the-point. Use bullet + points extensively and minimize prose. Focus only on what changed and why it matters, + with no marketing language or fluff. + """, + + ToneStyle.PIRATE: """ + Format your response in Markdown. Write the entire blog post in pirate speak, using + pirate slang, nautical references, and a swashbuckling tone. Say "arr" and "matey" + occasionally, refer to the software as "treasure" or "booty", and use other pirate + terminology throughout. Keep it fun but still make sure the information is clear. + """, + + ToneStyle.NERD: """ + Format your response in Markdown. Write the blog post in an extremely computer-nerdy tone. + Focus on technical programming details, algorithms, data structures, and system architecture. + Use programming jargon, reference computer science concepts, and show excitement about + implementation details like performance optimizations and elegant code patterns. Include + references to programming languages, tools, and computer science principles. Make technical + jokes and puns that would appeal to software developers and computer scientists. However, + stay grounded in the actual changes in the commit messages. + """, + + ToneStyle.APOLOGETIC: """ + Format your response in Markdown. Write the blog post in an apologetic tone, as if + the team is constantly apologizing for the changes or for taking so long to make them. + Express excessive gratitude for users' patience, apologize for any inconvenience, and + be overly humble about the achievements. Still describe all the changes accurately, + but frame them as if the team is nervous about how they'll be received. + """ + } + + prompt = base_prompt + tone_instructions[tone] + + agent = Agent(model=self.model_name) + + with Progress( + SpinnerColumn(), + TextColumn(f"[info]Generating {tone.value} blog post...[/info]"), + console=console, + transient=True, + ) as progress: + progress.add_task("generate", total=None) + result = agent.run_sync(prompt) + + return result.data + +def get_commit_messages(repo_path: Path, rev_range: Optional[str] = None, since_date: Optional[str] = None) -> List[str]: + """Get commit messages from the specified git repository and revision range.""" + try: + repo = git.Repo(repo_path) + + # Create a git command object + git_cmd = repo.git + + # Build the arguments for git log + kwargs = { + 'pretty': 'format:%h %s%n%b' # Use kwargs for format to avoid quoting issues + } + + # Add since date if provided + if since_date: + kwargs['since'] = since_date + + # Add revision range if provided + args = [] + if rev_range: + args.append(rev_range) + + # Execute the git log command using the git.cmd_override method + # This properly handles the arguments without shell quoting issues + result = git_cmd.log(*args, **kwargs) + + # Split the result into individual commit messages + commits = [] + current_commit = "" + + for line in result.split('\n'): + if line and line[0].isalnum() and len(line) >= 7 and line[7] == ' ': + # This is a new commit (starts with hash) + if current_commit: + commits.append(current_commit.strip()) + current_commit = line + else: + # This is part of the current commit message + current_commit += f"\n{line}" + + # Add the last commit + if current_commit: + commits.append(current_commit.strip()) + + return commits + + except git.exc.GitCommandError as e: + console.print(f"[error]Git error: {e}[/error]") + sys.exit(1) + +def get_project_name(repo_path: Path) -> str: + """Try to determine the project name from the git repository or project files.""" + try: + # Check for common project configuration files + # 1. Check pyproject.toml (Python) + pyproject_path = repo_path / "pyproject.toml" + if pyproject_path.exists(): + import tomli + with open(pyproject_path, "rb") as f: + pyproject_data = tomli.load(f) + if "project" in pyproject_data and "name" in pyproject_data["project"]: + return pyproject_data["project"]["name"] + elif "tool" in pyproject_data and "poetry" in pyproject_data["tool"] and "name" in pyproject_data["tool"]["poetry"]: + return pyproject_data["tool"]["poetry"]["name"] + + # 2. Check setup.py (Python) + setup_py_path = repo_path / "setup.py" + if setup_py_path.exists(): + # Try to extract name from setup.py using a simple regex + import re + setup_content = setup_py_path.read_text() + if match := re.search(r'name\s*=\s*[\'"]([^\'"]+)[\'"]', setup_content): + return match.group(1) + + # 3. Check package.json (Node.js) + package_json_path = repo_path / "package.json" + if package_json_path.exists(): + import json + with open(package_json_path, "r") as f: + package_data = json.load(f) + if "name" in package_data: + return package_data["name"] + + # 4. Check Cargo.toml (Rust) + cargo_toml_path = repo_path / "Cargo.toml" + if cargo_toml_path.exists(): + import tomli + with open(cargo_toml_path, "rb") as f: + cargo_data = tomli.load(f) + if "package" in cargo_data and "name" in cargo_data["package"]: + return cargo_data["package"]["name"] + + # 5. Check go.mod (Go) + go_mod_path = repo_path / "go.mod" + if go_mod_path.exists(): + import re + go_mod_content = go_mod_path.read_text() + if match := re.search(r'^module\s+([^\s]+)', go_mod_content, re.MULTILINE): + # Extract the last part of the module path as the name + module_path = match.group(1) + return module_path.split('/')[-1] + + # 6. Check composer.json (PHP) + composer_json_path = repo_path / "composer.json" + if composer_json_path.exists(): + import json + with open(composer_json_path, "r") as f: + composer_data = json.load(f) + if "name" in composer_data: + # Composer uses vendor/package format, extract just the package name + return composer_data["name"].split('/')[-1] + + # 7. Check build.gradle or settings.gradle (Java/Kotlin - Gradle) + gradle_files = [repo_path / "settings.gradle", repo_path / "settings.gradle.kts", + repo_path / "build.gradle", repo_path / "build.gradle.kts"] + for gradle_file in gradle_files: + if gradle_file.exists(): + import re + content = gradle_file.read_text() + # Try to find rootProject.name or project name + if match := re.search(r'rootProject\.name\s*=\s*[\'"]([^\'"]+)[\'"]', content): + return match.group(1) + elif match := re.search(r'project\([\'"]([^\'"]+)[\'"]\)', content): + return match.group(1) + + # 8. Check pom.xml (Java - Maven) + pom_xml_path = repo_path / "pom.xml" + if pom_xml_path.exists(): + import re + pom_content = pom_xml_path.read_text() + if match := re.search(r'([^<]+)', pom_content): + return match.group(1) + + # 9. Check gemspec files (Ruby) + import glob + gemspec_files = list(repo_path.glob("*.gemspec")) + if gemspec_files: + # Use the filename without extension as the project name + return gemspec_files[0].stem + + # 10. Check pubspec.yaml (Dart/Flutter) + pubspec_path = repo_path / "pubspec.yaml" + if pubspec_path.exists(): + import re + pubspec_content = pubspec_path.read_text() + if match := re.search(r'^name:\s*([^\s]+)', pubspec_content, re.MULTILINE): + return match.group(1) + + # 11. Try to get the name from the remote URL + repo = git.Repo(repo_path) + if repo.remotes: + url = repo.remotes[0].url + # Extract project name from URL (works for GitHub, GitLab, etc.) + if '/' in url: + name = url.split('/')[-1] + # Remove .git suffix if present + if name.endswith('.git'): + name = name[:-4] + return name + + # 12. Fallback: use the directory name + return repo_path.name + except Exception as e: + console.print(f"[warning]Error determining project name: {e}[/warning]", file=sys.stderr) + # If all else fails, use a generic name + return "Project" + +@click.command() +@click.argument('rev_range', required=False) +@click.option('--repo-path', '-r', type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path), + default=Path('.'), help='Path to the git repository') +@click.option('--project-name', '-p', help='Name of the project (defaults to repo name)') +@click.option('--output', '-o', type=click.Path(path_type=Path), + help='Save the blog post to this file instead of printing to console') +@click.option('--raw', is_flag=True, help='Output raw markdown without rich formatting') +@click.option('--since', help='Show commits more recent than a specific date (e.g. "1 day ago")') +@click.option('--tone', '-t', type=click.Choice([t.value for t in ToneStyle]), + default=ToneStyle.STANDARD.value, + help='Tone/style of the blog post') +def main(rev_range: Optional[str], repo_path: Path, project_name: Optional[str], + output: Optional[Path], raw: bool, since: Optional[str], tone: str): + """ + Generate a blog post announcing a new version based on git commits. + + REV_RANGE is an optional git revision range (e.g., 'v1.0..v1.1' or 'HEAD~10..HEAD'). + If not provided, all commits will be included. + + Examples: + git-ai-release-notes # Use all commits + git-ai-release-notes HEAD~5..HEAD # Use the last 5 commits + git-ai-release-notes v1.0..v1.1 # Use commits between tags v1.0 and v1.1 + git-ai-release-notes --since="1 day ago" # Use commits from the last day + git-ai-release-notes -o blog-post.md # Save output to a file + git-ai-release-notes --tone=technical # Use a technical tone + """ + # Get commit messages + if since and not rev_range: + # Pass the since parameter directly to get_commit_messages + commits = get_commit_messages(repo_path, since_date=since) + else: + commits = get_commit_messages(repo_path, rev_range=rev_range) + + if not commits: + console.print("[warning]No commits found in the specified range[/warning]") + return + + # Determine project name if not provided + if not project_name: + project_name = get_project_name(repo_path) + + # Convert tone string to enum - with error handling + try: + tone_enum = ToneStyle(tone) + except ValueError: + # This should not happen with Click's type checking, but just in case + available_tones = ", ".join([f"'{t.value}'" for t in ToneStyle]) + console.print(f"[error]Error: Invalid tone '{tone}'[/error]") + console.print(f"[info]Available tones: {available_tones}[/info]") + sys.exit(1) + + # Generate blog post + console.print(f"[info]Generating {tone_enum.value} blog post for [bold]{project_name}[/bold] based on {len(commits)} commits...[/info]") + + generator = BlogPostGenerator() + blog_post = generator.generate_blog_post(project_name, commits, tone_enum) + + # Output the blog post + if output: + output.write_text(blog_post) + console.print(f"[info]Blog post saved to [bold]{output}[/bold][/info]") + elif raw: + print(blog_post) + else: + md = Markdown(blog_post) + console.print(Panel(md, title=f"[bold]{project_name}[/bold] Release Announcement ({tone_enum.value})", + border_style="blue", padding=(1, 2))) + +if __name__ == "__main__": + main() diff --git a/git-apply-fixups b/git-apply-fixups new file mode 100755 index 0000000..1d9daa8 --- /dev/null +++ b/git-apply-fixups @@ -0,0 +1,253 @@ +#!/usr/bin/env -S uv --quiet run --script +# /// script +# requires-python = ">=3.13" +# dependencies = [ +# "gitpython", +# "click", +# "rich", +# ] +# /// + +import os +import re +import shlex +import tempfile +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import Iterator, Optional + +import click +from rich.console import Console +from rich.theme import Theme + +import git + +custom_theme = Theme({ + "fixup": "yellow", + "commit": "green", + "error": "red", + "hint": "blue", +}) +console = Console(theme=custom_theme) + +@dataclass +class Commit: + hash: str + timestamp: int + author: str + message: str + target_hash: Optional[str] = None + command: str = "pick" + + @property + def is_fixup(self) -> bool: + return bool(self.target_hash) + + @property + def formatted_date(self) -> str: + dt = datetime.fromtimestamp(self.timestamp) + return dt.strftime("[%Y-%m-%d %H:%M:%S]") + + def format_line(self, with_color: bool = True) -> str: + """Format the commit line for display or rebase todo list. + For non-colored output (used in the rebase todo), only the first line of + the commit message is used to avoid multi-line issues.""" + if with_color: + first_line = self.message.splitlines()[0].strip() if self.message else "" + line = f"{self.command} {self.hash} {self.formatted_date} ({self.author}) {first_line}" + style = "fixup" if self.is_fixup else "commit" + return f"[{style}]{line}[/{style}]" + else: + first_line = self.message.splitlines()[0].strip() if self.message else "" + return f"{self.command} {self.hash} {self.formatted_date} ({self.author}) {first_line}" + + def __str__(self) -> str: + return self.format_line(with_color=True) + +def parse_commits(repo_path: Path = Path("."), max_count: Optional[int] = None, since: Optional[str] = None) -> Iterator[Commit]: + """Parse git log and yield Commit objects.""" + repo = git.Repo(repo_path) + + # Build the options for iter_commits + kwargs = {} + if max_count is not None: + kwargs['max_count'] = max_count + if since is not None: + kwargs['since'] = since + + for commit in repo.iter_commits(**kwargs): + message = commit.message.strip() + target_hash = None + + if match := re.match(r'^f(?:ixup)?! \[([a-f0-9]+)\]', message): + target_hash = match.group(1) + + yield Commit( + hash=commit.hexsha[:7], + timestamp=commit.committed_date, + author=commit.author.name, + message=message, + target_hash=target_hash + ) + +def reorder_commits(commits: list[Commit], use_squash: bool = False) -> list[Commit]: + """Reorder commits so that each fixup commit comes after its target commit. + The commits are ordered from oldest to newest, and for each non-fixup commit, + its associated fixup commits (if any) are appended immediately after.""" + # Reverse the commits to get ascending order (oldest to newest) + sorted_commits = commits[::-1] + + # Build a mapping from target commit hash to list of fixup commits targeting that commit + fixups_by_target = {} + for commit in sorted_commits: + if commit.is_fixup and commit.target_hash: + fixups_by_target.setdefault(commit.target_hash, []).append(commit) + + result = [] + for commit in sorted_commits: + if not commit.is_fixup: + # Add the target commit first + result.append(commit) + # Then append any fixup commits targeting it + if commit.hash in fixups_by_target: + for fixup in fixups_by_target[commit.hash]: + fixup.command = "squash" if use_squash else "fixup" + result.append(fixup) + return result + +class DateTimeParamType(click.ParamType): + name = 'date' + + def convert(self, value, param, ctx): + if value is None: + return None + # git understands these formats directly, so just pass them through + return value + +def handle_rebase_error(error: git.exc.GitCommandError, repo: git.Repo) -> None: + """Handle rebase errors with helpful messages.""" + # Try to abort the rebase + try: + repo.git.rebase("--abort") + except git.exc.GitCommandError: + # If abort fails, just continue with the error + pass + + # Check for common error types + if "CONFLICT" in error.stderr: + console.print("[error]Rebase failed due to conflicts:[/error]") + console.print(error.stdout.strip()) + console.print("\n[hint]The rebase has been aborted. To fix this:[/hint]") + console.print("1. Resolve the conflicts manually first") + console.print("2. Then try running this command again") + console.print("\n[hint]Or try using --dry-run first to review the changes[/hint]") + else: + console.print(f"[error]Git error: {error}[/error]") + console.print("[error]The rebase has been aborted.[/error]") + +@click.command() +@click.option("--dry-run", "-n", is_flag=True, help="Show proposed reordering without executing") +@click.option("--squash", "-s", is_flag=True, help="Use 'squash' instead of 'pick' for fixup commits") +@click.option("--max-count", "-n", type=int, help="Limit the number of commits to process") +@click.option("--since", type=DateTimeParamType(), + help='Show commits more recent than a specific date (e.g. "2 days ago" or "2024-01-01")') +def main(dry_run: bool, squash: bool, max_count: Optional[int], since: Optional[str]) -> None: + """Reorder commits so that fixup commits are placed before their targets. + + This script is part of a workflow with git-create-fixups: + + 1. Make changes to multiple files + 2. Run git-create-fixups to create separate commits for each file + 3. Run this script (git-apply-fixups) to automatically reorder the commits + so that each fixup commit is placed before its target commit + + The --squash option will mark fixup commits with 'squash' instead of 'pick', + causing them to be combined with their target commits during the rebase. + + Examples: + # Show proposed reordering without executing + git-apply-fixups --dry-run + + # Reorder and mark fixups for squashing + git-apply-fixups --squash + + # Only process recent commits + git-apply-fixups --since="2 days ago" + git-apply-fixups -n 10 + """ + try: + # Parse and reorder commits + commits = list(parse_commits(max_count=max_count, since=since)) + if not commits: + console.print("[yellow]No commits found in the specified range[/yellow]") + return + + reordered = reorder_commits(commits, use_squash=squash) + + if dry_run: + console.print("Proposed commit order:") + for commit in reordered: + console.print(str(commit)) + else: + # Generate the rebase todo list using the same format as dry-run, but without colors + todo_list = "\n".join(commit.format_line(with_color=False) for commit in reordered) + + # Start interactive rebase + repo = git.Repo(".") + + # Build rebase command + cmd = ["git", "rebase", "-i", "--autostash"] + + # Find the base commit for rebase + if max_count: + # For max_count, we can use HEAD~N + base_commit = f"HEAD~{max_count}" + else: + # For since/until, find the oldest commit in our range and determine its parent via git.Repo + oldest_commit_obj = commits[-1] + oldest_git_commit = repo.commit(oldest_commit_obj.hash) + if not oldest_git_commit.parents: + # This is the root commit, so we use --root so that it appears in the todo list + cmd.append("--root") + base_commit = None + else: + base_commit = f"{oldest_git_commit.hexsha}^" + + if base_commit: + cmd.append(base_commit) + + # Create a script that will replace the rebase-todo file with our content + with tempfile.NamedTemporaryFile(mode='w', suffix='.sh', delete=False) as f: + # Properly escape the todo list for shell + escaped_todo = shlex.quote(todo_list) + f.write(f'''#!/bin/sh +echo {escaped_todo} > "$1" +''') + f.flush() + os.chmod(f.name, 0o755) + editor_script = f.name + + try: + # Use our script as the sequence editor + env = os.environ.copy() + env["GIT_SEQUENCE_EDITOR"] = editor_script + + # Run git rebase + repo.git.execute(cmd, env=env) + except git.exc.GitCommandError as e: + handle_rebase_error(e, repo) + raise click.Abort() + finally: + # Clean up the temporary script + os.unlink(editor_script) + + except git.exc.GitCommandError as e: + handle_rebase_error(e, git.Repo(".")) + raise click.Abort() + except Exception as e: + console.print(f"[error]Error: {e}[/error]") + raise click.Abort() + +if __name__ == "__main__": + main() diff --git a/git-create-fixups b/git-create-fixups new file mode 100755 index 0000000..4a61cef --- /dev/null +++ b/git-create-fixups @@ -0,0 +1,90 @@ +#!/usr/bin/env bash + +set -eu + +usage() { + echo "Usage: $(basename "$0") [-n|--dry-run]" >&2 + echo >&2 + echo "Creates separate commits for each modified file, with commit messages that reference" >&2 + echo "their previous commits. This creates a series of commits that can then be reordered" >&2 + echo "and merged into their previous commits using git-apply-fixups." >&2 + echo >&2 + echo "For each dirty file F, finds its most recent commit C and creates a new commit with" >&2 + echo "message 'f [] ' where is C's short hash and is C's" >&2 + echo "first message line. These commits can then be reordered next to their referenced" >&2 + echo "commits and marked as 'fixup' in an interactive rebase." >&2 + echo >&2 + echo "Typical workflow:" >&2 + echo "1. Make changes to multiple files" >&2 + echo "2. Run this script (git-create-fixups) to create separate commits" >&2 + echo "3. Run git-apply-fixups to automatically reorder and optionally squash the commits," >&2 + echo " or run 'git rebase -i HEAD~N' to manually reorder them" >&2 + echo >&2 + echo "Options:" >&2 + echo " -n, --dry-run Show what would be done" >&2 +} + +# Change to git root directory for the duration of the script +cd "$(git rev-parse --show-toplevel)" + +# Parse command line options +dry_run=false +while [[ $# -gt 0 ]]; do + case $1 in + -n|--dry-run) + dry_run=true + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + usage + exit 1 + ;; + esac +done + +# Get list of dirty (modified) files +dirty_files=$(git diff --name-only) + +# Create associative arrays to group files by commit +declare -A files_by_commit +declare -A messages_by_commit + +for file in $dirty_files; do + # Get the most recent commit that modified this file + commit_info=$(git log -1 --format="%h %s" -- "$file") + if [ -n "$commit_info" ]; then + # Split commit_info into hash and message + commit_hash=${commit_info%% *} + commit_msg=${commit_info#* } + + # Add file to the array for this commit + if [ -n "${files_by_commit[$commit_hash]:-}" ]; then + files_by_commit[$commit_hash]="${files_by_commit[$commit_hash]}"$'\n'"$file" + else + files_by_commit[$commit_hash]="$file" + messages_by_commit[$commit_hash]="$commit_msg" + fi + else + echo "Warning: No previous commits found for $file" + fi +done + +# Process each commit group +for commit_hash in "${!files_by_commit[@]}"; do + message="fixup! [$commit_hash] ${messages_by_commit[$commit_hash]}" + if [ "$dry_run" = true ]; then + echo "Would commit: $message" + echo "${files_by_commit[$commit_hash]}" | sed 's/^/- /' + echo + else + # Stage and commit all files for this commit + echo "${files_by_commit[$commit_hash]}" | while read -r file; do + git add "$file" + done + git commit --no-verify -m "$message" + fi +done diff --git a/git-rank-contributors b/git-rank-contributors new file mode 100755 index 0000000..3b27220 --- /dev/null +++ b/git-rank-contributors @@ -0,0 +1,60 @@ +#!/usr/bin/env ruby + +## git-rank-contributors: a simple script to trace through the logs and +## rank contributors by the total size of the diffs they're responsible for. +## A change counts twice as much as a plain addition or deletion. +## +## Output may or may not be suitable for inclusion in a CREDITS file. +## Probably not without some editing, because people often commit from more +## than one address. +## +## git-rank-contributors Copyright 2008 William Morgan . +## This program is free software: you can redistribute it and/or modify +## it under the terms of the GNU General Public License as published by +## the Free Software Foundation, either version 3 of the License, or (at +## your option) any later version. +## +## This program is distributed in the hope that it will be useful, +## but WITHOUT ANY WARRANTY; without even the implied warranty of +## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +## GNU General Public License for more details. +## +## You can find the GNU General Public License at: +## http://www.gnu.org/licenses/ + +class String + def obfuscate; gsub(/@/, " at the ").gsub(/\.(\w+)(>|$)/, ' dot \1s\2') end + def htmlize; gsub("&", "&").gsub("<", "<").gsub(">", ">") end +end + +lines = {} +verbose = ARGV.delete("-v") +obfuscate = ARGV.delete("-o") +htmlize = ARGV.delete("-h") + +author = nil +state = :pre_author +`git log -M -C -C -p --no-color`.each do |l| + case + when (state == :pre_author || state == :post_author) && l =~ /Author: (.*)$/ + author = $1 + state = :post_author + lines[author] ||= 0 + when state == :post_author && l =~ /^\+\+\+/ + state = :in_diff + when state == :in_diff && l =~ /^[\+\-]/ + lines[author] += 1 + when state == :in_diff && l =~ /^commit / + state = :pre_author + end +end + +lines.sort_by { |a, c| -c }.each do |a, c| + a = a.obfuscate if obfuscate + a = a.htmlize if htmlize + if verbose + puts "#{a}: #{c} lines of diff" + else + puts a + end +end diff --git a/git-show-large-objects b/git-show-large-objects new file mode 100755 index 0000000..ec60fd9 --- /dev/null +++ b/git-show-large-objects @@ -0,0 +1,33 @@ +#!/bin/bash +#set -x + +# Shows you the largest objects in your repo's pack file. +# Written for osx. +# +# @see http://stubbisms.wordpress.com/2009/07/10/git-script-to-show-largest-pack-objects-and-trim-your-waist-line/ +# @author Antony Stubbs + +# set the internal field spereator to line break, so that we can iterate easily over the verify-pack output +IFS=$'\n'; + +# list all objects including their size, sort by size, take top 10 +objects=`git verify-pack -v .git/objects/pack/pack-*.idx | grep -v chain | sort -k3nr | head` + +echo "All sizes are in kB. The pack column is the size of the object, compressed, inside the pack file." + +output="size,pack,SHA,location" +for y in $objects +do + # extract the size in bytes + size=$((`echo $y | cut -f 5 -d ' '`/1024)) + # extract the compressed size in bytes + compressedSize=$((`echo $y | cut -f 6 -d ' '`/1024)) + # extract the SHA + sha=`echo $y | cut -f 1 -d ' '` + # find the objects location in the repository tree + other=`git rev-list --all --objects | grep $sha` + #lineBreak=`echo -e "\n"` + output="${output}\n${size},${compressedSize},${other}" +done + +echo -e $output | column -t -s ', ' diff --git a/git-show-merges b/git-show-merges new file mode 100755 index 0000000..1290750 --- /dev/null +++ b/git-show-merges @@ -0,0 +1,49 @@ +#!/usr/bin/env ruby + +## git-show-merges: a simple script to show you which topic branches have +## been merged into the current branch, and which haven't. (Or, specify +## the set of merge branches you're interested in on the command line.) +## +## git-show-merges Copyright 2008 William Morgan . +## This program is free software: you can redistribute it and/or modify +## it under the terms of the GNU General Public License as published by +## the Free Software Foundation, either version 3 of the License, or (at +## your option) any later version. +## +## This program is distributed in the hope that it will be useful, +## but WITHOUT ANY WARRANTY; without even the implied warranty of +## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +## GNU General Public License for more details. +## +## You can find the GNU General Public License at: +## http://www.gnu.org/licenses/ +heads = if ARGV.empty? + [`git symbolic-ref HEAD`.chomp] +else + ARGV +end.map { |r| r.gsub(/refs\/heads\//, "") } + +branches = `git show-ref --heads`. + scan(/^\S+ refs\/heads\/(\S+)$/). + map { |a| a.first } + +unknown = heads - branches +unless unknown.empty? + $stderr.puts "Unknown branch: #{unknown.first}" + exit(-1) +end + +branches -= heads + +heads.each do |h| + merged = branches.select { |b| `git log #{h}..#{b}` == "" } + unmerged = branches - merged + + puts "merged into #{h}:" + merged.each { |b| puts " #{b}" } + puts + puts "not merged into #{h}: " + unmerged.each { |b| puts " #{b}" } + + puts +end diff --git a/git-wtf b/git-wtf new file mode 100755 index 0000000..e407eae --- /dev/null +++ b/git-wtf @@ -0,0 +1,364 @@ +#!/usr/bin/env ruby + +HELP = < +.git-wtfrc" and edit it. The config file is a YAML file that specifies the +integration branches, any branches to ignore, and the max number of commits to +display when --all-commits isn't used. git-wtf will look for a .git-wtfrc file +starting in the current directory, and recursively up to the root. + +IMPORTANT NOTE: all local branches referenced in .git-wtfrc must be prefixed +with heads/, e.g. "heads/master". Remote branches must be of the form +remotes//. +EOS + +COPYRIGHT = <. +This program is free software: you can redistribute it and/or modify it +under the terms of the GNU General Public License as published by the Free +Software Foundation, either version 3 of the License, or (at your option) +any later version. + +This program is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +more details. + +You can find the GNU General Public License at: http://www.gnu.org/licenses/ +EOS + +require 'yaml' +CONFIG_FN = ".git-wtfrc" + +class Numeric; def pluralize s; "#{to_s} #{s}" + (self != 1 ? "s" : "") end end + +if ARGV.delete("--help") || ARGV.delete("-h") + puts USAGE + exit +end + +## poor man's trollop +$long = ARGV.delete("--long") || ARGV.delete("-l") +$short = ARGV.delete("--short") || ARGV.delete("-s") +$all = ARGV.delete("--all") || ARGV.delete("-a") +$all_commits = ARGV.delete("--all-commits") || ARGV.delete("-A") +$dump_config = ARGV.delete("--dump-config") +$key = ARGV.delete("--key") || ARGV.delete("-k") +$show_relations = ARGV.delete("--relations") || ARGV.delete("-r") +ARGV.each { |a| abort "Error: unknown argument #{a}." if a =~ /^--/ } + +## search up the path for a file +def find_file fn + while true + return fn if File.exist? fn + fn2 = File.join("..", fn) + return nil if File.expand_path(fn2) == File.expand_path(fn) + fn = fn2 + end +end + +want_color = `git config color.wtf` +want_color = `git config color.ui` if want_color.empty? +$color = case want_color.chomp + when "true"; true + when "auto"; $stdout.tty? +end + +def red s; $color ? "\033[31m#{s}\033[0m" : s end +def green s; $color ? "\033[32m#{s}\033[0m" : s end +def yellow s; $color ? "\033[33m#{s}\033[0m" : s end +def cyan s; $color ? "\033[36m#{s}\033[0m" : s end +def grey s; $color ? "\033[1;30m#{s}\033[0m" : s end +def purple s; $color ? "\033[35m#{s}\033[0m" : s end + +## the set of commits in 'to' that aren't in 'from'. +## if empty, 'to' has been merged into 'from'. +def commits_between from, to + if $long + `git log --pretty=format:"- %s [#{yellow "%h"}] (#{purple "%ae"}; %ar)" #{from}..#{to}` + else + `git log --pretty=format:"- %s [#{yellow "%h"}]" #{from}..#{to}` + end.split(/[\r\n]+/) +end + +def show_commits commits, prefix=" " + if commits.empty? + puts "#{prefix} none" + else + max = $all_commits ? commits.size : $config["max_commits"] + max -= 1 if max == commits.size - 1 # never show "and 1 more" + commits[0 ... max].each { |c| puts "#{prefix}#{c}" } + puts grey("#{prefix}... and #{commits.size - max} more (use -A to see all).") if commits.size > max + end +end + +def ahead_behind_string ahead, behind + [ahead.empty? ? nil : "#{ahead.size.pluralize 'commit'} ahead", + behind.empty? ? nil : "#{behind.size.pluralize 'commit'} behind"]. + compact.join("; ") +end + +def widget merged_in, remote_only=false, local_only=false, local_only_merge=false + left, right = case + when remote_only; %w({ }) + when local_only; %w{( )} + else %w([ ]) + end + middle = case + when merged_in && local_only_merge; green("~") + when merged_in; green("x") + else " " + end + print left, middle, right +end + +def show b + have_both = b[:local_branch] && b[:remote_branch] + + pushc, pullc, oosync = if have_both + [x = commits_between(b[:remote_branch], b[:local_branch]), + y = commits_between(b[:local_branch], b[:remote_branch]), + !x.empty? && !y.empty?] + end + + if b[:local_branch] + puts "Local branch: " + green(b[:local_branch].sub(/^heads\//, "")) + + if have_both + if pushc.empty? + puts "#{widget true} in sync with remote" + else + action = oosync ? "push after rebase / merge" : "push" + puts "#{widget false} NOT in sync with remote (you should #{action})" + show_commits pushc unless $short + end + end + end + + if b[:remote_branch] + puts "Remote branch: #{cyan b[:remote_branch]} (#{b[:remote_url]})" + + if have_both + if pullc.empty? + puts "#{widget true} in sync with local" + else + action = pushc.empty? ? "merge" : "rebase / merge" + puts "#{widget false} NOT in sync with local (you should #{action})" + show_commits pullc unless $short + end + end + end + + puts "\n#{red "WARNING"}: local and remote branches have diverged. A merge will occur unless you rebase." if oosync +end + +def show_relations b, all_branches + ibs, fbs = all_branches.partition { |name, br| $config["integration-branches"].include?(br[:local_branch]) || $config["integration-branches"].include?(br[:remote_branch]) } + if $config["integration-branches"].include? b[:local_branch] + puts "\nFeature branches:" unless fbs.empty? + fbs.each do |name, br| + next if $config["ignore"].member?(br[:local_branch]) || $config["ignore"].member?(br[:remote_branch]) + next if br[:ignore] + local_only = br[:remote_branch].nil? + remote_only = br[:local_branch].nil? + name = if local_only + purple br[:name] + elsif remote_only + cyan br[:name] + else + green br[:name] + end + + ## for remote_only branches, we'll compute wrt the remote branch head. otherwise, we'll + ## use the local branch head. + head = remote_only ? br[:remote_branch] : br[:local_branch] + + remote_ahead = b[:remote_branch] ? commits_between(b[:remote_branch], head) : [] + local_ahead = b[:local_branch] ? commits_between(b[:local_branch], head) : [] + + if local_ahead.empty? && remote_ahead.empty? + puts "#{widget true, remote_only, local_only} #{name} #{local_only ? "(local-only) " : ""}is merged in" + elsif local_ahead.empty? + puts "#{widget true, remote_only, local_only, true} #{name} merged in (only locally)" + else + behind = commits_between head, (br[:local_branch] || br[:remote_branch]) + ahead = remote_only ? remote_ahead : local_ahead + puts "#{widget false, remote_only, local_only} #{name} #{local_only ? "(local-only) " : ""}is NOT merged in (#{ahead_behind_string ahead, behind})" + show_commits ahead unless $short + end + end + else + puts "\nIntegration branches:" unless ibs.empty? # unlikely + ibs.sort_by { |v, br| v }.each do |v, br| + next if $config["ignore"].member?(br[:local_branch]) || $config["ignore"].member?(br[:remote_branch]) + next if br[:ignore] + local_only = br[:remote_branch].nil? + remote_only = br[:local_branch].nil? + name = remote_only ? cyan(br[:name]) : green(br[:name]) + + ahead = commits_between v, (b[:local_branch] || b[:remote_branch]) + if ahead.empty? + puts "#{widget true, local_only} merged into #{name}" + else + #behind = commits_between b[:local_branch], v + puts "#{widget false, local_only} NOT merged into #{name} (#{ahead.size.pluralize 'commit'} ahead)" + show_commits ahead unless $short + end + end + end +end + +#### EXECUTION STARTS HERE #### + +## find config file and load it +$config = { "integration-branches" => %w(heads/master heads/next heads/edge), "ignore" => [], "max_commits" => 5 }.merge begin + fn = find_file CONFIG_FN + if fn && (h = YAML::load_file(fn)) # yaml turns empty files into false + h["integration-branches"] ||= h["versions"] # support old nomenclature + h + else + {} + end +end + +if $dump_config + puts $config.to_yaml + exit +end + +## first, index registered remotes +remotes = `git config --get-regexp ^remote\.\*\.url`.split(/[\r\n]+/).inject({}) do |hash, l| + l =~ /^remote\.(.+?)\.url (.+)$/ or next hash + hash[$1] ||= $2 + hash +end + +## next, index followed branches +branches = `git config --get-regexp ^branch\.`.split(/[\r\n]+/).inject({}) do |hash, l| + case l + when /branch\.(.*?)\.remote (.+)/ + name, remote = $1, $2 + + hash[name] ||= {} + hash[name].merge! :remote => remote, :remote_url => remotes[remote] + when /branch\.(.*?)\.merge ((refs\/)?heads\/)?(.+)/ + name, remote_branch = $1, $4 + hash[name] ||= {} + hash[name].merge! :remote_mergepoint => remote_branch + end + hash +end + +## finally, index all branches +remote_branches = {} +`git show-ref`.split(/[\r\n]+/).each do |l| + sha1, ref = l.chomp.split " refs/" + + if ref =~ /^heads\/(.+)$/ # local branch + name = $1 + next if name == "HEAD" + branches[name] ||= {} + branches[name].merge! :name => name, :local_branch => ref + elsif ref =~ /^remotes\/(.+?)\/(.+)$/ # remote branch + remote, name = $1, $2 + remote_branches["#{remote}/#{name}"] = true + next if name == "HEAD" + ignore = !($all || remote == "origin") + + branch = name + if branches[name] && branches[name][:remote] == remote + # nothing + else + name = "#{remote}/#{branch}" + end + + branches[name] ||= {} + branches[name].merge! :name => name, :remote => remote, :remote_branch => "#{remote}/#{branch}", :remote_url => remotes[remote], :ignore => ignore + end +end + +## assemble remotes +branches.each do |k, b| + next unless b[:remote] && b[:remote_mergepoint] + b[:remote_branch] = if b[:remote] == "." + b[:remote_mergepoint] + else + t = "#{b[:remote]}/#{b[:remote_mergepoint]}" + remote_branches[t] && t # only if it's still alive + end +end + +show_dirty = ARGV.empty? +targets = if ARGV.empty? + [`git symbolic-ref HEAD`.chomp.sub(/^refs\/heads\//, "")] +else + ARGV.map { |x| x.sub(/^heads\//, "") } +end.map { |t| branches[t] or abort "Error: can't find branch #{t.inspect}." } + +targets.each do |t| + show t + show_relations t, branches if $show_relations || t[:remote_branch].nil? +end + +modified = show_dirty && `git ls-files -m` != "" +uncommitted = show_dirty && `git diff-index --cached HEAD` != "" + +if $key + puts + puts KEY +end + +puts if modified || uncommitted +puts "#{red "NOTE"}: working directory contains modified files." if modified +puts "#{red "NOTE"}: staging area contains staged but uncommitted files." if uncommitted + +# the end! From 983e492d2a0a460251ce8fa9b333c6d6bbc2c8a2 Mon Sep 17 00:00:00 2001 From: Oliver Steele Date: Sun, 7 Sep 2025 13:43:06 +0800 Subject: [PATCH 7/9] feat: Add script to create and configure GitHub repositories - Introduce a new bash script `create-gh-repo.sh` for automating GitHub repository creation. - The script accepts repository name, description, and comma-separated topics as arguments. - It checks for `gh` CLI authentication and handles missing arguments. - The script creates a public repository without cloning it locally. - It supports adding topics to the newly created repository. - Finally, it displays the created repository's details including name, description, URL, visibility, and topics. --- create-gh-repo.sh | 54 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100755 create-gh-repo.sh diff --git a/create-gh-repo.sh b/create-gh-repo.sh new file mode 100755 index 0000000..0fb6bdf --- /dev/null +++ b/create-gh-repo.sh @@ -0,0 +1,54 @@ +#!/bin/bash + +# Script to create a GitHub repository and set its description and topics +# Usage: ./create-gh-repo.sh + +set -e + +# Check if required arguments are provided +if [ $# -lt 2 ]; then + echo "Usage: $0 [topics]" + echo "Example: $0 my-project 'A cool project' 'javascript,web,opensource'" + exit 1 +fi + +REPO_NAME="$1" +DESCRIPTION="$2" +TOPICS="${3:-}" + +echo "Creating GitHub repository: $REPO_NAME" + +# Check if gh CLI is authenticated +if ! gh auth status > /dev/null 2>&1; then + echo "Error: Not authenticated with GitHub CLI. Please run 'gh auth login' first." + exit 1 +fi + +# Create the repository +echo "Creating repository..." +gh repo create "$REPO_NAME" \ + --description "$DESCRIPTION" \ + --public \ + --clone=false + +echo "Repository created successfully!" + +# Set topics if provided +if [ -n "$TOPICS" ]; then + echo "Setting repository topics..." + # Convert comma-separated topics to space-separated for gh command + TOPICS_FORMATTED=$(echo "$TOPICS" | tr ',' ' ') + gh repo edit "$REPO_NAME" --add-topic $TOPICS_FORMATTED + echo "Topics set successfully!" +fi + +# Display repository information +echo "Repository details:" +gh repo view "$REPO_NAME" --json name,description,repositoryTopics,url,visibility \ + --template '{{.name}}: {{.description}} +URL: {{.url}} +Visibility: {{.visibility}} +Topics: {{range .repositoryTopics}}{{.name}} {{end}} +' + +echo "Done! Repository '$REPO_NAME' has been created and configured." From 7f7d2dddf203b9d4532bc76bd200245f8786d8f1 Mon Sep 17 00:00:00 2001 From: Test User Date: Sat, 18 Oct 2025 09:20:17 +0800 Subject: [PATCH 8/9] feat: Introduce customizable AI commit instructions and refactor core logic This commit introduces a new feature that allows users to customize AI commit message generation through instruction files. It also significantly refactors the underlying logic for generating commit messages to improve modularity and readability. Key changes include: - **New Feature: Customizable Instructions:** - Users can now provide global instructions at `~/.config/ai-commit-instructions`. - Project-specific instructions can be placed in `.ai-commit-instructions` within the repository root. - These instructions are combined with command-line prompts for finer control over AI output. - Documentation in `README.md` has been updated to explain this new feature. - **Refactoring: Shared Library (`git-ai-lib.sh`):** - Core functionalities like model selection, model alias resolution, instruction loading, and commit message generation have been extracted into a new shared library file. - This reduces code duplication across `git-ai-commit`, `git-ai-reword-commit-message`, and `git-ai-reword-message`. - The `generate_commit_message` function is now responsible for constructing prompts and calling the `llm` command. - Added `sanitize_utf8` function to handle potential encoding issues with diff content. - **Script Updates:** - `git-ai-commit`, `git-ai-reword-commit-message`, and `git-ai-reword-message` now source `git-ai-lib.sh` and utilize its functions. - The logic for applying additional instructions and combining them with prompts has been standardized. - Model alias resolution for display purposes is now handled more cleanly. These changes enhance the flexibility of the tool and make its codebase more maintainable. --- README.md | 24 +++++ git-ai-commit | 74 ++++---------- git-ai-lib.sh | 191 +++++++++++++++++++++++++++++++++++ git-ai-reword-commit-message | 106 ++++--------------- git-ai-reword-message | 106 ++++--------------- 5 files changed, 277 insertions(+), 224 deletions(-) create mode 100644 git-ai-lib.sh diff --git a/README.md b/README.md index ea906bb..ec1854f 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,30 @@ llm aliases llm aliases remove ai-commit-message ``` +#### Customizing AI Instructions + +You can provide additional instructions to guide the AI's commit message generation using instruction files. Instructions from multiple sources are combined: + +1. **Global instructions** (`~/.config/ai-commit-instructions`) - applies to all repositories +2. **Per-project instructions** (`.ai-commit-instructions` in repo root) - applies only to the current project +3. **Command-line prompt** (`--prompt` option) - applies to a single invocation + +**Example global instructions** (`~/.config/ai-commit-instructions`): +``` +Use conventional commit format. +Keep commit titles under 50 characters. +Use imperative mood in the commit title. +``` + +**Example project-specific instructions** (`.ai-commit-instructions`): +``` +Don't mention "Slepian-Wolf" in commit messages since it's implied by the project context. +Focus on what changed, not the project domain. +Emphasize performance implications of algorithmic changes. +``` + +The `.ai-commit-instructions` file can be version-controlled and shared with your team to ensure consistent commit message style across all contributors. + ### `git-ai-commit` Automatically generates and commits changes using AI-generated commit messages. Commits both staged and unstaged changes to tracked files (like `git commit -a`), and by default also stages and commits untracked files. diff --git a/git-ai-commit b/git-ai-commit index c72ecc0..4db7ec4 100755 --- a/git-ai-commit +++ b/git-ai-commit @@ -1,6 +1,10 @@ #!/bin/bash set -euo pipefail +# Source shared library +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/git-ai-lib.sh" + # Check for llm installation if ! command -v llm >/dev/null 2>&1; then echo "Error: llm command not found. Install from https://llm.datasette.io/en/stable/setup.html" >&2 @@ -32,38 +36,6 @@ EOF exit 1 } -# Default model selection -select_default_model() { - local models - models=$(llm models) - - # Check if we got any models - if [ -z "$models" ]; then - echo "Error: No LLM models found. Please install at least one model." >&2 - exit 1 - fi - - # Define preferred models in order of preference - local preferred_models=( - "ai-commit-message" - "claude-3.5-sonnet" - "gemini-2.0-flash-latest" - "deepseek-coder" - "gpt-4o-mini" - ) - - # Try each preferred model in order - for model in "${preferred_models[@]}"; do - if echo "$models" | grep -q "$model"; then - echo "$model" - return 0 - fi - done - - # If no preferred model is found, use the first available model - echo "$models" | head -n 1 -} - # Default values model=$(select_default_model) script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -137,36 +109,30 @@ if [ "$dry_run" = true ]; then git status --short fi +# Load additional instructions from config files +repo_root=$(git rev-parse --show-toplevel) +additional_instructions=$(load_additional_instructions "$repo_root") + +# Combine additional instructions with custom prompt (git-ai-commit doesn't have custom_prompt variable, so just use additional_instructions) +combined_prompt="$additional_instructions" + # Generate commit message with progress indicator -# Check if the model is an alias and resolve it -model_display="$model" -if [[ "$model" == "ai-commit-message" ]]; then - resolved_model=$(llm aliases list 2>/dev/null | grep "^ai-commit-message" | sed 's/^ai-commit-message[[:space:]]*:[[:space:]]*//') - if [[ -n "$resolved_model" ]]; then - model_display="ai-commit-message: $resolved_model" - fi -fi +model_display=$(resolve_model_display "$model") echo -e "\n== Commit Message (via ${model_display}) ==" echo -n "Generating commit message... " >&2 -commit_msg=$(llm \ - --model "$model" \ - "Create a commit message for the following changes: -\`\`\` -$(git diff HEAD; [ "$no_verify" = false ] && git ls-files --others --exclude-standard | xargs -I{} echo \"A {}\") -\`\`\` +# Generate the diff and pipe through the message generator +if [ "$no_verify" = false ]; then + # Include untracked files + commit_msg=$({ git diff HEAD; git ls-files --others --exclude-standard | xargs -I{} echo "A {}"; } | generate_commit_message "$model" "$combined_prompt" "") +else + # Only tracked files + commit_msg=$(git diff HEAD | generate_commit_message "$model" "$combined_prompt" "") +fi -Format the response as a conventional commit message with a brief title line followed by a more detailed description if needed. -Don't include any other text in the response, just the commit message. -") printf "\r\033[K" >&2 # Clear progress message -# Strip markdown code fences if present -if [[ "$commit_msg" =~ ^\`\`\`.* ]] && [[ "$commit_msg" =~ \`\`\`$ ]]; then - commit_msg=$(echo "$commit_msg" | sed -e '1s/^```.*//' -e '$s/```$//' | sed '/^$/d') -fi - # Display the message echo "$commit_msg" diff --git a/git-ai-lib.sh b/git-ai-lib.sh new file mode 100644 index 0000000..4cfa70a --- /dev/null +++ b/git-ai-lib.sh @@ -0,0 +1,191 @@ +#!/bin/bash +# Shared library for git AI commit message generation scripts + +# Default model selection +# Returns the name of the preferred model to use +select_default_model() { + local models + models=$(llm models) + + # Check if we got any models + if [ -z "$models" ]; then + echo "Error: No LLM models found. Please install at least one model." >&2 + exit 1 + fi + + # Define preferred models in order of preference + local preferred_models=( + "ai-commit-message" + "claude-3.5-sonnet" + "gemini-2.0-flash-latest" + "o3-mini" + "deepseek-coder" + "gpt-4o-mini" + ) + + # Try each preferred model in order + for model in "${preferred_models[@]}"; do + if echo "$models" | grep -q "$model"; then + echo "$model" + return 0 + fi + done + + # If no preferred model is found, use the first available model + echo "$models" | head -n 1 +} + +# Resolve model alias to actual model name for display +# Arguments: +# $1 - model name (may be an alias) +# Returns: "alias: actual-model" or just the model name +resolve_model_display() { + local model="$1" + local model_display="$model" + + if [[ "$model" == "ai-commit-message" ]]; then + local resolved_model + resolved_model=$(llm aliases list 2>/dev/null | grep "^ai-commit-message" | sed 's/^ai-commit-message[[:space:]]*:[[:space:]]*//') + if [[ -n "$resolved_model" ]]; then + model_display="ai-commit-message: $resolved_model" + fi + fi + + echo "$model_display" +} + +# Load additional instructions from global and project files +# Arguments: +# $1 - repository root path +# Returns: Combined instructions from global and project files +load_additional_instructions() { + local repo_root="$1" + local instructions="" + + # Load global instructions + local global_file="$HOME/.config/ai-commit-instructions" + if [ -f "$global_file" ]; then + instructions=$(cat "$global_file") + fi + + # Load project-local instructions + local project_file="$repo_root/.ai-commit-instructions" + if [ -f "$project_file" ]; then + if [ -n "$instructions" ]; then + instructions="$instructions + +" + fi + instructions="$instructions$(cat "$project_file")" + fi + + echo "$instructions" +} + +# Sanitize UTF-8 input by replacing invalid sequences +# Reads from stdin, writes sanitized output to stdout +sanitize_utf8() { + # Try iconv first (faster), fall back to Python if not available + if command -v iconv >/dev/null 2>&1; then + # -c skips invalid sequences + iconv -f UTF-8 -t UTF-8 -c + else + # Python fallback: replace invalid UTF-8 with replacement character + python3 -c " +import sys +data = sys.stdin.buffer.read() +sys.stdout.write(data.decode('utf-8', errors='replace')) +" + fi +} + +# Generate a commit message from diff using AI +# Arguments: +# $1 - model name +# $2 - custom prompt (optional, can be empty) +# $3 - current description (optional, can be empty) +# Reads diff from stdin +# Returns: Generated commit message +generate_commit_message() { + local model="$1" + local custom_prompt="$2" + local current_desc="$3" + + # Read and sanitize diff from stdin + local diff_content + diff_content=$(sanitize_utf8) + + # Check if diff is empty + if [ -z "$diff_content" ]; then + echo "Error: No diff content provided" >&2 + return 1 + fi + + # Build the prompt based on whether custom prompt is provided + local prompt_text + if [[ -n "$custom_prompt" ]]; then + prompt_text="Analyze these changes and create a commit message according to the following instructions: + +Changes: +\`\`\` +$diff_content +\`\`\`" + + if [[ -n "$current_desc" ]]; then + prompt_text="$prompt_text + +Current description: +\`\`\` +$current_desc +\`\`\`" + fi + + prompt_text="$prompt_text + +Instructions: +\`\`\` +$custom_prompt +\`\`\` + +Format the response as a conventional commit message with a brief title line followed by a more detailed description if needed. +Do not include a summary paragraph after any list of changes. +Do not use Markdown emphasis such as **bold**, *italics*, or similar styling. +Don't include any other text in the response, just the commit message." + else + prompt_text="Analyze these changes and create a conventional commit message: + +\`\`\` +$diff_content +\`\`\`" + + if [[ -n "$current_desc" ]]; then + prompt_text="$prompt_text + +Current description (if any): +\`\`\` +$current_desc +\`\`\`" + fi + + prompt_text="$prompt_text + +Format the response as a conventional commit message with a brief title line followed by a more detailed description if needed. +Do not include a summary paragraph after any list of changes. +Do not use Markdown emphasis such as **bold**, *italics*, or similar styling. +Follow the conventional commit format (e.g., feat:, fix:, docs:, chore:, refactor:, test:, style:). +Don't include any other text in the response, just the commit message." + fi + + # Generate commit message using llm + # Pass prompt as argument, which may still be large but less likely to hit ARG_MAX + # than including the diff in the argument + local commit_msg + commit_msg=$(echo "$prompt_text" | llm --model "$model") + + # Strip markdown code fences if present + if [[ "$commit_msg" =~ ^\`\`\`.* ]] && [[ "$commit_msg" =~ \`\`\`$ ]]; then + commit_msg=$(echo "$commit_msg" | sed -e '1s/^```.*//' -e '$s/```$//' | sed '/^$/d') + fi + + echo "$commit_msg" +} diff --git a/git-ai-reword-commit-message b/git-ai-reword-commit-message index 3d005a8..5d5967d 100755 --- a/git-ai-reword-commit-message +++ b/git-ai-reword-commit-message @@ -1,6 +1,10 @@ #!/bin/bash set -euo pipefail +# Source shared library +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/git-ai-lib.sh" + # Check for llm installation if ! command -v llm >/dev/null 2>&1; then echo "Error: llm command not found. Install from https://llm.datasette.io/en/stable/setup.html" >&2 @@ -26,39 +30,6 @@ EOF exit 1 } -# Default model selection -select_default_model() { - local models - models=$(llm models) - - # Check if we got any models - if [ -z "$models" ]; then - echo "Error: No LLM models found. Please install at least one model." >&2 - exit 1 - fi - - # Define preferred models in order of preference - local preferred_models=( - "ai-commit-message" - "claude-3.5-sonnet" - "gemini-2.0-flash-latest" - "o3-mini" - "deepseek-coder" - "gpt-4o-mini" - ) - - # Try each preferred model in order - for model in "${preferred_models[@]}"; do - if echo "$models" | grep -q "$model"; then - echo "$model" - return 0 - fi - done - - # If no preferred model is found, use the first available model - echo "$models" | head -n 1 -} - # Default values model=$(select_default_model) dry_run=false @@ -121,70 +92,35 @@ fi echo -e "\n== Changes in commit $commit ==" git --no-pager show --name-only --format=fuller "$commit" -# Generate new commit message with progress indicator -# Check if the model is an alias and resolve it -model_display="$model" -if [[ "$model" == "ai-commit-message" ]]; then - resolved_model=$(llm aliases list 2>/dev/null | grep "^ai-commit-message" | sed 's/^ai-commit-message[[:space:]]*:[[:space:]]*//') - if [[ -n "$resolved_model" ]]; then - model_display="ai-commit-message: $resolved_model" +# Load additional instructions from config files +repo_root=$(git rev-parse --show-toplevel) +additional_instructions=$(load_additional_instructions "$repo_root") + +# Combine additional instructions with custom prompt +combined_prompt="$additional_instructions" +if [ -n "$custom_prompt" ]; then + if [ -n "$combined_prompt" ]; then + combined_prompt="$combined_prompt + +" fi + combined_prompt="$combined_prompt$custom_prompt" fi +# Generate new commit message with progress indicator +model_display=$(resolve_model_display "$model") + echo -e "\n== New Commit Message (via ${model_display}) ==" echo -n "Generating commit message... " >&2 # Get the original commit message original_msg=$(git log -1 --pretty=%B "$commit") -if [[ -n "$custom_prompt" ]]; then - prompt_text="Analyze these changes and modify the existing commit message according to the following instructions: - -The original commit message was: -\`\`\` -$original_msg -\`\`\` - -Changes: -\`\`\` -$(git show --patch "$commit") -\`\`\` - -Modification instructions: -\`\`\` -$custom_prompt -\`\`\` - -Format the response as a conventional commit message with a brief title line followed by a more detailed description if needed. -Don't include any other text in the response, just the commit message." -else - prompt_text="Analyze these changes and create a new commit message: - -\`\`\` -$(git show --patch "$commit") -\`\`\` - -The original commit message was: -\`\`\` -$original_msg -\`\`\` - -Format the response as a conventional commit message with a brief title line followed by a more detailed description if needed. -Don't include any other text in the response, just the commit message." -fi - -commit_msg=$(llm \ - --model "$model" \ - "$prompt_text" -) +# Generate the commit message by piping the diff through the message generator +commit_msg=$(git show --patch "$commit" | generate_commit_message "$model" "$combined_prompt" "$original_msg") printf "\r\033[K" >&2 # Clear progress message -# Strip markdown code fences if present -if [[ "$commit_msg" =~ ^\`\`\`.* ]] && [[ "$commit_msg" =~ \`\`\`$ ]]; then - commit_msg=$(echo "$commit_msg" | sed -e '1s/^```.*//' -e '$s/```$//' | sed '/^$/d') -fi - # Display the message echo "$commit_msg" diff --git a/git-ai-reword-message b/git-ai-reword-message index 1d83888..9ddbc3c 100755 --- a/git-ai-reword-message +++ b/git-ai-reword-message @@ -1,6 +1,10 @@ #!/bin/bash set -euo pipefail +# Source shared library +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/git-ai-lib.sh" + # Check for llm installation if ! command -v llm >/dev/null 2>&1; then echo "Error: llm command not found. Install from https://llm.datasette.io/en/stable/setup.html" >&2 @@ -26,39 +30,6 @@ EOF exit 1 } -# Default model selection -select_default_model() { - local models - models=$(llm models) - - # Check if we got any models - if [ -z "$models" ]; then - echo "Error: No LLM models found. Please install at least one model." >&2 - exit 1 - fi - - # Define preferred models in order of preference - local preferred_models=( - "ai-commit-message" - "claude-3.5-sonnet" - "gemini-2.0-flash-latest" - "o3-mini" - "deepseek-coder" - "gpt-4o-mini" - ) - - # Try each preferred model in order - for model in "${preferred_models[@]}"; do - if echo "$models" | grep -q "$model"; then - echo "$model" - return 0 - fi - done - - # If no preferred model is found, use the first available model - echo "$models" | head -n 1 -} - # Default values model=$(select_default_model) dry_run=false @@ -121,70 +92,35 @@ fi echo -e "\n== Changes in commit $commit ==" git --no-pager show --name-only --format=fuller "$commit" -# Generate new commit message with progress indicator -# Check if the model is an alias and resolve it -model_display="$model" -if [[ "$model" == "ai-commit-message" ]]; then - resolved_model=$(llm aliases list 2>/dev/null | grep "^ai-commit-message" | sed 's/^ai-commit-message[[:space:]]*:[[:space:]]*//') - if [[ -n "$resolved_model" ]]; then - model_display="ai-commit-message: $resolved_model" +# Load additional instructions from config files +repo_root=$(git rev-parse --show-toplevel) +additional_instructions=$(load_additional_instructions "$repo_root") + +# Combine additional instructions with custom prompt +combined_prompt="$additional_instructions" +if [ -n "$custom_prompt" ]; then + if [ -n "$combined_prompt" ]; then + combined_prompt="$combined_prompt + +" fi + combined_prompt="$combined_prompt$custom_prompt" fi +# Generate new commit message with progress indicator +model_display=$(resolve_model_display "$model") + echo -e "\n== New Commit Message (via ${model_display}) ==" echo -n "Generating commit message... " >&2 # Get the original commit message original_msg=$(git log -1 --pretty=%B "$commit") -if [[ -n "$custom_prompt" ]]; then - prompt_text="Analyze these changes and modify the existing commit message according to the following instructions: - -The original commit message was: -\`\`\` -$original_msg -\`\`\` - -Changes: -\`\`\` -$(git show --patch "$commit") -\`\`\` - -Modification instructions: -\`\`\` -$custom_prompt -\`\`\` - -Format the response as a conventional commit message with a brief title line followed by a more detailed description if needed. -Don't include any other text in the response, just the commit message." -else - prompt_text="Analyze these changes and create a new commit message: - -\`\`\` -$(git show --patch "$commit") -\`\`\` - -The original commit message was: -\`\`\` -$original_msg -\`\`\` - -Format the response as a conventional commit message with a brief title line followed by a more detailed description if needed. -Don't include any other text in the response, just the commit message." -fi - -commit_msg=$(llm \ - --model "$model" \ - "$prompt_text" -) +# Generate the commit message by piping the diff through the message generator +commit_msg=$(git show --patch "$commit" | generate_commit_message "$model" "$combined_prompt" "$original_msg") printf "\r\033[K" >&2 # Clear progress message -# Strip markdown code fences if present -if [[ "$commit_msg" =~ ^\`\`\`.* ]] && [[ "$commit_msg" =~ \`\`\`$ ]]; then - commit_msg=$(echo "$commit_msg" | sed -e '1s/^```.*//' -e '$s/```$//' | sed '/^$/d') -fi - # Display the message echo "$commit_msg" From 74780e220cccb735dbbaeacbe20d161d07598989 Mon Sep 17 00:00:00 2001 From: Test User Date: Fri, 24 Oct 2025 15:11:51 +0800 Subject: [PATCH 9/9] docs: Improve commit message formatting instructions - Standardize instructions for creating AI commit messages in `git-ai-lib.sh` to use plain text formatting rules. - Remove explicit markdown emphasis rules, relying on the general plain text formatting. - Add a `llm models list` command example to the README for convenience. - Update example model aliases in README to reflect current model names more accurately (e.g., `claude-haiku-4.5`, `gpt-4o`). - Clarify that commit messages are viewed as plain text and backticks should be used for code, filenames, and identifiers. - Provide clear examples of good and bad formatting for commit messages. --- README.md | 10 +++++++--- git-ai-lib.sh | 50 ++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 55 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index ec1854f..3131535 100644 --- a/README.md +++ b/README.md @@ -89,10 +89,14 @@ These AI-powered tools use the `llm` command-line tool to generate and modify co ```bash # Set a preferred model for commit messages -llm aliases set ai-commit-message "openrouter/google/gemini-2.5-flash-lite" +llm aliases set ai-commit-message gemini-2.5-flash-lite # Or use any other available model -llm aliases set ai-commit-message "claude-3.5-sonnet" -llm aliases set ai-commit-message "gpt-4" +llm aliases set ai-commit-message claude-haiku-4.5 +llm aliases set ai-commit-message claude-sonnet-4.5 +llm aliases set ai-commit-message gpt-4o + +# List available models to find model IDs +llm models list # Check your current aliases llm aliases diff --git a/git-ai-lib.sh b/git-ai-lib.sh index 4cfa70a..840e3d5 100644 --- a/git-ai-lib.sh +++ b/git-ai-lib.sh @@ -149,7 +149,30 @@ $custom_prompt Format the response as a conventional commit message with a brief title line followed by a more detailed description if needed. Do not include a summary paragraph after any list of changes. -Do not use Markdown emphasis such as **bold**, *italics*, or similar styling. + +FORMATTING RULES: +- Commit messages are viewed as plain text, not rendered markdown +- Use backticks for \`code\`, \`filenames\`, and \`identifiers\` +- Do NOT use **bold** or *italic* markdown +- Write bullet lists as plain text with simple dashes (-) +- Keep formatting minimal and readable as plain text + +GOOD EXAMPLE: +feat: Add gradient compression pipeline + +- Implement bucket-based quantization codec +- Add compression ratio calculation in \`metrics.py\` +- Support 8-bit and 16-bit quantization modes +- Update documentation with usage examples + +AVOID (too much markdown): +feat: Add gradient compression pipeline + +- **Implement** bucket-based quantization codec +- Add compression ratio calculation in **metrics.py** +- Support **8-bit** and **16-bit** quantization modes +- Update **documentation** with usage examples + Don't include any other text in the response, just the commit message." else prompt_text="Analyze these changes and create a conventional commit message: @@ -171,8 +194,31 @@ $current_desc Format the response as a conventional commit message with a brief title line followed by a more detailed description if needed. Do not include a summary paragraph after any list of changes. -Do not use Markdown emphasis such as **bold**, *italics*, or similar styling. Follow the conventional commit format (e.g., feat:, fix:, docs:, chore:, refactor:, test:, style:). + +FORMATTING RULES: +- Commit messages are viewed as plain text, not rendered markdown +- Use backticks for \`code\`, \`filenames\`, and \`identifiers\` +- Do NOT use **bold** or *italic* markdown +- Write bullet lists as plain text with simple dashes (-) +- Keep formatting minimal and readable as plain text + +GOOD EXAMPLE: +feat: Add gradient compression pipeline + +- Implement bucket-based quantization codec +- Add compression ratio calculation in \`metrics.py\` +- Support 8-bit and 16-bit quantization modes +- Update documentation with usage examples + +AVOID (too much markdown): +feat: Add gradient compression pipeline + +- **Implement** bucket-based quantization codec +- Add compression ratio calculation in **metrics.py** +- Support **8-bit** and **16-bit** quantization modes +- Update **documentation** with usage examples + Don't include any other text in the response, just the commit message." fi