<!--
  canonical-url: https://www.avonture.be/fr/blog/ollama-git-precommit
  published:     2026-08-10
  This is a static plain-Markdown mirror generated at build time.
  Visit the canonical URL above for the fully rendered page, with images and interactive components.
-->

# ai-review, ai-secrets, ai-commit : trois vérifications zsh avant chaque git commit

> Trois fonctions zsh qui forment ensemble une barrière qualité locale et hors ligne avant le commit : ai-review repère les violations SOLID et les problèmes de nommage, ai-secrets sépare les vrais identifiants en dur des faux positifs, et ai-commit rédige le message Conventional Commits une fois que le diff est réellement propre.

---
![ai-review, ai-secrets, ai-commit : trois vérifications zsh avant chaque git commit](/img/v2/ai_review.webp)

**TL;DR**

Cet article ajoute trois fonctions à la série « Ollama daily use » — `ai-review`, `ai-secrets` et `ai-commit` — qui couvrent ensemble le moment juste avant un `git commit`. Toutes les trois lisent le même `git diff --staged` : elles partagent donc un helper unique extrait (`_git_staged_diff`) au lieu de répéter trois fois les mêmes clauses de garde. Le workflow est volontaire : d'abord la revue, ensuite la détection de secrets, et enfin l'écriture du message de commit — une fois que le diff est vraiment ce que vous voulez.

Le moment juste avant un `git commit` est probablement le plus précieux de tout le cycle de développement pour faire une pause et regarder ce que vous avez réellement écrit. Pas parce que quelque chose est forcément cassé — en général ça ne l'est pas — mais parce que c'est là que le diff est petit et délimité. Une minute de revue de plus, quand vous voyez encore clairement chaque ligne modifiée, vaut mieux qu'une heure d'archéologie plus tard.

J'ai déjà des [hooks pre-commit](/blog/git-precommit) qui lancent phpcbf, PHPStan et compagnie. Ceux-là attrapent des *règles*. Ce qu'ils n'attrapent pas, c'est le jugement : une méthode qui a discrètement pris trois responsabilités, un `0.21` en ligne 20 qui devrait avoir un nom (comme `discount_rate`), un `$d` qui était un nom de paramètre paresseux il y a trois mois et qui est illisible aujourd'hui — ou, plus grave, un vrai mot de passe de base de données qui s'est glissé dans un diff de configuration.

Ces trois fonctions comblent ce vide, en local, sans abonnement cloud.

## Ce que `ai-review` attrape avant même que vous écriviez un message de commit {#what-ai-review-catches-before-you-even-write-a-commit-message}

`ai-review` lit votre diff stagé et signale ce qu'un linter ne peut pas voir : une méthode qui fait discrètement trois choses à la fois, un nombre magique qui devrait avoir un nom, un paramètre qui avait du sens à une époque. Voici un diff stagé avec quelques problèmes plantés volontairement :

**src/Invoice/InvoiceProcessor.php (staged diff)**

```php
diff --git a/src/Invoice/InvoiceProcessor.php b/src/Invoice/InvoiceProcessor.php
index 1234567..89abcde 100644
--- a/src/Invoice/InvoiceProcessor.php
+++ b/src/Invoice/InvoiceProcessor.php
@@ -10,6 +10,32 @@ class InvoiceProcessor
 {
+    public function process($d)
+    {
+        if (!isset($d['amount']) || !isset($d['email'])) {
+            return false;
+        }
+
+        if ($d['amount'] <= 0) {
+            return false;
+        }
+
+        $tax = $d['amount'] * 0.21;
+        $total = $d['amount'] + $tax;
+
+        $conn = new PDO('mysql:host=localhost;dbname=billing', 'root', 'root');
+        $stmt = $conn->prepare('INSERT INTO invoices (amount, tax, total) VALUES (?, ?, ?)');
+        $stmt->execute([$d['amount'], $tax, $total]);
+
+        $subject = 'Your invoice';
+        $body = 'Total due: ' . $total;
+        mail($d['email'], $subject, $body);
+
+        return true;
+    }
 }
```

