From acdf662d10f271d8449a8af681685532ba5ce1b5 Mon Sep 17 00:00:00 2001 From: Javid Date: Thu, 1 Oct 2026 07:25:17 +0200 Subject: [PATCH 1/5] feat: add terminal UI and multi-remote push flow --- gitai | 420 ++++++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 294 insertions(+), 126 deletions(-) diff --git a/gitai b/gitai index 9f27e08..de6c80c 100755 --- a/gitai +++ b/gitai @@ -1,45 +1,154 @@ -#!/bin/bash +#!/usr/bin/env bash -# gitai - Generate Conventional Commit messages using AI, without temp files +# GitAI - AI-assisted Conventional Commits with a terminal-native workflow. set -euo pipefail +VERSION="0.2.0" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# Load config if present + +fail() { + printf 'gitai: %s\n' "$*" >&2 + exit 1 +} + +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +for dependency in git curl jq; do + command_exists "$dependency" || fail "Missing dependency: $dependency" +done + +git rev-parse --is-inside-work-tree >/dev/null 2>&1 || fail "Run gitai inside a Git repository." +GIT_ROOT="$(git rev-parse --show-toplevel)" + +# Configuration precedence (later files win): legacy install-local, user, repository. +# Keeping the legacy location preserves compatibility with older installations. if [ -f "$SCRIPT_DIR/.gitai" ]; then + # shellcheck disable=SC1091 source "$SCRIPT_DIR/.gitai" fi +if [ -f "${XDG_CONFIG_HOME:-$HOME/.config}/gitai/config" ]; then + # shellcheck disable=SC1090 + source "${XDG_CONFIG_HOME:-$HOME/.config}/gitai/config" +fi +if [ -f "$GIT_ROOT/.gitai" ]; then + # shellcheck disable=SC1090 + source "$GIT_ROOT/.gitai" +fi PROVIDER_CHAIN_RAW="${GITAI_PROVIDER:-avalapis}" MAX_DIFF_LENGTH="${GITAI_MAX_DIFF_LENGTH:-12000}" DEFAULT_API_KEY="${GITAI_API_KEY:-}" +TUI_ENABLED="${GITAI_TUI:-1}" +AUTO_PUSH_MODE="" -if ! [[ "$MAX_DIFF_LENGTH" =~ ^[0-9]+$ ]]; then - echo "Error: GITAI_MAX_DIFF_LENGTH must be a positive integer." >&2 - exit 1 +if ! [[ "$MAX_DIFF_LENGTH" =~ ^[0-9]+$ ]] || [ "$MAX_DIFF_LENGTH" -eq 0 ]; then + fail "GITAI_MAX_DIFF_LENGTH must be a positive integer." fi +is_interactive() { + [ -t 0 ] && [ -t 1 ] +} + +use_gum() { + [ "$TUI_ENABLED" != "0" ] && is_interactive && command_exists gum && [ "${TERM:-}" != "dumb" ] +} + +ui_header() { + local text="$1" + if use_gum; then + gum style --bold --border rounded --padding "0 2" "$text" + else + printf '\n== %s ==\n' "$text" + fi +} + +ui_note() { + local text="$1" + if use_gum; then + gum style --faint "$text" + else + printf '%s\n' "$text" + fi +} + +ui_success() { + local text="$1" + if use_gum; then + gum style --bold "✓ $text" + else + printf '✓ %s\n' "$text" + fi +} + +ui_warn() { + local text="$1" + if use_gum; then + gum style --bold "! $text" + else + printf '! %s\n' "$text" + fi +} + +ui_confirm() { + local prompt="$1" + if use_gum; then + gum confirm "$prompt" + else + local answer + read -r -p "$prompt [y/N] " answer + [[ "$answer" =~ ^[Yy]$ ]] + fi +} + +ui_choose() { + local prompt="$1" + shift + + if use_gum; then + gum choose --header "$prompt" "$@" + return + fi + + local options=("$@") + local i choice + printf '%s\n' "$prompt" >&2 + for ((i = 0; i < ${#options[@]}; i++)); do + printf ' %d) %s\n' "$((i + 1))" "${options[$i]}" >&2 + done + while true; do + read -r -p "> " choice + if [[ "$choice" =~ ^[0-9]+$ ]] && [ "$choice" -ge 1 ] && [ "$choice" -le "${#options[@]}" ]; then + printf '%s\n' "${options[$((choice - 1))]}" + return + fi + printf 'Choose a number between 1 and %d.\n' "${#options[@]}" >&2 + done +} + trim_value() { local value="$1" - # shellcheck disable=SC2001 - echo "$value" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//' + printf '%s' "$value" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//' } build_chat_prompt() { local diff_content="$1" cat <(): -Description: +Description: -- Use one of these types: feat, fix, docs, chore, refactor, test, build, ci, style -- The scope is optional, but if identifiable, use the folder or feature name (e.g., auth, api, ui) +Rules: +- Allowed types: feat, fix, docs, chore, refactor, test, build, ci, style +- Scope is optional; use a concise feature/folder scope when clear - Keep the title under 72 characters -- Write the description in full sentences and include reasoning if possible +- Do not use Markdown fences +- Keep the description concise but informative Git diff: $diff_content @@ -50,21 +159,11 @@ resolve_provider_api_key() { local provider="$1" case "$provider" in - avalapis) - echo "${GITAI_AVALAPIS_API_KEY:-$DEFAULT_API_KEY}" - ;; - openrouter) - echo "${GITAI_OPENROUTER_API_KEY:-$DEFAULT_API_KEY}" - ;; - cloudflare) - echo "${GITAI_CLOUDFLARE_API_KEY:-$DEFAULT_API_KEY}" - ;; - google) - echo "${GITAI_GOOGLE_API_KEY:-$DEFAULT_API_KEY}" - ;; - *) - echo "" - ;; + avalapis) printf '%s' "${GITAI_AVALAPIS_API_KEY:-$DEFAULT_API_KEY}" ;; + openrouter) printf '%s' "${GITAI_OPENROUTER_API_KEY:-$DEFAULT_API_KEY}" ;; + cloudflare) printf '%s' "${GITAI_CLOUDFLARE_API_KEY:-$DEFAULT_API_KEY}" ;; + google) printf '%s' "${GITAI_GOOGLE_API_KEY:-$DEFAULT_API_KEY}" ;; + *) printf '' ;; esac } @@ -93,93 +192,69 @@ set_provider_defaults() { return 1 ;; esac - - return 0 } call_chat_completions_api() { local provider="$1" local prompt="$2" local api_key="$3" + local response JSON_PAYLOAD=$(jq -n \ --arg model "$ATTEMPT_MODEL" \ --arg user "$prompt" \ - '{ - model: $model, - messages: [ - {role: "user", content: $user} - ], - temperature: 0.4 - }') + '{model: $model, messages: [{role: "user", content: $user}], temperature: 0.4}') local -a headers headers=(-H "Content-Type: application/json" -H "Authorization: Bearer $api_key") if [ "$provider" = "openrouter" ]; then - headers+=(-H "HTTP-Referer: https://github.com" -H "X-Title: GitAI") + headers+=(-H "HTTP-Referer: https://github.com/jaavid/gitai" -H "X-Title: GitAI") fi - RESPONSE=$(curl -sfL "$ATTEMPT_API_BASE" "${headers[@]}" -d "$JSON_PAYLOAD" 2>&1) || { - LAST_ERROR="API request failed: $RESPONSE" + if ! response=$(curl -sfL "$ATTEMPT_API_BASE" "${headers[@]}" -d "$JSON_PAYLOAD" 2>&1); then + LAST_ERROR="API request failed: $response" return 1 - } + fi - COMMIT_MESSAGE=$(echo "$RESPONSE" | jq -r '.choices[0].message.content // empty') + COMMIT_MESSAGE=$(printf '%s' "$response" | jq -r '.choices[0].message.content // empty') if [ -z "$COMMIT_MESSAGE" ]; then LAST_ERROR="Empty/invalid response from provider '$provider'" return 1 fi - - return 0 } call_google_api() { local prompt="$1" local api_key="$2" + local response JSON_PAYLOAD=$(jq -n \ --arg user "$prompt" \ - '{ - contents: [ - { - role: "user", - parts: [ - {text: $user} - ] - } - ], - generationConfig: { - temperature: 0.4 - } - }') - - RESPONSE=$(curl -sfL "$ATTEMPT_API_BASE?key=$api_key" \ + '{contents: [{role: "user", parts: [{text: $user}]}], generationConfig: {temperature: 0.4}}') + + if ! response=$(curl -sfL "$ATTEMPT_API_BASE?key=$api_key" \ -H "Content-Type: application/json" \ - -d "$JSON_PAYLOAD" 2>&1) || { - LAST_ERROR="API request failed: $RESPONSE" + -d "$JSON_PAYLOAD" 2>&1); then + LAST_ERROR="API request failed: $response" return 1 - } + fi - COMMIT_MESSAGE=$(echo "$RESPONSE" | jq -r '.candidates[0].content.parts[0].text // empty') + COMMIT_MESSAGE=$(printf '%s' "$response" | jq -r '.candidates[0].content.parts[0].text // empty') if [ -z "$COMMIT_MESSAGE" ]; then LAST_ERROR="Empty/invalid response from provider 'google'" return 1 fi - - return 0 } try_provider() { local provider="$1" local prompt="$2" - - if ! set_provider_defaults "$provider"; then - return 1 - fi - local provider_key + + set_provider_defaults "$provider" || return 1 provider_key="$(resolve_provider_api_key "$provider")" + if [ -z "$provider_key" ]; then LAST_ERROR="Missing API key for provider '$provider'." return 1 @@ -197,43 +272,92 @@ try_provider() { fi } -echo -echo "==== Git Status ====" -git status -echo "====================" -echo - -if [[ -n "$(git status --porcelain)" ]]; then - read -p "Stage all changes (git add -A)? (y/n): " ADD_CONFIRM - if [[ "$ADD_CONFIRM" =~ ^[Yy]$ ]]; then - git add -A - echo "All changes staged." - else - echo "Continuing without adding files." - fi +show_help() { + cat <<'HELP' +GitAI — AI-assisted Conventional Commits + +Usage: + gitai [options] + +Options: + --push Push after committing (asks for remote if needed) + --push-all Push the new commit to every configured remote + --no-push Never ask to push + --no-tui Disable Gum UI and use plain terminal prompts + -v, --version Show version + -h, --help Show this help + +Configuration: + ~/.config/gitai/config User-wide config + /.gitai Repository-specific config + +Environment: + GITAI_PROVIDER Provider or fallback chain + GITAI_TUI=0 Disable terminal UI + GITAI_MAX_DIFF_LENGTH Maximum staged diff characters sent to AI +HELP +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --push) AUTO_PUSH_MODE="ask-remote" ;; + --push-all) AUTO_PUSH_MODE="all" ;; + --no-push) AUTO_PUSH_MODE="none" ;; + --no-tui) TUI_ENABLED=0 ;; + -v|--version) printf 'gitai %s\n' "$VERSION"; exit 0 ;; + -h|--help) show_help; exit 0 ;; + *) fail "Unknown option: $1 (try --help)" ;; + esac + shift +done + +ui_header "GitAI · $(basename "$GIT_ROOT")" +CURRENT_BRANCH="$(git symbolic-ref --quiet --short HEAD 2>/dev/null || true)" +if [ -n "$CURRENT_BRANCH" ]; then + ui_note "Branch: $CURRENT_BRANCH" +else + ui_warn "Detached HEAD: committing is allowed, pushing is disabled." fi -# Check for staged changes only -GIT_DIFF=$(git diff --cached --no-ext-diff) +git status --short -if [ -z "$GIT_DIFF" ]; then - echo "No staged changes found. Stage your changes first." - exit 1 +if [ -n "$(git status --porcelain)" ]; then + STAGED_COUNT="$(git diff --cached --name-only | wc -l | tr -d ' ')" + UNSTAGED_COUNT="$(git diff --name-only | wc -l | tr -d ' ')" + UNTRACKED_COUNT="$(git ls-files --others --exclude-standard | wc -l | tr -d ' ')" + ui_note "Staged: $STAGED_COUNT · Unstaged: $UNSTAGED_COUNT · Untracked: $UNTRACKED_COUNT" fi -if [[ $(git diff --name-only) ]]; then - echo "⚠️ Warning: You have unstaged changes that will NOT be included in this commit." +if [ -n "$(git status --porcelain)" ] && [ -z "$(git diff --cached --name-only)" ]; then + STAGE_ACTION=$(ui_choose "No staged changes. What should GitAI do?" \ + "Stage all changes" \ + "Cancel") + case "$STAGE_ACTION" in + "Stage all changes") git add -A; ui_success "All changes staged" ;; + *) exit 0 ;; + esac +elif [ -n "$(git diff --name-only)$(git ls-files --others --exclude-standard)" ]; then + STAGE_ACTION=$(ui_choose "There are changes outside the current index." \ + "Keep current staged changes" \ + "Stage all changes" \ + "Cancel") + case "$STAGE_ACTION" in + "Stage all changes") git add -A; ui_success "All changes staged" ;; + "Cancel") exit 0 ;; + esac fi +GIT_DIFF="$(git diff --cached --no-ext-diff)" +[ -n "$GIT_DIFF" ] || fail "No staged changes found." + DIFF_LENGTH=${#GIT_DIFF} TRIMMED_DIFF="$GIT_DIFF" if [ "$DIFF_LENGTH" -gt "$MAX_DIFF_LENGTH" ]; then - echo "⚠️ Diff is too long ($DIFF_LENGTH characters). Trimming to $MAX_DIFF_LENGTH characters." + ui_warn "Diff is $DIFF_LENGTH characters; sending the first $MAX_DIFF_LENGTH characters." TRIMMED_DIFF="${GIT_DIFF:0:$MAX_DIFF_LENGTH}" fi -PROMPT=$(build_chat_prompt "$TRIMMED_DIFF") - +PROMPT="$(build_chat_prompt "$TRIMMED_DIFF")" IFS=',' read -r -a PROVIDER_CHAIN <<< "$PROVIDER_CHAIN_RAW" COMMIT_MESSAGE="" LAST_ERROR="" @@ -241,48 +365,92 @@ SUCCESS_PROVIDER="" for raw_provider in "${PROVIDER_CHAIN[@]}"; do provider="$(trim_value "$raw_provider")" + [ -n "$provider" ] || continue - if [ -z "$provider" ]; then - continue - fi - - echo "Trying provider: $provider" - + ui_note "Trying provider: $provider" if try_provider "$provider" "$PROMPT"; then SUCCESS_PROVIDER="$provider" break fi - - echo "⚠️ Provider '$provider' failed: $LAST_ERROR" + ui_warn "Provider '$provider' failed: $LAST_ERROR" done -if [ -z "$SUCCESS_PROVIDER" ]; then - echo "All configured providers failed. Last error: $LAST_ERROR" >&2 - exit 2 +[ -n "$SUCCESS_PROVIDER" ] || fail "All configured providers failed. Last error: $LAST_ERROR" +ui_success "Generated with $SUCCESS_PROVIDER" + +TITLE="$(printf '%s\n' "$COMMIT_MESSAGE" | sed -n 's/^[Tt]itle:[[:space:]]*//p' | head -n 1)" +DESCRIPTION="$(printf '%s\n' "$COMMIT_MESSAGE" | sed -n 's/^[Dd]escription:[[:space:]]*//p' | head -n 1)" + +[ -n "$TITLE" ] || fail "Provider response did not contain a Title line." +[ -n "$DESCRIPTION" ] || DESCRIPTION="AI-generated commit from staged changes." + +ui_header "Suggested commit" +printf '%s\n\n%s\n' "$TITLE" "$DESCRIPTION" + +if ! ui_confirm "Create this commit?"; then + ui_note "Commit canceled." + exit 0 fi -echo "✅ Provider succeeded: $SUCCESS_PROVIDER" +git commit -m "$TITLE" -m "$DESCRIPTION" +ui_success "Commit created" -echo -echo "==== Suggested Commit Message ====" -echo "$COMMIT_MESSAGE" -echo "==================================" -echo +if [ -z "$CURRENT_BRANCH" ]; then + exit 0 +fi -read -p "Use this commit message? (y/n): " CONFIRM +REMOTE_NAMES=() +while IFS= read -r remote; do + [ -n "$remote" ] && REMOTE_NAMES+=("$remote") +done < <(git remote) -if [[ "$CONFIRM" =~ ^[Yy]$ ]]; then - TITLE=$(echo "$COMMIT_MESSAGE" | grep -i '^Title:' | sed 's/^[Tt]itle:[[:space:]]*//') - DESCRIPTION=$(echo "$COMMIT_MESSAGE" | grep -i '^Description:' | sed 's/^[Dd]escription:[[:space:]]*//') +if [ "${#REMOTE_NAMES[@]}" -eq 0 ]; then + ui_note "No Git remotes configured; push skipped." + exit 0 +fi - git commit -m "$TITLE" -m "$DESCRIPTION" - echo - read -p "Push to remote? (y/n): " PUSH_CONFIRM - if [[ "$PUSH_CONFIRM" =~ ^[Yy]$ ]]; then - git push - else - echo "Push skipped." +push_remote() { + local remote="$1" + ui_note "Pushing $CURRENT_BRANCH → $remote" + git push "$remote" "$CURRENT_BRANCH" + ui_success "Pushed to $remote" +} + +push_all_remotes() { + local remote + for remote in "${REMOTE_NAMES[@]}"; do + push_remote "$remote" + done +} + +case "$AUTO_PUSH_MODE" in + none) exit 0 ;; + all) push_all_remotes; exit 0 ;; +esac + +if [ "${#REMOTE_NAMES[@]}" -eq 1 ]; then + if [ "$AUTO_PUSH_MODE" = "ask-remote" ] || ui_confirm "Push to ${REMOTE_NAMES[0]}?"; then + push_remote "${REMOTE_NAMES[0]}" fi -else - echo "Commit canceled." + exit 0 fi + +PUSH_OPTIONS=("Skip push" "All remotes") +for remote in "${REMOTE_NAMES[@]}"; do + url="$(git remote get-url --push "$remote" 2>/dev/null || git remote get-url "$remote" 2>/dev/null || true)" + if [ -n "$url" ]; then + PUSH_OPTIONS+=("$remote · $url") + else + PUSH_OPTIONS+=("$remote") + fi +done + +PUSH_SELECTION="$(ui_choose "Where should this commit be pushed?" "${PUSH_OPTIONS[@]}")" +case "$PUSH_SELECTION" in + "Skip push") exit 0 ;; + "All remotes") push_all_remotes ;; + *) + SELECTED_REMOTE="${PUSH_SELECTION%% · *}" + push_remote "$SELECTED_REMOTE" + ;; +esac From 17d2c88833ab7808d1630bd82636ae0bf63fd2ae Mon Sep 17 00:00:00 2001 From: Javid Date: Thu, 1 Oct 2026 07:25:37 +0200 Subject: [PATCH 2/5] build: add Homebrew formula --- Formula/gitai.rb | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 Formula/gitai.rb diff --git a/Formula/gitai.rb b/Formula/gitai.rb new file mode 100644 index 0000000..9bb4b65 --- /dev/null +++ b/Formula/gitai.rb @@ -0,0 +1,18 @@ +class Gitai < Formula + desc "AI-assisted Conventional Commits with a terminal-native workflow" + homepage "https://github.com/jaavid/gitai" + head "https://github.com/jaavid/gitai.git", branch: "main" + license "MIT" + + depends_on "gum" + depends_on "jq" + + def install + bin.install "gitai" + end + + test do + system "git", "init", testpath + assert_match "gitai 0.2.0", shell_output("#{bin}/gitai --version") + end +end From 400823caab4df838f9118092d7e1913999a541ae Mon Sep 17 00:00:00 2001 From: Javid Date: Thu, 1 Oct 2026 07:26:04 +0200 Subject: [PATCH 3/5] docs: document Homebrew TUI and multi-remote workflow --- README.md | 165 ++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 124 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 0fbea42..5d01eb1 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,74 @@ # GitAI -**GitAI** is a small Bash script that generates commit messages using an AI model. The script inspects your staged changes and asks the configured API to suggest a commit title and description following the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) style. +GitAI is a small terminal-first assistant for generating Conventional Commit messages from staged Git changes, reviewing the result, committing it, and optionally pushing it to one or more remotes. + +It stays close to normal Git instead of replacing your workflow. The UI uses [Gum](https://github.com/charmbracelet/gum) when available and falls back to plain terminal prompts when it is not. ## Features -- Works with your existing Git workflow. -- Supports multiple providers: AvalAI, OpenRouter, Cloudflare Workers AI, and Google AI Studio (Gemini API). -- Warns about unstaged changes before committing. -- Trims very large diffs to avoid sending excessively long requests. -- Interactive prompts let you review and accept the generated message. +- Generates Conventional Commit titles and descriptions from staged changes. +- Supports AvalAI, OpenRouter, Cloudflare Workers AI, and Google AI Studio (Gemini API). +- Supports provider fallback chains. +- Terminal UI for staging, reviewing, committing, and choosing a push target. +- Multi-remote aware: push to one remote or all configured remotes. +- Works well in Warp, iTerm2, Terminal.app, and other modern terminals without requiring terminal-specific APIs. +- Falls back to simple text prompts when Gum is unavailable or `GITAI_TUI=0`. +- Warns about changes outside the index and lets you keep the current staged set or stage everything. +- Trims very large diffs before sending them to an AI provider. +- User-wide and repository-specific configuration. ## Requirements - `bash` -- [`curl`](https://curl.se/) for making HTTP requests -- [`jq`](https://stedolan.github.io/jq/) for parsing JSON +- `git` +- `curl` +- [`jq`](https://jqlang.github.io/jq/) +- [`gum`](https://github.com/charmbracelet/gum) for the richer terminal UI (optional outside Homebrew installs) -## Installation +## Install with Homebrew -Clone this repository and place the `gitai` script somewhere in your `PATH` or run it directly from the repository root: +This repository contains a Homebrew formula. Until a dedicated `homebrew-tap` repository exists, add this repository as a custom tap and install the HEAD formula: ```bash -# Clone the repository -git clone -cd gitai +brew tap jaavid/gitai https://github.com/jaavid/gitai +brew install --HEAD jaavid/gitai/gitai +``` + +Upgrade later with: -# Optional: copy the script somewhere in your PATH -cp gitai /usr/local/bin/ +```bash +brew update +brew upgrade --fetch-HEAD gitai +``` + +Homebrew installs `jq` and `gum` automatically. + +### Manual install + +```bash +git clone https://github.com/jaavid/gitai.git +cd gitai +install -m 755 gitai /usr/local/bin/gitai ``` ## Configuration -GitAI needs API credentials. You can either use a shared key (`GITAI_API_KEY`) or provider-specific keys, and you can set them as environment variables or in a `.gitai` file next to the script. +GitAI reads configuration in this order, with later files overriding earlier values: + +1. Legacy `.gitai` next to the executable, if present. +2. `~/.config/gitai/config` (or `$XDG_CONFIG_HOME/gitai/config`). +3. `.gitai` in the current repository root. + +For API keys, prefer the user-wide config instead of committing credentials into a repository. -Example `.gitai` file: +Create it with: + +```bash +mkdir -p ~/.config/gitai +$EDITOR ~/.config/gitai/config +``` + +Example: ```bash export GITAI_PROVIDER="google,openrouter,cloudflare,avalapis" @@ -46,7 +81,7 @@ export GITAI_CLOUDFLARE_ACCOUNT_ID="your-cloudflare-account-id" ### Provider options -Set `GITAI_PROVIDER` to one provider **or a comma-separated fallback chain**: +Set `GITAI_PROVIDER` to one provider or a comma-separated fallback chain: - `avalapis` (default) - `openrouter` @@ -55,56 +90,104 @@ Set `GITAI_PROVIDER` to one provider **or a comma-separated fallback chain**: Examples: -- `GITAI_PROVIDER="google,openrouter,cloudflare,avalapis"` -- `GITAI_PROVIDER="openrouter"` - -Additional provider-specific variables: +```bash +export GITAI_PROVIDER="google,openrouter,cloudflare,avalapis" +``` -- `openrouter` - - Optional: `GITAI_MODEL` (default `openai/gpt-4o-mini`) -- `cloudflare` - - Required unless `GITAI_API_BASE` is set: `GITAI_CLOUDFLARE_ACCOUNT_ID` - - Optional: `GITAI_MODEL` (default `@cf/meta/llama-3.3-70b-instruct-fp8-fast`) -- `google` - - Optional: `GITAI_MODEL` (default `gemini-2.0-flash`) +```bash +export GITAI_PROVIDER="openrouter" +``` Global optional variables: -- `GITAI_API_BASE`: Override endpoint URL (advanced use) -- `GITAI_MAX_DIFF_LENGTH`: Max diff chars sent to model (default `12000`) -- `GITAI_MODEL`: Shared default model override +- `GITAI_API_BASE`: override endpoint URL for advanced/custom setups. +- `GITAI_MAX_DIFF_LENGTH`: maximum staged diff characters sent to a provider (default: `12000`). +- `GITAI_MODEL`: shared model override. +- `GITAI_TUI=0`: disable Gum and use plain terminal prompts. -Per-provider API keys (recommended for fallback chains): +Per-provider API keys: - `GITAI_AVALAPIS_API_KEY` - `GITAI_OPENROUTER_API_KEY` - `GITAI_CLOUDFLARE_API_KEY` - `GITAI_GOOGLE_API_KEY` -Per-provider model overrides (optional): +Per-provider model overrides: - `GITAI_AVALAPIS_MODEL` - `GITAI_OPENROUTER_MODEL` - `GITAI_CLOUDFLARE_MODEL` - `GITAI_GOOGLE_MODEL` +Cloudflare additionally needs `GITAI_CLOUDFLARE_ACCOUNT_ID` unless `GITAI_API_BASE` is explicitly set. + ## Usage -Stage your changes and run the script: +From any Git repository: + +```bash +gitai +``` + +A typical flow is: + +1. GitAI shows the repository, branch, and short status. +2. If needed, choose whether to stage all changes or keep only the current staged set. +3. GitAI sends only the staged diff to the configured AI provider chain. +4. Review the generated Conventional Commit message. +5. Confirm the commit. +6. Choose whether and where to push. + +If the repository has multiple remotes, GitAI presents all of them together with an **All remotes** option. The current branch is pushed explicitly to each selected remote. + +### Push options + +```bash +# Commit, then ask where to push +gitai --push + +# Commit and push to every configured remote +gitai --push-all + +# Commit without offering to push +gitai --no-push +``` + +### Disable the richer TUI + +```bash +gitai --no-tui +``` + +or: ```bash -git add file.txt -./gitai +export GITAI_TUI=0 ``` -GitAI will print git status, send your diff to the configured provider, and show the suggested commit message. You can then choose whether to commit and optionally push. +## Warp compatibility + +GitAI deliberately uses normal shell input/output rather than a full-screen alternate-buffer UI. In Warp this means each run remains a normal command block, output stays searchable/copyable, and Git/GitAI subprocess output remains visible in the same block. + +No Warp-specific configuration is required. -A complete example is available in [`examples/basic_usage.sh`](examples/basic_usage.sh). +## CLI reference + +```text +gitai [options] + +--push Push after committing; asks for a remote when necessary +--push-all Push to every configured remote +--no-push Never ask to push +--no-tui Use plain prompts instead of Gum +-v, --version +-h, --help +``` ## Contributing -Contributions are welcome! Please open issues or pull requests on GitHub. +Issues and pull requests are welcome. ## License -This project is licensed under the MIT License. See [LICENSE](LICENSE) for details. +MIT — see [LICENSE](LICENSE). From 689510bdf59b69d20a38b5e917f5387741516b0d Mon Sep 17 00:00:00 2001 From: Javid Date: Thu, 1 Oct 2026 07:26:31 +0200 Subject: [PATCH 4/5] ci: validate script and Homebrew formula --- .github/workflows/ci.yml | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..fb8c4c9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Validate Bash syntax + run: bash -n gitai + + - name: Run ShellCheck + run: shellcheck gitai + + - name: Validate Homebrew formula Ruby syntax + run: ruby -c Formula/gitai.rb + + - name: Verify executable bit and CLI version + run: | + test -x gitai + tmp="$(mktemp -d)" + git -C "$tmp" init + (cd "$tmp" && "$GITHUB_WORKSPACE/gitai" --version) From e652f1db8db9897ae644eed5e35b4c8fc7f32e84 Mon Sep 17 00:00:00 2001 From: Javid Date: Thu, 1 Oct 2026 07:27:09 +0200 Subject: [PATCH 5/5] ci: allow dynamic config sources in shellcheck --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb8c4c9..742930e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: run: bash -n gitai - name: Run ShellCheck - run: shellcheck gitai + run: shellcheck -e SC1091 gitai - name: Validate Homebrew formula Ruby syntax run: ruby -c Formula/gitai.rb