# SourceTree Ai Commit message

> Source: <https://gist.github.com/AlexApostolSource/45a1bdd43d5fae9b03c06b53ec70c29c>
> Published: 2026-07-12 15:33:15+00:00

| #!/bin/bash | |
| # SourceTree Custom Action: generates a commit message with Claude from the | |
| # staged files and commits directly. | |
| # Usage from SourceTree: Parameter = $REPO | |
| set -euo pipefail | |
| # GUI apps on macOS (SourceTree included) start with a minimal PATH, so we | |
| # need to manually add the paths where the `claude` binary might live. | |
| export PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH" | |
| REPO="${1:-}" | |
| if [ -z "$REPO" ]; then | |
| echo "Error: missing repository path (use \$REPO as the Custom Action parameter)." | |
| exit 1 | |
| fi | |
| cd "$REPO" | |
| if git diff --cached --quiet; then | |
| echo "No staged files. Run 'git add' on your changes before running this action." | |
| exit 1 | |
| fi | |
| CACHE_DIR="$HOME/.cache/sourcetree-ai-commit" | |
| mkdir -p "$CACHE_DIR" | |
| CACHE_KEY=$(printf '%s' "$REPO" | tr '/' '_') | |
| CACHE_FILE="$CACHE_DIR/${CACHE_KEY}.txt" | |
| generate_message() { | |
| if ! command -v claude >/dev/null 2>&1; then | |
| echo "Error: 'claude' CLI not found in PATH." | |
| exit 1 | |
| fi | |
| STAT=$(git diff --cached --stat) | |
| RAW_DIFF=$(git diff --cached -- . \ | |
| ':(exclude)*.lock' \ | |
| ':(exclude)*.resolved' \ | |
| ':(exclude)*.xcworkspace/**' \ | |
| ':(exclude)*.pbxproj') | |
| # Truncate with bash parameter expansion instead of "| head -c": with | |
| # `pipefail` enabled, `head -c` closes the pipe as soon as it has enough | |
| # bytes, `git diff` gets SIGPIPE and the whole pipeline "fails" (exit 141), | |
| # killing the script with no output because of `set -e`. | |
| DIFF="${RAW_DIFF:0:12000}" | |
| PROMPT="You are a commit message generator. Analyze the staged diff and return ONLY the commit message, with no explanations and no surrounding quotes. | |
| Rules: | |
| - Conventional Commits format (feat, fix, refactor, chore, docs, test, style, perf) followed by ':'. | |
| - English, imperative mood, subject line max 72 characters. | |
| - If the change warrants it, add a short bulleted body after a blank line explaining 'why', not 'what'. | |
| - If there are several unrelated changes, prioritize the most relevant one in the subject. | |
| - Plain text only: no markdown whatsoever — no backticks, no bold/italic with * or _, no headers, no code fences. Write identifiers (class/function names, etc.) as plain words, not wrapped in special quoting. | |
| File summary: | |
| ${STAT} | |
| Diff: | |
| ${DIFF}" | |
| MESSAGE=$(claude -p "$PROMPT" --output-format text 2>/dev/null | sed -e '/^[[:space:]]*$/d' -e '1{/^```/d;}' -e '${/^```$/d;}' -e 's/`//g') | |
| if [ -z "$MESSAGE" ]; then | |
| echo "Error: Claude did not return a message." | |
| exit 1 | |
| fi | |
| } | |
| if [ -f "$CACHE_FILE" ]; then | |
| MESSAGE=$(<"$CACHE_FILE") | |
| else | |
| generate_message | |
| fi | |
| escape_for_applescript() { | |
| local input="$1" | |
| input="${input//\\/\\\\}" | |
| input="${input//\"/\\\"}" | |
| printf '%s' "$input" | |
| } | |
| while true; do | |
| ESCAPED_MESSAGE=$(escape_for_applescript "$MESSAGE") | |
| # AppleScript doesn't interpret "\n" inside a string: each line break must be | |
| # concatenated explicitly as the ASCII 10 character between literals. | |
| AS_DEFAULT_ANSWER=$(printf '%s' "$ESCAPED_MESSAGE" | awk 'BEGIN{ORS=""} {if(NR>1) printf " & (ASCII character 10) & "; printf "\"%s\"", $0}') | |
| set +e | |
| DIALOG_RESULT=$(osascript <<EOF | |
| set msgText to $AS_DEFAULT_ANSWER | |
| set dialogResult to display dialog "Review and edit the commit message generated by Claude:" default answer msgText with title "Confirm commit" buttons {"Cancel", "Regenerate", "Commit"} default button "Commit" cancel button "Cancel" with icon note | |
| return (button returned of dialogResult) & linefeed & (text returned of dialogResult) | |
| EOF | |
| ) | |
| DIALOG_EXIT=$? | |
| set -e | |
| if [ $DIALOG_EXIT -ne 0 ]; then | |
| echo "Commit canceled." | |
| exit 0 | |
| fi | |
| # First line is the button name, everything after the first newline is | |
| # the (possibly multi-line) edited message text. | |
| DIALOG_BUTTON="${DIALOG_RESULT%%$'\n'*}" | |
| DIALOG_TEXT="${DIALOG_RESULT#*$'\n'}" | |
| if [ "$DIALOG_BUTTON" = "Regenerate" ]; then | |
| generate_message | |
| continue | |
| fi | |
| MESSAGE="$DIALOG_TEXT" | |
| break | |
| done | |
| TMP_MSG_FILE=$(mktemp) | |
| trap 'rm -f "$TMP_MSG_FILE"' EXIT | |
| echo "$MESSAGE" > "$TMP_MSG_FILE" | |
| git commit -F "$TMP_MSG_FILE" | |
| echo "$MESSAGE" > "$CACHE_FILE" | |
| echo "Commit created with message:" | |
| echo "---" | |
| echo "$MESSAGE" |