```bash
$ git diff --staged --stat

 src/Invoice/InvoiceProcessor.php | 26 ++++++++++++++++++++++++++
 1 file changed, 26 insertions(+)

$ ai-review

## SOLID violations
- src/Invoice/InvoiceProcessor.php:11 — process() validates input, calculates tax, persists to the database, and sends an email: four responsibilities in one method, violating Single Responsibility. Split into validateInvoiceData(), calculateTax(), saveInvoice() and sendInvoiceEmail().

## Magic constants
- src/Invoice/InvoiceProcessor.php:20 — 0.21 is a hardcoded VAT rate with no named meaning. Extract it to a class constant such as VAT_RATE_STANDARD = 0.21, or load it from configuration.

## Naming
- src/Invoice/InvoiceProcessor.php:11 — the parameter $d gives no indication of its shape or purpose. Rename to $invoiceData, and consider a typed value object instead of a raw array.

## Overall quality
- src/Invoice/InvoiceProcessor.php:23 — database credentials ('root', 'root') are hardcoded directly in the method body. Move them to environment variables or a configuration service; this connection also should not be constructed inside a method that also handles business logic.
```

Quatre vrais problèmes remontés, sous quatre des cinq titres fixes autorisés par le prompt. « Long functions » est resté vide — le modèle a estimé que 26 lignes ne franchissaient pas la limite, et le prompt lui disait explicitement de ne pas inventer une section juste pour la remplir.

> **En lecture seule, volontairement:**
>
> `ai-review` ne touche jamais au diff, ne bloque jamais un commit, n'écrit jamais rien. Il affiche une revue dans votre terminal. Ce que vous en faites, c'est votre décision.

## Pourquoi c'est construit comme ça {#why-its-built-this-way}

* Toutes les trois lisent le même `git diff --staged` — elles partagent un seul helper pour les clauses de garde (vérification du repo, diff vide, alerte de taille) au lieu de les répéter trois fois.
* `ai-secrets` lance d'abord une regex peu coûteuse ; le modèle n'est appelé que quand quelque chose a vraiment l'air suspect, donc un diff propre coûte zéro appel au modèle.
* `ai-commit` ne commite jamais tout seul — la réponse par défaut est « non », vous tapez explicitement `y` pour accepter ou `e` pour éditer.
* L'ordre n'est pas arbitraire : la revue pendant que vous pouvez encore corriger le diff, la recherche de secrets une fois que c'est bien le diff que vous voulez, puis le message pour ce que vous avez réellement nettoyé.

## Installer les trois fonctions {#installing-the-three-functions}

Toutes les trois s'appuient sur une base commune `_ollama.zsh` (présentée dans [ai-test](/blog/ollama-test-generator)) qui porte les clauses de garde une seule fois, au lieu que chaque fonction les répète :

**\~/.zsh/fns/\_ollama.zsh**

````bash
# Foundation file for every "ai-*" function in this series. Named with a
# leading underscore: the ~/.zsh/fns/*.zsh loader sources files in
# alphabetical order, and "_" sorts before any letter — so this file is
# always loaded first, no matter how many ai-* functions get added later.
#
# Provides:
#   - _ollama_check       reachability guard for Ollama
#   - _ollama_query       shared HTTP client (curl + jq)
#   - _ai_strip_fences    removes the Markdown fences models add anyway
#   - _ai_bat             resolves "bat" vs Debian's "batcat"
#   - _ai_confirm         yes/no prompt
#   - _git_staged_diff    validated staged-diff fetcher (shared by pre-commit functions)
#   - AI_COMMANDS         registry each ai-* function adds itself to
#   - AI_PARAMS           registry of interactive parameter types (file/language/number/text/none)
#   - _ai_prompt_*        FZF/read helpers for interactive argument collection
#   - ai                  entry-point dispatcher / interactive menu

typeset -gA AI_COMMANDS
typeset -gA AI_PARAMS

# ---------------------------------------------------------------------------
# Ollama helpers
# ---------------------------------------------------------------------------

