Aller au contenu principal

Homebrew, Docker, nvm, worktrees : une seule commande pour tout nettoyer

Ma fonction Zsh update : topgrade, Homebrew, Docker, nvm et les worktrees Claude Code sous une seule commande, avec un bilan d'espace en Go.

20 min de lecture
Sommaire · 44

Comme tout développeur, il arrive un moment où je me rends compte qu'il ne me reste 10 Go sur mon ordinateur. Vient alors un gros travail pour tenter de comprendre ce qui pose problème. Qu'est-ce qui prend de la place ? Que dois-je faire ? Que puis-je supprimer sans risquer de provoquer un drame ?

Vient alors un travail minutieux d'archéologue qui hélas commence à se compliquer car je suis développeur : containers, volumes et builds de Docker pour des stacks jetables ou que je n'ai pas touchées depuis longtemps, des worktrees Git de merges depuis longtemps fusionnés, douze versions de Node ou un cache Homebrew qui n'a pas été purgé depuis des mois.

Le constat

Le problème n'est pas le manque d'outils. Le problème est tout simplement l'inverse : trop d'outils, tous à moitié utiles et aucun ne parle aux autres. brew cleanup ne connait pas Docker, Docker ne connaît pas NVM.

Alors j'ai écrit une fonction. Elle s'appelle update. Elle vit dans mon ~/.zshrc et elle fait trois choses :

  1. Elle met à jour tout ce qui peut l'être, avec topgrade comme chef d'orchestre ;
  2. Elle nettoie ce que ces mises à jour et mon travail au quotidien laissent derrière elles ;
  3. Elle rend des comptes : rien ne part sans confirmation, et le bilan d'espace s'affiche en gigaoctets, chiffré et signé df.

Ce billet est son analyse. Chaque étape, chaque idiome Zsh, y compris ceux qui m'ont coûté une anecdote.

L'inventaire des déchets d'un développeur

Avant le code, il faut savoir ce que l'on chasse. Voici les formes de gaspillage que la fonction traque, et pourquoi chacune survit sans elle.

Déchet D'où il vient Pourquoi il s'accumule
Worktrees Git orphelins Les worktrees temporaires de Claude Code sous <repo>/.claude/worktrees/ Le merge ferme la PR, la branche distante disparaît… mais le dossier reste.
Enregistrements de plugins fantômes Le registre ~/.claude/plugins/installed_plugins.json Un worktree supprimé laisse son projectPath en base ; topgrade tente alors de démarrer claude dans un trou noir.
Dépendances Homebrew orphelines Les installs au fil de l'eau Les dépendances d'un paquet désinstallé restent installées.
Cache de téléchargement Homebrew Chaque brew upgrade Homebrew garde tout par défaut pendant 120 jours.
Cache de build Docker Chaque docker build BuildKit cache les couches avec une générosité qui n'a d'égale que son silence.
Images Docker inutilisées Chaque changement de version d'une image de base L'ancienne image devient dangling, puis unused — et reste.
Volumes Docker anonymes Un VOLUME dans un Dockerfile sans nom explicite Docker leur donne un ID de 64 caractères hexadécimaux et les oublie.
Versions Node périmées Chaque sortie de version LTS nvm installe, jamais de désinstallation spontanée.
Artefacts de build Chaque projet visité node_modules, vendor, .venv : trois dossiers, dix gigaoctets, zéro pitié.

Aucune de ces catégories n'est exotique et tout le problème est là : elles sont banales, dispersées et leur somme dépasse toujours ce qu'on imagine.

Le contrat de la fonction

Avant d'écrire la fonction, j'ai posé quelques contraintes qui ont fini par en dicter toute la structure.

Rien ne doit partir sans confirmation — soit un prompt [y/N] avec un défaut sur non, soit la revue interactive d'un outil qui en propose une. Un outil manquant doit désactiver son étape, jamais casser le reste : si fd, jq, docker ou cleanmymac sont absents, la section correspondante saute avec un message, et la fonction continue. Et certaines données doivent être hors de portée par construction plutôt que par un filtre qu'on espère ne pas avoir oublié — volumes Docker nommés, branches Git locales, versions Node épinglées, scope user des plugins Claude.

Ce dernier point mérite qu'on s'y arrête, parce que c'est la partie la plus délicate du script : la différence entre filtrer les volumes dangereux et ne sélectionner que les volumes anonymes, c'est la différence entre un prompt inutile et une base de données perdue. On y revient en détail à l'étape Docker.

Vue d'ensemble

# Étape Outil Confirmation
0 Mesure de l'espace libre avant df + awk
1 Worktrees Claude Code orphelins fd + git worktree y/N
2 Registre de plugins d'agents obsolètes jq automatique (avec backup)
3 Mise à jour système et outils topgrade géré par topgrade
4 Orphelins et cache Homebrew brew automatique
5 Cache build, images et volumes Docker docker y/N
6 Versions Node orphelines nvm + fd y/N
7 Junk, caches, artefacts IA, corbeille, RAM cleanmymac y/N (ou revue CLI)
8 Bilan d'espace libre après awk

