@@ -29,6 +29,18 @@ BOLD='\033[1m'
2929DIM=' \033[2m'
3030NC=' \033[0m' # No Color
3131
32+ # Escape a string for safe embedding in a JSON value.
33+ # Uses only bash parameter expansion β zero subprocess forks.
34+ _json_str () {
35+ local s=" ${1// \\ / \\\\ } " # \ β \\
36+ local _q=' "'
37+ s=" ${s// $_q / \\ $_q } " # " β \"
38+ s=" ${s// $' \n ' / \\ n} "
39+ s=" ${s// $' \t ' / \\ t} "
40+ s=" ${s// $' \r ' / \\ r} "
41+ printf ' %s' " $s "
42+ }
43+
3244# Snake spinner β loops forever until killed by the caller.
3345# Usage: snake_spinner [label]
3446# Run in background (&), capture PID, kill after the task completes.
@@ -214,101 +226,54 @@ if [ -n "$SQUISH_BIN" ]; then
214226 stat_summary=$( git diff --cached --stat | tail -1)
215227 changed_names=$( git diff --cached --name-only | head -10 | tr ' \n' ' ' )
216228
217- # Write diff to a temp file so Python reads it safely
218- echo " $diff " > /tmp/squish_diff.txt
219-
220- # Use python3 to build the JSON payload β all values go through
221- # json.dumps() so control characters are properly escaped.
222- PAYLOAD=$( SQUISH_CHANGED=" $changed_names " SQUISH_STAT=" $stat_summary " MAX_DIFF_CHARS=" $MAX_DIFF_CHARS " \
223- python3 - << 'PYEOF '
224- import json, os, re
225-
226- def strip_diff(raw: str, max_chars: int) -> str:
227- """Keep only added/removed lines; skip headers and unchanged context."""
228- lines = []
229- for line in raw.splitlines():
230- # +++ / --- are file headers β skip
231- if line.startswith("---") or line.startswith("+++"):
232- continue
233- # @@ hunk headers β include as section markers but shorten
234- if line.startswith("@@"):
235- lines.append(line.split("@@")[-1].strip() or "~~")
236- continue
237- # diff --git / index headers β skip
238- if line.startswith("diff ") or line.startswith("index ") or line.startswith("new file") or line.startswith("deleted file"):
239- continue
240- # Keep + / - changed lines, drop unchanged context lines
241- if line.startswith("+") or line.startswith("-"):
242- lines.append(line)
243- return "\n".join(lines)[:max_chars]
244-
245- diff_raw = open("/tmp/squish_diff.txt").read()
246- diff = strip_diff(diff_raw, int(os.environ.get("MAX_DIFF_CHARS", "1200")))
247-
248- system = (
249- "You are a git commit message writer. "
250- "Read the diff and write ONE concise commit message describing what actually changed. "
251- "Reply with ONLY the commit message β no labels, no filenames, no markdown, no period. "
252- "Must be a complete thought under 72 characters. Imperative mood (e.g. 'Add', 'Fix', 'Update', 'Remove')."
253- )
254- user = (
255- f"Files: {os.environ['SQUISH_CHANGED']}\n"
256- f"Stat: {os.environ['SQUISH_STAT']}\n\n"
257- f"Changed lines:\n{diff}\n"
258- "--- END DIFF ---\n\n"
259- "Commit message (imperative, < 72 chars):"
260- )
261- print(json.dumps({
262- "model": "squish",
263- "messages": [
264- {"role": "system", "content": system},
265- {"role": "user", "content": user},
266- ],
267- "max_tokens": 50,
268- "temperature": 0.2,
269- "stream": False,
270- "stop": ["\n", "\r"],
271- }))
272- PYEOF
273- )
274- rm -f /tmp/squish_diff.txt
275-
276- # Run squish with timeout and spinner
229+ # Strip diff to +/- lines only (awk, no Python)
230+ stripped_diff=$( git diff --cached | awk '
231+ /^---/ || /^\+\+\+/ { next }
232+ /^diff / || /^index / || /^new file/ || /^deleted file/ { next }
233+ /^@@/ { sub(/^@@[^@]*@@ */, ""); print (length($0)>0 ? $0 : "~~"); next }
234+ /^\+/ || /^-/ { print }
235+ ' | head -c " $MAX_DIFF_CHARS " )
236+
237+ # Build JSON payload in pure bash β _json_str escapes all special chars
238+ _sys=" You are a git commit message writer. Read the diff and write ONE concise commit message describing what actually changed. Reply with ONLY the commit message β no labels, no filenames, no markdown, no period. Must be a complete thought under 72 characters. Imperative mood (e.g. 'Add', 'Fix', 'Update', 'Remove')."
239+ _usr=" Files: ${changed_names} \nStat: ${stat_summary} \n\nChanged lines:\n${stripped_diff} \n--- END DIFF ---\n\nCommit message (imperative, < 72 chars):"
240+ PAYLOAD=' {"model":"squish","messages":[{"role":"system","content":"' " $( _json_str " $_sys " ) " ' "},{"role":"user","content":"' " $( _json_str " $_usr " ) " ' "}],"max_tokens":50,"temperature":0.2,"stream":false,"stop":["\n","\r"]}'
241+
242+ # Run squish β curl in background, spinner inline in foreground (no subprocess)
277243 print_step " Asking AI for commit message (Squish local LLM)..."
278244 _port=" ${SQUISH_PORT:- 11435} "
279- _llm_start=$SECONDS
280245 curl -s --max-time $TIMEOUT_SECONDS \
281246 -X POST " http://127.0.0.1:${_port} /v1/chat/completions" \
282247 -H " Content-Type: application/json" \
283248 -H " Authorization: Bearer ${SQUISH_API_KEY:- squish} " \
284249 -d " $PAYLOAD " 2> /tmp/squish_stderr.txt \
285250 > /tmp/squish_response.txt &
286251 LLM_PID=$!
287- # Spinner runs in background; wait reaps curl immediately in foreground
288- snake_spinner " Generating commit message" &
289- _SPINNER_PID=$!
290- wait $LLM_PID
252+ # Inline spinner β runs in the main shell, no subprocess, no background PID
253+ _sp_frames=(' β£Ύ' ' β£½' ' β£»' ' β’Ώ' ' β‘Ώ' ' β£' ' β£―' ' β£·' )
254+ _sp_cols=(" $CYAN " " $BLUE " " $PURPLE " " $CYAN " " $BLUE " " $PURPLE " " $CYAN " " $BLUE " )
255+ _si=0; _step=0
256+ while kill -0 " $LLM_PID " 2> /dev/null; do
257+ _secs=$(( _step / 10 )) ; _tenths=$(( _step % 10 ))
258+ printf " \r${_sp_cols[$_si]}${_sp_frames[$_si]}${NC} ${WHITE} Generating commit message${NC}${GRAY} ...${NC} ${DIM} (${_secs} .${_tenths} s)${NC} "
259+ read -t 0.1 < /dev/null 2> /dev/null || true
260+ _si=$(( (_si + 1 ) % 8 ))
261+ _step=$(( _step + 1 ))
262+ done
263+ wait $LLM_PID # reap immediately β zombie cleared within one loop tick
291264 exit_code=$?
292- kill $_SPINNER_PID 2> /dev/null
293- wait $_SPINNER_PID 2> /dev/null
294265 printf " \r${GREEN} β${NC} ${WHITE} Done!${NC} \n"
295- _llm_elapsed=$(( SECONDS - _llm_start ))
296- print_info " model response time: ${CYAN}${_llm_elapsed} s${NC} "
266+ # step is already in tenths of a second β free decimal timing, no extra call
267+ _llm_secs=$(( _step / 10 )) ; _llm_tenths=$(( _step % 10 ))
268+ print_info " model response time: ${CYAN}${_llm_secs} .${_llm_tenths} s${NC} "
297269
298270 # ββ Debug: result diagnostics βββββββββββββββββββββββββββββββββββββββββ
299271 raw_response=$( cat /tmp/squish_response.txt 2> /dev/null)
300272 squish_stderr=$( cat /tmp/squish_stderr.txt 2> /dev/null)
301273 rm -f /tmp/squish_response.txt /tmp/squish_stderr.txt
302274
303- # Extract the message content from the JSON response
304- commit_message=$( echo " $raw_response " | python3 -c "
305- import sys, json
306- try:
307- data = json.load(sys.stdin)
308- print(data['choices'][0]['message']['content'].strip())
309- except Exception:
310- pass
311- " 2> /dev/null)
275+ # Extract content field from JSON response with sed β no Python
276+ commit_message=$( printf ' %s' " $raw_response " | sed ' s/.*"content":"\([^"]*\)".*/\1/' | sed ' s/^[[:space:]]*//;s/[[:space:]]*$//' )
312277
313278 print_info " squish exit code: ${CYAN} $exit_code ${NC} "
314279 if [ -n " $commit_message " ]; then
0 commit comments