# _ollama_check — verify that the Ollama daemon is reachable.
# Fails loudly so callers can bail early rather than watching curl hang.
_ollama_check() {
  local host="${OLLAMA_HOST:-http://localhost:11434}"
  if ! curl --silent --max-time 2 "${host}/api/tags" >/dev/null 2>&1; then
    echo "ollama: cannot reach ${host} — is the Ollama container running?" >&2
    return 1
  fi
}

# _ollama_query <prompt> — send a prompt to the local model and print the response.
# Assumes Ollama is reachable; call _ollama_check first if you need an early guard.
_ollama_query() {
  local prompt="$1"
  local model="${OLLAMA_MODEL:-qwen3-coder:30b}"
  local host="${OLLAMA_HOST:-http://localhost:11434}"

  _ollama_check || return 1

  # The prompt is piped into jq rather than passed with "--arg": Linux caps a
  # single argv entry at 128 KB (MAX_ARG_STRLEN), and a large staged diff or a
  # long source file sails straight through that ceiling — jq then dies with
  # "argument list too long". "-R -s" slurps stdin as one raw string, which jq
  # escapes exactly like --arg would.
  print -r -- "$prompt" \
    | jq -Rs --arg model "$model" '{model: $model, prompt: ., stream: false}' \
    | curl --silent "${host}/api/generate" --data-binary @- \
    | jq -r '.response // empty'
}

# ---------------------------------------------------------------------------
# Output helpers
# ---------------------------------------------------------------------------

