-
-
Notifications
You must be signed in to change notification settings - Fork 8
Expand file tree
/
Copy pathfaff.sh
More file actions
executable file
·513 lines (448 loc) · 20.6 KB
/
faff.sh
File metadata and controls
executable file
·513 lines (448 loc) · 20.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
#!/usr/bin/env bash
# Drop the faff, dodge the judgment, get back to coding.
#
# Another bloody AI commit generator, but this one stays local 🦙
# System prompt for commit message generation
readonly SYSTEM_PROMPT='You will act as a git commit message generator. When receiving a git diff, you will ONLY output the commit message itself, nothing else. No explanations, no questions, no additional comments.
Commits must follow the Conventional Commits 1.0.0 specification and be further refined using the rules outlined below.
The commit message must include the following fields: "type", "description", "body".
The commit message must be in the format:
<type>([optional scope]): <description>
[body]
[optional footer(s)]
- "type": Choose one of the following:
- feat: MUST be used when commits that introduce new features or functionalities to the project (this correlates with MINOR in Semantic Versioning)
- fix: MUST be used when commits address bug fixes or resolve issues in the project (this correlates with PATCH in Semantic Versioning)
- types other than feat: and fix: can be used in your commit messages:
- build: Used when a commit affects the build system or external dependencies. It includes changes to build scripts, build configurations, or build tools used in the project
- chore: Typically used for routine or miscellaneous tasks related to the project, such as code reformatting, updating dependencies, or making general project maintenance
- ci: CI stands for continuous integration. This type is used for changes to the project'\''s continuous integration or deployment configurations, scripts, or infrastructure
- docs: Documentation plays a vital role in software projects. The docs type is used for commits that update or add documentation, including readme files, API documentation, user guides or code comments that act as documentation
- i18n: This type is used for commits that involve changes related to internationalization or localization. It includes changes to localization files, translations, or internationalization-related configurations.
- perf: Short for performance, this type is used when a commit improves the performance of the code or optimizes certain functionalities
- refactor: Commits typed as refactor involve making changes to the codebase that neither fix a bug nor add a new feature. Refactoring aims to improve code structure, organization, or efficiency without changing external behavior
- revert: Commits typed as revert are used to undo previous commits. They are typically used to reverse changes made in previous commits
- style: The style type is used for commits that focus on code style changes, such as formatting, indentation, or whitespace modifications. These commits do not affect the functionality of the code but improve its readability and maintainability
- test: Used for changes that add or modify test cases, test frameworks, or other related testing infrastructure.
- "description": A very brief summary line (max 72 characters). Do not end with a period. Use imperative mood (e.g., '\''add feature'\'' not '\''added feature'\'').
- "body": A more detailed explanation of the changes, focusing on what problem this commit solves and why this change was necessary. Small changes can be a concise, specific sentence. Larger changes should be a bulleted list of concise, specific changes. Include optional footers like BREAKING CHANGE here.
Guidelines for writing the commit message:
- The <description> must be in English
- The [optional scope] must be in English
- The <description> must be imperative mood
- The <description> must avoid capitalization
- The <description> will not have a period at the end
- The <description> will have a maximum of 72 characters including any spaces or special characters
- The <description> must avoid using the <type> as the first word
- Follow the <description> with a blank line, then the [body].
- The [body] must be in English
- The [body] should provide a more detailed explanation. Small changes as one sentence, larger changes as a bulleted list.
- The [body] should explain what and why
- The [body] will be objective
- Bullet points in the [body] start with "-"
- The [optional footer(s)] can be used for things like referencing issues or indicating breaking changes.
Specification for Conventional Commits:
- Commits MUST be prefixed with a type, which consists of a noun, feat, fix, etc., followed by the OPTIONAL scope, OPTIONAL !, and REQUIRED terminal colon and space.
- A scope MAY be provided after a type. A scope MUST consist of a noun describing a section of the codebase surrounded by parenthesis, e.g., fix(parser):
- A description MUST immediately follow the colon and space after the type/scope prefix. The description is a short summary of the code changes, e.g., fix: array parsing issue when multiple spaces were contained in string.
- A longer commit body MAY be provided after the short description, providing additional contextual information about the code changes. The body MUST begin one blank line after the description.
- A commit body is free-form and MAY consist of any number of newline separated paragraphs.
- One or more footers MAY be provided one blank line after the body. Each footer MUST consist of a word token, followed by either a :<space> or <space># separator, followed by a string value (this is inspired by the git trailer convention).
- A footer'\''s token MUST use - in place of whitespace characters, e.g., Acked-by (this helps differentiate the footer section from a multi-paragraph body). An exception is made for BREAKING CHANGE, which MAY also be used as a token.
- A footer'\''s value MAY contain spaces and newlines, and parsing MUST terminate when the next valid footer token/separator pair is observed.
- Breaking changes MUST be indicated in the type/scope prefix of a commit, or as an entry in the footer.
- If included as a footer, a breaking change MUST consist of the uppercase text BREAKING CHANGE, followed by a colon, space, and description, e.g., BREAKING CHANGE: environment variables now take precedence over config files.
- If included in the type/scope prefix, breaking changes MUST be indicated by a ! immediately before the :. If ! is used, BREAKING CHANGE: MAY be omitted from the footer section, and the commit description SHALL be used to describe the breaking change.
- The units of information that make up Conventional Commits MUST NOT be treated as case sensitive by implementors, with the exception of BREAKING CHANGE which MUST be uppercase.
- BREAKING-CHANGE MUST be synonymous with BREAKING CHANGE, when used as a token in a footer.'
# Spinner characters for progress indication
readonly SPINNER_CHARS=("⠋" "⠙" "⠹" "⠸" "⠼" "⠴" "⠦" "⠧" "⠇" "⠏")
readonly VERSION="0.2.0"
FAFF_MODEL=${FAFF_MODEL:-"qwen2.5-coder:7b"}
OLLAMA_HOST=${OLLAMA_HOST:-"localhost"}
OLLAMA_PORT=${OLLAMA_PORT:-"11434"}
OLLAMA_PROTOCOL=${OLLAMA_PROTOCOL:-"http"}
OLLAMA_BASE_URL="${OLLAMA_PROTOCOL}://${OLLAMA_HOST}:${OLLAMA_PORT}"
OLLAMA_API_CHAT="${OLLAMA_BASE_URL}/api/chat"
OLLAMA_API_BASE="${OLLAMA_BASE_URL}/api"
# Timeout in seconds for Ollama API calls
FAFF_TIMEOUT=${FAFF_TIMEOUT:-180}
# Optional auth token for Ollama
OLLAMA_TOKEN="${OLLAMA_TOKEN:-""}"
if [ -z "${OLLAMA_TOKEN}" ]; then
CURL_TOKEN_HEADER=()
else
CURL_TOKEN_HEADER=('-H' "Authorization: Bearer ${OLLAMA_TOKEN}")
fi
# Calculate next spinner index
function next_spinner_index() {
local current_index=$1
echo $(((current_index + 1) % ${#SPINNER_CHARS[@]}))
}
# Output error message to stderr
function error_exit() {
echo "Error: $1" >&2
exit "${2:-1}"
}
# Clean up temporary files
function cleanup_temp_files() {
rm -f "$@"
}
# Get allowed scopes from commitlint configuration files
function get_allowed_scopes() {
local config_files=(".commitlintrc.json" "commitlint.config.json")
for file in "${config_files[@]}"; do
if [[ -f "$file" ]]; then
jq -r '.rules."scope-enum"[2] // [] | join(", ")' "$file" 2>/dev/null
return
fi
done
echo ""
}
# Format download size in human-readable format (MB/GB)
function format_download_size() {
local bytes="$1"
local size unit
if [[ $bytes -ge 1073741824 ]]; then
# >= 1GB, show in GB
size=$(echo "scale=1; $bytes/1073741824" | bc 2>/dev/null || echo "0")
unit="GB"
else
# < 1GB, show in MB
size=$(echo "scale=0; $bytes/1048576" | bc 2>/dev/null || echo "0")
unit="MB"
fi
echo "${size}${unit}"
}
# Check dependencies
function check_dependencies() {
command -v bc &>/dev/null || error_exit "bc is not installed. Please install it and try again."
command -v curl &>/dev/null || error_exit "curl is not installed. Please install it and try again."
command -v jq &>/dev/null || error_exit "jq is not installed. Please install it and try again."
command -v timeout &>/dev/null || error_exit "timeout is not installed. Please install coreutils or uutils and try again."
git rev-parse --is-inside-work-tree &>/dev/null || error_exit "This script must be run inside a Git repository."
((BASH_VERSINFO[0] < 4)) && error_exit "bash version 4.0 or higher is not installed. Please install a recent version of bash and try again."
}
# Function to show spinner during API calls
function show_spinner() {
local pid=$1
local message="$2"
local i=0
while kill -0 $pid 2>/dev/null; do
local spin_char=${SPINNER_CHARS[$i]}
printf "\r%s %s" "$spin_char" "$message" >&2
i=$(next_spinner_index $i)
sleep 0.1
done
printf "\r%*s\r" "50" "" >&2 # Clear the spinner line completely
}
# Get the staged git diff
function get_git_diff() {
git --no-pager diff --staged --no-color --function-context | tr -d '\r'
}
# Function to generate the commit message using Ollama
function generate_commit_message() {
local diff="$1"
# Check for allowed scopes from commitlint configuration
local allowed_scopes
allowed_scopes=$(get_allowed_scopes)
# Create temporary files for all data to avoid ARG_MAX limits
local SYSTEM_PROMPT_FILE DIFF_FILE PAYLOAD_FILE RESPONSE_FILE EXIT_CODE_FILE
SYSTEM_PROMPT_FILE=$(mktemp)
DIFF_FILE=$(mktemp)
PAYLOAD_FILE=$(mktemp)
RESPONSE_FILE=$(mktemp)
EXIT_CODE_FILE=$(mktemp)
# Build system prompt, adding scope constraint if allowed scopes are found
if [[ -n "$allowed_scopes" ]]; then
printf '%s\n\nIMPORTANT: The [optional scope] MUST be one of these allowed values: %s. Do not use any other scope.' "$SYSTEM_PROMPT" "$allowed_scopes" >"$SYSTEM_PROMPT_FILE"
else
printf '%s' "$SYSTEM_PROMPT" >"$SYSTEM_PROMPT_FILE"
fi
# Write diff to temp file and escape for JSON using file input to avoid ARG_MAX
printf '%s' "$diff" >"$DIFF_FILE"
local GIT_DIFF
GIT_DIFF=$(jq -Rs . <"$DIFF_FILE")
# Build the JSON payload with optional scope constraint
if [[ -n "$allowed_scopes" ]]; then
# Convert comma-separated scopes to JSON array
local scope_array
scope_array=$(echo "$allowed_scopes" | jq -R 'split(", ")')
jq -n \
--arg model "$FAFF_MODEL" \
--rawfile system "$SYSTEM_PROMPT_FILE" \
--argjson diff_content "$GIT_DIFF" \
--argjson scopes "$scope_array" \
'{
model: $model,
messages: [
{
role: "system",
content: $system
},
{
role: "user",
content: ("Here is the diff:\n\n" + $diff_content)
}
],
stream: false,
format: {
type: "object",
properties: {
type: {
type: "string",
enum: ["feat", "fix", "build", "chore", "ci", "docs", "i18n", "perf", "refactor", "revert", "style", "test" ]
},
scope: {
type: "string",
enum: $scopes
},
description: {
type: "string"
},
body: {
type: "string"
}
},
required: ["type", "description"],
optional: ["scope", "body"]
},
options: {
"temperature": 0.3
}
}' >"$PAYLOAD_FILE"
else
jq -n \
--arg model "$FAFF_MODEL" \
--rawfile system "$SYSTEM_PROMPT_FILE" \
--argjson diff_content "$GIT_DIFF" \
'{
model: $model,
messages: [
{
role: "system",
content: $system
},
{
role: "user",
content: ("Here is the diff:\n\n" + $diff_content)
}
],
stream: false,
format: {
type: "object",
properties: {
type: {
type: "string",
enum: ["feat", "fix", "build", "chore", "ci", "docs", "i18n", "perf", "refactor", "revert", "style", "test" ]
},
description: {
type: "string"
},
body: {
type: "string"
}
},
required: ["type", "description"],
optional: ["body"]
},
options: {
"temperature": 0.3
}
}' >"$PAYLOAD_FILE"
fi
local response
local curl_exit_code=0
# Start the API call in background and show spinner
# Use --data-binary @file to avoid ARG_MAX limits with large payloads
(
timeout "$FAFF_TIMEOUT" curl -s -X POST "$OLLAMA_API_CHAT" \
"${CURL_TOKEN_HEADER[@]}" \
-H "Content-Type: application/json" \
--max-time "$FAFF_TIMEOUT" \
--data-binary "@${PAYLOAD_FILE}" >"$RESPONSE_FILE"
echo "$?" >"$EXIT_CODE_FILE"
) &
local api_pid=$!
show_spinner $api_pid "Generating commit message..."
wait $api_pid
# Clear the spinner line completely
printf "\r%*s\r" "50" "" >&2
# Read results
curl_exit_code=$(cat "$EXIT_CODE_FILE" 2>/dev/null || echo "1")
response=$(cat "$RESPONSE_FILE" 2>/dev/null || echo "")
# Clean up all temp files
cleanup_temp_files "$SYSTEM_PROMPT_FILE" "$DIFF_FILE" "$PAYLOAD_FILE" "$RESPONSE_FILE" "$EXIT_CODE_FILE"
if [ $curl_exit_code -ne 0 ]; then
echo "Error: Ollama API call failed with exit code $curl_exit_code." >&2
if [ $curl_exit_code -eq 124 ]; then
echo "Error: Request timed out after $FAFF_TIMEOUT seconds." >&2
fi
return 1
fi
# Check for error in response
if echo "$response" | jq -e '.error' >/dev/null 2>&1; then
local error_msg
error_msg=$(echo "$response" | jq -r '.error')
echo "Error: Ollama API returned an error: $error_msg" >&2
return 1
fi
local message_content
message_content=$(echo "$response" | jq -r '.message.content')
if [ -z "$message_content" ] || [ "$message_content" == "null" ]; then
echo "Error: Failed to extract message content from Ollama response." >&2
echo "Full response: $response" >&2
return 1
fi
# Attempt to parse the message content as JSON
local type scope description body
if ! type=$(echo "$message_content" | jq -r '.type // empty') ||
! scope=$(echo "$message_content" | jq -r '.scope // empty') ||
! description=$(echo "$message_content" | jq -r '.description // empty') ||
! body=$(echo "$message_content" | jq -r '.body // empty'); then
echo "Error: Could not parse type, description, or body from Ollama's message content." >&2
echo "Message content: $message_content" >&2
# Fallback: use the whole message content as the commit message if it's not JSON
# This might happen if the model doesn't strictly follow the JSON format instruction
echo "$message_content"
return 0
fi
if [ -z "$type" ] || [ -z "$description" ]; then
echo "Error: Ollama response missing 'type' or 'description'." >&2
echo "Parsed content: Type='$type', Description='$description'" >&2
echo "Message content from API: $message_content" >&2
# Fallback to using the raw message content if essential parts are missing
echo "$message_content"
return 0
fi
# Build commit message with optional scope
local final_commit_message
if [ -n "$scope" ] && [ "$scope" != "null" ]; then
final_commit_message="${type}(${scope}): ${description}"
else
final_commit_message="${type}: ${description}"
fi
if [ -n "$body" ] && [ "$body" != "null" ]; then
final_commit_message="${final_commit_message}\\n\\n${body}"
fi
echo -e "$final_commit_message"
}
function check_model() {
local model="$1"
local error
local completed
local total
local percent
local spin_char
local i=0
# Check if model exists by running curl/jq directly (avoids command injection via eval)
if ! curl -s "${CURL_TOKEN_HEADER[@]}" "${OLLAMA_API_BASE}/tags" | jq -e --arg M "$model" '.models[] | select(.name == $M)' >/dev/null; then
echo "Model '$model' not found. Attempting to pull it automatically..." >&2
echo "Downloading model '$model'. This may take several minutes..." >&2
local pull_payload
pull_payload=$(printf '{"name": "%s", "stream": true}' "$model")
# Use stream mode to show progress; curl command on a single line
curl -s "${CURL_TOKEN_HEADER[@]}" -X POST "${OLLAMA_API_BASE}/pull" -H "Content-Type: application/json" -d "$pull_payload" |
while read -r line; do
if echo "$line" | grep -q "error"; then
error=$(echo "$line" | jq -r '.error')
echo -e "\\rFailed to pull model '$model': $error " >&2
echo "Try using one of these available models instead:" >&2
curl -s "${CURL_TOKEN_HEADER[@]}" "${OLLAMA_API_BASE}/tags" | jq -r '.models[].name' | head -5 | sed 's/^/ - /' >&2
return 1
elif echo "$line" | grep -q "status"; then
# Check if this line contains progress information
if echo "$line" | jq -e '.completed' >/dev/null 2>&1 && echo "$line" | jq -e '.total' >/dev/null 2>&1; then
completed=$(echo "$line" | jq -r '.completed // 0')
total=$(echo "$line" | jq -r '.total // 0')
percent=0
if [[ $total != "0" && $total != "" && $total != "null" ]]; then
percent=$(echo "scale=0; 100*$completed/$total" | bc 2>/dev/null || echo "0")
fi
# Format sizes in human-readable format
local completed_formatted total_formatted
completed_formatted=$(format_download_size "$completed")
total_formatted=$(format_download_size "$total")
spin_char=${SPINNER_CHARS[$i]}
i=$(next_spinner_index $i)
echo -ne "\\r$spin_char Downloading: $percent% ($completed_formatted/$total_formatted) " >&2
else
# Show spinner even without detailed progress
spin_char=${SPINNER_CHARS[$i]}
i=$(next_spinner_index $i)
status=$(echo "$line" | jq -r '.status // "downloading"')
echo -ne "\\r$spin_char $status... " >&2
fi
fi
done
# Give Ollama a moment to index the new model
sleep 2
# Re-check if model was downloaded successfully
if curl -s "${CURL_TOKEN_HEADER[@]}" "${OLLAMA_API_BASE}/tags" | jq -e --arg M "$model" '.models[] | select(.name == $M)' >/dev/null; then
echo -e "\\rModel '$model' downloaded successfully! " >&2
return 0
else
echo -e "\\rSomething went wrong during download. Model '$model' not available. " >&2
return 1
fi
fi
return 0
}
# Function to check Ollama service and model
function check_ollama_service_and_model() {
# Check if Ollama service is running
if ! curl -s "${CURL_TOKEN_HEADER[@]}" -o /dev/null "${OLLAMA_API_BASE}/version"; then
error_exit "Ollama service is not running at ${OLLAMA_HOST}:${OLLAMA_PORT}.\nPlease start Ollama and try again."
fi
echo "Ollama service is running."
# Check if model exists using the new function
if ! check_model "$FAFF_MODEL"; then
error_exit "Failed to download or verify model '$FAFF_MODEL'"
fi
echo "Model '$FAFF_MODEL' is available."
}
# Function to handle user interaction
function confirm_commit() {
local generated_message="$1"
echo "Generated commit message:"
echo "-------------------------"
echo "$generated_message"
echo "-------------------------"
echo ""
read -p "Do you want to use or edit this commit message? (y/n/e): " choice
case "${choice,,}" in
y | yes)
git commit -m "$generated_message"
echo "Changes committed with the generated message."
;;
n | no)
echo "Generated commit message only (not committed):"
echo "$generated_message"
;;
e | edit)
git commit -m "$generated_message" --edit
echo "Changes committed with the edited message."
;;
*)
echo "Invalid input. Commit aborted."
;;
esac
}
# Main script logic
function main() {
check_dependencies
local diff
diff=$(get_git_diff)
if [ -z "$diff" ]; then
error_exit "No changes to commit"
fi
check_ollama_service_and_model
local commit_message
echo "Generating commit message with Ollama..."
commit_message=$(generate_commit_message "$diff")
if [ -z "$commit_message" ]; then
error_exit "Failed to generate commit message"
fi
confirm_commit "$commit_message"
}
main