L'ordre n'est pas innocent. Les worktrees partent avant topgrade, pour que la mise à jour des plugins Claude Code parte d'un registre propre. Homebrew se nettoie après topgrade, parce que c'est la mise à jour qui remplit le cache. Et la mesure initiale encadre tout : sans elle, le bilan final n'est qu'une impression.

Le code complet

update() {
  local reply kb_before kb_after
  kb_before=$(df -k / | awk 'NR==2 {print $4}')

  # Remove the Claude Code worktrees that hold no work any more.
  # A worktree becomes a candidate when its tree is clean and one condition is true:
  #   - the remote deleted the upstream branch, so the pull request is closed;
  #   - the HEAD is detached on a commit that a remote branch contains.
  # The removal runs without `--force`. Git refuses a dirty worktree and a locked
  # worktree. This is the second guard. The local branches stay unchanged.
  local -a wt_candidates wt_skipped
  local -aU wt_repos
  local wt_dir wt_repo wt_path wt_branch wt_sha wt_line
  if command -v fd >/dev/null; then
    for wt_dir in ${(f)"$(fd -H -t d --no-ignore --max-depth 5 '^worktrees$' ~/Sites 2>/dev/null)"}; do
      wt_dir=${wt_dir%/}
      [[ "$wt_dir" == */.claude/worktrees ]] || continue
      wt_repo=${wt_dir%/.claude/worktrees}
      git -C "$wt_repo" rev-parse --git-dir >/dev/null 2>&1 || continue
      for wt_path in "$wt_dir"/*(N/); do
        git -C "$wt_path" rev-parse --git-dir >/dev/null 2>&1 || continue
        if [[ -n "$(git -C "$wt_path" status --porcelain 2>/dev/null)" ]]; then
          wt_skipped+=("${wt_path:t} : modifications locales")
          continue
        fi
        wt_branch=$(git -C "$wt_path" symbolic-ref --quiet --short HEAD 2>/dev/null)
        wt_sha=$(git -C "$wt_path" rev-parse HEAD 2>/dev/null)
        if [[ -n "$wt_branch" ]]; then
          # `git branch -vv` writes `: gone]` when the upstream branch is absent.
          if git -C "$wt_repo" branch -vv --list "$wt_branch" | grep -q ': gone\]'; then
            wt_candidates+=("$wt_path"); wt_repos+=("$wt_repo")
          else
            wt_skipped+=("${wt_path:t} : la branche $wt_branch vit encore sur origin")
          fi
        elif [[ -n "$(git -C "$wt_repo" branch -r --contains "$wt_sha" 2>/dev/null)" ]]; then
          wt_candidates+=("$wt_path"); wt_repos+=("$wt_repo")
        else
          wt_skipped+=("${wt_path:t} : HEAD détaché absent de origin")
        fi
      done
    done
  fi
  if (( ${#wt_candidates} )); then
    echo "Worktrees Claude sans travail en cours :"
    for wt_path in $wt_candidates; do
      echo "   ${${wt_path%/.claude/worktrees/*}:t}/${wt_path:t}"
    done
    if (( ${#wt_skipped} )); then
      echo "Conservés :"
      for wt_line in $wt_skipped; do echo "   $wt_line"; done
    fi
    echo -n "🧹 Retirer ces ${#wt_candidates} worktree(s) Claude ? (les branches locales restent) [y/N] "
    read -r reply
    if [[ "$reply" =~ ^[Yy]$ ]]; then
      for wt_path in $wt_candidates; do
        wt_repo=${wt_path%/.claude/worktrees/*}
        git -C "$wt_repo" worktree remove "$wt_path" && echo "   ✔ ${wt_path:t}"
      done
      for wt_repo in $wt_repos; do git -C "$wt_repo" worktree prune; done
    else
      echo "⏭️  Worktrees Claude conservés."
    fi
  fi

  # Remove the plugin records of the projects that do not exist any more.
  # A deleted worktree keeps its `projectPath` in the registry of Claude Code.
  # The topgrade step `claude_code_plugins` then starts `claude` in that folder,
  # the spawn fails, and the full step gets the FAILED status.
  # The records of the `user` scope stay unchanged.
  local plugins_db="$HOME/.claude/plugins/installed_plugins.json" dead_paths dead_count
  if [[ -f "$plugins_db" ]] && command -v jq >/dev/null; then
    dead_paths=$(jq -r '[.plugins[][] | select(.scope == "project") | .projectPath] | unique[]' "$plugins_db" \
      | while IFS= read -r p; do [[ -d "$p" ]] || printf '%s\n' "$p"; done \
      | jq -R -s 'split("\n") | map(select(length > 0))')
    dead_count=$(print -r -- "$dead_paths" | jq 'length')
    if (( dead_count )); then
      cp "$plugins_db" "$plugins_db.bak"
      jq --argjson dead "$dead_paths" '
        .plugins |= (
            with_entries(.value |= map(select(.scope != "project" or (.projectPath | IN($dead[]) | not))))
          | with_entries(select(.value | length > 0))
        )' "$plugins_db" > "$plugins_db.tmp" && mv "$plugins_db.tmp" "$plugins_db"
      echo "🧹 Plugins Claude : $dead_count projet(s) disparu(s) purgé(s) du registre."
    fi
  fi

  # topgrade updates macOS, brew, mas, Claude Code, and the other tools.
  # The disabled steps are in ~/.config/topgrade.toml.
  topgrade

  # topgrade does not remove the orphan dependencies or the cached downloads.
  brew autoremove; brew cleanup --prune=all -s; brew doctor

  # Skip the Docker step when the daemon is not running.
  if docker info >/dev/null 2>&1; then
    docker system df
    echo -n "🧹 Nettoyage Docker (cache de build + images inutilisées + volumes anonymes 64-hex) ? [y/N] "
    read -r reply
    if [[ "$reply" =~ ^[Yy]$ ]]; then
      echo "🧹 Docker : cache de build + images inutilisées…"
      docker builder prune -f
      docker image prune -af

      echo "🧹 Docker : volumes anonymes orphelins (64-hex, jamais les volumes nommés)…"
      docker volume ls -f dangling=true -q | grep -E '^[0-9a-f]{64}$' | xargs -r docker volume rm
    else
      echo "⏭️  Nettoyage Docker ignoré."
    fi
  else
    echo "⏭️  Daemon Docker éteint — nettoyage Docker ignoré."
  fi

  # Remove the nvm versions that no `.nvmrc` under ~/Sites pins.
  # The version of the `default` alias is always kept.
  # `fd` is necessary: without it, the pin list is empty and all the versions
  # look orphan. Skip the step in that case.
  if [[ -d "$HOME/.nvm/versions/node" ]] && command -v fd >/dev/null; then
    local -a nvm_keep nvm_remove
    local pin v
    command -v _load_nvm >/dev/null && _load_nvm
    nvm_keep=("$(nvm version default)")
    for pin in $(fd -H --no-ignore '^\.nvmrc$' ~/Sites 2>/dev/null | grep -v node_modules | while read -r f; do tr -d '[:space:]' < "$f"; echo; done | sort -u); do
      v=$(nvm version "$pin")
      [[ "$v" != "N/A" ]] && nvm_keep+=("$v")
    done
    for v in $(command ls "$HOME/.nvm/versions/node"); do
      (( ${nvm_keep[(Ie)$v]} )) || nvm_remove+=("$v")
    done
    if (( ${#nvm_remove} )); then
      echo "Node orphelines (aucun .nvmrc sous ~/Sites, défaut conservé) : ${nvm_remove[*]}"
      echo -n "🧹 nvm uninstall de ces ${#nvm_remove} versions ? [y/N] "
      read -r reply
      if [[ "$reply" =~ ^[Yy]$ ]]; then
        for v in $nvm_remove; do nvm uninstall "$v"; done
      else
        echo "⏭️  Purge nvm ignorée."
      fi
    else
      echo "✅ nvm : aucune version orpheline."
    fi
  fi

  # Skip all the CleanMyMac steps when the `cleanmymac` binary is not available.
  if command -v cleanmymac >/dev/null; then
    echo -n "🧹 CleanMyMac : junk système (logs utilisateur, fichiers temporaires — revue interactive avant suppression) ? [y/N] "
    read -r reply
    if [[ "$reply" =~ ^[Yy]$ ]]; then
      cleanmymac clean junk
    else
      echo "⏭️  Junk système ignoré."
    fi

    echo -n "🧹 CleanMyMac : caches d'outils dev (brew, npm, Docker, Xcode, JetBrains… — recoupe le bloc Docker ci-dessus ; revue interactive avant suppression) ? [y/N] "
    read -r reply
    if [[ "$reply" =~ ^[Yy]$ ]]; then
      cleanmymac clean dev
    else
      echo "⏭️  Caches dev ignorés."
    fi

    echo -n "🧹 CleanMyMac : artefacts d'outils IA (Claude, etc. — revue interactive avant suppression) ? [y/N] "
    read -r reply
    if [[ "$reply" =~ ^[Yy]$ ]]; then
      cleanmymac clean ai
    else
      echo "⏭️  Artefacts IA ignorés."
    fi

    echo -n "🧹 CleanMyMac : vider la Corbeille (revue interactive avant suppression) ? [y/N] "
    read -r reply
    if [[ "$reply" =~ ^[Yy]$ ]]; then
      cleanmymac clean trash
    else
      echo "⏭️  Corbeille ignorée."
    fi

    # Do not use `--force` here. The review lets you keep the vendor/ and
    # node_modules directories of the active projects.
    echo -n "🧹 CleanMyMac : artefacts de build sous ~/Sites (node_modules, vendor, .venv… — présélection > 7 jours, DÉCOCHER les projets actifs dans la revue) ? [y/N] "
    read -r reply
    if [[ "$reply" =~ ^[Yy]$ ]]; then
      cleanmymac purge ~/Sites
    else
      echo "⏭️  Purge ~/Sites ignorée."
    fi

    echo -n "🧹 CleanMyMac : libérer la RAM inactive (immédiat, sans revue) ? [y/N] "
    read -r reply
    if [[ "$reply" =~ ^[Yy]$ ]]; then
      cleanmymac optimize ram
    else
      echo "⏭️  Optimisation RAM ignorée."
    fi

    echo -n "🧹 CleanMyMac : libérer l'espace purgeable macOS (immédiat, sans revue) ? [y/N] "
    read -r reply
    if [[ "$reply" =~ ^[Yy]$ ]]; then
      cleanmymac optimize purgeable
    else
      echo "⏭️  Espace purgeable ignoré."
    fi
  else
    echo "⏭️  cleanmymac absent — étapes CleanMyMac ignorées."
  fi

  kb_after=$(df -k / | awk 'NR==2 {print $4}')
  echo "💾 Bilan espace libre : $(awk -v a="$kb_before" -v b="$kb_after" 'BEGIN { printf "%+.1f", (b - a) / 1048576 }') Go."
}

Mesurer pour prouver

local reply kb_before kb_after
kb_before=$(df -k / | awk 'NR==2 {print $4}')

df -k / liste l'usage disque de la racine en kilooctets — le -k évite les unités "lisibles" (1K-blocks, Gi…) qui varient d'un système à l'autre :

Filesystem     1024-blocks      Used Available Capacity  Mounted on
/dev/disk3s1s1  494384472 402318104   78451248    84%    /

awk 'NR==2 {print $4}' isole la quatrième colonne de la deuxième ligne. NR==2 plutôt qu'une recherche par nom de colonne, parce que la première ligne est toujours l'entête et la deuxième toujours la ligne de données de /, sur macOS comme sur Linux.

À la fin de la fonction, la même mesure repasse :

kb_after=$(df -k / | awk 'NR==2 {print $4}')
echo "💾 Bilan espace libre : $(awk -v a="$kb_before" -v b="$kb_after" 'BEGIN { printf "%+.1f", (b - a) / 1048576 }') Go."

La division par 1048576 (1024²) convertit les kilooctets en gigaoctets binaires. printf "%+.1f" force l'affichage du signe : un bilan négatif se voit, et ça arrive — une mise à jour macOS peut très bien occuper quinze gigaoctets de plus qu'elle n'en libère. Les valeurs passent en variables (-v a=... -v b=...) plutôt qu'interpolées directement dans le programme awk, ce qui évite tout souci de guillemets ou d'injection.

Étape 1: retirer les worktrees orphelins

Qu'est-ce qu'un worktree ?

Un worktree Git est un second répertoire de travail branché sur le même dépôt — même historique, même base d'objets, checkout séparé :

$ git worktree add ../mon-repo-feature feature-x
$ git worktree list
/Users/moi/mon-repo             a1b2c3d [main]
/Users/moi/mon-repo-feature     e4f5g6h [feature-x]

Claude Code en crée automatiquement sous <repo>/.claude/worktrees/ pour isoler le travail de ses agents (mécanique décrite dans la documentation officielle). L'agent travaille dans son bac à sable, on reviewe, on merge, la PR se ferme — et le dossier reste. Rien ne le supprime automatiquement. Après quelques semaines de travail avec des agents, ~/Sites accumule des dossiers dont plus rien ne justifie la présence.

Ce qui fait un bon candidat à la suppression

La fonction n'utilise ni date de modification ni heuristique de taille. Un worktree devient candidat si son arbre est propre (git status --porcelain ne renvoie rien) et si l'une de ces deux conditions est vraie : sa branche a une upstream disparue du distant, ou son HEAD est détaché sur un commit qu'aucune branche distante ne contient.

wt_branch=$(git -C "$wt_path" symbolic-ref --quiet --short HEAD 2>/dev/null)
if [[ -n "$wt_branch" ]]; then
  if git -C "$wt_repo" branch -vv --list "$wt_branch" | grep -q ': gone\]'; then
    wt_candidates+=("$wt_path")
  fi
elif [[ -n "$(git -C "$wt_repo" branch -r --contains "$wt_sha" 2>/dev/null)" ]]; then
  wt_candidates+=("$wt_path")
fi

Le cas branche. Quand une branche suit une upstream qui n'existe plus sur le distant, git branch -vv l'indique explicitement :

  feature/paiement a1b2c3d [origin/feature/paiement: gone] Ajout du module de paiement

grep -q ': gone\]' cherche cette signature exacte, avec le crochet échappé pour ne pas fermer une classe de caractères dans la regex. C'est une source fiable pour "la PR est fermée ou la branche distante a disparu" — Git l'a déjà noté localement, pas besoin d'interroger l'API GitHub.

Le cas HEAD détaché. Un worktree posé sur un commit brut, sans branche locale. La question devient : ce commit existe-t-il encore sur le distant ?

git branch -r --contains "$wt_sha"

Une réponse vide signifie que le commit n'appartient à aucune branche distante — le travail a été mergé et squashé, ou abandonné. Le test se fait localement, sans réseau.

La détection : fd, pas find

fd -H -t d --no-ignore --max-depth 5 '^worktrees$' ~/Sites

-H inclut les dossiers cachés, nécessaire puisque .claude commence par un point. --no-ignore outrepasse les .gitignore, ce qui est le but recherché puisque les worktrees y figurent généralement. -t d limite aux répertoires, et le motif ancré ^worktrees$ évite les faux positifs du type worktrees-archives.

Chaque résultat est revalidé : le chemin doit finir par */.claude/worktrees, et le dépôt parent doit répondre à git rev-parse --git-dir.

Les gardes-fous

Trois couches. D'abord le tri en amont : git status --porcelain écarte tout worktree avec des modifications non commitées, en l'ajoutant à la liste des worktrees conservés avec la raison. Ensuite Git lui-même : la suppression appelle git worktree remove sans --force, et Git refuse de son propre chef un worktree sale ou verrouillé — ce qui couvre le cas où l'état a changé entre la détection et la suppression. Enfin le prompt [y/N], défaut sur non.

Une garantie qui ne varie jamais : les branches locales restent. Seuls les répertoires de travail sont retirés (worktree remove puis worktree prune). Une branche dont la PR est fermée depuis trois mois reste consultable et réexploitable.

Le prompt affiche les candidats et les exclusions avant de demander :

Worktrees Claude sans travail en cours :
   Monpetitponey/fix-og-image
   Portfolio/retry-deploy
Conservés :
   feature-auth : modifications locales
   refactor-api : la branche refactor-api vit encore sur origin
🧹 Retirer ces 2 worktree(s) Claude ? (les branches locales restent) [y/N]

Étape 2 : purger le registre de plugins Claude Code

L'incident qui a motivé cette étape

À un moment, topgrade a commencé à afficher une étape claude_code_plugins en FAILED — impossible de démarrer claude dans un dossier qui n'existait plus. La cause tenait en trois lignes : un worktree supprimé laisse dans ~/.claude/plugins/installed_plugins.json un enregistrement de portée project, avec un projectPath absolu (mécanique documentée dans la référence des plugins). topgrade parcourt ce registre pour mettre à jour les plugins de chaque projet ; un chemin mort fait échouer le spawn, et toute l'étape passe en FAILED.

Cette étape du script nettoie ce registre.

Le pipeline jq

# 1. Extraire les projectPath de portée "project" uniques
jq -r '[.plugins[][] | select(.scope == "project") | .projectPath] | unique[]' "$plugins_db"

# 2. Ne garder que ceux dont le dossier a disparu
| while IFS= read -r p; do [[ -d "$p" ]] || printf '%s\n' "$p"; done

# 3. Repasser en tableau JSON
| jq -R -s 'split("\n") | map(select(length > 0))'

.plugins[][] itère la structure imbriquée du registre ; select(.scope == "project") ne garde que les installations de portée projet ; unique[] dédoublonne, plusieurs plugins pouvant pointer vers le même projet mort. La boucle while teste chaque chemin avec [[ -d ]] — un tableau vide au départ, sinon la logique croirait qu'aucun projet vivant signifie que tous sont morts. jq -R -s relit le texte brut en un bloc, le découpe sur les sauts de ligne et rend un tableau JSON exploitable.

La réécriture

cp "$plugins_db" "$plugins_db.bak"
jq --argjson dead "$dead_paths" '...' "$plugins_db" > "$plugins_db.tmp" && mv "$plugins_db.tmp" "$plugins_db"

Un backup systématique avant modification — coût négligeable, et un registre corrompu coûte bien plus cher à réparer. L'écriture passe par un fichier temporaire puis un mv, atomique au sein d'un même système de fichiers, donc sans risque de fichier à moitié écrit sur une coupure de courant. Le filtrage select(.scope != "project" or (.projectPath | IN($dead[]) | not)) laisse intacts les enregistrements de portée user. Et with_entries(select(.value | length > 0)) supprime les groupements devenus vides.

C'est le seul endroit de la fonction qui modifie un fichier d'état sans demander confirmation. Ça reste défendable : le fichier est sauvegardé, l'écriture est atomique, et les enregistrements supprimés pointent vers des dossiers déjà absents du disque.

Étape 3 : la mise à jour elle-même avec topgrade

topgrade

topgrade détecte les gestionnaires de paquets présents et enchaîne leurs mises à jour : macOS, Homebrew, Mac App Store (mas), Claude Code, et le reste selon ce qui est installé sur la machine. Le fichier ~/.config/topgrade.toml permet de désactiver certaines étapes — utile quand une étape de topgrade fait doublon avec une partie de cette fonction.

L'ordre dans la fonction place topgrade après le nettoyage des worktrees et du registre de plugins, pour qu'il démarre sur un état propre, et avant le nettoyage Homebrew, parce que c'est justement la mise à jour qui remplit le cache.

Étape 4 : Homebrew

brew autoremove
brew cleanup --prune=all -s
brew doctor
Commande Effet
brew autoremove Désinstalle les formules installées comme dépendances et devenues inutiles.
brew cleanup --prune=all -s Purge le cache de téléchargement en entier — --prune=all outrepasse le délai par défaut de 120 jours — et -s (scrub) retire aussi les anciennes versions des formules installées.
brew doctor Diagnostic post-nettoyage : liens symboliques cassés, permissions anormales.

Sans l'option --prune=all, Homebrew garde par défaut les téléchargements de moins de 120 jours. Sur une machine où brew upgrade tourne chaque semaine, ça fait une différence réelle sur l'espace récupéré. Le compromis à connaître : -s (scrub) retire aussi les anciennes versions des formules installées, donc plus de retour rapide en arrière via brew switch ou équivalent si une mise à jour pose problème. Sur cette machine, je préfère l'espace récupéré au filet de sécurité d'un rollback local — mais c'est un choix, pas un gain sans contrepartie.

Étape 5 : Docker

Attention : attention à cette étape qui peut-être dangereuse si on ne filtre pas.

Le daemon

if docker info >/dev/null 2>&1; then

Le daemon Docker peut être éteint — c'est même son état de repos favori sur un Mac. La fonction le teste et saute toute la section au lieu de cracher des erreurs de connexion.

Le rapport avant la décision

docker system df
TYPE            TOTAL     ACTIVE    SIZE      RECLAIMABLE
Images          47        3         12.4GB    10.9GB (87%)
Containers      3         0         1.2GB     1.2GB (100%)
Local Volumes   31        4         6.7GB     6.1GB (91%)
Build Cache     214       0         4.3GB     4.3GB (100%)

La colonne RECLAIMABLE donne le gain attendu avant toute suppression.

Les deux prunes sans risque particulier

docker builder prune -f     # cache de build BuildKit
docker image prune -af      # images sans container associé

builder prune efface le cache de build : le prochain docker build recompilera une fois plus lentement, puis recachera. image prune -a retire toutes les images sans container associé — y compris des images "au repos" pour des projets dormants, qu'il faudra retélécharger au prochain docker compose up. C'est un compromis assumé, pas un oubli.

Le filtre sur les volumes

docker volume ls -f dangling=true -q | grep -E '^[0-9a-f]{64}$' | xargs -r docker volume rm

docker volume ls -f dangling=true -q liste les volumes sans container qui les référence. grep -E '^[0-9a-f]{64}$' ne garde que les noms de 64 caractères hexadécimaux. xargs -r docker volume rm supprime la liste, le -r évitant d'appeler docker volume rm si rien ne matche.

Il n'existe pas de commande Docker native pour "supprimer les volumes orphelins sans toucher aux données nommées" — ce filtre le fabrique. Les volumes créés par un VOLUME dans un Dockerfile sans nom explicite reçoivent un ID hexadécimal de 64 caractères. Les volumes nommés (monprojet_db_data, monprojet_postgres_data) ne matchent jamais ce motif dans la pratique, la regex ne les voit tout simplement pas. À prendre avec une réserve : rien n'empêche techniquement de créer soi-même un volume nommé avec exactement 64 caractères hexadécimaux (docker volume create $(openssl rand -hex 32)), auquel cas il matcherait aussi. C'est une garantie de convention, pas une impossibilité absolue — mais un scénario suffisamment improbable pour ne pas remettre en cause l'usage courant du filtre.

Les trois opérations partagent un seul y/N, parce qu'elles partagent aussi le même niveau de risque.

Étape 6 : NVM

Le principe

Une version Node reste sur la machine si elle porte l'alias default, ou si un .nvmrc quelque part sous ~/Sites l'épingle.

nvm_keep=("$(nvm version default)")
for pin in $(fd -H --no-ignore '^\.nvmrc$' ~/Sites 2>/dev/null | grep -v node_modules | while read -r f; do tr -d '[:space:]' < "$f"; echo; done | sort -u); do
  v=$(nvm version "$pin")
  [[ "$v" != "N/A" ]] && nvm_keep+=("$v")
done

fd cherche tous les .nvmrc, y compris cachés, en excluant node_modules. Chaque contenu passe par tr -d '[:space:]' pour retirer les sauts de ligne traînants, puis sort -u dédoublonne. nvm version "$pin" résout l'épingle vers une version installée — N/A signifie qu'elle ne l'est pas encore, auquel cas nvm l'installera à la prochaine visite du projet.

Reste ensuite à identifier ce qui n'est pas dans la liste blanche :

for v in $(command ls "$HOME/.nvm/versions/node"); do
  (( ${nvm_keep[(Ie)$v]} )) || nvm_remove+=("$v")
done

${nvm_keep[(Ie)$v]} est l'idiome d'appartenance de Zsh : I cherche un index, e impose une correspondance exacte — utile pour distinguer 20 de 20.11.1. L'index vaut 0 si la valeur est absente, donc faux, et la version part dans nvm_remove.

Node orphelines (aucun .nvmrc sous ~/Sites, défaut conservé) : 16.20.2 19.9.0 21.7.3
🧹 nvm uninstall de ces 3 versions ? [y/N]

Le garde-fou

if [[ -d "$HOME/.nvm/versions/node" ]] && command -v fd >/dev/null; then

Sans fd, la liste des .nvmrc serait vide, et une liste blanche vide ferait paraître orphelines des versions utilisées par des projets actifs. La fonction saute l'étape plutôt que de risquer une désinstallation erronée — avec un message, jamais en silence.

Étape 7 : CleanMyMac CLI

CleanMyMac CLI de MacPaw suit la même logique que le reste : scan, revue interactive, suppression sur confirmation. La fonction lui passe la main catégorie par catégorie.

Prompt Commande Détail
Junk système cleanmymac clean junk Logs utilisateur, fichiers temporaires, dérivés Xcode.
Caches dev cleanmymac clean dev brew, npm, Docker, Xcode, JetBrains — recoupe partiellement le bloc Docker ci-dessus.
Artefacts IA cleanmymac clean ai Artefacts de Claude et des outils IA.
Corbeille cleanmymac clean trash Vide la corbeille système et celles des volumes montés.
Artefacts de build cleanmymac purge ~/Sites node_modules, vendor, .venv — présélection à plus de 7 jours, les projets actifs restent décochés dans la revue.
RAM inactive cleanmymac optimize ram Immédiat, sans revue.
Espace purgeable cleanmymac optimize purgeable Libère l'espace qu'Apple a marqué purgeable mais pas encore rendu.

purge ~/Sites scanne les projets, groupe les artefacts trouvés, et laisse décocher avant suppression. La présélection à sept jours est une heuristique raisonnable — un artefact de build vieux d'une semaine sur un projet non touché se reconstruit en une commande — mais c'est la revue, projet par projet, qui valide réellement. Utiliser --force ici ferait disparaître sans retour les vendor/ et node_modules/ d'un projet en cours.

Le recouvrement entre le bloc Docker et clean dev est volontaire : l'un est scriptable et précis, l'autre exhaustif et visuel. Ils se recoupent plutôt qu'ils ne font strictement doublon.

Anatomie d'un prompt [y/N]

Le même motif revient dix-sept fois dans la fonction. Il tient en trois lignes et mérite qu'on le démonte une bonne fois :

echo -n "🧹 Question ? [y/N] "
read -r reply
if [[ "$reply" =~ ^[Yy]$ ]]; then
  • echo -n : sans saut de ligne final — la réponse se tape sur la ligne de la question ;
  • read -r : le -r interdit à read d'interpréter les backslashes — la lecture brute, toujours ;
  • =~ ^[Yy]$ : expression régulière ancrée aux deux bouts. y passe, Y passe, yes ne passe pas, et surtout Entrée seul ne passe pas : la regex exige un unique caractère. Le défaut est le refus, structurellement.

Ce dernier point est le cœur du contrat : sur dix-sept prompts, le réflexe "Entrée pour avancer" ne déclenche jamais une suppression. Il la saute. Le seul geste destructeur possible est un y délibéré.

Les idiomes Zsh à retenir

Quelques constructions Zsh qui reviennent dans le script et qui valent la peine au-delà de ce cas précis.

local -a et local -aU. -U rend un tableau dédoublonné par construction, ce qui évite de vérifier soi-même si une valeur y figure déjà avant de l'ajouter — utile pour wt_repos, où plusieurs worktrees candidats peuvent appartenir au même dépôt.

${(f)"$(…)"}. Le drapeau (f) découpe une chaîne sur les sauts de ligne, un élément par ligne de sortie. C'est la façon idiomatique de boucler sur une commande multi-ligne sans casser sur les espaces dans les chemins.

*(N/). Le qualificateur N (NULL_GLOB) produit une liste vide au lieu d'une erreur si le dossier est vide ; / limite aux répertoires.

${var%motif}. Le % coupe le suffixe le plus court qui matche ; %% le plus long.

${var:t}. Le modificateur :t (tail) équivaut à un basename, sans sous-shell.

${${var%…}:t}. La composition des deux : ${wt_path%/.claude/worktrees/*} retire le worktree et son préfixe, puis :t en extrait le nom de base — le nom du dépôt, en une expression.

(( ${arr[(Ie)$v]} )). (Ie) renvoie l'index d'une valeur exacte, ou 0 si absente. Zéro vaut faux, donc la ligne se lit : si $v n'est pas dans le tableau, il part.

command -v outil >/dev/null. Le test canonique de présence d'un binaire — plus portable que which, prévu pour être utilisé dans des scripts.

print -r -- "$var". -r empêche l'interprétation des séquences d'échappement, -- empêche toute confusion entre une valeur commençant par - et une option. Utile quand la variable vient d'un JSON externe.

Une session type

Reconstitution composite (sortie assemblée à partir de plusieurs passages réels, pour illustrer un cas où chaque étape trouve quelque chose à faire) :

$ update
Worktrees Claude sans travail en cours :
   Monpetitponey/fix-og-image
   Portfolio/retry-deploy
Conservés :
   feature-auth : modifications locales
🧹 Retirer ces 2 worktree(s) Claude ? (les branches locales restent) [y/N] y
   ✔ fix-og-image
   ✔ retry-deploy
🧹 Plugins Claude : 2 projet(s) disparu(s) purgé(s) du registre.
✔ macOS : à jour
✔ Homebrew : 14 formules mises à jour
✔ mas : 3 applications
✔ Claude Code : 2.1.x → 2.2.x
✔ …
Removed 6 orphan formulae.
Free space gained: 1.8 GB.
🧹 Nettoyage Docker (cache de build + images inutilisées + volumes anonymes 64-hex) ? [y/N] y
🧹 Docker : cache de build + images inutilisées…
Total reclaimed space: 9.7GB
🧹 Docker : volumes anonymes orphelins (64-hex, jamais les volumes nommés)…
Node orphelines (aucun .nvmrc sous ~/Sites, défaut conservé) : 19.9.0 21.7.3
🧹 nvm uninstall de ces 2 versions ? [y/N] y
🧹 CleanMyMac : junk système … ? [y/N] y
🧹 CleanMyMac : caches d'outils dev … ? [y/N] y
🧹 CleanMyMac : artefacts d'outils IA … ? [y/N] y
🧹 CleanMyMac : vider la Corbeille … ? [y/N] y
🧹 CleanMyMac : artefacts de build sous ~/Sites … ? [y/N] y
🧹 CleanMyMac : libérer la RAM inactive … ? [y/N] y
🧹 CleanMyMac : libérer l'espace purgeable macOS … ? [y/N] N
⏭️  Espace purgeable ignoré.
💾 Bilan espace libre : +14.3 Go.

Quatorze gigaoctets en dix-huit confirmations — le plus souvent, c'est moins. Chaque suppression a été affichée avant d'être exécutée.

Adapter la fonction à votre machine

Chaque étape est indépendante et s'ajoute ou se retire sans casser le reste.

Les dépendances, si vous partez de zéro

$ brew install topgrade fd jq nvm
$ brew install --cask macpaw/taps/cleanmymac-cli

Chacune est optionnelle : son absence fait sauter l'étape correspondante, sans rien casser d'autre.

Ajouter une étape

Le même patron s'adapte à pnpm store prune, cargo cache, podman, ou n'importe quel autre outil :

if command -v mon-outil >/dev/null; then
  echo -n "🧹 Mon outil : décrire ce qui va partir ? [y/N] "
  read -r reply
  if [[ "$reply" =~ ^[Yy]$ ]]; then
    mon-outil clean
  else
    echo "⏭️  Mon outil ignoré."
  fi
fi

Avant d'ajouter un bloc : le test de présence est-il là, le prompt est-il en défaut-non, et la commande peut-elle détruire des données en cas de mauvaise réponse ? Si l'une de ces réponses manque, l'étape n'est pas prête à être ajoutée.

Retirer des étapes

Chaque bloc est autonome. Le recouvrement entre cleanmymac clean dev et le bloc Docker peut être supprimé en retirant l'un des deux, dans un sens ou dans l'autre selon la préférence.

Raccourcir

J'utilise parfois une variante update rapide qui saute topgrade — pas encore un alias formel, mais découper la fonction en sous-fonctions serait la suite logique si elle continue à grossir.

Ce que la fonction ne fait pas

Pas d'automatisation. Pas de cron, pas de launchd, pas de mode silencieux. La fonction repose sur des prompts interactifs et sur des revues d'outils comme CleanMyMac ; l'automatiser reviendrait à retirer la seule protection réelle qu'elle offre.

Pas de --force. Nulle part. git worktree remove --force écraserait du travail non commité apparu entre la détection et la suppression ; cleanmymac --force passerait outre la revue qui protège les projets actifs.

Pas de détection par heuristique de date ou de taille pour les worktrees. Seulement des critères Git exacts — arbre propre et upstream disparue, ou HEAD détaché absent du distant. Une heuristique approximative n'a pas sa place dans une logique de suppression.

FAQ

Le nettoyage Docker peut-il supprimer mes bases de données ?

Non. Le filtre ^[0-9a-f]{64}$ ne matche que les volumes anonymes, ceux dont l'ID hexadécimal de 64 caractères est généré par Docker. Les volumes nommés du type *_db_data ou *_postgres_data ne matchent jamais ce motif, quel que soit leur nom.

La fonction supprime-t-elle mes branches Git locales ?

Non. Seuls les répertoires de worktree sont retirés, via git worktree remove sans --force. Les branches locales restent, y compris celles dont l'upstream a disparu.

Pourquoi fd est-il requis pour l'étape nvm ?

Sans fd, la liste des .nvmrc serait vide et toutes les versions Node paraîtraient orphelines, y compris celles utilisées par des projets actifs. La fonction saute l'étape plutôt que de risquer une erreur.

Comment n'exécuter qu'une partie des étapes ?

En répondant N (ou en appuyant sur Entrée) à chaque prompt indésirable. Chaque étape est indépendante et gère son propre garde-fou ; un binaire absent saute aussi sa section, avec un message.

Peut-on lancer update dans un cron ou un script non interactif ?

Non, volontairement. La fonction repose sur des prompts et des revues interactives ; l'automatiser supprimerait la barrière qui protège les données.

Combien de temps prend la fonction en pratique ?

De dix minutes à une heure selon les mises à jour disponibles. Les nettoyages eux-mêmes sont rapides ; c'est topgrade qui prend le temps, en mettant à jour macOS.

Où sont configurées les étapes désactivées de topgrade ?

Dans ~/.config/topgrade.toml, que la fonction ne modifie pas.

Le bilan peut-il être négatif ?

Oui. Une mise à jour macOS qui occupe plus d'espace qu'elle n'en libère produit un bilan négatif — une information tout aussi utile qu'un gain, puisqu'elle indique lequel, de la mise à jour ou du nettoyage, l'a emporté.

Ce billet est publié sous licence Creative Commons BY-NC-SA 4.0 (attribution, pas d'usage commercial, partage dans les mêmes conditions).

Vous aimerez aussi

Activez uniquement ce que vous souhaitez. Vos choix sont conservés 6 mois.

Strictement nécessaires

Indispensables au fonctionnement du site (session, sécurité, préférence d'affichage). Aucune donnée n'est partagée à des tiers et aucun consentement n'est requis.

Toujours actif

Mesure d'audience

Statistiques via Google Analytics (GA4) : pages vues, source du trafic, navigateur et interactions clés. Dépose des cookies de mesure, activés seulement avec votre accord (Consent Mode). Sans publicité ciblée, sans Google Signals, sans partage commercial.