# _ai_strip_fences <text> — return only the code, without the Markdown fences.
#
# Every prompt in this series ends with "no markdown fences" and models still
# wrap their answer in ```language … ``` often enough that the output can't be
# piped or pasted as-is. When at least one fence is present, only the fenced
# regions are kept (which also drops the "Here is your test suite:" preamble);
# when there is none, the text is returned untouched.
_ai_strip_fences() {
  local text="$1"

  if [[ "$text" != *'```'* ]]; then
    print -r -- "$text"
    return 0
  fi

  print -r -- "$text" | awk '
    /^[[:space:]]*```/ { inside = !inside; next }
    inside             { print }
  '
}

# _ai_bat — print the name of the "bat" binary installed on this machine, if any.
# Debian and Ubuntu ship the package as "batcat" because the "bat" name was
# already taken by bacula-console-qt, so both spellings have to be probed.
_ai_bat() {
  local candidate
  for candidate in bat batcat; do
    if command -v "$candidate" >/dev/null 2>&1; then
      print -- "$candidate"
      return 0
    fi
  done
  return 1
}

# _ai_relpath <path> — print <path> relative to the current folder when it sits
# below it. Purely cosmetic: it keeps prompts readable instead of asking
# "Save as /home/christophe/projects/…/tests/backup.bats?".
_ai_relpath() {
  local path="${1:A}"
  print -- "${path#${PWD}/}"
}

# _ai_confirm <question> — ask a yes/no question; return 0 only on an explicit yes.
_ai_confirm() {
  local answer
  print -n "${1} [y/N] "
  read -r answer
  [[ "$answer" == [yY]* ]]
}

# ---------------------------------------------------------------------------
# Git helpers
# ---------------------------------------------------------------------------

# _git_staged_diff <caller> — validate the git context and return the staged diff.
# Handles three early-exit cases (not a repo, nothing staged, oversized diff)
# so every pre-commit function gets them for free without repeating the guards.
#
# Past AI_DIFF_MAX_CHARS the full diff is *not* returned: a diff of several
# hundred kilobytes overflows the model's context window, and the answer that
# comes back is worse than the one you get from a summary. The fallback keeps
# the shape of the change — per-file stat, plus the file and hunk headers,
# which carry the enclosing function names git puts after each "@@" — and
# drops the line-by-line content.
_git_staged_diff() {
  local caller="${1:-ai}"
  local max="${AI_DIFF_MAX_CHARS:-12000}"

  if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
    echo "${caller}: not inside a git repository" >&2
    return 1
  fi

  local diff
  diff=$(git diff --staged)

  if [[ -z "$diff" ]]; then
    echo "${caller}: nothing staged — run 'git add' first" >&2
    return 1
  fi

  # "print -r" everywhere below: without it, print expands escape sequences and
  # a literal \n or \t inside the diff would come back mangled.
  if (( ${#diff} <= max )); then
    print -r -- "$diff"
    return 0
  fi

  echo "${caller}: staged diff is large (${#diff} chars) — sending a structural summary instead of the full diff. Commit in smaller chunks for a more precise answer." >&2

  local summary
  summary="$(git diff --staged --stat)

--- FILE AND HUNK HEADERS ONLY (full diff omitted, ${#diff} chars) ---
$(print -r -- "$diff" | grep -E '^(diff --git|new file|deleted file|rename (from|to)|@@)')"

  # Even the summary can exceed the ceiling on a very wide change.
  if (( ${#summary} > max )); then
    summary="${summary[1,$max]}
[…summary truncated…]"
  fi

  print -r -- "$summary"
}

# ---------------------------------------------------------------------------
# Interactive argument helpers (used by the ai() dispatcher)
# ---------------------------------------------------------------------------

# _ai_prompt_file [label] — open an fzf file picker; print the chosen path.
_ai_prompt_file() {
  local label="${1:-Select a file:}"
  local bat_bin preview='cat {}'
  bat_bin=$(_ai_bat) && preview="${bat_bin} --color=always {} 2>/dev/null || cat {}"

  if command -v fd >/dev/null 2>&1; then
    fd --type f | fzf --prompt="${label} " --height=50% --reverse --preview="$preview"
  else
    find . -type f | fzf --prompt="${label} " --height=50% --reverse --preview="$preview"
  fi
}

# _ai_prompt_language — open an fzf language picker; print the chosen language.
_ai_prompt_language() {
  printf '%s\n' English French Dutch German Spanish Italian Portuguese Japanese Chinese Korean \
    | fzf --prompt='Target language: ' --height=40% --reverse
}

# _ai_prompt_number [label] — read a positive integer; print it (or nothing on Enter, which
# lets the caller fall back to its own default). Returns 1 only on invalid input.
_ai_prompt_number() {
  local label="${1:-Number (Enter for default):}"
  print -n "${label} "
  local n
  read n
  if [[ -z "$n" ]]; then
    return 0
  fi
  if [[ ! "$n" =~ ^[1-9][0-9]*$ ]]; then
    echo "Expected a positive integer." >&2
    return 1
  fi
  print -- "$n"
}

# _ai_prompt_text [label] — read a free-form string; print it. Returns 1 if empty.
_ai_prompt_text() {
  local label="${1:-Input:}"
  print -n "${label} "
  local t
  read t
  if [[ -z "$t" ]]; then
    echo "No input given." >&2
    return 1
  fi
  print -- "$t"
}

# ---------------------------------------------------------------------------
# Entry-point dispatcher
# ---------------------------------------------------------------------------

# ai              → interactive fzf menu of every registered ai-* command
# ai <cmd> [args] → dispatch straight to ai-<cmd> [args]
#
# When opened via the fzf menu, each command's declared AI_PARAMS type triggers
# a secondary picker before the command runs:
#   file     → fzf file picker (fd + preview if available)
#   language → fzf language list
#   number   → read prompt, empty input uses the command's own default
#   text     → read prompt, required
#   none     → run the command directly (it collects its own input)
# Space-separated values declare multiple parameters in order, e.g. "file language".
ai() {
  local cmd="$1"

  # Direct dispatch: ai <cmd> [args]
  if [[ -n "$cmd" && "$cmd" != "help" && "$cmd" != "list" ]]; then
    if (( ${+AI_COMMANDS[$cmd]} )); then
      shift
      "ai-${cmd}" "$@"
      return
    fi
    echo "ai: unknown command '${cmd}'" >&2
    echo >&2
  fi

  # Interactive menu
  if ! command -v fzf >/dev/null 2>&1; then
    echo "Available ai commands:"
    for key in ${(ok)AI_COMMANDS}; do
      printf "  ai %-10s %s\n" "$key" "$AI_COMMANDS[$key]"
    done
    return
  fi

  local picked
  picked=$(
    for key in ${(ok)AI_COMMANDS}; do
      printf '%s\t%s\n' "$key" "$AI_COMMANDS[$key]"
    done \
      | fzf --delimiter='\t' --with-nth=2 --prompt='ai > ' --height=40% --reverse \
      | cut -f1
  )
  [[ -z "$picked" ]] && return

  # Collect arguments interactively based on AI_PARAMS declaration
  local -a args=()
  local param_type
  for param_type in ${(s: :)${AI_PARAMS[$picked]:-none}}; do
    case "$param_type" in
      file)
        local f
        f=$(_ai_prompt_file "File for ai-${picked}:") || return 1
        args+=("$f")
        ;;
      language)
        local l
        l=$(_ai_prompt_language) || return 1
        args+=("$l")
        ;;
      number)
        local n
        n=$(_ai_prompt_number) || return 1
        [[ -n "$n" ]] && args+=("$n")
        ;;
      text)
        local t
        t=$(_ai_prompt_text) || return 1
        args+=("$t")
        ;;
      none) ;;
    esac
  done

  "ai-${picked}" "${args[@]}"
}
````

Ajoutez les trois fonctions par-dessus. Enregistrez chaque fichier dans `~/.zsh/fns/` sous le nom indiqué juste au-dessus (`mkdir -p ~/.zsh/fns` d'abord si le dossier n'existe pas encore), en y collant son contenu :

**\~/.zsh/fns/ai-review\.zsh**

```bash
# ai-review — SOLID, magic-constants, naming and overall quality review of
# staged changes, printed straight to the terminal. Read-only: it never
# modifies the diff or blocks a commit — that decision stays with you.

AI_COMMANDS[review]="ai-review  — code review of staged changes (SOLID, magic constants, naming)"
AI_PARAMS[review]="none"

ai-review() {
  local diff
  diff=$(_git_staged_diff ai-review) || return 1

  local prompt="You are a strict senior code reviewer. Review the staged diff below and report ONLY genuine issues, organized under these exact headings (omit a heading entirely if there is nothing to report under it):

## SOLID violations
## Magic constants
## Long functions
## Naming
## Overall quality

For each issue: cite the file and approximate line, state the problem in one sentence, and suggest a concrete fix in one sentence. Do not invent issues to fill every heading — an empty section is a good outcome, not a failure. Do not comment on formatting or style already enforced by a linter.

--- STAGED DIFF ---
${diff}"

  _ollama_query "$prompt"
}
```

**\~/.zsh/fns/ai-secrets.zsh**

```bash
# ai-secrets — scan staged changes for hardcoded credentials.
# Phase 1: a cheap regex pass over the diff finds candidate lines
#           (no model call wasted if nothing matches).
# Phase 2: the LLM judges each candidate — real leak vs. safe pattern
#           (getenv(), placeholder, variable name only, already-redacted value).

AI_COMMANDS[secrets]="ai-secrets  — scan staged changes for hardcoded credentials"
AI_PARAMS[secrets]="none"

ai-secrets() {
  local diff
  diff=$(_git_staged_diff ai-secrets) || return 1

  local suspects
  suspects=$(print -- "$diff" | grep -nEi \
    '(api[_-]?key|secret|password|passwd|token|access[_-]?key|private[_-]?key|AKIA[0-9A-Z]{16}|-----BEGIN [A-Z ]*PRIVATE KEY-----)')

  if [[ -z "$suspects" ]]; then
    echo "ai-secrets: no suspicious patterns in the staged diff." >&2
    return 0
  fi

  local prompt="You are a secrets-detection expert reviewing a git diff. Below are ONLY the lines that matched a suspicious pattern — context around them has been trimmed. For each line: quote it, then state whether it is a genuine hardcoded secret or a false positive. False positives include: a variable NAME containing 'password' with no real value, a placeholder like 'changeme', an already-redacted value, or a correct read from the environment via getenv()/os.environ/process.env. Output only genuine findings, one per line with a one-sentence reason, or the single line 'No secrets detected.' if every match is a false positive.

--- SUSPICIOUS LINES ---
${suspects}"

  _ollama_query "$prompt"
}
```

**\~/.zsh/fns/ai-commit.zsh**

```bash
# ai-commit — draft a Conventional Commits message from staged changes using
# a local LLM, then ask for explicit confirmation before committing anything.
# The default answer (Enter) is always "no" — this function never commits
# on your behalf without a deliberate y or e.

AI_COMMANDS[commit]="ai-commit  — draft a Conventional Commits message from staged changes"
AI_PARAMS[commit]="none"

ai-commit() {
  local diff
  diff=$(_git_staged_diff ai-commit) || return 1

  local prompt="You are a git commit message expert. Write a single Conventional Commits message (type(scope): subject, optionally a short body) for the staged diff below. Keep the subject line under 72 characters, imperative mood. Output ONLY the commit message — no explanations, no markdown fences, no surrounding quotes.

--- STAGED DIFF ---
${diff}"

  local message
  message=$(_ollama_query "$prompt") || return 1

  echo "--------------------------------------------------"
  echo "$message"
  echo "--------------------------------------------------"
  print -n "Use this message? [y]es / [e]dit / [N]o: "
  local answer
  read -k1 answer
  echo

  case "$answer" in
    y|Y) git commit -m "$message" ;;
    e|E) git commit -e -m "$message" ;;
    *)   echo "ai-commit: aborted, nothing committed." ;;
  esac
}
```

Si un article précédent de cette série a déjà fait en sorte que votre `~/.zshrc` charge ce dossier, il n'y a rien de plus à configurer. Sinon, ajoutez ces lignes à la fin de `~/.zshrc` — elles sourcent chaque fichier `.zsh` du dossier :

```zsh title="~/.zshrc"
for fn_file in ~/.zsh/fns/*.zsh; do
  source "$fn_file"
done
```

Ouvrez ensuite un nouveau terminal (ou lancez `exec zsh`), et vérifiez que le shell connaît les trois fonctions :

```zsh
type ai-review ai-secrets ai-commit
```

Chacune doit répondre `… is a shell function from …` ; `not found` signifie que la boucle dans `~/.zshrc` manque.

## `ai-secrets` : détecter les vrais identifiants {#ai-secrets-catching-real-credentials}

Un simple scanner à base de regex ne peut pas distinguer `'password' => 'Sup3rSecret!'` (une vraie fuite) de `'password' => getenv('DB_PASSWORD')` (le bon pattern) — les deux lignes contiennent « password » à côté d'un `=`. C'est exactement l'écart que le modèle comble :

**config/database.php (staged diff)**

```php
diff --git a/config/database.php b/config/database.php
index 1234567..89abcde 100644
--- a/config/database.php
+++ b/config/database.php
@@ -5,6 +5,14 @@ return [

+    'mysql' => [
+        'host' => 'db.internal.example.com',
+        'username' => 'app_user',
+        'password' => 'Sup3rSecret!2027',
+    ],
+
+    'legacy' => [
+        'password' => getenv('LEGACY_DB_PASSWORD'),
+    ],

 ];
```

```bash
$ git diff --staged --stat

 config/database.php | 8 ++++++++
 1 file changed, 8 insertions(+)

$ ai-secrets

- config/database.php:8 — 'password' => 'Sup3rSecret!2027' is a genuine hardcoded credential in plain text. Move it to an environment variable and load it with getenv('DB_PASSWORD'), matching the pattern already used two lines below for 'legacy'.
- config/database.php:12 — 'password' => getenv('LEGACY_DB_PASSWORD') is the correct pattern (reads from an environment variable), not a leak. No action needed.
```

Deux lignes ont matché la regex, toutes deux contenant « password ». Le modèle les a correctement séparées : l'une est une vraie fuite avec un correctif concret, l'autre est exactement le pattern `getenv()` que ce correctif devrait suivre — laissée tranquille, à juste titre.

> **Un filet de sécurité, pas une garantie:**
>
> Ça attrape ce qui apparaît dans un diff textuel. Ça n'attrapera pas un secret réparti sur plusieurs commits, embarqué dans un binaire, ou encodé d'une façon que la regex ne reconnaît pas. Voyez-le comme une deuxième paire d'yeux avant un commit, pas comme un remplacement pour un vrai outil de scan de secrets en CI.

## `ai-commit` : écrire le message du diff que vous venez de nettoyer {#ai-commit-writing-the-message-for-the-diff-youve-just-cleaned-up}

Une fois que le diff correspond à ce que vous voulez vraiment committer, passez-le à `ai-commit` plutôt que de taper « fix » pour la quatrième fois cette semaine — il contient déjà tout ce qu'il faut pour décrire ce qui a changé :

**api/reactions.php (staged diff)**

```php
diff --git a/api/reactions.php b/api/reactions.php
index 1234567..89abcde 100644
--- a/api/reactions.php
+++ b/api/reactions.php
@@ -12,6 +12,10 @@ header('Content-Type: application/json');

 $slug = $_POST['slug'] ?? '';

+if ($slug === '') {
+    http_response_code(400);
+    exit(json_encode(['error' => 'Missing slug parameter']));
+}
+
 $reaction = $_POST['reaction'] ?? '';
```

```bash
$ git diff --staged --stat

 api/reactions.php | 4 ++++
 1 file changed, 4 insertions(+)

$ ai-commit

--------------------------------------------------
fix(api): reject empty slug parameter in reactions endpoint

Return a 400 response with a clear error message when the
`slug` POST parameter is missing or empty, instead of silently
processing an invalid reaction.
--------------------------------------------------
Use this message? [y]es / [e]dit / [N]o: y
[main a1b2c3d] fix(api): reject empty slug parameter in reactions endpoint
 1 file changed, 4 insertions(+)
```

Un message `fix(api):` correctement scopé avec un court corps explicatif, généré dans les deux ou trois secondes qu'il faut au modèle pour lire un diff de quatre lignes.

> **Rien ne se commite tout seul:**
>
> `ai-commit` n'appelle `git commit` qu'après un `y` ou un `e` explicite. Appuyer sur Entrée, Échap ou n'importe quoi d'autre annule proprement.

## Sous le capot : `_git_staged_diff` (passez cette section si vous voulez juste l'utiliser) {#under-the-hood-\_git\_staged\_diff-skip-this-if-you-just-want-to-use-it}

Les clauses de garde partagées par les trois fonctions — vérification du repo, vérification du diff vide, plafond de taille — vivent au même endroit :

```zsh title="~/.zsh/fns/_ollama.zsh (extract)"
_git_staged_diff() {
  local caller="${1:-ai}"
  local max="${AI_DIFF_MAX_CHARS:-12000}"

  if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
    echo "${caller}: not inside a git repository" >&2
    return 1
  fi

  local diff
  diff=$(git diff --staged)

  if [[ -z "$diff" ]]; then
    echo "${caller}: nothing staged — run 'git add' first" >&2
    return 1
  fi

  if (( ${#diff} <= max )); then
    print -r -- "$diff"
    return 0
  fi

  echo "${caller}: staged diff is large (${#diff} chars) — sending a structural summary instead of the full diff." >&2

  local summary
  summary="$(git diff --staged --stat)

--- FILE AND HUNK HEADERS ONLY (full diff omitted, ${#diff} chars) ---
$(print -r -- "$diff" | grep -E '^(diff --git|new file|deleted file|rename (from|to)|@@)')"

  if (( ${#summary} > max )); then
    summary="${summary[1,$max]}
[…summary truncated…]"
  fi

  print -r -- "$summary"
}
```

Chacune des fonctions ci-dessus appelle `_git_staged_diff` et récupère le diff — ou sort proprement avec un message clair. Dans les versions d'origine de ces fonctions, ces clauses de garde étaient copiées-collées dans chacune — une violation classique du SRP cachée en pleine vue. Une responsabilité, un endroit, maintenant.

Le plafond de taille mérite un mot, parce que je l'ai appris à mes dépens : stager quelques centaines de fichiers et lancer `ai-commit` sur un diff de 490 Ko vous donne un *moins bon* message de commit, pas un plus long — la fenêtre de contexte du modèle débord et ce qui revient est vague ou vide. Donc au-delà de `AI_DIFF_MAX_CHARS` (12 000 par défaut, à surcharger dans votre shell si votre modèle a de la marge), le helper arrête d'envoyer le contenu et envoie la *forme* à la place : le `--stat` par fichier, plus chaque en-tête de fichier et de hunk. Ces lignes `@@` portent le nom de la fonction englobante que git place juste après, donc le modèle sait encore quels fichiers ont changé, dans quelle proportion et où — assez pour une ligne `feat(scope):` correcte, sans se noyer.

> **Pourquoi le prompt est passé à jq via un pipe, et non en argument:**
>
> Dans `_ollama_query`, vous verrez `print -r -- "$prompt" | jq -Rs …` plutôt que le plus évident `jq -n --arg prompt "$prompt"`. C'est volontaire : Linux limite un **seul** argument de ligne de commande à 128 Ko (`MAX_ARG_STRLEN`), indépendamment du `ARG_MAX` total bien plus grand. Passez un gros diff ou un long fichier source en `--arg` et `jq` meurt avant de démarrer, avec `argument list too long`. Un pipe n'a pas cette limite, et `jq -R -s` avale stdin comme une seule chaîne brute — en échappant les guillemets, les backslashes et les retours à la ligne exactement comme `--arg` le ferait.

## Les utiliser ensemble {#running-them-together}

Ces trois fonctions ne forment pas un pipeline — vous n'êtes pas obligé de les lancer toutes les trois, ni de les lancer dans l'ordre. Mais l'ordre a du sens quand vous le faites :

```zsh
# 1. Look at the diff for design issues
ai-review

# 2. Fix anything worth fixing, then re-stage
git add -p

# 3. Make sure you haven't accidentally left a real credential
ai-secrets

# 4. Write the commit message for the diff you've now cleaned up
ai-commit
```

Chaque fonction est aussi joignable directement (`ai-review`, `ai-secrets`, `ai-commit`) ou via le dispatcher `ai` — tapez `ai` tout court, choisissez une fonction dans le menu fzf, et si la fonction a besoin d'un paramètre, le menu le récupère de façon interactive avant de lancer. Toutes les trois s'enregistrent avec une seule ligne chacune — `AI_COMMANDS[review]=...`, `AI_COMMANDS[secrets]=...`, `AI_COMMANDS[commit]=...` — et déclarent `AI_PARAMS[...]=none`, puisqu'elles lisent elles-mêmes le diff stagé et n'ont besoin d'aucun argument.

C'est ce registre qui rend la famille extensible. [`ai-blog-search`](/blog/anythingllm-chat-with-your-docs) l'a rejointe plus tard avec les deux mêmes lignes — sauf qu'elle déclare `AI_PARAMS[blog-search]="text"`, donc le menu demande d'abord une question, et elle interroge un workspace AnythingLLM plutôt qu'Ollama directement.

## Conclusion {#conclusion}

Aucune de ces trois fonctions ne fait quoi que ce soit que vous ne pourriez pas faire à la main. `ai-review` est une version plus lente de la lecture de votre propre diff. `ai-secrets` est une version plus rapide de `grep -i password`. `ai-commit` est un brouillon du message que vous alliez écrire de toute façon. La valeur n'est pas dans les capacités individuelles — elle est dans le fait que les trois tournent en local, prennent moins de dix secondes chacune, et s'insèrent dans le moment où vous êtes déjà : tout est stagé, prêt à committer, juste avant de le faire. C'est le seul moment où le coût de « je corrige maintenant » est encore inférieur au coût de « je corrigerai plus tard ».
