2026-04-29 22:52:42 +02:00
#!/usr/bin/env bash
# jj_commit_msg.sh — generate a commit message from the current jj change using aichat
#
2026-05-02 16:28:44 +02:00
# Usage: jj_commit_msg.sh [REV]
2026-04-29 22:52:42 +02:00
# Summarises each changed file's diff individually, then combines all
# summaries into a single commit message via aichat.
2026-05-02 16:28:44 +02:00
# REV defaults to `@` (current working copy). Accepts any jj revision:
2026-05-02 18:01:31 +02:00
# `@-`, `lk`, a commit ID, a branch name, etc.
2026-04-29 22:52:42 +02:00
#
# Typical use:
# jj describe -m "$(jj_commit_msg.sh)"
2026-05-02 16:28:44 +02:00
# jj describe -m "$(jj_commit_msg.sh @-)"
# jj describe -m "$(jj_commit_msg.sh lk)"
2026-04-29 22:52:42 +02:00
set -euo pipefail
2026-05-02 16:28:44 +02:00
# Optional revision to diff (default: @ = current working copy)
REV = " ${ 1 :- @ } "
2026-04-29 22:52:42 +02:00
# Log to stderr so progress doesn't pollute the commit message on stdout
2026-05-02 18:01:31 +02:00
log( ) { printf '\033[1;34m==>\033[0m %s\n' " $* " >& 2; }
info( ) { printf ' \033[0;37m%s\033[0m\n' " $* " >& 2; }
ok( ) { printf ' \033[0;32m✓\033[0m %s\n' " $* " >& 2; }
# _strip_think — remove reasoning tags from stdin
# Buffer all input, locate the LAST </think> line, emit only what follows.
# This handles think blocks that themselves contain </think> fragments.
_strip_think( ) {
awk '{
lines[NR] = $0
print $0 > "/dev/stderr"
if (/^<\/think>/) last_end = NR
}
END {
start = (last_end ? last_end + 1 : 1)
for (i = start; i <= NR; i++) print lines[i]
}'
}
2026-04-29 22:52:42 +02:00
# _readable_diff <file>
# Returns a human-readable diff for <file>.
# For pathological single-line formats (JSON, minified JS/CSS…), pretty-prints
# both the parent and working versions before diffing so the LLM sees
# structured changes rather than one enormous ±line.
_readable_diff( ) {
local file = " $1 "
local raw_diff
2026-05-02 16:28:44 +02:00
raw_diff = $( jj diff -r " $REV " -- " $file " )
2026-05-02 18:01:31 +02:00
[ [ -z " $raw_diff " ] ] && return 0
2026-04-29 22:52:42 +02:00
2026-05-02 18:01:31 +02:00
# Detect pathological diff: any +/- content line longer than 500 chars
2026-04-29 22:52:42 +02:00
local max_len
max_len = $( grep '^[+-]' <<< " $raw_diff " | awk '{ if (length > m) m = length } END { print m+0 }' )
if ( ( max_len <= 500 ) ) ; then
printf '%s' " $raw_diff "
return
fi
2026-05-02 18:01:31 +02:00
# Pretty-print strategy per extension
2026-04-29 22:52:42 +02:00
local ext = " ${ file ##*. } "
local pretty_old pretty_new
case " $ext " in
json)
2026-05-02 16:28:44 +02:00
pretty_old = $( jj file show -r " $REV @- " -- " $file " 2>/dev/null | python3 -m json.tool 2>/dev/null || true )
pretty_new = $( jj file show -r " $REV " -- " $file " 2>/dev/null | python3 -m json.tool 2>/dev/null || true )
2026-05-02 18:01:31 +02:00
; ;
2026-04-29 22:52:42 +02:00
js| mjs| cjs| css| ts)
local node_fmt = '
const chunks = [];
process.stdin.on("data", d => chunks.push(d));
process.stdin.on("end", () => {
const src = chunks.join("");
2026-05-02 18:01:31 +02:00
// Insert newline before { } ( ) ; and after ,
2026-04-29 22:52:42 +02:00
const out = src
2026-05-02 18:01:31 +02:00
.replace(/([{(])/g, "$1\n ")
.replace(/([;}])/g, "\n$1\n")
.replace(/,\s*/g, ",\n ");
2026-04-29 22:52:42 +02:00
process.stdout.write(out);
2026-05-02 18:01:31 +02:00
});'
2026-05-02 16:28:44 +02:00
pretty_old = $( jj file show -r " $REV @- " -- " $file " 2>/dev/null | node -e " $node_fmt " 2>/dev/null || true )
pretty_new = $( jj file show -r " $REV " -- " $file " 2>/dev/null | node -e " $node_fmt " 2>/dev/null || true )
2026-05-02 18:01:31 +02:00
; ;
*)
# Generic fallback: fold long lines at 120 chars
2026-05-02 16:28:44 +02:00
pretty_old = $( jj file show -r " $REV @- " -- " $file " 2>/dev/null | fold -s -w 120 || true )
pretty_new = $( jj file show -r " $REV " -- " $file " 2>/dev/null | fold -s -w 120 || true )
2026-05-02 18:01:31 +02:00
; ;
2026-04-29 22:52:42 +02:00
esac
if [ [ -n " $pretty_old " && -n " $pretty_new " ] ] ; then
diff <( printf '%s\n' " $pretty_old " ) <( printf '%s\n' " $pretty_new " ) \
2026-05-02 18:01:31 +02:00
--label " a/ ${ file } " --label " b/ ${ file } " -u || true
2026-04-29 22:52:42 +02:00
else
printf '%s' " $raw_diff "
fi
}
# Collect changed files in the current working copy change
2026-05-02 16:28:44 +02:00
changed_files = $( jj diff -r " $REV " --name-only)
2026-04-29 22:52:42 +02:00
if [ [ -z " $changed_files " ] ] ; then
echo "No changed files." >& 2
exit 1
fi
file_count = $( wc -l <<< " $changed_files " | tr -d ' ' )
log " Found $file_count changed file(s) "
summaries = ""
n = 0
while IFS = read -r file; do
diff = $( _readable_diff " $file " )
if [ [ -z " $diff " ] ] ; then
continue
fi
n = $(( n + 1 ))
log " [ $n / $file_count ] Summarising $file … "
2026-05-02 18:01:31 +02:00
summary = $( printf '%s' " $diff " | aichat " In 2-3 lines, summarise what this diff changes in the file ' $file '. Be concise and technical. " | _strip_think)
2026-04-29 22:52:42 +02:00
2026-05-02 18:01:31 +02:00
# Print the summary indented to stderr
2026-04-29 22:52:42 +02:00
while IFS = read -r line; do
info " $line "
done <<< " $summary "
summaries += " ### $file
$summary
"
done <<< " $changed_files "
if [ [ -z " $summaries " ] ] ; then
echo "No non-empty diffs found." >& 2
exit 1
fi
log " Generating commit message from $n summary/summaries … "
2026-05-02 18:01:31 +02:00
result = $( printf '%s' " $summaries " | aichat "From these per-file summaries of a jj diff, write a single conventional commit message in English. First line: short imperative summary (max 72 chars). Then a blank line. Then a short paragraph with more detail if needed. Output only the commit message, nothing else." | _strip_think)
2026-04-29 22:52:42 +02:00
ok "Done"
printf '\n' >& 2
2026-05-02 18:01:31 +02:00
# Commit message goes to stdout (strip leading blank lines so jj sees content)
printf '%s\n' " $result " | sed '/./,$!d'