diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..d163711 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +version: 2 +updates: + - package-ecosystem: gomod + directory: / + schedule: + interval: weekly + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml new file mode 100644 index 0000000..1567a4a --- /dev/null +++ b/.github/workflows/go.yml @@ -0,0 +1,69 @@ +name: Go + +on: + workflow_dispatch: + push: + branches: [master, main] + tags: [v*] + pull_request: + branches: [master, main] + +permissions: + contents: read + +jobs: + ci: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Lint + uses: golangci/golangci-lint-action@v7 + with: + version: v2.12.1 + + - name: Vulnerability scan + run: go install golang.org/x/vuln/cmd/govulncheck@latest && govulncheck ./... + + - name: Test + run: go test ./... -count=1 -coverprofile=coverage.out + + - name: Test (integration) + run: go test -tags=integration ./... -count=1 + + - name: Upload coverage + if: success() + uses: codecov/codecov-action@v5 + with: + file: coverage.out + fail_ci_if_error: false + + release: + if: startsWith(github.ref, 'refs/tags/v') + needs: ci + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Build cross-platform binaries + run: make cross + + - name: Create release + uses: softprops/action-gh-release@v2 + with: + files: | + git-meh-linux-x86_64 + git-meh-linux-arm64 + git-meh-macos-x86_64 + git-meh-macos-arm64 + generate_release_notes: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2b974fb --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# Built binary (repo root) +git-meh +gitmeh +git-meh.exe +gitmeh.exe +git-meh-* + +# Go +*.exe +*.test +*.out +coverage.* +profile.cov + +# OS +.DS_Store +Thumbs.db + +# Editor / IDE +.idea/ +*.swp +*.swo +*~ + +# Local env / secrets +.env +.env.* +!.env.example diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..91d8bf0 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,16 @@ +version: "2" +linters: + enable: + - staticcheck + - gosec + - govet + - errcheck + - ineffassign + - unused + - misspell + exclusions: + presets: [] +run: + timeout: 5m + tests: true + diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7f2bae5 --- /dev/null +++ b/Makefile @@ -0,0 +1,24 @@ +.PHONY: build test lint clean cross all + +build: + go build -o git-meh . + ln -sf git-meh gitmeh + +test: + go test ./... -count=1 + +lint: + go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.1 && golangci-lint run ./... + govulncheck ./... + +clean: + rm -f git-meh gitmeh git-meh.exe gitmeh.exe git-meh-linux-* git-meh-macos-* + +cross: clean + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o git-meh-linux-x86_64 . + CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o git-meh-linux-arm64 . + CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -o git-meh-macos-x86_64 . + CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -o git-meh-macos-arm64 . + go build -o git-meh . + +all: lint test cross diff --git a/README.md b/README.md index 4d8f0ab..d28833e 100644 --- a/README.md +++ b/README.md @@ -2,60 +2,105 @@ **AI-powered git commits for the terminally lazy.** -**gitmeh** is a high-speed shortcut for your personal garbage repositories. It is designed specifically for those projects where quality does not matter and the only thing you care about is closing the laptop as fast as humanly possible. +Stages everything (`git add --all`), AI-guesses a commit message, then shovels it to the cloud. Designed for people who can't be bothered writing their own commit messages. -> **⚠️ WARNING:** Using this on a professional team project is a great way to get a stern talking-to from your engineering manager. This tool is reckless, indifferent, and definitely not "enterprise-ready." - -![gitmeh in action](images/screenshot.avif) +> **⚠️** Probably fine for personal projects. Tread carefully on shared repos — know what you're staging before you let AI push it. ### Why use this? -Because writing thoughtful commit messages for your 14th unfinished side project is a waste of your precious nap time. +Because writing commit messages takes effort and you've got better things to do. + +* **Automated Staging:** Runs `git add --all` so you don't have to think about what changed. +* **AI Guesswork:** Generates a commit message via an OpenAI-compatible chat API, with retry logic and configurable fallback models. +* **Automatic Pushing:** Commits and pushes in one step. + +### Default API service -* **Nuclear Staging:** It runs `git add --all` without asking. It stages your unfinished thoughts, your secrets, and that one large `test.mp4` you forgot was there. -* **AI Guesswork:** By default it flings your staged diff at a free hosted API so you do not have to pretend you will ever sign up for anything. It still explains what you did because you have already forgotten. If you are picky, wave an OpenRouter key around and make it beg a model of your choosing instead. -* **Automatic Pushing:** Shovels your changes directly to the cloud so you can stop looking at the terminal. -* **Built-in Judgement:** Features 40+ randomized status messages that mock your lack of professional standards. +If you don't set `GITMEH_API_KEY`, gitmeh uses a **free hosted API** at `https://ai.hellyer.test/`, run by the author (Ryan Hellyer). The backend automatically selects whichever AI model is working best and cheapest at the time, so models will vary between requests without warning. -### Quick Start +**Your staged diff (code) is sent to this server** and then forwarded to whichever model the backend picks. If you are not comfortable sharing your code with me (Ryan) or with the random third-party model I route it through, **do not use the default service**. Set `GITMEH_API_BASE`, `GITMEH_API_KEY`, `GITMEH_MODEL` etc. to use your own AI provider instead. -1. **Default (no API key):** Do nothing. The script shovels your **staged diff** at `https://ai.hellyer.kiwi/gitmeh` as `text/plain` and whatever text comes back is your commit message. The free tier is **limited to 1000 requests per day per IP address**, so if you and your twelve roommates all commit-spam at once, you will hit the wall together. Pace yourselves. -2. **Optional — OpenRouter:** If you insist on owning the relationship, **get an OpenRouter API key** from [OpenRouter](https://openrouter.ai/keys) and **dump it in your shell config** (`~/.bashrc` or `~/.zshrc`): - ```bash - export OPENROUTER_API_KEY='your_key_here' - ``` - With that set, gitmeh bothers OpenRouter instead of the default URL. Optional: `OPENROUTER_MODEL` (default: `google/gemma-3-4b-it`). See [openrouter.ai/models](https://openrouter.ai/models). - Optional: `GITMEH_PROMPT` to customize the instruction sent to the AI (the diff is always appended; OpenRouter only — the free endpoint does not care about your feelings). - Optional: `GITMEH_DEFAULT_URL` if you want a different keyless endpoint (full URL; default: `https://ai.hellyer.kiwi/gitmeh`). -3. **Install the thing globally** so you can run it from anywhere without that annoying `.sh` extension: +I have zero interest in your code and no intention of looking at it, but it will be processed through my server and the model provider's servers. + +## Quick Start -macOS / Linux: ```bash -bash install.sh +# 1. Install +make build && cp git-meh ~/.local/bin/ # from the repo root (requires Go) +# Or: ./install.sh # uses a prebuilt binary + +# 2. Set up an API key (OpenCode Zen recommended) +export GITMEH_API_BASE='https://opencode.ai/zen/v1' +export GITMEH_API_KEY='your_zen_key' + +# 3. Run +git meh ``` -Windows (Git Bash - _totally untested as I don't use Windows_): +Git discovers the binary as a subcommand — works in any repository. + +## Configuration + +| Env var | Description | Default | +|---|---|---| +| `GITMEH_API_BASE` | API base URL | `https://ai.hellyer.test/v1` (built-in) | +| `GITMEH_API_KEY` | API key | built-in public key | +| `GITMEH_MODEL` | Model name | `gitmeh-hosted` or `google/gemma-3-4b-it` | +| `GITMEH_PROMPT` | System prompt for the model | Conventional Commits prompt | +| `GITMEH_FALLBACK_MODELS` | Comma-separated models to try if the primary fails | — | +| `GITMEH_MAX_DIFF_BYTES` | Per-file diff truncation limit (0 = no limit) | `10000` (10 KB) | + +**Auth priority**: `GITMEH_API_KEY` > built-in public key. + +**Fallback models**: If the primary model fails (timeout, 5xx, context-length exceeded), gitmeh retries up to 3 times with exponential backoff, then tries each fallback model in order. A 401 or other client error skips retries immediately. + +**Diff truncation**: When the staged diff exceeds `GITMEH_MAX_DIFF_BYTES`, gitmeh keeps all file headers and proportionally trims hunk content per file. Truncated sections are marked with `# hunk truncated`. + +## Developer Guide + +### Prerequisites + +- Go (see `go.mod` for version) +- `golangci-lint` and `govulncheck` for linting (install via `go install`) + +### Commands + ```bash -mkdir -p ~/bin -cp gitmeh.sh ~/bin/gitmeh -# Ensure ~/bin is in your PATH +make build # build native binary +make test # run unit tests +make lint # run golangci-lint + govulncheck +make cross # cross-compile for Linux/macOS, amd64/arm64 +make clean # remove built binaries +make all # lint + test + cross-compile + +go test -tags=integration ./... -count=1 # integration tests (require git) +``` + +### Project structure + +``` +main.go — entry point, CLI orchestration, user review prompt +internal/ + aiapi/ — AI API communication (chat, HTTP client, spinner) + config/ — env var parsing + git/ — git command wrappers (add, diff, commit, push) + version/ — version string ``` -### Requirements +### Architecture notes -* `git`: duh! -* `curl`: to send the SOS signal (default API or OpenRouter). -* `jq`: to handle the robot's feelings — **only if** you are on the OpenRouter path. The keyless mode does not need it, because apparently plain text is easier than JSON. +- The API call wraps a spinner goroutine for terminal feedback. Ctrl+C cancels the HTTP context, which immediately aborts the request and cleans up the terminal. +- Model retries use exponential backoff (1s, 2s, 4s). Context-length errors and non-retryable status codes skip retries and advance to the next fallback model. +- Diff truncation splits the unified diff at `diff --git` boundaries, preserves all file headers, and allocates the remaining byte budget proportionally by hunk size. -### Changelog +## Changelog -* `2.1.0`: Default to the free hosted plain-text API so you can avoid another signup; OpenRouter when you set `OPENROUTER_API_KEY`; whine about the 1000 requests/day/IP limit on the free tier -* `2.0.2`: Fixing default model documentation -* `2.0.1`: Set default model to Google Gemma 3 4B as it is free -* `2.0`: Conversion to use OpenRouter API and implementing ability to change model used and prompt -* `1.0`: Initial implementation using Google Gemini +- **3.x:** Retry and fallback models, graceful Ctrl+C, diff truncation, CI linting/security scanning, Dependabot, Makefile, support for OpenAI compatible APIs +- **3.0:** Rewrite in Go; run via `git meh` +- **2.x:** OpenRouter and plain-text API versions +- **1.0:** Initial Google Gemini implementation -### Author +## Author **Ryan Hellyer** [ryan.hellyer.kiwi](https://ryan.hellyer.kiwi) | [GitHub Repo](https://github.com/ryanhellyer/gitmeh) diff --git a/fatal.go b/fatal.go new file mode 100644 index 0000000..4dafa8a --- /dev/null +++ b/fatal.go @@ -0,0 +1,16 @@ +package main + +import ( + "fmt" + "os" +) + +func fatalErr(err error) { + fmt.Println(err) + os.Exit(1) +} + +func fatalMsg(msg string) { + fmt.Println(msg) + os.Exit(1) +} diff --git a/gitmeh.sh b/gitmeh.sh deleted file mode 100755 index 5ce7ad5..0000000 --- a/gitmeh.sh +++ /dev/null @@ -1,198 +0,0 @@ -#!/usr/bin/env bash - -# gitmeh: AI-powered git commits for the terminally lazy. -# Author: Ryan Hellyer -# Website: https://ryan.hellyer.kiwi -# GitHub: https://github.com/ryanhellyer/gitmeh - -# Configuration -# Default: POST plain-text diff to the free hosted API (no key). Override with OpenRouter by setting OPENROUTER_API_KEY. -GITMEH_DEFAULT_URL="${GITMEH_DEFAULT_URL:-https://ai.hellyer.kiwi/gitmeh}" -API_KEY="${OPENROUTER_API_KEY:-$GEMINI_API_KEY}" -MODEL="${OPENROUTER_MODEL:-google/gemma-3-4b-it}" -BRANCH=$(git rev-parse --abbrev-ref HEAD) -MAX_TOTAL_CHARS=10000 -CHARS_PER_FILE=800 - -# Colors -GREEN='\033[0;32m' -CYAN='\033[0;36m' -YELLOW='\033[1;33m' -NC='\033[0m' - -# --- The "Lazy" Phrase Arrays --- - -INTRO_PHRASES=( - "gitmeh: For when your career is a series of shortcuts." - "gitmeh: Lowering the bar for commit history since today." - "gitmeh: Because typing is the enemy." - "gitmeh: The 'I am just here for the paycheck' utility." - "gitmeh: For the developer who has truly given up." - "gitmeh: Automating your lack of interest." - "gitmeh: Because 'fixed stuff' isn't a professional commit message." - "gitmeh: Helping you pretend you worked hard today." - "gitmeh: Your personal ghostwriter for mediocrity." - "gitmeh: The 'close the laptop and walk away' button." -) - -STAGING_PHRASES=( - "Staging everything because you're too lazy to pick..." - "Tossing everything into the stage like a laundry pile..." - "Adding everything because nuance is for people with energy..." - "Staging your messy room of code... don't look too closely." - "Grabbing everything. Hope there's no secrets in there. (There probably are.)" - "Nuclear staging initiated. RIP clean history." - "Shoveling your changes into the commit bucket..." - "Staging everything. It is not like you were going to review it anyway." - "Blindly adding files because life is too short for git add -p." - "Bulk staging. Let God (or the AI) sort them out." -) - -THINKING_PHRASES=( - "Consulting the robot because thinking is hard..." - "Asking the AI to lie about how much work you did..." - "Delegating your cognitive load to a server in Oregon..." - "Letting the algorithm pretend you are a professional..." - "Begging the AI to explain your own code back to you..." - "Outsourcing your last two brain cells to the cloud..." - "Waiting for the robot to find a nice way to say 'you broke it'..." - "Requesting a miracle from the API..." - "Pinging the mothership for a crumb of inspiration..." - "Asking the AI to cover for you. Again." -) - -SUCCESS_PHRASES=( - "It's pushed. Go outside." - "The deed is done. Go be useless elsewhere." - "Done. Don't check the logs. Just walk away." - "Success. Your secret is safe with the AI." - "It's in the cloud now. Not your problem anymore." - "Mission accomplished. Nap time." - "Pushed. Let the CI/CD pipeline deal with your mess." - "And... stay out. See you tomorrow (maybe)." - "Finished. That's enough 'work' for one day." - "The code is gone. Fly, little bird, fly." -) - -# --- Helper to pick a random phrase --- -get_random() { - local arr=("$@") - echo "${arr[$(( RANDOM % ${#arr[@]} ))]}" -} - -# --- Script Logic --- - -# Help instructions -if [[ "$1" == "--help" ]] || [[ "$1" == "-h" ]]; then - echo -e "${CYAN}$(get_random "${INTRO_PHRASES[@]}")${NC}" - echo "Usage: gitmeh" - echo "" - echo "By default this uses the free hosted API (no key). Optional: use your own OpenRouter account:" - echo " export OPENROUTER_API_KEY='your_key_here'" - echo "" - echo "Optional (OpenRouter): OPENROUTER_MODEL (default: google/gemma-3-4b-it). See https://openrouter.ai/models" - echo "Optional (OpenRouter): GITMEH_PROMPT to customize the instruction (the diff is always appended)." - echo "Optional: GITMEH_DEFAULT_URL for a different keyless endpoint URL (default: https://ai.hellyer.kiwi/gitmeh)" - echo "" - echo "Author: Ryan Hellyer (https://ryan.hellyer.kiwi)" - exit 0 -fi - -# jq is only required when using OpenRouter (JSON responses) -if [ -n "$API_KEY" ] && ! command -v jq &> /dev/null; then - echo "Error: 'jq' is missing. Install it, or unset OPENROUTER_API_KEY to use the keyless default API." - exit 1 -fi - -# Add changes -git add --all -echo -e "${CYAN}$(get_random "${STAGING_PHRASES[@]}")${NC}" -git status --short - -FILES=$(git diff --cached --name-only) - -if [ -z "$FILES" ]; then - echo "No changes. Go back to sleep." - exit 0 -fi - -echo -e "\n$(get_random "${THINKING_PHRASES[@]}")" - -if [ -n "$API_KEY" ]; then - # OpenRouter: chunked diff + JSON API - SMART_DIFF="" - for FILE in $FILES; do - if [ ${#SMART_DIFF} -gt $MAX_TOTAL_CHARS ]; then - SMART_DIFF+=$'\n' "... [Truncated because I'm bored] ..." - break - fi - FILE_DIFF=$(git diff --cached -- "$FILE" | head -c $CHARS_PER_FILE) - SMART_DIFF+=$'\n'"--- File: $FILE ---"$'\n'"$FILE_DIFF"$'\n' - done - - GITMEH_PROMPT_DEFAULT="Write a short, professional git commit message for these changes. Use imperative mood. Only return the message text:" - PROMPT="${GITMEH_PROMPT:-$GITMEH_PROMPT_DEFAULT} $SMART_DIFF" - JSON_PAYLOAD=$(jq -n --arg msg "$PROMPT" --arg model "$MODEL" '{model: $model, messages: [{role: "user", content: $msg}]}') - - RESPONSE=$(curl -sS -X POST "https://openrouter.ai/api/v1/chat/completions" \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer $API_KEY" \ - -d "$JSON_PAYLOAD") - - API_ERR=$(echo "$RESPONSE" | jq -r '.error.message // .error // empty' 2>/dev/null) - if [ -n "$API_ERR" ]; then - echo -e "${YELLOW}OpenRouter error: ${API_ERR}${NC}" - exit 1 - fi - - COMMIT_MSG=$(echo "$RESPONSE" | jq -r '.choices[0].message.content // empty' 2>/dev/null | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | head -1) -else - # Default: plain-text cached diff to hosted API (prompt is server-side) - DIFF_BODY=$(git diff --cached) - if [ ${#DIFF_BODY} -gt $MAX_TOTAL_CHARS ]; then - DIFF_BODY="${DIFF_BODY:0:$MAX_TOTAL_CHARS}"$'\n'"... [Truncated because I'm bored] ..." - fi - - if ! RESPONSE=$(curl -sS -f -X POST "$GITMEH_DEFAULT_URL" \ - -H "Content-Type: text/plain" \ - --data-binary "$DIFF_BODY"); then - echo -e "${YELLOW}Request to default API failed (network or HTTP error). Set OPENROUTER_API_KEY to use OpenRouter instead.${NC}" - exit 1 - fi - - COMMIT_MSG=$(printf '%s' "$RESPONSE" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | head -1) -fi - -if [ -z "$COMMIT_MSG" ] || [ "$COMMIT_MSG" == "null" ]; then - echo -e "${YELLOW}The AI failed. Probably went on a coffee break.${NC}" - echo "Response snippet: $(echo "$RESPONSE" | head -c 500)" - exit 1 -fi - -# Confirmation -echo -e "------------------------------------------------" -echo -e "Proposed: ${GREEN}${COMMIT_MSG}${NC}" -echo -e "------------------------------------------------" -read -p "Commit and push? [Y/n/e]: " USER_INPUT -USER_INPUT=${USER_INPUT:-y} - -case "$USER_INPUT" in - [yY][eE][sS]|[yY]) - git commit -m "$COMMIT_MSG" - if git push origin "$BRANCH"; then - echo -e "${CYAN}$(get_random "${SUCCESS_PHRASES[@]}")${NC}" - else - echo -e "${YELLOW}Push failed. You actually have to do some work now (git pull).${NC}" - fi - ;; - [eE][dD][iI][tT]|[eE]) - read -p "Fine, fix it yourself: " MANUAL_MSG - git commit -m "$MANUAL_MSG" - git push origin "$BRANCH" - echo -e "${CYAN}Manually fixed and pushed. Look at you go.${NC}" - ;; - *) - echo "Aborted. Coward." - exit 1 - ;; -esac \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100755 index 0000000..f2814b5 --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +module gitmeh + +go 1.25.9 + +require golang.org/x/term v0.29.0 + +require golang.org/x/sys v0.30.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..234e8b5 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU= +golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= diff --git a/help.txt b/help.txt new file mode 100644 index 0000000..79f9651 --- /dev/null +++ b/help.txt @@ -0,0 +1,36 @@ +gitmeh — stage, suggest a commit message, then commit and push + +USAGE + gitmeh [-h] [-v] + +DESCRIPTION + Stages all changes (git add --all), sends the staged diff to a remote service + to propose a commit message, then lets you accept, reject, or edit that + message before running git commit and git push origin HEAD (current branch). + +OPTIONS + -h Show this help and exit. + -v Print the program name and version, then exit. + +ENVIRONMENT + OpenAI-compatible chat (default) + POST JSON to /v1/chat/completions. With no API key env vars, uses the + compiled-in hosted base URL, bearer token, and model (see internal/config). + + GITMEH_API_BASE Optional API root, no trailing slash (overrides the + default hosted base when no user API key is set). + GITMEH_MODEL Optional model id. OPENROUTER_MODEL is used if + GITMEH_MODEL is unset (overrides default hosted model + when no user API key is set). + GITMEH_PROMPT Optional system instructions; unified diff is always sent + in a separate user message after your text. + + User API key (OpenAI-compatible host, e.g. OpenRouter) + GITMEH_API_KEY Bearer token for /v1/chat/completions. + GITMEH_API_BASE Optional API root (default: https://openrouter.ai/api/v1). + GITMEH_MODEL Optional model id (default with user key: google/gemma-3-4b-it). + OPENROUTER_API_KEY Legacy alias for GITMEH_API_KEY (from earlier versions). + OPENROUTER_MODEL Legacy alias for GITMEH_MODEL (from earlier versions). + +SEE ALSO + git-add(1), git-commit(1), git-push(1) diff --git a/images/screenshot.avif b/images/screenshot.avif deleted file mode 100644 index 293156b..0000000 Binary files a/images/screenshot.avif and /dev/null differ diff --git a/install.sh b/install.sh old mode 100644 new mode 100755 index e998b18..c181899 --- a/install.sh +++ b/install.sh @@ -1,3 +1,143 @@ -mkdir -p ~/.local/bin -chmod +x gitmeh.sh -mv gitmeh.sh ~/.local/bin/gitmeh +#!/usr/bin/env bash +# Install git-meh from the latest GitHub release into ~/.local/bin. +set -euo pipefail + +REPO="ryanhellyer/gitmeh" +dest_dir="${HOME}/.local/bin" +dest="${dest_dir}/git-meh" +marker='# git-meh PATH (added by install.sh)' +path_line='export PATH="${HOME}/.local/bin:${PATH}"' + +select_artifact_name() { + local os arch + os=$(uname -s) + arch=$(uname -m) + case "${os}:${arch}" in + Linux:x86_64) echo "git-meh-linux-x86_64" ;; + Linux:aarch64|Linux:arm64) echo "git-meh-linux-arm64" ;; + Darwin:x86_64) echo "git-meh-macos-x86_64" ;; + Darwin:arm64) echo "git-meh-macos-arm64" ;; + *) + echo "error: unsupported system (${os} ${arch})." >&2 + echo " Supported: Linux x86_64 / arm64, macOS x86_64 / arm64." >&2 + exit 1 + ;; + esac +} + +download() { + local url=$1 dst=$2 + if command -v curl >/dev/null 2>&1; then + curl -fsSL -o "${dst}" "${url}" + elif command -v wget >/dev/null 2>&1; then + wget -qO "${dst}" "${url}" + else + echo "error: need curl or wget to download the binary." >&2 + exit 1 + fi +} + +verify_binary_kind() { + local src=$1 artifact=$2 + if ! command -v file >/dev/null 2>&1; then + return 0 + fi + local desc + desc=$(file -b "${src}" 2>/dev/null || true) + if [[ -z "${desc}" ]]; then + return 0 + fi + case "${artifact}" in + git-meh-linux-x86_64) + echo "${desc}" | grep -qi 'ELF' || { echo "error: ${artifact} should be an ELF binary; file(1) says: ${desc}" >&2; exit 1; } + echo "${desc}" | grep -qiE 'x86-64|x86_64' || { echo "error: ${artifact} should be x86-64; file(1) says: ${desc}" >&2; exit 1; } + ;; + git-meh-linux-arm64) + echo "${desc}" | grep -qi 'ELF' || { echo "error: ${artifact} should be an ELF binary; file(1) says: ${desc}" >&2; exit 1; } + echo "${desc}" | grep -qiE 'aarch64|ARM aarch64|ARM, EABI64' || { echo "error: ${artifact} should be ARM aarch64; file(1) says: ${desc}" >&2; exit 1; } + ;; + git-meh-macos-x86_64) + echo "${desc}" | grep -qi 'Mach-O' || { echo "error: ${artifact} should be a Mach-O binary; file(1) says: ${desc}" >&2; exit 1; } + echo "${desc}" | grep -qi 'x86_64' || { echo "error: ${artifact} should be x86_64; file(1) says: ${desc}" >&2; exit 1; } + ;; + git-meh-macos-arm64) + echo "${desc}" | grep -qi 'Mach-O' || { echo "error: ${artifact} should be a Mach-O binary; file(1) says: ${desc}" >&2; exit 1; } + echo "${desc}" | grep -qi 'arm64' || { echo "error: ${artifact} should be arm64; file(1) says: ${desc}" >&2; exit 1; } + ;; + esac +} + +artifact=$(select_artifact_name) +tmp=$(mktemp) +trap 'rm -f "${tmp}"' EXIT + +url="https://github.com/${REPO}/releases/latest/download/${artifact}" +echo "Downloading ${artifact} from GitHub releases ..." +download "${url}" "${tmp}" + +verify_binary_kind "${tmp}" "${artifact}" + +mkdir -p "${dest_dir}" +install -m 0755 "${tmp}" "${dest}" +ln -sf git-meh "${dest_dir}/gitmeh" + +echo "Installed: ${dest}" +echo "Symlink: ${dest_dir}/gitmeh -> git-meh" + +path_has_local_bin() { + case ":${PATH}:" in + *:"${HOME}/.local/bin":*) return 0 ;; + *) return 1 ;; + esac +} + +already_marked() { + local f + for f in "${HOME}/.zshrc" "${HOME}/.bashrc" "${HOME}/.bash_profile" "${HOME}/.profile"; do + [[ -f "${f}" ]] || continue + if grep -qF "${marker}" "${f}" 2>/dev/null; then + echo "${f}" + return 0 + fi + done + return 1 +} + +choose_rc() { + local shell_base + shell_base=$(basename "${SHELL:-bash}" 2>/dev/null || echo bash) + if [[ "${shell_base}" == zsh ]]; then + echo "${HOME}/.zshrc" + return + fi + if [[ "${shell_base}" == bash ]]; then + if [[ "$(uname -s)" == Darwin ]] && [[ -f "${HOME}/.bash_profile" ]]; then + echo "${HOME}/.bash_profile" + return + fi + echo "${HOME}/.bashrc" + return + fi + echo "${HOME}/.profile" +} + +hash -r 2>/dev/null || true + +if command -v git-meh >/dev/null 2>&1; then + echo "On PATH as: $(command -v git-meh)" +elif path_has_local_bin; then + echo "Open a new terminal (or run: hash -r) so your shell picks up git-meh." +else + existing="" + if existing=$(already_marked); then + echo "Run: source ${existing}" + echo "Then: git meh (or just: gitmeh)" + else + rc=$(choose_rc) + touch "${rc}" + printf '\n%s\n%s\n' "${marker}" "${path_line}" >>"${rc}" + echo "Added ~/.local/bin to PATH in ${rc}" + echo "Run: source ${rc}" + echo "Then: git meh (or just: gitmeh)" + fi +fi diff --git a/internal/aiapi/chat.go b/internal/aiapi/chat.go new file mode 100644 index 0000000..383c9f3 --- /dev/null +++ b/internal/aiapi/chat.go @@ -0,0 +1,262 @@ +package aiapi + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" +) + +// maxRetriesPerModel is the number of attempts per model for transient errors. +const maxRetriesPerModel = 3 + +// OpenAIChatParams configures a call to an OpenAI-compatible chat completions API. +type OpenAIChatParams struct { + BaseURL string // e.g. https://openrouter.ai/api/v1 (no trailing slash) + APIKey string + Model string + SystemPrompt string // instructions; diff is sent in a separate user message + FallbackModels []string // models to try if primary fails with retryable error +} + +type chatMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type chatRequest struct { + Model string `json:"model"` + Messages []chatMessage `json:"messages"` + Temperature float64 `json:"temperature"` + MaxTokens int `json:"max_tokens"` +} + +type chatResponse struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` +} + +type apiErrorBody struct { + Error *struct { + Message string `json:"message"` + } `json:"error"` +} + +// CommitMessageOpenAIChat POSTs to {BaseURL}/chat/completions with the unified diff +// in the user message and SystemPrompt as the system message. Returns assistant +// text trimmed of outer whitespace. +// +// If FallbackModels are provided and the primary model fails with a retryable +// error (network errors, 5xx, 429, context-length exceeded), fallback models +// are tried in order. Each model is retried up to [maxRetriesPerModel] times +// with exponential backoff for transient errors before moving to the next +// fallback. +func CommitMessageOpenAIChat(ctx context.Context, client *http.Client, p OpenAIChatParams, diff string) (string, error) { + if client == nil { + return "", fmt.Errorf("http client is nil") + } + key := strings.TrimSpace(p.APIKey) + if key == "" { + return "", fmt.Errorf("api key is empty") + } + base := strings.TrimRight(strings.TrimSpace(p.BaseURL), "/") + if base == "" { + return "", fmt.Errorf("api base url is empty") + } + model := strings.TrimSpace(p.Model) + if model == "" { + return "", fmt.Errorf("model is empty") + } + sys := strings.TrimSpace(p.SystemPrompt) + if sys == "" { + return "", fmt.Errorf("system prompt is empty") + } + + models := buildModelList(model, p.FallbackModels) + + return withGeneratingCommitSpinner(func() (string, error) { + var lastErr error + for i, m := range models { + result, err := tryModelWithRetry(ctx, client, base, key, m, sys, diff) + if err == nil { + return result, nil + } + lastErr = err + if i < len(models)-1 { + fmt.Fprintf(os.Stderr, "\n → trying fallback model %q ...\n", models[i+1]) + } + } + return "", &AllModelsFailedError{ + Models: models, + Cause: lastErr, + } + }) +} + +func buildModelList(primary string, fallbacks []string) []string { + models := make([]string, 0, 1+len(fallbacks)) + models = append(models, primary) + for _, m := range fallbacks { + m = strings.TrimSpace(m) + if m != "" && m != primary { + models = append(models, m) + } + } + return models +} + +func tryModelWithRetry(ctx context.Context, client *http.Client, baseURL, apiKey, model, systemPrompt, diff string) (string, error) { + var lastErr error + for attempt := 0; attempt < maxRetriesPerModel; attempt++ { + if attempt > 0 { + backoff := time.Duration(1<<(attempt-1)) * time.Second + timer := time.NewTimer(backoff) + select { + case <-ctx.Done(): + timer.Stop() + return "", ctx.Err() + case <-timer.C: + } + } + result, err := doChatRequest(ctx, client, baseURL, apiKey, model, systemPrompt, diff) + if err == nil { + return result, nil + } + lastErr = err + + if isContextLengthError(err) { + fmt.Fprintf(os.Stderr, "\n %s: context length exceeded\n", model) + return "", err + } + if !isRetryable(err) { + fmt.Fprintf(os.Stderr, "\n %s: %v\n", model, err) + return "", err + } + fmt.Fprintf(os.Stderr, "\n %s attempt %d/%d: %v\n", model, attempt+1, maxRetriesPerModel, err) + } + fmt.Fprintf(os.Stderr, "\n %s failed after %d attempts\n", model, maxRetriesPerModel) + return "", lastErr +} + +func doChatRequest(ctx context.Context, client *http.Client, baseURL, apiKey, model, systemPrompt, diff string) (string, error) { + body := chatRequest{ + Model: model, + Messages: []chatMessage{ + {Role: "system", Content: systemPrompt}, + {Role: "user", Content: "Unified diff:\n" + diff}, + }, + Temperature: 0.3, + MaxTokens: 4096, + } + rawBody, err := json.Marshal(body) + if err != nil { + return "", err + } + + endpoint := baseURL + "/chat/completions" + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(rawBody)) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+apiKey) + if strings.Contains(strings.ToLower(baseURL), "openrouter.ai") { + req.Header.Set("HTTP-Referer", "https://github.com/ryanhellyer/gitmeh") + req.Header.Set("X-Title", "gitmeh") + } + + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer func() { _ = resp.Body.Close() }() + + respBytes, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + raw := string(respBytes) + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + msg := summarizeChatAPIError(respBytes, raw) + return "", fmt.Errorf("%s | %s", resp.Status, msg) + } + + var parsed chatResponse + if err := json.Unmarshal(respBytes, &parsed); err != nil { + return "", fmt.Errorf("decode response: %w (body: %q)", err, truncateForErr(raw)) + } + if len(parsed.Choices) == 0 { + return "", fmt.Errorf("no choices in response: %q", truncateForErr(raw)) + } + out := strings.TrimSpace(parsed.Choices[0].Message.Content) + if out == "" { + return "", fmt.Errorf("empty assistant content: %q", truncateForErr(raw)) + } + return out, nil +} + +func isRetryable(err error) bool { + s := err.Error() + if strings.Contains(s, "timeout") || + strings.Contains(s, "connection refused") || + strings.Contains(s, "no such host") || + strings.Contains(s, "connection reset") || + strings.Contains(s, "TLS handshake") { + return true + } + if strings.Contains(s, "429") || strings.Contains(s, "500") || strings.Contains(s, "502") || + strings.Contains(s, "503") || strings.Contains(s, "504") { + return true + } + if strings.Contains(s, "Provider returned error") { + return true + } + return false +} + +func isContextLengthError(err error) bool { + s := err.Error() + return strings.Contains(s, "maximum context length") || + strings.Contains(s, "context length") || + strings.Contains(s, "too many tokens") +} + +// AllModelsFailedError is returned when every model (primary + fallbacks) fails. +type AllModelsFailedError struct { + Models []string + Cause error +} + +func (e *AllModelsFailedError) Error() string { + return fmt.Sprintf("all %d models failed: %v", len(e.Models), e.Cause) +} + +func (e *AllModelsFailedError) Unwrap() error { + return e.Cause +} + +func summarizeChatAPIError(respBytes []byte, raw string) string { + var eb apiErrorBody + if json.Unmarshal(respBytes, &eb) == nil && eb.Error != nil && strings.TrimSpace(eb.Error.Message) != "" { + return eb.Error.Message + } + return fmt.Sprintf("raw body: %q", truncateForErr(raw)) +} + +func truncateForErr(s string) string { + const max = 800 + if len(s) <= max { + return s + } + return s[:max] + "…" +} diff --git a/internal/aiapi/chat_test.go b/internal/aiapi/chat_test.go new file mode 100644 index 0000000..c7e4092 --- /dev/null +++ b/internal/aiapi/chat_test.go @@ -0,0 +1,268 @@ +//go:build !integration + +package aiapi + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestCommitMessageOpenAIChat_nilClient(t *testing.T) { + t.Parallel() + + _, err := CommitMessageOpenAIChat(context.Background(),nil, OpenAIChatParams{ + BaseURL: "http://x", + APIKey: "k", + Model: "m", + SystemPrompt: "p", + }, "diff") + if err == nil || !strings.Contains(err.Error(), "nil") { + t.Fatalf("got %v", err) + } +} + +func TestCommitMessageOpenAIChat_emptyKey(t *testing.T) { + t.Parallel() + + _, err := CommitMessageOpenAIChat(context.Background(),DefaultHTTPClient(), OpenAIChatParams{ + BaseURL: "http://x", + APIKey: " ", + Model: "m", + SystemPrompt: "p", + }, "diff") + if err == nil || !strings.Contains(err.Error(), "api key") { + t.Fatalf("got %v", err) + } +} + +func TestCommitMessageOpenAIChat_success(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/chat/completions" { + t.Errorf("path: %s", r.URL.Path) + } + if r.Method != http.MethodPost { + t.Errorf("method: %s", r.Method) + } + if r.Header.Get("Authorization") != "Bearer secret" { + t.Errorf("Authorization: %q", r.Header.Get("Authorization")) + } + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatal(err) + } + var req chatRequest + if err := json.Unmarshal(body, &req); err != nil { + t.Fatal(err) + } + if req.Model != "test-model" { + t.Errorf("model: %q", req.Model) + } + if len(req.Messages) != 2 { + t.Fatalf("messages len: %d", len(req.Messages)) + } + if req.Messages[0].Role != "system" || req.Messages[0].Content != "sys-here" { + t.Errorf("system msg: %+v", req.Messages[0]) + } + if req.Messages[1].Role != "user" || !strings.Contains(req.Messages[1].Content, "diff-here") { + t.Errorf("user msg: %+v", req.Messages[1]) + } + + _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":" fix typo \n"}}]}`)) + })) + defer srv.Close() + + got, err := CommitMessageOpenAIChat(context.Background(),srv.Client(), OpenAIChatParams{ + BaseURL: srv.URL, + APIKey: "secret", + Model: "test-model", + SystemPrompt: "sys-here", + }, "diff-here") + if err != nil { + t.Fatal(err) + } + if got != "fix typo" { + t.Fatalf("got %q want trimmed", got) + } +} + +func TestCommitMessageOpenAIChat_retryOnTransientError(t *testing.T) { + t.Parallel() + + attempt := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attempt++ + if attempt == 1 { + w.WriteHeader(http.StatusBadGateway) + _, _ = w.Write([]byte(`{"error":{"message":"Provider returned error"}}`)) + return + } + _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"fix: retry worked"}}]}`)) + })) + defer srv.Close() + + got, err := CommitMessageOpenAIChat(context.Background(),srv.Client(), OpenAIChatParams{ + BaseURL: srv.URL, + APIKey: "k", + Model: "m", + SystemPrompt: "p", + }, "diff") + if err != nil { + t.Fatal(err) + } + if got != "fix: retry worked" { + t.Fatalf("got %q", got) + } +} + +func TestCommitMessageOpenAIChat_fallbackOnPrimaryFail(t *testing.T) { + t.Parallel() + + callCount := make(map[string]int) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + model := r.URL.Query().Get("model") + callCount[model]++ + + if model == "" { + var req chatRequest + _ = json.NewDecoder(r.Body).Decode(&req) + model = req.Model + } + + if model == "primary" { + w.WriteHeader(http.StatusBadGateway) + _, _ = w.Write([]byte(`{"error":{"message":"upstream error"}}`)) + return + } + _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"feat: fallback saved the day"}}]}`)) + })) + defer srv.Close() + + got, err := CommitMessageOpenAIChat(context.Background(),srv.Client(), OpenAIChatParams{ + BaseURL: srv.URL, + APIKey: "k", + Model: "primary", + SystemPrompt: "p", + FallbackModels: []string{"backup"}, + }, "diff") + if err != nil { + t.Fatal(err) + } + if got != "feat: fallback saved the day" { + t.Fatalf("got %q", got) + } +} + +func TestCommitMessageOpenAIChat_contextLengthTriggersFallback(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req chatRequest + _ = json.NewDecoder(r.Body).Decode(&req) + + if req.Model == "small-context" { + w.WriteHeader(http.StatusBadGateway) + _, _ = w.Write([]byte(`{"error":{"message":"maximum context length is 4096 tokens"}}`)) + return + } + _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"fix: larger context model works"}}]}`)) + })) + defer srv.Close() + + got, err := CommitMessageOpenAIChat(context.Background(),srv.Client(), OpenAIChatParams{ + BaseURL: srv.URL, + APIKey: "k", + Model: "small-context", + SystemPrompt: "p", + FallbackModels: []string{"big-context"}, + }, "diff") + if err != nil { + t.Fatal(err) + } + if got != "fix: larger context model works" { + t.Fatalf("got %q", got) + } +} + +func TestCommitMessageOpenAIChat_allModelsFail(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadGateway) + _, _ = w.Write([]byte(`{"error":{"message":"always fails"}}`)) + })) + defer srv.Close() + + _, err := CommitMessageOpenAIChat(context.Background(),srv.Client(), OpenAIChatParams{ + BaseURL: srv.URL, + APIKey: "k", + Model: "m1", + SystemPrompt: "p", + FallbackModels: []string{"m2", "m3"}, + }, "diff") + if err == nil { + t.Fatal("expected error") + } + var allFailed *AllModelsFailedError + if !errors.As(err, &allFailed) { + t.Fatalf("expected *AllModelsFailedError, got %T: %v", err, err) + } + if len(allFailed.Models) != 3 { + t.Fatalf("expected 3 models in error, got %d", len(allFailed.Models)) + } + if !strings.Contains(err.Error(), "always fails") { + t.Errorf("error should contain cause: %v", err) + } +} + +func TestCommitMessageOpenAIChat_non2xxUsesErrorMessage(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusPaymentRequired) + _, _ = io.WriteString(w, `{"error":{"message":"insufficient_quota"}}`) + })) + defer srv.Close() + + _, err := CommitMessageOpenAIChat(context.Background(),srv.Client(), OpenAIChatParams{ + BaseURL: srv.URL, + APIKey: "k", + Model: "m", + SystemPrompt: "p", + }, "d") + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "402") { + t.Errorf("want status in err: %v", err) + } + if !strings.Contains(err.Error(), "insufficient_quota") { + t.Errorf("want API message: %v", err) + } +} + +func TestCommitMessageOpenAIChat_noChoices(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"choices":[]}`)) + })) + defer srv.Close() + + _, err := CommitMessageOpenAIChat(context.Background(),srv.Client(), OpenAIChatParams{ + BaseURL: srv.URL, + APIKey: "k", + Model: "m", + SystemPrompt: "p", + }, "d") + if err == nil || !strings.Contains(err.Error(), "no choices") { + t.Fatalf("got %v", err) + } +} diff --git a/internal/aiapi/client.go b/internal/aiapi/client.go new file mode 100644 index 0000000..baca687 --- /dev/null +++ b/internal/aiapi/client.go @@ -0,0 +1,81 @@ +package aiapi + +import ( + "crypto/tls" + "fmt" + "net/http" + "net/url" + "os" + "strings" + "time" +) + +const httpTimeout = 60 * time.Second + +// DefaultHTTPClient returns an [http.Client] configured with the timeout used +// for gitmeh's API requests. +func DefaultHTTPClient() *http.Client { + return &http.Client{Timeout: httpTimeout} +} + +// HTTPClientForChatBase returns a client with [httpTimeout]. For +// ai.hellyer.test (typical self-signed dev TLS), certificate verification is +// skipped so the default hosted endpoint matches curl -k behavior. +func HTTPClientForChatBase(baseURL string) *http.Client { + if !chatBaseSkipsTLSVerify(baseURL) { + return DefaultHTTPClient() + } + tr, ok := http.DefaultTransport.(*http.Transport) + if !ok { + return DefaultHTTPClient() + } + ct := tr.Clone() + if ct.TLSClientConfig == nil { + ct.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12} + } else if ct.TLSClientConfig.MinVersion == 0 { + ct.TLSClientConfig.MinVersion = tls.VersionTLS12 + } + ct.TLSClientConfig.InsecureSkipVerify = true //nolint:gosec // ai.hellyer.test dev TLS only; see chatBaseSkipsTLSVerify + return &http.Client{Timeout: httpTimeout, Transport: ct} +} + +func chatBaseSkipsTLSVerify(baseURL string) bool { + u, err := url.Parse(strings.TrimSpace(baseURL)) + if err != nil || u.Hostname() == "" { + return false + } + return strings.EqualFold(u.Hostname(), "ai.hellyer.test") +} + +// stderrCommitSpinner draws a simple ASCII spinner on stderr until stop is closed. +func stderrCommitSpinner(stop <-chan struct{}, done chan<- struct{}) { + defer close(done) + + frames := []string{"-", "\\", "|", "/"} + ticker := time.NewTicker(90 * time.Millisecond) + defer ticker.Stop() + + i := 0 + for { + select { + case <-stop: + _, _ = fmt.Fprint(os.Stderr, "\r\033[K") + return + case <-ticker.C: + _, _ = fmt.Fprintf(os.Stderr, "\r\033[K%s Generating commit message...", frames[i%len(frames)]) + i++ + } + } +} + +// withGeneratingCommitSpinner runs fn while showing a stderr spinner until fn returns. +func withGeneratingCommitSpinner(fn func() (string, error)) (string, error) { + stopSpinner := make(chan struct{}) + spinnerDone := make(chan struct{}) + go stderrCommitSpinner(stopSpinner, spinnerDone) + defer func() { + close(stopSpinner) + <-spinnerDone + }() + return fn() +} diff --git a/internal/aiapi/client_test.go b/internal/aiapi/client_test.go new file mode 100644 index 0000000..48239b5 --- /dev/null +++ b/internal/aiapi/client_test.go @@ -0,0 +1,20 @@ +//go:build !integration + +package aiapi + +import ( + "testing" +) + +func TestHTTPClientForChatBase_insecureTransportOnlyForDevHost(t *testing.T) { + t.Parallel() + + dev := HTTPClientForChatBase("https://ai.hellyer.test/v1") + if dev.Transport == nil { + t.Fatal("expected custom transport for ai.hellyer.test") + } + prod := HTTPClientForChatBase("https://openrouter.ai/api/v1") + if prod.Transport != nil { + t.Fatal("expected default transport for non-dev host") + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..835d5ed --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,128 @@ +package config + +import ( + "os" + "strconv" + "strings" +) + +// Defaults for the built-in hosted OpenAI-compatible API (no user API key). +const ( + DefaultHostedChatBaseURL = "https://ai.hellyer.test/v1" + DefaultPublicAPIKey = "gitmeh-public-client" //nolint:gosec // public key for the default hosted endpoint + DefaultHostedModel = "gitmeh-hosted" +) + +// Backend selects how gitmeh talks to the model service. +type Backend int + +const ( + // BackendOpenAIChat uses an OpenAI-compatible /v1/chat/completions JSON API. + BackendOpenAIChat Backend = iota +) + +const DefaultMaxDiffBytes = 10_000 + +// OpenAIChat holds settings for [BackendOpenAIChat]. +type OpenAIChat struct { + BaseURL string + APIKey string + Model string + Prompt string + FallbackModels []string + MaxDiffBytes int // max staged diff size before refusing; 0 = no limit +} + +// App is resolved configuration from the environment. +type App struct { + Backend Backend + Chat OpenAIChat +} + +const defaultOpenAIBase = "https://openrouter.ai/api/v1" +const defaultModel = "google/gemma-3-4b-it" + +const defaultCommitPrompt = `Write a Git commit message (Conventional Commits format) for this diff. Reply with ONLY the commit message. No analysis, no explanation, no preamble. Start with a verb. No numbering. No bullet points.` + +// Load reads environment variables. +// +// With GITMEH_API_KEY set: GITMEH_API_BASE defaults to OpenRouter, model to +// [defaultModel] unless GITMEH_MODEL is set. +// +// With no key set: [DefaultHostedChatBaseURL], [DefaultPublicAPIKey], and +// [DefaultHostedModel] unless GITMEH_API_BASE and/or GITMEH_MODEL override the +// URL or model. +// +// GITMEH_PROMPT optionally overrides the system instructions. +// +// OPENROUTER_API_KEY and OPENROUTER_MODEL are also accepted as legacy aliases +// for GITMEH_API_KEY and GITMEH_MODEL (from earlier versions that only +// supported OpenRouter). +func Load() App { + userKey := strings.TrimSpace(os.Getenv("GITMEH_API_KEY")) + if userKey == "" { + userKey = strings.TrimSpace(os.Getenv("OPENROUTER_API_KEY")) // legacy alias + } + + prompt := strings.TrimSpace(os.Getenv("GITMEH_PROMPT")) + if prompt == "" { + prompt = defaultCommitPrompt + } + + base := strings.TrimSpace(os.Getenv("GITMEH_API_BASE")) + base = strings.TrimRight(base, "/") + + model := strings.TrimSpace(os.Getenv("GITMEH_MODEL")) + if model == "" { + model = strings.TrimSpace(os.Getenv("OPENROUTER_MODEL")) // legacy alias + } + + fallbackRaw := strings.TrimSpace(os.Getenv("GITMEH_FALLBACK_MODELS")) + var fallbackModels []string + if fallbackRaw != "" { + for _, p := range strings.Split(fallbackRaw, ",") { + p = strings.TrimSpace(p) + if p != "" { + fallbackModels = append(fallbackModels, p) + } + } + } + + maxDiff := DefaultMaxDiffBytes + if raw := strings.TrimSpace(os.Getenv("GITMEH_MAX_DIFF_BYTES")); raw != "" { + if v, err := strconv.Atoi(raw); err == nil && v >= 0 { + maxDiff = v + } + } + + var apiKey string + if userKey != "" { + apiKey = userKey + if base == "" { + base = defaultOpenAIBase + } + if model == "" { + model = defaultModel + } + } else { + apiKey = DefaultPublicAPIKey + if base == "" { + base = DefaultHostedChatBaseURL + } + if model == "" { + model = DefaultHostedModel + } + } + + return App{ + Backend: BackendOpenAIChat, + Chat: OpenAIChat{ + BaseURL: base, + APIKey: apiKey, + Model: model, + Prompt: prompt, + FallbackModels: fallbackModels, + MaxDiffBytes: maxDiff, + }, + } +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..f93a827 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,175 @@ +//go:build !integration + +package config + +import ( + "testing" +) + +func TestLoad_defaultChatWhenNoUserKey(t *testing.T) { + t.Setenv("GITMEH_API_KEY", "") + t.Setenv("OPENROUTER_API_KEY", "") + t.Setenv("GITMEH_API_BASE", "") + t.Setenv("GITMEH_MODEL", "") + t.Setenv("OPENROUTER_MODEL", "") + t.Setenv("GITMEH_PROMPT", "") + + got := Load() + if got.Backend != BackendOpenAIChat { + t.Fatalf("backend: got %v want chat", got.Backend) + } + if got.Chat.BaseURL != DefaultHostedChatBaseURL { + t.Fatalf("BaseURL: got %q", got.Chat.BaseURL) + } + if got.Chat.APIKey != DefaultPublicAPIKey { + t.Fatalf("APIKey: got %q want DefaultPublicAPIKey", got.Chat.APIKey) + } + if got.Chat.Model != DefaultHostedModel { + t.Fatalf("Model: got %q", got.Chat.Model) + } + if got.Chat.Prompt == "" { + t.Fatal("expected default prompt") + } +} + +func TestLoad_userAPIKeyOverridesDefaultPublic(t *testing.T) { + t.Setenv("GITMEH_API_KEY", "user-key") + t.Setenv("OPENROUTER_API_KEY", "") + t.Setenv("GITMEH_API_BASE", "") + t.Setenv("GITMEH_MODEL", "") + t.Setenv("OPENROUTER_MODEL", "") + + got := Load() + if got.Chat.APIKey != "user-key" { + t.Fatalf("APIKey: got %q want user override", got.Chat.APIKey) + } +} + +func TestLoad_customBaseWithDefaultPublicKey(t *testing.T) { + t.Setenv("GITMEH_API_KEY", "") + t.Setenv("OPENROUTER_API_KEY", "") + t.Setenv("GITMEH_API_BASE", "https://staging.example/v1") + t.Setenv("GITMEH_MODEL", "") + t.Setenv("OPENROUTER_MODEL", "") + t.Setenv("GITMEH_PROMPT", "") + + got := Load() + if got.Backend != BackendOpenAIChat { + t.Fatalf("backend") + } + if got.Chat.BaseURL != "https://staging.example/v1" { + t.Fatalf("BaseURL: got %q", got.Chat.BaseURL) + } + if got.Chat.APIKey != DefaultPublicAPIKey { + t.Fatalf("APIKey: got %q", got.Chat.APIKey) + } + if got.Chat.Model != DefaultHostedModel { + t.Fatalf("Model: got %q", got.Chat.Model) + } +} + +func TestLoad_chatOpenRouterKey(t *testing.T) { + // OPENROUTER_API_KEY is a legacy alias for GITMEH_API_KEY. + t.Setenv("GITMEH_API_KEY", "") + t.Setenv("OPENROUTER_API_KEY", "sk-test") + t.Setenv("GITMEH_API_BASE", "") + t.Setenv("GITMEH_MODEL", "") + t.Setenv("OPENROUTER_MODEL", "") + t.Setenv("GITMEH_PROMPT", "") + + got := Load() + if got.Backend != BackendOpenAIChat { + t.Fatalf("backend: got %v want chat", got.Backend) + } + if got.Chat.APIKey != "sk-test" { + t.Fatalf("APIKey") + } + if got.Chat.BaseURL != "https://openrouter.ai/api/v1" { + t.Fatalf("BaseURL: got %q", got.Chat.BaseURL) + } + if got.Chat.Model != "google/gemma-3-4b-it" { + t.Fatalf("Model: got %q", got.Chat.Model) + } +} + +func TestLoad_chatGITMEHKeyOverridesBase(t *testing.T) { + t.Setenv("GITMEH_API_KEY", "k") + t.Setenv("OPENROUTER_API_KEY", "") + t.Setenv("GITMEH_API_BASE", "https://api.openai.com/v1") + t.Setenv("GITMEH_MODEL", "gpt-4o-mini") + t.Setenv("GITMEH_PROMPT", "Be brief.") + + got := Load() + if got.Backend != BackendOpenAIChat { + t.Fatalf("backend") + } + if got.Chat.APIKey != "k" { + t.Fatalf("APIKey") + } + if got.Chat.BaseURL != "https://api.openai.com/v1" { + t.Fatalf("BaseURL: got %q", got.Chat.BaseURL) + } + if got.Chat.Model != "gpt-4o-mini" { + t.Fatalf("Model") + } + if got.Chat.Prompt != "Be brief." { + t.Fatalf("Prompt: got %q", got.Chat.Prompt) + } +} + +func TestLoad_chatGITMEHKeyPreferredOverOpenRouter(t *testing.T) { + // GITMEH_API_KEY takes precedence over the legacy OPENROUTER_API_KEY alias. + t.Setenv("GITMEH_API_KEY", "primary") + t.Setenv("OPENROUTER_API_KEY", "secondary") + t.Setenv("GITMEH_API_BASE", "") + t.Setenv("GITMEH_MODEL", "") + t.Setenv("OPENROUTER_MODEL", "") + + got := Load() + if got.Chat.APIKey != "primary" { + t.Fatalf("want GITMEH_API_KEY to win, got %q", got.Chat.APIKey) + } +} + +func TestLoad_fallbackModels(t *testing.T) { + t.Setenv("GITMEH_API_KEY", "k") + t.Setenv("GITMEH_FALLBACK_MODELS", " model-b,model-c , ") + t.Setenv("GITMEH_API_BASE", "") + t.Setenv("GITMEH_MODEL", "") + t.Setenv("OPENROUTER_MODEL", "") + t.Setenv("GITMEH_PROMPT", "") + + got := Load() + if len(got.Chat.FallbackModels) != 2 { + t.Fatalf("expected 2 fallback models, got %d: %v", len(got.Chat.FallbackModels), got.Chat.FallbackModels) + } + if got.Chat.FallbackModels[0] != "model-b" { + t.Errorf("fallback[0] = %q", got.Chat.FallbackModels[0]) + } + if got.Chat.FallbackModels[1] != "model-c" { + t.Errorf("fallback[1] = %q", got.Chat.FallbackModels[1]) + } +} + +func TestLoad_fallbackModelsEmptyEnv(t *testing.T) { + t.Setenv("GITMEH_API_KEY", "k") + t.Setenv("GITMEH_FALLBACK_MODELS", "") + t.Setenv("GITMEH_API_BASE", "") + t.Setenv("GITMEH_MODEL", "") + + got := Load() + if len(got.Chat.FallbackModels) != 0 { + t.Fatalf("expected 0 fallback models, got %d", len(got.Chat.FallbackModels)) + } +} + +func TestLoad_chatOpenRouterModelEnv(t *testing.T) { + t.Setenv("GITMEH_API_KEY", "x") + t.Setenv("GITMEH_MODEL", "") + t.Setenv("OPENROUTER_MODEL", "anthropic/claude-3-haiku") + + got := Load() + if got.Chat.Model != "anthropic/claude-3-haiku" { + t.Fatalf("Model: got %q", got.Chat.Model) + } +} diff --git a/internal/git/commit.go b/internal/git/commit.go new file mode 100644 index 0000000..e94b5a6 --- /dev/null +++ b/internal/git/commit.go @@ -0,0 +1,5 @@ +package git + +func Commit(message string) error { + return runGit("commit", "-m", message) +} diff --git a/internal/git/diff.go b/internal/git/diff.go new file mode 100644 index 0000000..3916eca --- /dev/null +++ b/internal/git/diff.go @@ -0,0 +1,156 @@ +package git + +import ( + "errors" + "os/exec" + "strings" +) + +// StagedDiff returns the unified diff of staged changes (git diff --cached). +func StagedDiff() (string, error) { + out, err := exec.Command("git", "diff", "--cached").Output() + if err != nil { + var exit *exec.ExitError + if errors.As(err, &exit) && len(exit.Stderr) > 0 { + return "", errors.New(strings.TrimSpace(string(exit.Stderr))) + } + return "", err + } + return string(out), nil +} + +// StagedDiffTruncated returns the staged diff, truncated per-file if it +// exceeds maxBytes. If maxBytes <= 0 the full diff is returned unchanged. +func StagedDiffTruncated(maxBytes int) (string, error) { + diff, err := StagedDiff() + if err != nil { + return "", err + } + if maxBytes <= 0 || len(diff) <= maxBytes { + return diff, nil + } + return truncateByFile(diff, maxBytes), nil +} + +type diffSection struct { + header string // "diff --git ..." through "+++ b/..." + hunks string // everything after the header +} + +func parseSections(diff string) []diffSection { + if diff == "" { + return nil + } + // Split on "\ndiff --git " to get file sections. + parts := strings.Split(diff, "\ndiff --git ") + sections := make([]diffSection, len(parts)) + for i, p := range parts { + raw := p + if i > 0 { + raw = "diff --git " + p + } + sections[i] = splitHeader(raw) + } + return sections +} + +// splitHeader splits a file section into its 4-line header and the hunk body. +func splitHeader(section string) diffSection { + start := 0 + for i := 0; i < 4; i++ { + off := strings.IndexByte(section[start:], '\n') + if off < 0 { + return diffSection{header: section} + } + start += off + 1 + } + if start >= len(section) { + return diffSection{header: section} + } + return diffSection{ + header: section[:start], + hunks: section[start:], + } +} + +func truncateByFile(diff string, maxBytes int) string { + sections := parseSections(diff) + if len(sections) <= 1 { + // Single file — just truncate the hunk portion. + // Header is small, keep it all. Truncate hunks to fit. + h := sections[0].header + avail := maxBytes - len(h) + if avail <= 0 { + return diff[:maxBytes] + "\n# diff truncated\n" + } + if len(sections[0].hunks) <= avail { + return diff + } + return h + sections[0].hunks[:avail] + "\n# hunk truncated\n" + } + + // Keep all headers — they're small and essential. + var headerBuf strings.Builder + for _, s := range sections { + headerBuf.WriteString(s.header) + } + headerLen := headerBuf.Len() + + if headerLen >= maxBytes { + // Can't fit all headers — skip some file sections entirely. + var buf strings.Builder + for _, s := range sections { + if buf.Len()+len(s.header) > maxBytes { + break + } + buf.WriteString(s.header) + } + return buf.String() + } + + hunkBudget := maxBytes - headerLen + + // Calculate total hunk size across all files. + totalHunkSize := 0 + for _, s := range sections { + totalHunkSize += len(s.hunks) + } + if totalHunkSize == 0 { + return headerBuf.String() + } + + // Proportional allocation: each file gets a share of the hunk budget + // based on its hunk size relative to the total. + var buf strings.Builder + buf.WriteString(headerBuf.String()) + + remainingHunkBudget := hunkBudget + remainingHunkSize := totalHunkSize + + for _, s := range sections { + if len(s.hunks) == 0 { + continue + } + alloc := int(int64(hunkBudget) * int64(len(s.hunks)) / int64(totalHunkSize)) + if alloc > remainingHunkBudget { + alloc = remainingHunkBudget + } + if alloc < 20 { + alloc = 20 // minimum meaningful hunk content + } + if alloc > len(s.hunks) { + alloc = len(s.hunks) + } + if alloc >= len(s.hunks) { + buf.WriteString(s.hunks) + } else { + buf.WriteString(s.hunks[:alloc]) + buf.WriteString("\n# hunk truncated\n") + } + remainingHunkBudget -= alloc + remainingHunkSize -= len(s.hunks) + } + + return buf.String() +} + diff --git a/internal/git/diff_test.go b/internal/git/diff_test.go new file mode 100644 index 0000000..5552d6f --- /dev/null +++ b/internal/git/diff_test.go @@ -0,0 +1,133 @@ +//go:build !integration + +package git + +import ( + "strings" + "testing" +) + +func TestSplitHeader(t *testing.T) { + t.Parallel() + + section := "diff --git a/a.go b/a.go\nindex abc..def 100644\n--- a/a.go\n+++ b/a.go\n@@ -1 +1 @@\n-foo\n+bar\n" + + got := splitHeader(section) + if got.header != "diff --git a/a.go b/a.go\nindex abc..def 100644\n--- a/a.go\n+++ b/a.go\n" { + t.Errorf("header: %q", got.header) + } + if got.hunks != "@@ -1 +1 @@\n-foo\n+bar\n" { + t.Errorf("hunks: %q", got.hunks) + } +} + +func TestSplitHeader_noHunks(t *testing.T) { + t.Parallel() + + section := "diff --git a/a.go b/a.go\nindex abc..def 100644\n--- a/a.go\n+++ b/a.go\n" + + got := splitHeader(section) + if got.header != section { + t.Errorf("expected full section as header: %q", got.header) + } +} + +func TestParseSections(t *testing.T) { + t.Parallel() + + diff := "diff --git a/a.go b/a.go\nindex a..b 100644\n--- a/a.go\n+++ b/a.go\n@@ -1 +1 @@\n-foo\n+bar\n" + + "diff --git b/b.go b/b.go\nindex c..d 100644\n--- b/b.go\n+++ b/b.go\n@@ -2 +2 @@\n-baz\n+qux\n" + + sections := parseSections(diff) + if len(sections) != 2 { + t.Fatalf("expected 2 sections, got %d", len(sections)) + } + if !strings.Contains(sections[0].header, "a.go") { + t.Errorf("section 0 header: %q", sections[0].header) + } + if !strings.Contains(sections[1].header, "b.go") { + t.Errorf("section 1 header: %q", sections[1].header) + } +} + +func TestParseSections_single(t *testing.T) { + t.Parallel() + + diff := "diff --git a/a.go b/a.go\nindex a..b 100644\n--- a/a.go\n+++ b/a.go\n@@ -1 +1 @@\n-foo\n+bar\n" + + sections := parseSections(diff) + if len(sections) != 1 { + t.Fatalf("expected 1 section, got %d", len(sections)) + } + if !strings.Contains(sections[0].header, "a.go") { + t.Errorf("header: %q", sections[0].header) + } + if sections[0].hunks != "@@ -1 +1 @@\n-foo\n+bar\n" { + t.Errorf("hunks: %q", sections[0].hunks) + } +} + +func TestParseSections_empty(t *testing.T) { + t.Parallel() + + sections := parseSections("") + if len(sections) != 0 { + t.Fatalf("expected 0 sections, got %d", len(sections)) + } +} + +func TestTruncateByFile_singleFits(t *testing.T) { + t.Parallel() + + diff := "diff --git a/a.go b/a.go\nindex a..b 100644\n--- a/a.go\n+++ b/a.go\n@@ -1 +1 @@\n-foo\n+bar\n" + + got := truncateByFile(diff, len(diff)+100) + if got != diff { + t.Errorf("expected full diff, got %q", got) + } +} + +func TestTruncateByFile_singleTooBig(t *testing.T) { + t.Parallel() + + diff := "diff --git a/a.go b/a.go\nindex a..b 100644\n--- a/a.go\n+++ b/a.go\n@@ -1 +1 @@\n-foo\n+bar\n" + + got := truncateByFile(diff, 80) + if !strings.Contains(got, "truncated") { + t.Errorf("expected truncated: %q", got) + } + if !strings.Contains(got, "diff --git") { + t.Errorf("expected header: %q", got) + } +} + +func TestTruncateByFile_multiFiles(t *testing.T) { + t.Parallel() + + diff := "diff --git a/a.go b/a.go\nindex a..b 100644\n--- a/a.go\n+++ b/a.go\n@@ -1 +1 @@\n-foo\n+bar\n" + + "diff --git b/b.go b/b.go\nindex c..d 100644\n--- b/b.go\n+++ b/b.go\n@@ -2 +2 @@\n-baz\n+qux\n" + + // 140 bytes: fits both headers (~132 B) with a little hunk room + got := truncateByFile(diff, 140) + if !strings.Contains(got, "a.go") { + t.Errorf("missing a.go: %q", got) + } + if !strings.Contains(got, "b.go") { + t.Errorf("missing b.go: %q", got) + } + // Should have truncated markers since both hunks don't fully fit + if !strings.Contains(got, "truncated") { + t.Errorf("expected truncation: %q", got) + } +} + +func TestTruncateByFile_notTooBig(t *testing.T) { + t.Parallel() + + diff := "diff --git a/a.go b/a.go\nindex a..b 100644\n--- a/a.go\n+++ b/a.go\n@@ -1 +1 @@\n-foo\n+bar\n" + + got := truncateByFile(diff, 200) + if got != diff { + t.Errorf("expected full diff, got %q", got) + } +} diff --git a/internal/git/integration_test.go b/internal/git/integration_test.go new file mode 100644 index 0000000..1dc765b --- /dev/null +++ b/internal/git/integration_test.go @@ -0,0 +1,170 @@ +//go:build integration + +package git + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func skipWithoutGit(t *testing.T) { + t.Helper() + if _, err := exec.LookPath("git"); err != nil { + t.Skipf("git not on PATH: %v", err) + } +} + +func mustGit(t *testing.T, args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } +} + +func mustWriteFile(t *testing.T, name, content string) { + t.Helper() + if err := os.WriteFile(name, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func initWorktree(t *testing.T) { + t.Helper() + skipWithoutGit(t) + + root := t.TempDir() + repo := filepath.Join(root, "work") + if err := os.MkdirAll(repo, 0o755); err != nil { + t.Fatal(err) + } + t.Chdir(repo) + + mustGit(t, "init", "-b", "main") + mustGit(t, "config", "user.email", "integration-test@example.com") + mustGit(t, "config", "user.name", "Integration Test") +} + +// Checks StagedDiff() after a real staged edit: initial commit, change file, stage with git add, then expect a non-empty unified diff naming the file and showing the added line. +func TestIntegration_StagedDiff(t *testing.T) { + initWorktree(t) + + mustWriteFile(t, "tracked.txt", "hello\n") + mustGit(t, "add", "tracked.txt") + mustGit(t, "commit", "-m", "init") + + mustWriteFile(t, "tracked.txt", "hello\nworld\n") + mustGit(t, "add", "tracked.txt") + + diff, err := StagedDiff() + if err != nil { + t.Fatal(err) + } + if diff == "" { + t.Fatal("expected non-empty staged diff") + } + if !strings.Contains(diff, "tracked.txt") { + t.Fatalf("diff should name file; got:\n%s", diff) + } + if !strings.Contains(diff, "+world") { + t.Fatalf("diff should contain added line; got:\n%s", diff) + } +} + +// Checks StagedDiff() by Staging an untracked file: one commit exists, a new file appears on disk only, AddAll runs, then StagedDiff must mention that file. +func TestIntegration_AddAll_stagesNewFile(t *testing.T) { + initWorktree(t) + + mustWriteFile(t, "first.txt", "a\n") + mustGit(t, "add", "first.txt") + mustGit(t, "commit", "-m", "init") + + mustWriteFile(t, "second.txt", "new\n") + if err := AddAll(); err != nil { + t.Fatal(err) + } + + diff, err := StagedDiff() + if err != nil { + t.Fatal(err) + } + if !strings.Contains(diff, "second.txt") { + t.Fatalf("staged diff should include new file; got:\n%s", diff) + } +} + +// Checks Commit with staged changes: after staging an edit, Commit records the given message; verify with git log -1. +func TestIntegration_Commit(t *testing.T) { + initWorktree(t) + + mustWriteFile(t, "a.txt", "1\n") + mustGit(t, "add", "a.txt") + mustGit(t, "commit", "-m", "init") + + mustWriteFile(t, "a.txt", "2\n") + mustGit(t, "add", "a.txt") + + if err := Commit("second message"); err != nil { + t.Fatal(err) + } + + out, err := exec.Command("git", "log", "-1", "--format=%s").CombinedOutput() + if err != nil { + t.Fatalf("git log: %v\n%s", err, out) + } + if got := strings.TrimSpace(string(out)); got != "second message" { + t.Fatalf("HEAD subject: got %q want %q", got, "second message") + } +} + +// Exercises the full path with a real remote: bare repo as origin, initial commit pushed by hand, then staged edit + CommitAndPush; assert the latest commit subject on branch main in the bare repo (bare default HEAD stays on empty master, so we read main explicitly). +func TestIntegration_CommitAndPush(t *testing.T) { + skipWithoutGit(t) + + root := t.TempDir() + bare := filepath.Join(root, "origin.git") + if err := os.MkdirAll(bare, 0o755); err != nil { + t.Fatal(err) + } + cmd := exec.Command("git", "init", "--bare", bare) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git init --bare: %v\n%s", err, out) + } + + repo := filepath.Join(root, "work") + if err := os.MkdirAll(repo, 0o755); err != nil { + t.Fatal(err) + } + t.Chdir(repo) + + mustGit(t, "init", "-b", "main") + mustGit(t, "config", "user.email", "integration-test@example.com") + mustGit(t, "config", "user.name", "Integration Test") + mustGit(t, "remote", "add", "origin", bare) + + mustWriteFile(t, "file.txt", "v1\n") + mustGit(t, "add", "file.txt") + mustGit(t, "commit", "-m", "init") + mustGit(t, "push", "origin", "HEAD") + + mustWriteFile(t, "file.txt", "v1\nv2\n") + if err := AddAll(); err != nil { + t.Fatal(err) + } + if err := CommitAndPush("pushed from integration test"); err != nil { + t.Fatal(err) + } + + // Bare repos often default HEAD to master (empty); first push created main. + logOut, err := exec.Command("git", "-C", bare, "log", "-1", "main", "--format=%s").CombinedOutput() + if err != nil { + t.Fatalf("git log in bare: %v\n%s", err, logOut) + } + if got := strings.TrimSpace(string(logOut)); got != "pushed from integration test" { + t.Fatalf("bare HEAD subject: got %q want %q", got, "pushed from integration test") + } +} diff --git a/internal/git/push.go b/internal/git/push.go new file mode 100644 index 0000000..756510c --- /dev/null +++ b/internal/git/push.go @@ -0,0 +1,7 @@ +package git + +// PushOriginHead runs `git push origin HEAD`, pushing the current branch to +// origin using the same branch name on the remote. +func PushOriginHead() error { + return runGit("push", "origin", "HEAD") +} diff --git a/internal/git/run.go b/internal/git/run.go new file mode 100644 index 0000000..30f05c7 --- /dev/null +++ b/internal/git/run.go @@ -0,0 +1,33 @@ +package git + +import ( + "errors" + "fmt" + "os" + "os/exec" + "strings" +) + +func runGit(args ...string) error { + cmd := exec.Command("git", args...) //nolint:gosec + + cmd.Stdout = os.Stdout + + var stderr strings.Builder + cmd.Stderr = &stderr + + err := cmd.Run() + msg := strings.TrimSpace(stderr.String()) + + if err != nil { + if msg != "" { + return errors.New(msg) + } + return err + } + + if msg != "" { + fmt.Fprintln(os.Stderr, msg) + } + return nil +} diff --git a/internal/git/stage.go b/internal/git/stage.go new file mode 100644 index 0000000..7169034 --- /dev/null +++ b/internal/git/stage.go @@ -0,0 +1,5 @@ +package git + +func AddAll() error { + return runGit("add", "--all") +} diff --git a/internal/git/workflow.go b/internal/git/workflow.go new file mode 100644 index 0000000..a61aedb --- /dev/null +++ b/internal/git/workflow.go @@ -0,0 +1,12 @@ +package git + +// CommitAndPush runs git commit with message then pushes the current branch to origin. +func CommitAndPush(message string) error { + if err := Commit(message); err != nil { + return err + } + if err := PushOriginHead(); err != nil { + return err + } + return nil +} diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..bd539c2 --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,6 @@ +package version + +// Version is the release version. Override at link time, for example: +// +// go build -ldflags "-X gitmeh/internal/version.Version=1.0.0" +var Version = "3.0" diff --git a/main.go b/main.go new file mode 100755 index 0000000..0f4b45d --- /dev/null +++ b/main.go @@ -0,0 +1,267 @@ +package main + +import ( + "bufio" + "context" + _ "embed" + "errors" + "fmt" + "io" + "os" + "os/signal" + "strings" + "syscall" + "unicode/utf8" + + "gitmeh/internal/aiapi" + "gitmeh/internal/config" + "gitmeh/internal/git" + "gitmeh/internal/version" + + "golang.org/x/term" +) + +// helpText is filled at compile time from help.txt. The next line is a Go +// compiler directive (not ordinary documentation): it tells the toolchain to +// copy that file into the binary. import _ "embed" is required so the compiler +// enables //go:embed even though we do not reference embed.FS in code. +// +//go:embed help.txt +var helpText string + +const commitMsgPrompt = "Commit message: " + +func main() { + for _, arg := range os.Args[1:] { + switch arg { + case "-h": + fmt.Print(helpText) + return + case "-v": + fmt.Printf("gitmeh %s\n", version.Version) + return + } + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + if err := git.AddAll(); err != nil { + fatalErr(err) + } + + cfg := config.Load() + + diff, err := git.StagedDiffTruncated(cfg.Chat.MaxDiffBytes) + if err != nil { + fatalErr(err) + } + if strings.TrimSpace(diff) == "" { + fatalMsg("nothing staged to commit") + } + + httpClient := aiapi.HTTPClientForChatBase(cfg.Chat.BaseURL) + msg, err := aiapi.CommitMessageOpenAIChat(ctx, httpClient, aiapi.OpenAIChatParams{ + BaseURL: cfg.Chat.BaseURL, + APIKey: cfg.Chat.APIKey, + Model: cfg.Chat.Model, + SystemPrompt: cfg.Chat.Prompt, + FallbackModels: cfg.Chat.FallbackModels, + }, diff) + if err != nil { + if errors.Is(err, context.Canceled) { + fatalMsg("cancelled") + } + fatalErr(err) + } + if strings.TrimSpace(msg) == "" { + fatalMsg("empty commit message from API") + } + + final, proceed, err := reviewCommitMessage(msg, os.Stdin, os.Stdout) + if err != nil { + fatalErr(err) + } + if !proceed { + os.Exit(1) + } + + if err := git.CommitAndPush(final); err != nil { + fatalErr(err) + } + fmt.Println("Git commands executed successfully!") +} + +// reviewCommitMessage loops until the user accepts (Y), aborts (n), or edits (e). +// After edit, pressing Enter on the edited line submits that message (ok true). +// ok is false when the user aborts without error. +// stdin and stdout are injected for tests (e.g. strings.NewReader, io.Discard). +// If stdout is nil, os.Stdout is used. +//nolint:errcheck // UI output writes — safe to ignore +func reviewCommitMessage(suggested string, stdin io.Reader, stdout io.Writer) (final string, ok bool, err error) { + if stdout == nil { + stdout = os.Stdout + } + + current := strings.TrimSpace(suggested) + rd := bufio.NewReader(stdin) + + for { + fmt.Fprintln(stdout, "\nSuggested commit message:") + fmt.Fprintln(stdout, current) + fmt.Fprint(stdout, "\nAccept this message? [Y]es / [n]o / [e]dit: ") + + line, err := rd.ReadString('\n') + if err != nil { + if err == io.EOF { + return "", false, fmt.Errorf("no input (EOF)") + } + return "", false, err + } + + ans := strings.ToLower(strings.TrimSpace(line)) + switch ans { + case "", "y", "yes": + return current, true, nil + case "n", "no": + fmt.Fprintln(stdout, "Aborted.") + return "", false, nil + case "e", "edit": + fmt.Fprintln(stdout) + edited, err := readCommitMessageInline(current, stdin, rd, stdout) + if err != nil { + return "", false, err + } + edited = strings.TrimSpace(edited) + if edited == "" { + fmt.Fprintln(stdout, "Commit message is empty; keeping previous text.") + continue + } + return edited, true, nil + default: + fmt.Fprintln(stdout, "Please enter y, n, or e (or press Enter for yes).") + } + } +} + +// stdinTerminalFD returns the FD to put in raw mode when stdin is an +// [os.File] connected to a terminal; otherwise ok is false. +func stdinTerminalFD(stdin io.Reader) (fd int, ok bool) { + f, isFile := stdin.(*os.File) + if !isFile { + return 0, false + } + fd = int(f.Fd()) + if !term.IsTerminal(fd) { + return 0, false + } + return fd, true +} + +// readCommitMessageInline shows an editable single-line field prefilled with +// initial; Enter submits the current line. Ctrl+C aborts with an error. +// stdin must be the same reader rd wraps (e.g. bufio.NewReader(stdin)). +// stdout is used for prompts (inject io.Discard in tests). If nil, os.Stdout. +//nolint:errcheck // UI output writes — safe to ignore +func readCommitMessageInline(initial string, stdin io.Reader, rd *bufio.Reader, stdout io.Writer) (string, error) { + if stdout == nil { + stdout = os.Stdout + } + + fd, useTTY := stdinTerminalFD(stdin) + if !useTTY { + fmt.Fprintf(stdout, "%s%s\n", commitMsgPrompt, initial) + fmt.Fprint(stdout, "(not a terminal — press Enter to keep, or type a new message)\n> ") + line, err := rd.ReadString('\n') + if err != nil { + return "", err + } + t := strings.TrimSpace(line) + if t == "" { + return initial, nil + } + return t, nil + } + + old, err := term.MakeRaw(fd) + if err != nil { + return "", err + } + defer func() { _ = term.Restore(fd, old) }() + + line := []rune(initial) + pos := len(line) + + redraw := func() { + left := "" + if pos > 0 { + left = string(line[:pos]) + } + right := "" + if pos < len(line) { + right = string(line[pos:]) + } + fmt.Fprintf(stdout, "\r\033[K%s%s%s", commitMsgPrompt, left, right) + if n := len(right); n > 0 { + fmt.Fprintf(stdout, "\033[%dD", n) + } + } + + redraw() + + for { + r, size, err := rd.ReadRune() + if err != nil { + if err == io.EOF { + fmt.Fprint(stdout, "\r\n") + return "", io.EOF + } + return "", err + } + if r == utf8.RuneError && size == 1 { + continue + } + + switch r { + case '\r', '\n': + fmt.Fprint(stdout, "\r\n") + return string(line), nil + case 3: // Ctrl+C + fmt.Fprint(stdout, "\r\n") + return "", fmt.Errorf("interrupted") + case 127, '\b': + if pos > 0 { + line = append(line[:pos-1], line[pos:]...) + pos-- + redraw() + } + case 27: // ESC — arrow keys + br, _, err := rd.ReadRune() + if err != nil || br != '[' { + continue + } + dir, _, err := rd.ReadRune() + if err != nil { + continue + } + switch dir { + case 'D': + if pos > 0 { + pos-- + redraw() + } + case 'C': + if pos < len(line) { + pos++ + redraw() + } + } + default: + if r >= 32 { + line = append(line[:pos], append([]rune{r}, line[pos:]...)...) + pos++ + redraw() + } + } + } +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..70c2bae --- /dev/null +++ b/main_test.go @@ -0,0 +1,107 @@ +//go:build !integration + +package main + +import ( + "io" + "strings" + "testing" +) + +func TestReviewCommitMessage(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + suggest string + want string + ok bool + wantErr bool + errSub string + }{ + { + name: "yes", + input: "y\n", + suggest: "hello", + want: "hello", + ok: true, + }, + { + name: "empty line means yes", + input: "\n", + suggest: "hello", + want: "hello", + ok: true, + }, + { + name: "YES uppercase", + input: "YES\n", + suggest: "hello", + want: "hello", + ok: true, + }, + { + name: "no aborts", + input: "n\n", + suggest: "hello", + want: "", + ok: false, + }, + { + name: "invalid then yes", + input: "foo\ny\n", + suggest: "hi", + want: "hi", + ok: true, + }, + { + name: "eof before newline", + input: "", + suggest: "hi", + wantErr: true, + errSub: "EOF", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + in := strings.NewReader(tt.input) + got, ok, err := reviewCommitMessage(tt.suggest, in, io.Discard) + if tt.wantErr { + if err == nil { + t.Fatal("expected error") + } + if tt.errSub != "" && !strings.Contains(err.Error(), tt.errSub) { + t.Fatalf("error %q should contain %q", err.Error(), tt.errSub) + } + return + } + if err != nil { + t.Fatal(err) + } + if ok != tt.ok { + t.Fatalf("ok: got %v want %v", ok, tt.ok) + } + if got != tt.want { + t.Fatalf("message: got %q want %q", got, tt.want) + } + }) + } +} + +func TestReviewCommitMessage_editNonTerminal(t *testing.T) { + in := strings.NewReader("e\nreplaced msg\n") + got, ok, err := reviewCommitMessage("original", in, io.Discard) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected ok") + } + if got != "replaced msg" { + t.Fatalf("got %q", got) + } +}