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 index b9cbc35..3131535 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,67 @@ -# README +# Git Scripts -- [Version Control \& Git Tools](#version-control--git-tools) - - [Repository Management](#repository-management) - - [`git-stage-fixups`](#git-stage-fixups) - - [`git-apply-fixups`](#git-apply-fixups) - - [AI-Assisted Git Tools](#ai-assisted-git-tools) - - [`git-ai-commit`](#git-ai-commit) - - [`git-ai-rewrite-commit`](#git-ai-rewrite-commit) - - [`git-squash-commit-messages`](#git-squash-commit-messages) +A collection of utility scripts for Git repositories, including AI-powered commit message generation and repository management tools. -This repository contains various utility scripts, primarily in Bash and Python, to assist with git. +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. -## Version Control & Git Tools +For Jujutsu (jj) version control scripts, see: https://github.com/osteele/jj-scripts -### Repository Management +For more development tools, see: https://osteele.com/software/development-tools -#### `git-stage-fixups` +## 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-stage-fixups [-n|--dry-run] +git-create-fixups [-n|--dry-run] ``` -Options: -- `-n, --dry-run`: Show what would be done without making changes - -#### `git-apply-fixups` -Automatically reorders and optionally squashes fixup commits created by `git-stage-fixups`. This provides an automated alternative to manually reordering commits in an interactive rebase. +### `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-stage-fixups` to create separate commits for each file +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**: @@ -53,42 +83,154 @@ git-apply-fixups -n 10 - `--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 +## AI-Assisted Git Tools -#### `git-ai-commit` -Generates commit messages based on changes using AI assistance. Designed to streamline commit message creation and ensure consistent descriptions. +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 -git-ai-commit # Analyzes current changes and suggests commit message +# Set a preferred model for commit messages +llm aliases set ai-commit-message gemini-2.5-flash-lite +# Or use any other available model +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 + +# Remove the alias to use the default model selection +llm aliases remove ai-commit-message ``` -#### `git-ai-rewrite-commit` +#### 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. + +```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-rewrite-commit +git-ai-reword-message # Rewrite message for a specific commit -git-ai-rewrite-commit +git-ai-reword-message # Preview the new message without applying it -git-ai-rewrite-commit -n +git-ai-reword-message -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-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-squash-commit-messages` +### `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-squash-commit-messages HEAD~3..HEAD +git-ai-squash-commit-messages HEAD~3..HEAD # Combine messages between specific commits -git-squash-commit-messages abc123..def456 +git-ai-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. + +### `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/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." 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 index f29db95..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 @@ -12,43 +16,36 @@ usage() { 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, ... - -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, 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) - 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 - fi -} - # 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 +54,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 +71,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 +88,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 @@ -93,27 +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 -echo -e "\n== Commit Message (via ${model}) ==" +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; 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" @@ -122,7 +141,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[*]}" @@ -132,6 +151,10 @@ if [ "$dry_run" = true ]; then 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!" + 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-lib.sh b/git-ai-lib.sh new file mode 100644 index 0000000..840e3d5 --- /dev/null +++ b/git-ai-lib.sh @@ -0,0 +1,237 @@ +#!/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. + +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: + +\`\`\` +$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. +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 + + # 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-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..5d5967d --- /dev/null +++ b/git-ai-reword-commit-message @@ -0,0 +1,186 @@ +#!/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 + 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 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" + +# 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") + +# 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 + +# 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..9ddbc3c --- /dev/null +++ b/git-ai-reword-message @@ -0,0 +1,173 @@ +#!/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 + 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 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" + +# 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") + +# 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 + +# 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-rewrite-commit b/git-ai-rewrite-commit index 36119f0..e890969 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 } @@ -29,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 @@ -48,6 +63,7 @@ model=$(select_default_model) dry_run=false force=false commit="HEAD" +custom_prompt="" # Parse arguments while [[ $# -gt 0 ]]; do @@ -69,6 +85,10 @@ while [[ $# -gt 0 ]]; do llm models exit 0 ;; + -p|--prompt) + custom_prompt="$2" + shift 2 + ;; -h|--help) usage ;; @@ -107,9 +127,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 +160,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 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 index 728c7fa..1d9daa8 100755 --- a/git-apply-fixups +++ b/git-apply-fixups @@ -8,20 +8,21 @@ # ] # /// +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 from rich.console import Console from rich.theme import Theme +import git + custom_theme = Theme({ "fixup": "yellow", "commit": "green", @@ -49,12 +50,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 +68,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 +92,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 +133,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 +150,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 + 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 @@ -186,9 +182,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 +192,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-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-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) 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/^/- /' 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!