26

Automatizar Git con Bash

Vistas: 1
Tutorial: Scripts en Bash
Automatizar Git con Bash

Git es el sistema de control de versiones más usado del mundo. Pero usar Git a mano para todo — subir versiones, generar changelogs, limpiar ramas, desplegar, verificar hooks — es tedioso, propenso a errores y nada escalable.

Bash es el lenguaje natural para automatizar Git porque:

  • Git es un programa de línea de comandos — todo lo que haces en la GUI se puede hacer desde Bash
  • Los hooks de Git son scripts — son Bash (o cualquier intérprete) que se ejecutan en eventos específicos
  • No necesitas herramientas externasgit, grep, awk, sed, sort, date es todo lo que necesitas
  • Integración con CI/CD — los pipelines de GitHub Actions, GitLab CI, Jenkins, todos ejecutan Bash

En este capítulo verás como,

  • Escribir hooks de Git profesionales para pre-commit, post-commit, pre-push y post-merge
  • Parsear el log de Git con formato personalizado y generar changelogs automáticos
  • Detectar el estado del repositorio (sucio, rama actual, ahead/behind) desde scripts
  • Automatizar tags, version bump, despliegues, limpieza de ramas y backups
  • Construir un script de automatización Git completo y reutilizable

Filosofía del capítulo: Git no es solo add, commit, push. Es una base de datos de tu proyecto, y Bash es el lenguaje para consultarla, transformarla y actuar sobre ella automáticamente.

Git Hooks — El punto de entrada

Anatomía de un hook

Los hooks de Git son scripts que Git ejecuta automáticamente cuando ocurren ciertos eventos. Viven en .git/hooks/ de cada repositorio (no se versionan) o se configuran globalmente con core.hooksPath.

# hook es simplemente un script ejecutable
# le pasa información por stdin y argumentos

#!/bin/bash
#
echo "Ejecutando pre-commit hook..."
# el script sale con código distinto de 0, Git aborta la acción

Tipos de hooks por fase:

HookEvento¿Puede abortar?Argumentos
pre-commitAntes de crear el commitNinguno
prepare-commit-msgAntes de abrir el editor de mensajeArchivo, tipo, SHA (merge)
commit-msgDespués de escribir el mensajeArchivo del mensaje
post-commitDespués de crear el commitNoNinguno
pre-pushAntes de enviar al remotoRemoto, URL
pre-receiveEn el servidor, antes de recibir pushNinguno (lee stdin)
updatePor cada ref en el servidorRef, SHA anterior, SHA nuevo
post-receiveEn el servidor, tras recibir pushNoNinguno (lee stdin)
post-mergeDespués de un merge exitosoNo1 si squash
pre-auto-gcAntes de git gc --autoNinguno

pre-commit — Validar antes de confirmar

El hook pre-commit se ejecuta antes de que se cree el commit. Si falla (exit ≠ 0), el commit se cancela. Es el lugar perfecto para linters, formateadores, verificaciones de seguridad, tests rápidos.

#!/bin/bash
# — Validaciones antes de cada commit
set -euo pipefail

echo "🔍 pre-commit: ejecutando validaciones..."

# Verificar que no hay conflictos de merge
if grep -rI '^<<<<<<< ' --include='*.py' --include='*.js' --include='*.sh' . 2>/dev/null; then
   echo "❌ ERROR: Hay marcadores de conflicto sin resolver"
   exit 1
fi

# Verificar que no hay credenciales (patrón básico)
if git diff --cached -U0 | grep -Pi '(password|secret|api.?key|token)\s*[:=]\s*["'"'"']?[^"'"'"'\s]{8,}' > /dev/null; then
   echo "❌ ERROR: Posible credencial en el diff"
   exit 1
fi

# Verificar que no hay archivos binarios grandes (> 1 MB)
large_files=$(git diff --cached --name-only -z | xargs -0 -I{} sh -c '
   [ -f "{}" ] && [ "$(stat -f%z "{}" 2>/dev/null || stat --format=%s "{}" 2>/dev/null)" -gt 1048576 ] && echo "{}"
')
if [ -n "$large_files" ]; then
   echo "⚠️  Archivos grandes (> 1MB) en el commit:"
   echo "$large_files"
   echo "¿Continuar de todas formas? (s/N): "
   read -r respuesta
   [ "$respuesta" != "s" ] && exit 1
fi

# Shellcheck en scripts Bash (si está instalado)
if command -v shellcheck &>/dev/null; then
   while IFS= read -r -d '' script; do
       echo "   shellcheck: $script"
       if ! shellcheck -x "$script" 2>/dev/null; then
           echo "❌ shellcheck falló en $script"
           exit 1
       fi
   done < <(git diff --cached --name-only -z -- '*.sh')
fi

echo "✅ pre-commit: validaciones pasadas"

post-commit — Reaccionar tras confirmar

post-commit se ejecuta después de crear el commit. No puede abortar, pero es ideal para notificaciones, logs, o disparar procesos.

#!/bin/bash
# — Notificar commit
set -euo pipefail

commit_hash=$(git rev-parse HEAD)
commit_msg=$(git log -1 --format='%s')
branch=$(git branch --show-current)

# a Telegram (ejemplo)
if [ -n "${TELEGRAM_BOT_TOKEN:-}" ] && [ -n "${TELEGRAM_CHAT_ID:-}" ]; then
   mensaje="🔨 Commit en $branch: $commit_msg ($commit_hash)"
   curl -s -X POST "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/sendMessage" \
       -d "chat_id=$TELEGRAM_CHAT_ID&text=$mensaje" &>/dev/null
fi

# en log local
echo "[$(date '+%Y-%m-%d %H:%M:%S')] COMMIT $commit_hash en $branch: $commit_msg" \
   >> "$HOME/.git_commit_log"

pre-push — Bloquear push inseguros

pre-push se ejecuta antes de enviar objetos al remoto. Recibe el nombre del remoto y su URL como argumentos, y por stdin recibe líneas con el formato <local-ref> <local-sha> <remote-ref> <remote-sha>.

#!/bin/bash
# — Validar antes de hacer push
set -euo pipefail

remote="$1"
url="$2"

echo "🔒 pre-push: validando push a $remote..."

# No permitir push directo a main/master (a menos que sea --force consciente)
current_branch=$(git branch --show-current)
protected_branches="main master develop"

for pb in $protected_branches; do
   if [ "$current_branch" = "$pb" ]; then
       # Si es push forzado, permitir (quien fuerza sabe lo que hace)
       # Pero pedir confirmación
       echo "⚠️  Estás haciendo push a '$pb' (rama protegida)"
       echo "¿Continuar? (s/N): "
       read -r respuesta
       [ "$respuesta" != "s" ] && exit 1
   fi
done

# Verificar que los tests pasan (si existe script de test)
if [ -f "test.sh" ] || [ -f "tests/test.sh" ]; then
   echo "   Ejecutando tests..."
   if [ -f "test.sh" ]; then
       bash test.sh || { echo "❌ Tests fallaron"; exit 1; }
   fi
fi

echo "✅ pre-push: validaciones pasadas, push permitido"

post-merge — Reaccionar tras fusionar

Útil para actualizar dependencias, migraciones de base de datos, o reconstruir algo después de un merge.

#!/bin/bash
# — Reaccionar tras merge
set -euo pipefail

echo "🔀 post-merge: actualizando después del merge..."

# recibimos '1' como argumento, fue un squash merge
squash="${1:-0}"
[ "$squash" = "1" ] && echo "   (squash merge detectado)"

# dependencias si package.json o requirements.txt cambió
if git diff HEAD@{1} --name-only | grep -qE '^(package.json|requirements\.txt)$'; then
   echo "   Dependencias cambiadas — reinstalando..."
   if [ -f "package.json" ]; then
       npm install &>/dev/null && echo "   npm install OK"
   fi
   if [ -f "requirements.txt" ]; then
       pip install -r requirements.txt &>/dev/null && echo "   pip install OK"
   fi
fi

# assets si hubo cambios en src/
if git diff HEAD@{1} --name-only | grep -q '^src/'; then
   echo "   Código fuente cambiado — reconstruyendo..."
   if [ -f "Makefile" ]; then
       make build &>/dev/null && echo "   build OK"
   fi
fi

pre-receive — Hooks del lado del servidor

Los hooks del lado del servidor (pre-receive, update, post-receive) viven en el repositorio remoto (el del servidor Git) y se ejecutan cuando alguien hace push. Son la última línea de defensa.

#!/bin/bash
# — En el servidor, validar antes de aceptar push
set -euo pipefail

# recibe por stdin: <old-sha> <new-sha> <ref>
# ejemplo: 0000...  abcd...  refs/heads/main

while read -r old_sha new_sha ref; do
   branch="${ref#refs/heads/}"

   # Proteger ramas específicas
   case "$branch" in
       main|master|production)
           echo "❌ Push a '$branch' no permitido mediante pre-receive"
           exit 1
           ;;
   esac

   # Verificar que el commit está firmado (GPG)
   if ! git verify-commit "$new_sha" &>/dev/null; then
       echo "❌ El commit $new_sha no está firmado GPG"
       exit 1
   fi

   # Verificar tamaño máximo del push
   size=$(git rev-list --count "$old_sha..$new_sha" 2>/dev/null || echo 0)
   if [ "$size" -gt 100 ]; then
       echo "❌ Más de 100 commits en un solo push no está permitido"
       exit 1
   fi
done

⚠️ Nota: Para usar hooks del lado del servidor en un repositorio bare, colócalos en /ruta/al/repo.git/hooks/. En GitHub/GitLab esto se hace con reglas de protección de rama y acciones CI, no con hooks locales.

Git Log — Minería de datos del repositorio

Git almacena toda la historia de tu proyecto. Con Bash puedes extraer, filtrar y transformar esa información de formas que ninguna GUI permite.

Formatos de salida de git log

# formato más compacto
git log --oneline
# feat: añadir autenticación OAuth
# fix: corregir timeout en conexión

# personalizado con --format
git log --format="%h | %an | %ar | %s"
# | Lorenzo | 2 days ago | feat: añadir autenticación OAuth

# gráfico de ramas
git log --oneline --graph --all
#   a1b2c3d (HEAD -> main) merge
#
# * e5f6g7h (feature/login) fix login redirect
# | 8i9j0k1 chore: update deps
#

# rango de fechas
git log --since="2025-01-01" --until="2025-06-30" --oneline

# autor
git log --author="Lorenzo" --oneline

# modificados en cada commit
git log --name-only --oneline

# (líneas +- por commit)
git log --stat --oneline

Formatos de --format más útiles:

PlaceholderSignificadoEjemplo
%HSHA completoa1b2c3d4e5f6...
%hSHA abreviadoa1b2c3d
%anAuthor nameLorenzo
%aeAuthor emaillorenzo@example.com
%arFecha relativa2 days ago
%aiFecha ISO-86012025-06-11 10:30:00 +0000
%sSubject (mensaje)feat: añadir login
%bBodyCuerpo del mensaje
%DRef namesHEAD -> main, tag: v1.0
%G?Firma GPGG (good) / B (bad)

Parsear author, fecha y mensaje

#!/bin/bash
# datos estructurados del log

# commits por autor con conteo
git log --format="%an" --since="2025-01-01" | sort | uniq -c | sort -rn
# 42  Lorenzo
# 15  María
#  8  Carlos

# el último commit de cada autor
git log --format="%an | %h | %s" | awk -F'|' '!seen[$1]++'
# | a1b2c3d | feat: añadir login
#   | e5f6g7h | fix: corregir timeout

# por día de la semana (¿cuándo trabajas más?)
git log --date=format:"%u" --format="%ad" | sort | uniq -c | sort -rn
# 18  1  (lunes)
# 15  3  (miércoles)
# 12  2  (martes)
#  8  5  (viernes)
#  6  4  (jueves)

# fecha ISO para cálculos
while IFS='|' read -r sha date author message; do
   year="${date:0:4}"
   month="${date:5:2}"
   echo "$year-$month: $sha por $author → $message"
done < <(git log --format="%h|%ai|%an|%s")

Generar changelog desde el log

Una de las automatizaciones más útiles: generar un CHANGELOG.md a partir de los mensajes de commit, agrupados por versión.

#!/bin/bash
# — Genera CHANGELOG.md desde git log
# ./generar_changelog.sh [desde_tag] [hasta_tag]

desde="${1:-$(git tag --sort=-creatordate | head -1)}"
hasta="${2:-HEAD}"

if [ -z "$desde" ]; then
   # Sin tags: todo el historial
   rango="HEAD"
   nuevo=true
else
   rango="${desde}..${hasta}"
   nuevo=false
fi

echo "# Changelog"
echo ""
echo "## [$hasta] - $(date +%Y-%m-%d)"
echo ""

# commits agrupados por tipo (Conventional Commits)
git log "$rango" --format="%s" --reverse | while IFS= read -r line; do
   case "$line" in
       feat:*|feature:*)  echo "### 🚀 Nuevas funcionalidades" >&3; echo "- $line" ;;
       fix:*)             echo "### 🐛 Correcciones" >&3; echo "- $line" ;;
       chore:*|refactor:*) echo "### 🔧 Mantenimiento" >&3; echo "- $line" ;;
       docs:*)            echo "### 📚 Documentación" >&3; echo "- $line" ;;
       test:*)            echo "### 🧪 Tests" >&3; echo "- $line" ;;
       perf:*)            echo "### ⚡ Rendimiento" >&3; echo "- $line" ;;
       *)                 echo "### Otros cambios" >&3; echo "- $line" ;;
   esac
done 3>/tmp/changelog_sections.tmp

cat /tmp/changelog_sections.tmp 2>/dev/null
rm -f /tmp/changelog_sections.tmp

Versión más sofisticada: changelog por tags

#!/bin/bash
# — Changelog completo entre versiones etiquetadas
set -euo pipefail

tags=$(git tag --sort=creatordate)
prev=""
echo "# Changelog"
echo ""

for tag in $tags; do
   if [ -z "$prev" ]; then
       rango="$tag"
   else
       rango="$prev..$tag"
   fi

   fecha=$(git log -1 --format="%ai" "$tag" 2>/dev/null | cut -d' ' -f1)

   echo "## [$tag] - $fecha"
   echo ""

   # Agrupar por tipo
   git log "$rango" --format="%s" --reverse 2>/dev/null | while IFS= read -r line; do
       tipo="${line%%:*}"
       mensaje="${line#*: }"
       echo "- **$tipo**: $mensaje"
   done

   echo ""
   prev="$tag"
done

# cambios no etiquetados
if git log "$prev..HEAD" --oneline 2>/dev/null | grep -q .; then
   echo "## [Sin publicar] - $(date +%Y-%m-%d)"
   echo ""
   git log "$prev..HEAD" --format="- %s" --reverse 2>/dev/null
fi

Estado del repositorio

Saber en qué estado está tu repositorio es esencial para cualquier automatización.

¿Working tree sucio? — git status –porcelain

git status --porcelain devuelve una salida estable (sin colores, sin adornos) ideal para scripts.

#!/bin/bash
# si hay cambios sin commit

# 1: git status --porcelain
if [ -n "$(git status --porcelain)" ]; then
   echo "⚠️  Working tree sucio: hay cambios sin commit"
   git status --porcelain | head -10
else
   echo "✅ Working tree limpio"
fi

# 2: diff-index (más rápido)
if ! git diff-index --quiet HEAD --; then
   echo "⚠️  Hay cambios sin commitear"
fi

# staged pero no commiteados
if [ -n "$(git diff --cached --name-only)" ]; then
   echo "📦 Archivos staged listos para commit:"
   git diff --cached --name-only
fi

# sin seguimiento (untracked)
untracked=$(git ls-files --others --exclude-standard)
if [ -n "$untracked" ]; then
   echo "📄 Archivos sin seguimiento:"
   echo "$untracked" | head -10
fi

¿En qué rama estoy?

#!/bin/bash
# rama actual de forma robusta

# 1: git branch --show-current (Git 2.22+)
branch=$(git branch --show-current)

# 2: git rev-parse (fallback universal)
branch=$(git rev-parse --abbrev-ref HEAD)

# estado detached HEAD
if [ "$branch" = "HEAD" ]; then
   echo "⚠️  Estado detached HEAD en $(git rev-parse HEAD)"
fi

# si es rama de feature (no main/master/develop)
case "$branch" in
   main|master|develop) echo "🏠 Rama principal: $branch" ;;
   feature/*)           echo "🚧 Feature branch: $branch" ;;
   hotfix/*)            echo "🔧 Hotfix branch: $branch" ;;
   release/*)           echo "📦 Release branch: $branch" ;;
   *)                   echo "🌿 Rama: $branch" ;;
esac

¿Ahead/Behind del remoto?

#!/bin/bash
# divergencia con el remoto

remote_branch="${1:-origin/$(git branch --show-current)}"

# ahead (no enviados)
ahead=$(git rev-list --count "$remote_branch..HEAD" 2>/dev/null || echo 0)

# behind (por recibir)
behind=$(git rev-list --count "HEAD..$remote_branch" 2>/dev/null || echo 0)

echo "📊 Estado vs $remote_branch:"
echo "   Ahead:  $ahead commits (por enviar)"
echo "   Behind: $behind commits (por recibir)"

if [ "$ahead" -gt 0 ] && [ "$behind" -gt 0 ]; then
   echo "⚠️  Divergencia detectada — necesitas rebase o merge"
elif [ "$ahead" -gt 0 ]; then
   echo "💡 Haz git push para enviar tus cambios"
elif [ "$behind" -gt 0 ]; then
   echo "💡 Haz git pull para actualizarte"
else
   echo "✅ Alineado con el remoto"
fi

Integridad y limpieza

#!/bin/bash
# del repositorio

# integridad del repositorio
echo "🔍 Verificando integridad..."
if git fsck --no-dangling 2>&1 | grep -qE '(error|missing)'; then
   echo "⚠️  Problemas de integridad detectados"
   git fsck --no-dangling
else
   echo "✅ Repositorio íntegro"
fi

# objetos no referenciados
echo "🧹 Limpiando objetos inalcanzables..."
git gc --auto --prune=now 2>/dev/null && echo "✅ gc completado"

# del repositorio
size=$(du -sh .git | cut -f1)
echo "📦 Tamaño del .git: $size"

Tag Management

Crear tags anotados y ligeros

# ligero (solo un puntero, sin metadatos)
git tag v1.0.0

# anotado (con mensaje, autor, fecha — recomendado para releases)
git tag -a v1.0.0 -m "Release v1.0.0: Primera versión estable"

# firmado con GPG
git tag -s v1.0.0 -m "Release v1.0.0 firmada"

# tags
git tag -l
git tag --sort=-creatordate  # Más recientes primero

# detalles de un tag
git show v1.0.0

Push de tags

# todos los tags
git push --tags

# un tag específico
git push origin v1.0.0

# tag local y remoto
git tag -d v1.0.0
git push --delete origin v1.0.0

Version bump automático

#!/bin/bash
# — Incrementa versión semántica automáticamente
# version_bump.sh [major|minor|patch]
# en Conventional Commits (Angular style)

set -euo pipefail

bump_type="${1:-patch}"
last_tag=$(git tag --sort=-creatordate | head -1)

if [ -z "$last_tag" ]; then
   echo "⚠️  No hay tags previos — Starting desde v0.1.0"
   new_version="v0.1.0"
else
   # Extraer major.minor.patch del tag (eliminando 'v' si existe)
   version="${last_tag#v}"
   IFS='.' read -r major minor patch <<< "$version"

   case "$bump_type" in
       major)
           major=$((major + 1))
           minor=0
           patch=0
           ;;
       minor)
           minor=$((minor + 1))
           patch=0
           ;;
       patch)
           patch=$((patch + 1))
           ;;
       *)
           echo "❌ Tipo inválido: $bump_type (major|minor|patch)"
           exit 1
           ;;
   esac
   new_version="v${major}.${minor}.${patch}"
fi

# el tag
git tag -a "$new_version" -m "Release $new_version"
echo "✅ Tag creado: $new_version"

# si hacer push
echo "¿Hacer push del tag? (s/N): "
read -r respuesta
if [ "$respuesta" = "s" ]; then
   git push origin "$new_version"
   echo "✅ Push de $new_version completado"
fi

# changelog del último release
echo ""
echo "📋 Cambios desde $last_tag:"
if [ -n "$last_tag" ]; then
   git log "$last_tag..HEAD" --oneline --reverse
else
   git log --oneline --reverse | head -20
fi

Versión avanzada con detección automática del tipo de bump:

# — Detecta automáticamente major/minor/patch
# los Conventional Commits desde el último tag

last_tag=$(git tag --sort=-creatordate | head -1)
[ -z "$last_tag" ] && last_tag="HEAD~0"

# breaking changes (commit con ! o BREAKING CHANGE)
if git log "$last_tag..HEAD" --format="%s" | grep -qE '!\:|BREAKING CHANGE'; then
   bump_type="major"
# features
elif git log "$last_tag..HEAD" --format="%s" | grep -qE '^feat'; then
   bump_type="minor"
else
   bump_type="patch"
fi

echo "🔍 Detección automática: $bump_type"
exec bash "$0" "$bump_type"

Estrategias de automatización

Auto-deploy: pull + build + restart

Un script que se ejecuta en el servidor de producción, tira de los últimos cambios, construye y reinicia.

#!/bin/bash
# — Despliegue automático
# para ejecutar via webhook, cron, o post-merge
set -euo pipefail

DEPLOY_DIR="/var/www/mi-app"
BRANCH="main"
SERVICE_NAME="mi-app"
LOG_FILE="/var/log/deploy.log"

log() {
   echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"
}

log "🚀 Iniciando despliegue..."

cd "$DEPLOY_DIR"

# Verificar estado
log "📡 Verificando rama..."
current=$(git branch --show-current)
if [ "$current" != "$BRANCH" ]; then
   log "❌ Rama actual es '$current', se esperaba '$BRANCH'"
   exit 1
fi

# Guardar hash actual para comparar
old_hash=$(git rev-parse HEAD)

# Pull
log "📥 Haciendo pull..."
git fetch origin "$BRANCH"
git reset --hard "origin/$BRANCH"
new_hash=$(git rev-parse HEAD)

if [ "$old_hash" = "$new_hash" ]; then
   log "✅ Sin cambios nuevos — deploy no necesario"
   exit 0
fi

log "📋 Cambios: $(git log --oneline "$old_hash..$new_hash" | wc -l) commits"

# Instalar dependencias
if [ -f "package.json" ]; then
   log "📦 npm install..."
   npm install --production >> "$LOG_FILE" 2>&1
fi
if [ -f "requirements.txt" ]; then
   log "🐍 pip install..."
   pip install -r requirements.txt >> "$LOG_FILE" 2>&1
fi

# Build
if [ -f "Makefile" ] && grep -q '^build:' Makefile; then
   log "🔨 make build..."
   make build >> "$LOG_FILE" 2>&1
fi

# Migraciones
if [ -f "manage.py" ]; then
   log "🗄️ Migraciones Django..."
   python manage.py migrate >> "$LOG_FILE" 2>&1
fi

# Reiniciar servicio
log "🔄 Reiniciando servicio $SERVICE_NAME..."
sudo systemctl restart "$SERVICE_NAME" >> "$LOG_FILE" 2>&1

# Healthcheck
sleep 3
if curl -sf http://localhost/health > /dev/null 2>&1; then
   log "✅ Healthcheck OK — despliegue exitoso"
else
   log "⚠️  Healthcheck falló — revisar manualmente"
   # Rollback opcional
   # git reset --hard "$old_hash"
   # sudo systemctl restart "$SERVICE_NAME"
fi

Branch cleanup — Eliminar ramas fusionadas

El clásico script de limpieza que mantiene tu repositorio ordenado.

#!/bin/bash
# — Limpiar ramas locales y remotas
set -euo pipefail

echo "🧹 Limpieza de ramas"
echo "═══════════════════"

# Ramas locales ya fusionadas en main
echo ""
echo "📌 Ramas locales fusionadas en main (candidatas a eliminar):"
git branch --merged main | grep -vE '^\*|main|master|develop' | while IFS= read -r branch; do
   echo "   $branch"
done

if git branch --merged main | grep -vE '^\*|main|master|develop' | grep -q .; then
   echo ""
   echo "¿Eliminar estas ramas locales? (s/N): "
   read -r respuesta
   if [ "$respuesta" = "s" ]; then
       git branch --merged main | grep -vE '^\*|main|master|develop' | xargs -r git branch -d
       echo "✅ Ramas locales eliminadas"
   fi
fi

# Ramas remotas huérfanas (sin contraparte local activa)
echo ""
echo "📌 Ramas remotas sin actividad en 30 días:"
git for-each-ref --format='%(refname:short) %(committerdate:unix)' refs/remotes/origin/ |
   while IFS=' ' read -r branch date; do
       now=$(date +%s)
       diff=$(( (now - date) / 86400 ))
       if [ "$diff" -gt 30 ] && ! echo "$branch" | grep -qE 'main|master|develop|HEAD'; then
           echo "   $branch (último commit hace ${diff}d)"
       fi
   done

# Ramas remotas sin tracking local
echo ""
echo "📌 Ramas remotas sin tracking local:"
for branch in $(git branch -r | grep -vE 'origin/(main|master|develop|HEAD)'); do
   local_name="${branch#origin/}"
   if ! git branch | grep -qE "^\*?\s*$local_name$"; then
       echo "   $branch"
   fi
done

Stale branch detection

#!/bin/bash
# — Detectar ramas inactivas
set -euo pipefail

THRESHOLD_DAYS="${1:-90}"

echo "🔍 Ramas sin actividad en los últimos $THRESHOLD_DAYS días"
echo "══════════════════════════════════════════════════════"

git for-each-ref --format='%(refname:short)|%(committerdate:relative)|%(committerdate:unix)' refs/heads/ |
   while IFS='|' read -r branch relative unix; do
       now=$(date +%s)
       age=$(( (now - unix) / 86400 ))

       # Saltar ramas principales
       case "$branch" in
           main|master|develop) continue ;;
       esac

       if [ "$age" -gt "$THRESHOLD_DAYS" ]; then
           author=$(git log -1 --format="%an" "$branch" 2>/dev/null || echo "desconocido")
           echo "   $branch — $relative — último autor: $author"
       fi
   done

Git bisect automatizado

git bisect hace búsqueda binaria para encontrar el commit que introdujo un bug. Con Bash puedes automatizarlo completamente.

#!/bin/bash
# — Búsqueda binaria automatizada
# el commit que rompió un test
set -euo pipefail

SCRIPT_TO_TEST="/ruta/al/test.sh"

# commit actual como malo
git bisect start
git bisect bad HEAD

# un commit bueno (último release)
last_tag=$(git tag --sort=-creatordate | head -1)
if [ -n "$last_tag" ]; then
   git bisect good "$last_tag"
else
   # Si no hay tags, ir hacia atrás N commits
   git bisect good HEAD~50 2>/dev/null || {
       echo "❌ No se pudo encontrar un commit bueno"
       git bisect reset
       exit 1
   }
fi

# bisect automático
git bisect run bash "$SCRIPT_TO_TEST"

# resultado
echo ""
echo "🔍 Resultado del bisect:"
git log -1 --oneline "$(git bisect view --oneline 2>/dev/null | head -1 | awk '{print $1}')"

git bisect reset

Git submodule management

#!/bin/bash
# — Gestión de submódulos
set -euo pipefail

cmd="${1:-status}"

case "$cmd" in
   status)
       echo "📦 Estado de submódulos:"
       git submodule status
       ;;

   init-update)
       echo "📦 Inicializando y actualizando submódulos..."
       git submodule init
       git submodule update --recursive
       echo "✅ Submódulos actualizados"
       ;;

   latest)
       echo "📦 Actualizando cada submódulo a su última versión..."
       git submodule foreach --recursive 'git checkout main && git pull origin main'
       echo "✅ Submódulos en su última versión"
       ;;

   push-recursive)
       echo "📦 Haciendo push recursivo..."
       git submodule foreach --recursive 'git push origin HEAD'
       echo "✅ Push recursivo completado"
       ;;

   diff)
       echo "📦 Diferencia de submódulos:"
       git diff --submodule
       ;;

   *)
       echo "Uso: $0 {status|init-update|latest|push-recursive|diff}"
       ;;
esac

Backup con git bundle

git bundle crea un solo archivo con todo el repositorio — ideal para backups, transferencias offline o copias de seguridad.

#!/bin/bash
# — Backup del repositorio con git bundle
set -euo pipefail

REPO_DIR="${1:-.}"
BACKUP_DIR="${2:-/tmp/git-backups}"

cd "$REPO_DIR"

# directorio de backups
mkdir -p "$BACKUP_DIR"

# del bundle con fecha
backup_file="${BACKUP_DIR}/backup_$(basename "$(pwd)")_$(date +%Y%m%d_%H%M%S).bundle"

echo "📦 Creando bundle del repositorio $(basename "$(pwd)")..."

# completo (todas las ramas y tags)
git bundle create "$backup_file" --all

# integridad del bundle
if git bundle verify "$backup_file" &>/dev/null; then
   size=$(du -h "$backup_file" | cut -f1)
   echo "✅ Bundle creado: $backup_file ($size)"
else
   echo "❌ Bundle corrupto"
   rm -f "$backup_file"
   exit 1
fi

#
gzip "$backup_file"
echo "✅ Comprimido: ${backup_file}.gz"

# solo los últimos 7 backups
ls -t "$BACKUP_DIR"/*.bundle.gz 2>/dev/null | tail -n +8 | xargs -r rm
echo "🧹 Backups antiguos limpiados (últimos 7)"

Restaurar desde un bundle:

# desde un bundle
git clone backup_20250611_143000.bundle nuevo_repo

# añadirlo como remoto a un repo existente
git remote add backup /ruta/al/backup.bundle
git fetch backup

Git worktree — Múltiples ramas simultáneas

git worktree permite tener múltiples ramas checkouteadas al mismo tiempo en directorios diferentes. Bash lo hace aún más práctico.

#!/bin/bash
# — Gestiona worktrees
set -euo pipefail

WORKTREE_DIR="${HOME}/git-worktrees"

cmd="${1:-list}"

case "$cmd" in
   list)
       echo "📂 Worktrees activos:"
       git worktree list
       ;;

   add)
       branch="${2:?Uso: $0 add <rama> [directorio]}"
       dir="${3:-${WORKTREE_DIR}/${branch}}"
       mkdir -p "$(dirname "$dir")"
       git worktree add "$dir" "$branch"
       echo "✅ Worktree creado: $dir → $branch"
       ;;

   remove)
       dir="${2:?Uso: $0 remove <directorio>}"
       git worktree remove "$dir"
       echo "✅ Worktree eliminado: $dir"
       ;;

   prune)
       echo "🧹 Limpiando worktrees huérfanos..."
       git worktree prune
       echo "✅ Worktrees huérfanos eliminados"
       ;;

   create-features)
       echo "🔨 Creando worktrees para todas las ramas feature/*..."
       git branch | grep 'feature/' | while IFS= read -r branch; do
           branch="${branch#  }"  # eliminar espacios
           dir="${WORKTREE_DIR}/${branch}"
           if [ ! -d "$dir" ]; then
               echo "   Creando worktree: $dir → $branch"
               git worktree add "$dir" "$branch" 2>/dev/null || \
                   echo "   ⚠️  Error creando worktree para $branch"
           fi
       done
       ;;

   *)
       echo "Uso: $0 {list|add|remove|prune|create-features}"
       ;;
esac

CI/CD desde Bash

Pipeline mínimo: lint + test + build + deploy

Un pipeline CI/CD no es más que una serie de comandos Bash que se ejecutan en orden. Aquí tienes un pipeline portable que funciona en cualquier sistema CI (GitHub Actions, GitLab CI, Jenkins):

#!/bin/bash
# — Pipeline CI/CD portable
set -euo pipefail

# Configuración ───
PROJECT_NAME="mi-app"
BRANCH="${CI_COMMIT_BRANCH:-${GITHUB_REF_NAME:-$(git branch --show-current)}}"
COMMIT_SHA="${CI_COMMIT_SHA:-${GITHUB_SHA:-$(git rev-parse HEAD)}}"
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')

echo "════════════════════════════════════════════"
echo "  Pipeline CI/CD — $PROJECT_NAME"
echo "  Rama:   $BRANCH"
echo "  Commit: $COMMIT_SHA"
echo "  Fecha:  $TIMESTAMP"
echo "════════════════════════════════════════════"

# Step 1: Lint ───
echo ""
echo "🔍 [1/4] Lint — Verificando estilo y calidad..."
if command -v shellcheck &>/dev/null; then
   find . -name '*.sh' -exec shellcheck -x {} + || {
       echo "❌ Shellcheck encontró errores"
       exit 1
   }
   echo "✅ Shellcheck: OK"
else
   echo "⚠️  shellcheck no instalado — saltando"
fi

# Step 2: Test ───
echo ""
echo "🧪 [2/4] Test — Ejecutando pruebas..."
if [ -f "test.sh" ]; then
   bash test.sh || {
       echo "❌ Tests fallaron"
       exit 1
   }
   echo "✅ Tests: OK"
else
   # Tests unitarios con BATS si existe
   if [ -d "tests" ] && command -v bats &>/dev/null; then
       bats tests/ || {
           echo "❌ Tests BATS fallaron"
           exit 1
       }
       echo "✅ Tests BATS: OK"
   else
       echo "⚠️  No se encontraron tests — saltando"
   fi
fi

# Step 3: Build ───
echo ""
echo "🔨 [3/4] Build — Construyendo artefactos..."
if [ -f "Makefile" ]; then
   make build || {
       echo "❌ Build falló"
       exit 1
   }
   echo "✅ Build: OK"
elif [ -f "Dockerfile" ]; then
   docker build -t "${PROJECT_NAME}:${COMMIT_SHA::7}" . || {
       echo "❌ Docker build falló"
       exit 1
   }
   echo "✅ Docker build: OK"
else
   echo "⚠️  No se encontró Makefile ni Dockerfile — saltando"
fi

# Step 4: Deploy (solo en main/master/develop) ───
echo ""
echo "🚀 [4/4] Deploy — Desplegando..."
case "$BRANCH" in
   main|master)
       echo "   Rama principal detectada — desplegando a producción..."
       if [ -f "deploy.sh" ]; then
           bash deploy.sh production || {
               echo "❌ Deploy a producción falló"
               exit 1
           }
           echo "✅ Deploy a producción: OK"
       fi
       ;;
   develop)
       echo "   Rama develop detectada — desplegando a staging..."
       if [ -f "deploy.sh" ]; then
           bash deploy.sh staging || {
               echo "❌ Deploy a staging falló"
               exit 1
           }
           echo "✅ Deploy a staging: OK"
       fi
       ;;
   feature/*)
       echo "   Rama feature — deploy no automático"
       ;;
   *)
       echo "   Rama '$BRANCH' — deploy no configurado"
       ;;
esac

echo ""
echo "════════════════════════════════════════════"
echo "  ✅ Pipeline completado exitosamente"
echo "════════════════════════════════════════════"

Pre-commit lint hooks con shellcheck

#!/bin/bash
# — Hook especializado para linting
set -euo pipefail

echo "🔍 Verificando scripts Bash..."

# scripts Bash staged (añadidos o modificados)
staged_scripts=$(git diff --cached --name-only --diff-filter=ACM -- '*.sh' ':!node_modules/*' ':!vendor/*')

if [ -z "$staged_scripts" ]; then
   echo "   No hay scripts Bash en el commit"
   exit 0
fi

errors=0
while IFS= read -r script; do
   if [ ! -f "$script" ]; then
       continue
   fi

   # Verificar sintaxis con bash -n
   if ! bash -n "$script" 2>/dev/null; then
       echo "❌ Error de sintaxis: $script"
       errors=$((errors + 1))
       continue
   fi

   # Verificar con shellcheck si está disponible
   if command -v shellcheck &>/dev/null; then
       if ! shellcheck -x -s bash "$script"; then
           echo "⚠️  Advertencia de shellcheck: $script"
           errors=$((errors + 1))
       else
           echo "✅ $script — OK"
       fi
   fi
done <<< "$staged_scripts"

# otros archivos con shebang bash
other_scripts=$(git diff --cached --name-only --diff-filter=ACM | while IFS= read -r f; do
   [ -f "$f" ] && head -1 "$f" | grep -q '^#!/bin/bash' && echo "$f"
done)

while IFS= read -r script; do
   [ -z "$script" ] && continue
   echo "   ${script}: tiene shebang bash"
done <<< "$other_scripts"

if [ "$errors" -gt 0 ]; then
   echo "❌ $errors error(es) de linting — commit cancelado"
   exit 1
fi

echo "✅ Linting Bash completado"

Variables de entorno en CI

En entornos CI, las variables de entorno reemplazan la interacción manual. Aquí hay un patrón para manejarlas correctamente:

#!/bin/bash
# seguro de variables de entorno CI

# por defecto para desarrollo local
export CI=${CI:-false}
export GITHUB_ACTIONS=${GITHUB_ACTIONS:-false}
export GITLAB_CI=${GITLAB_CI:-false}

# con defaults seguros
export DEPLOY_ENV=${DEPLOY_ENV:-development}
export LOG_LEVEL=${LOG_LEVEL:-info}
export TIMEOUT=${TIMEOUT:-30}

# — solo disponibles en CI
if [ "$CI" = "true" ]; then
   # Estas variables vienen del CI, no del script
   : "${DOCKER_USERNAME:?DOCKER_USERNAME no está definida en CI}"
   : "${DOCKER_PASSWORD:?DOCKER_PASSWORD no está definida en CI}"
   : "${DEPLOY_KEY:?DEPLOY_KEY no está definida en CI}"
   echo "🔐 CI detectado — credenciales verificadas"
else
   echo "💻 Entorno local — usando defaults"
fi

# helper para obtener variables con fallback
get_env() {
   local var="$1"
   local default="${2:-}"
   echo "${!var:-$default}"
}

#
db_host=$(get_env "DB_HOST" "localhost")
db_port=$(get_env "DB_PORT" "5432")
echo "Conectando a $db_host:$db_port..."

Funciones profesionales (4+)

Funciones reutilizables para tu biblioteca personal:

#
# profesionales — Automatización Git
#

# — Version bump automático
# git_auto_version [major|minor|patch]
git_auto_version() {
   local bump="${1:-patch}"
   local last_tag=$(git tag --sort=-creatordate | head -1)

   if [ -z "$last_tag" ]; then
       new_version="v0.1.0"
   else
       local version="${last_tag#v}"
       IFS='.' read -r major minor patch <<< "$version"
       case "$bump" in
           major) ((major++)); minor=0; patch=0 ;;
           minor) ((minor++)); patch=0 ;;
           patch) ((patch++)) ;;
           *) echo "❌ Uso: git_auto_version {major|minor|patch}"; return 1 ;;
       esac
       new_version="v${major}.${minor}.${patch}"
   fi

   git tag -a "$new_version" -m "Release $new_version"
   echo "✅ Tag $new_version creado"

   echo "¿Hacer push? (s/N): " && read -r r
   [[ "$r" = "s" ]] && git push origin "$new_version"
}

# — Generar changelog markdown entre tags
# git_changelog [desde_tag] [hasta_tag]
git_changelog() {
   local desde="${1:-$(git tag --sort=-creatordate | tail -1)}"
   local hasta="${2:-HEAD}"
   local rango="${desde:+${desde}..}${hasta}"

   echo "# Changelog"
   echo "## [$hasta] - $(date +%Y-%m-%d)"
   echo ""

   git log "$rango" --format="- %s" --reverse 2>/dev/null | while IFS= read -r line; do
       echo "$line"
   done

   [ -z "$desde" ] && return 0
   echo ""
   echo "---"
   git log "$rango" --format="%h | %an | %ar | %s" --reverse 2>/dev/null
}

# — Limpiar ramas fusionadas (local y remoto)
# git_cleanup_branches [main|master]
git_cleanup_branches() {
   local base="${1:-main}"

   echo "🧹 Ramas locales fusionadas en $base:"
   local branches
   branches=$(git branch --merged "$base" | grep -vE "^\*|$base|master|develop" | tr -d ' ')

   if [ -z "$branches" ]; then
       echo "   (ninguna)"
       return 0
   fi

   echo "$branches" | sed 's/^/   /'

   echo "¿Eliminar? (s/N): " && read -r r
   [[ "$r" != "s" ]] && return 0

   echo "$branches" | xargs -r git branch -d
   echo "✅ Ramas locales eliminadas"

   # Ramas remotas huérfanas
   echo ""
   echo "¿Eliminar ramas remotas sin tracking local? (s/N): " && read -r r
   [[ "$r" != "s" ]] && return 0

   git branch -r | grep -vE "origin/($base|master|develop|HEAD)" | while IFS= read -r remote_branch; do
       local name="${remote_branch#origin/}"
       if ! git branch | grep -qE "^\*?\s*$name$"; then
           git push origin --delete "$name" 2>/dev/null && echo "   Eliminada: $remote_branch"
       fi
   done
}

# — Despliegue automático (pull + build + restart)
# git_auto_deploy <directorio> <rama> <servicio>
git_auto_deploy() {
   local dir="${1:?Uso: git_auto_deploy <dir> <rama> <servicio>}"
   local branch="${2:?Falta rama}"
   local service="${3:?Falta servicio}"

   cd "$dir"

   # Verificar rama
   local current
   current=$(git branch --show-current)
   [ "$current" != "$branch" ] && {
       echo "❌ Rama $current ≠ $branch"; return 1
   }

   # Guardar hash y hacer pull
   local old_hash
   old_hash=$(git rev-parse HEAD)
   git fetch origin "$branch"
   git reset --hard "origin/$branch"

   [ "$old_hash" = "$(git rev-parse HEAD)" ] && {
       echo "✅ Sin cambios"; return 0
   }

   # Build si existe Makefile
   [ -f "Makefile" ] && make build

   # Dependencias
   [ -f "package.json" ] && npm install --production
   [ -f "requirements.txt" ] && pip install -r requirements.txt

   # Reiniciar
   if command -v systemctl &>/dev/null && [ -n "$service" ]; then
       sudo systemctl restart "$service"
   fi

   echo "✅ Deploy completado: $(git log --oneline "$old_hash..HEAD" | wc -l) commits"
}

Script completo: git_automation.sh

Un gestor de automatización Git todo-en-uno, en ~120 líneas:

#!/bin/bash
# — Automatización profesional de Git
# ./git_automation.sh <comando> [args]
# version|changelog|cleanup|deploy|status|backup|bisect|worktree|submodule|install-hooks
set -euo pipefail

GRN='\033[0;32m'; YLW='\033[1;33m'; RED='\033[0;31m'; BLU='\033[0;34m'; NC='\033[0m'
ok()   { echo -e "${GRN}✓${NC} $*"; }
warn() { echo -e "${YLW}⚠${NC} $*" >&2; }
err()  { echo -e "${RED}✗${NC} $*" >&2; }
info() { echo -e "${BLU}ℹ${NC} $*"; }
die()  { err "$*"; exit 1; }

# Asegurar que estamos en un repositorio Git ──
ensure_git_repo() {
   git rev-parse --git-dir &>/dev/null || die "No estás en un repositorio Git"
}

# version — Version bump automático ──
cmd_version() {
   ensure_git_repo
   local bump="${1:-patch}"
   local last_tag; last_tag=$(git tag --sort=-creatordate | head -1)

   local new_version
   if [ -z "$last_tag" ]; then
       new_version="v0.1.0"
   else
       local ver="${last_tag#v}"; IFS='.' read -r major minor patch <<< "$ver"
       case "$bump" in
           major) ((major++)); minor=0; patch=0 ;;
           minor) ((minor++)); patch=0 ;;
           patch) ((patch++)) ;;
           *) die "Uso: version {major|minor|patch}" ;;
       esac
       new_version="v${major}.${minor}.${patch}"
   fi

   git tag -a "$new_version" -m "Release $new_version"
   ok "Tag creado: $new_version"

   echo -n "¿Push? (s/N): "; read -r r
   [[ "$r" =~ ^[sS]$ ]] && git push origin "$new_version" && ok "Push de $new_version"
}

# changelog — Generar changelog ──
cmd_changelog() {
   ensure_git_repo
   local desde="${1:-}"
   local hasta="${2:-HEAD}"

   [ -z "$desde" ] && desde=$(git tag --sort=-creatordate | tail -1)
   local rango="${desde:+${desde}..}${hasta}"

   echo "# Changelog"
   echo "## [$hasta] - $(date +%Y-%m-%d)"
   echo ""
   echo "### Cambios desde ${desde:-(inicio)}:"
   git log "$rango" --format="- %s" --reverse 2>/dev/null || warn "Rango vacío: $rango"
   echo ""
   echo "### Detalle de commits:"
   git log "$rango" --format="%h | %an | %ar | %s" --reverse 2>/dev/null || true
}

# cleanup — Limpiar ramas fusionadas ──
cmd_cleanup() {
   ensure_git_repo
   local base="${1:-main}"
   local branches

   branches=$(git branch --merged "$base" | grep -vE "^\*|$base|master|develop" | tr -d ' ')
   if [ -z "$branches" ]; then
       info "No hay ramas fusionadas para limpiar"
       return 0
   fi

   warn "Ramas fusionadas en $base:"
   echo "$branches" | sed 's/^/   /'
   echo -n "¿Eliminar locales? (s/N): "; read -r r
   [[ "$r" =~ ^[sS]$ ]] && echo "$branches" | xargs -r git branch -d && ok "Ramas locales eliminadas"

   echo -n "¿Eliminar remotas huérfanas? (s/N): "; read -r r
   [[ "$r" =~ ^[sS]$ ]] || return 0

   git branch -r | grep -vE "origin/($base|master|develop|HEAD)" | while IFS= read -r rb; do
       local name="${rb#origin/}"
       git branch | grep -qE "^\*?\s*$name$" && continue
       git push origin --delete "$name" 2>/dev/null && ok "Eliminada remota: $rb" || warn "No se pudo eliminar $rb"
   done
}

# status — Estado completo del repo ──
cmd_status() {
   ensure_git_repo
   echo "═══ ESTADO DEL REPOSITORIO ═══"
   local branch; branch=$(git branch --show-current)
   echo "Rama:        $branch"

   # Sucio?
   if [ -n "$(git status --porcelain)" ]; then
       warn "Working tree: SUCIO"
       git status --short | head -10
   else
       ok "Working tree: limpio"
   fi

   # Ahead/Behind
   local remote_branch="origin/${branch}"
   if git rev-parse "$remote_branch" &>/dev/null; then
       local ahead; ahead=$(git rev-list --count "$remote_branch..HEAD" 2>/dev/null || echo 0)
       local behind; behind=$(git rev-list --count "HEAD..$remote_branch" 2>/dev/null || echo 0)
       echo "Ahead:       $ahead commits"
       echo "Behind:      $behind commits"
   fi

   # Último tag
   local last_tag; last_tag=$(git tag --sort=-creatordate | head -1)
   echo "Último tag:  ${last_tag:-(ninguno)}"

   # Tamaño del .git
   local size; size=$(du -sh .git 2>/dev/null | cut -f1)
   echo "Tamaño .git: ${size:-desconocido}"

   # Integridad
   if git fsck --no-dangling 2>&1 | grep -qE '(error|missing)'; then
       warn "⚠️  Problemas de integridad — ejecuta 'git fsck'"
   fi
}

# deploy — Auto-deploy ──
cmd_deploy() {
   ensure_git_repo
   local branch="${1:-main}"
   local service="${2:-}"
   local current; current=$(git branch --show-current)
   die_if_not_branch() { [ "$current" = "$1" ] || die "Rama actual: $current (se requiere: $1)"; }
   die_if_not_branch "$branch"

   local old_hash; old_hash=$(git rev-parse HEAD)
   info "Haciendo pull de $branch..."
   git fetch origin "$branch" && git reset --hard "origin/$branch"

   local new_hash; new_hash=$(git rev-parse HEAD)
   [ "$old_hash" = "$new_hash" ] && { ok "Sin cambios nuevos"; return 0; }

   git log "$old_hash..$new_hash" --oneline --reverse | sed 's/^/   /'

   [ -f "Makefile" ] && { info "make build..."; make build; }
   [ -f "package.json" ] && { info "npm install..."; npm install --production; }
   [ -f "requirements.txt" ] && { info "pip install..."; pip install -r requirements.txt; }

   if [ -n "$service" ] && command -v systemctl &>/dev/null; then
       info "Reiniciando $service..."
       sudo systemctl restart "$service"
   fi

   ok "Deploy completado en $branch"
}

# backup — Crear bundle de backup ──
cmd_backup() {
   ensure_git_repo
   local backup_dir="${1:-/tmp/git-backups}"
   mkdir -p "$backup_dir"
   local name; name=$(basename "$(pwd)")
   local file="${backup_dir}/${name}_$(date +%Y%m%d_%H%M%S).bundle"

   info "Creando bundle: $file"
   git bundle create "$file" --all || die "Error creando bundle"
   git bundle verify "$file" &>/dev/null && ok "Bundle verificado" || die "Bundle corrupto"

   gzip "$file" && ok "Comprimido: ${file}.gz"
   ls -t "$backup_dir"/*.bundle.gz 2>/dev/null | tail -n +8 | xargs -r rm
   info "Últimos 7 backups conservados"
}

# install-hooks — Instalar hooks predeterminados ──
cmd_install_hooks() {
   ensure_git_repo
   local hook_dir
   hook_dir=$(git rev-parse --git-dir)/hooks

   info "Instalando hooks en $hook_dir/..."

   # pre-commit
   cat > "$hook_dir/pre-commit" << 'HOOK'
#!/bin/bash
set -euo pipefail
echo "🔍 pre-commit: validando..."
if git diff --cached -U0 | grep -Pi '(password|secret|api.?key|token)\s*[:=]\s*["'"'"']?[^"'"'"'\s]{8,}' > /dev/null; then
   echo "❌ Posible credencial en el diff"; exit 1
fi
if command -v shellcheck &>/dev/null; then
   git diff --cached --name-only --diff-filter=ACM -- '*.sh' | while IFS= read -r f; do
       [ -f "$f" ] && shellcheck -x "$f" || exit 1
   done
fi
HOOK
   chmod +x "$hook_dir/pre-commit"
   ok "pre-commit instalado"

   # pre-push
   cat > "$hook_dir/pre-push" << 'HOOK'
#!/bin/bash
set -euo pipefail
branch=$(git branch --show-current)
case "$branch" in main|master|develop)
   echo "⚠️ Push a $branch — confirma (s/N): "; read -r r
   [ "$r" = "s" ] || exit 1
   ;;
esac
HOOK
   chmod +x "$hook_dir/pre-push"
   ok "pre-push instalado"

   # post-commit
   cat > "$hook_dir/post-commit" << 'HOOK'
#!/bin/bash
echo "[$(date '+%Y-%m-%d %H:%M:%S')] COMMIT $(git rev-parse --short HEAD): $(git log -1 --format='%s')" >> "$HOME/.git_commit_log"
HOOK
   chmod +x "$hook_dir/post-commit"
   ok "post-commit instalado"

   ok "Hooks instalados. Git ahora ejecutará validaciones automáticas."
}

# bisect — Ejecutar bisect automatizado ──
cmd_bisect() {
   ensure_git_repo
   local test_script="${1:?Uso: bisect <script_test> [commit_bueno]}"
   local good_commit="${2:-}"

   [ -f "$test_script" ] || die "Script de test no encontrado: $test_script"
   [ -x "$test_script" ] || die "Script no ejecutable: haz chmod +x"

   git bisect start
   git bisect bad HEAD

   if [ -n "$good_commit" ]; then
       git bisect good "$good_commit"
   else
       local tag; tag=$(git tag --sort=-creatordate | head -1)
       [ -n "$tag" ] && git bisect good "$tag" || git bisect good HEAD~50 2>/dev/null || die "No se encontró commit bueno"
   fi

   info "Ejecutando bisect con: $test_script"
   git bisect run bash "$test_script"
   local result_sha; result_sha=$(git rev-parse HEAD)
   local result_msg; result_msg=$(git log -1 --oneline "$result_sha")

   info "Resultado del bisect:"
   info "$result_msg"

   git bisect reset
}

# Main ──
main() {
   local cmd="${1:-help}"; shift 2>/dev/null || true
   case "$cmd" in
       version|changelog|cleanup|status|deploy|backup|install-hooks|bisect)
           "cmd_${cmd}" "$@"
           ;;
       help|*)
           echo "╔══════════════════════════════════════════╗"
           echo "║   git_automation.sh — Automatización Git ║"
           echo "╚══════════════════════════════════════════╝"
           echo "Uso: $0 <comando> [args]"
           echo ""
           echo "  version [major|minor|patch]  — Version bump + tag"
           echo "  changelog [desde] [hasta]    — Generar changelog"
           echo "  cleanup [rama_base]          — Limpiar ramas fusionadas"
           echo "  status                       — Estado completo del repo"
           echo "  deploy [rama] [servicio]     — Auto-deploy (pull+build+restart)"
           echo "  backup [directorio]          — Backup con git bundle"
           echo "  install-hooks                — Instalar hooks predeterminados"
           echo "  bisect <script> [bueno]      — Búsqueda binaria automatizada"
           echo ""
           echo "Ej: $0 version patch"
           echo "    $0 changelog v1.0.0 HEAD"
           echo "    $0 deploy main mi-app"
           echo "    $0 install-hooks"
           ;;
   esac
}

main "$@"

Cómo usarlo:

chmod +x git_automation.sh

# bump (patch, minor, major)
./git_automation.sh version patch
./git_automation.sh version minor
./git_automation.sh version major

# changelog desde el último tag
./git_automation.sh changelog

# completo del repositorio
./git_automation.sh status

# ramas fusionadas
./git_automation.sh cleanup main

# en servidor
./git_automation.sh deploy main mi-app

# con git bundle
./git_automation.sh backup /backups/git

# hooks en el repositorio actual
./git_automation.sh install-hooks

# binaria para encontrar bug
./git_automation.sh bisect tests/test_login.sh

Errores comunes (10)

1: Hook no ejecutable

# El hook no tiene permiso de ejecución
# existe pero:
ls -la .git/hooks/pre-commit
#   (sin 'x')

# Hacerlo ejecutable
chmod +x .git/hooks/pre-commit
#   (con 'x')

2: Hook sale con código incorrecto

# El hook imprime error pero sale con 0
#
grep -q "BUG" src/ && echo "Error encontrado"
# con 0 aunque grep encontró error porque no hay exit 1!

# Forzar código de salida correcto
grep -q "BUG" src/ || exit 0  # Si no encuentra, ok
echo "Error: se encontró BUG"
exit 1

3: Git status –porcelain en directorio vacío

# Asumir que git status --porcelain siempre funciona
if [ "$(git status --porcelain)" != "" ]; then
   echo "Hay cambios"
fi
# no hay cambios, el comando devuelve STRING VACÍO pero exit 0

# Verificar de forma robusta
if [ -n "$(git status --porcelain 2>/dev/null)" ]; then
   echo "Hay cambios"
fi

# O usar diff-index (más eficiente)
if ! git diff-index --quiet HEAD --; then
   echo "Hay cambios sin commitear"
fi

4: Push de tags sin actualizar ramas

# Subir tags sin subir las ramas asociadas
git tag v1.0.0
git push origin v1.0.0
# rama con el código de v1.0.0 no está en el remoto

# Subir rama y tag juntos
git push origin main
git push origin v1.0.0

# O subir todo de una vez
git push origin main --tags

5: Olvidar que los hooks no se versionan

# Clonas el repo en otro lado y los hooks no están
git clone git@server:proyecto.git
# solo tiene los hooks de ejemplo (terminados en .sample)

# Solución 1: Usar core.hooksPath (ruta compartida)
git config core.hooksPath .githooks
# .githooks/pre-commit (SÍ se versiona)

# Solución 2: Script de bootstrap
# scripts/setup-hooks.sh que copia los hooks

6: git log –format con espacios en el mensaje

# Parsear con for/while ingenuo
for commit in $(git log --format="%h %s"); do
   echo "SHA: $commit"
done
# mensaje tiene espacios, for rompe todo!

# Usar delimitador e IFS
git log --format="%h|%s" | while IFS='|' read -r sha msg; do
   echo "SHA: $sha | Mensaje: $msg"
done

# O con read -r -d
git log --format="%h%n%s%n---SEPARADOR---%n" | while IFS= read -r sha; do
   IFS= read -r msg
   IFS= read -r sep
   echo "SHA: $sha | Mensaje: $msg"
done

7: Asumir que la rama remota existe

# Calcular ahead/behind sin verificar que la remota existe
ahead=$(git rev-list --count origin/main..HEAD 2>/dev/null)
# origin/main no existe, devuelve 0 sin error

# Verificar que la referencia remota existe
if git rev-parse --verify "origin/main" &>/dev/null; then
   ahead=$(git rev-list --count origin/main..HEAD)
   behind=$(git rev-list --count HEAD..origin/main)
else
   echo "⚠️ origin/main no existe (primera vez?)"
   ahead=0; behind=0
fi

8: git bisect reset olvidado

# Bisect se queda en medio de la búsqueda
git bisect bad HEAD
git bisect good v1.0.0
git bisect run ./test.sh
# el script falla internamente, git bisect queda activo
# el repo está en estado DETACHED HEAD

# Siempre envolver en try/finally
cleanup() { git bisect reset 2>/dev/null || true; }
trap cleanup EXIT

git bisect start
git bisect bad HEAD
git bisect good v1.0.0
git bisect run ./test.sh
# se ejecuta siempre aunque falle

9: Trabajar con submodules sin –recursive

# Clonar sin --recursive, submodules vacíos
git clone git@server:proyecto-con-submodulos.git
cd proyecto-con-submodulos
ls libs/mi-libreria/  # ¡Directorio vacío!

# Clonar con submodules
git clone --recursive git@server:proyecto-con-submodulos.git

# Si ya clonaste, inicializar manualmente
git submodule init
git submodule update --recursive

# Mejor: alias para olvidarte del problema
git config --global alias.clone-full 'clone --recursive'

10: Hacer commit tras pre-commit sin staged changes

# El pre-commit modifica archivos pero no los añade al stage
#
terraform fmt  # Formatea .tf files
# cambio queda sin stage → commit con código sin formatear

# Añadir los cambios formateados al stage
# (avanzado)
files_changed=false
git diff --cached --name-only --diff-filter=ACM -- '*.tf' | while IFS= read -r f; do
   terraform fmt "$f"
   git add "$f"
   files_changed=true
done

Conclusiones

ConceptoComando / ScriptPropósito
pre-commit hook.git/hooks/pre-commitValidar antes de crear el commit (linters, credenciales)
post-commit hook.git/hooks/post-commitReaccionar tras el commit (notificaciones, logs)
pre-push hook.git/hooks/pre-pushValidar antes de enviar al remoto (tests, protección)
post-merge hook.git/hooks/post-mergeReaccionar tras fusionar (deps, builds)
pre-receive hookhooks/pre-receive (servidor)Validar en el servidor antes de aceptar push
git log –format%h, %an, %ar, %s, %aiFormato personalizado de salida del log
git log –oneline –graphgit log --oneline --graph --allVisualizar historial con ramas
git status –porcelaingit status --porcelainEstado estable y parseable del working tree
git diff-index –quietgit diff-index --quiet HEAD --Verificar si hay cambios (rápido)
git rev-list –countrev-list --count A..BContar commits ahead/behind
git tag -agit tag -a v1.0.0 -m "..."Tag anotado (recomendado para releases)
git push –tagsgit push origin --tagsSubir todos los tags al remoto
Version bumpgit_auto_version patchIncrementar versión y crear tag automáticamente
Changeloggit_changelog v1.0.0 HEADGenerar CHANGELOG.md desde git log
Branch cleanupgit_cleanup_branches mainEliminar ramas locales y remotas fusionadas
Auto-deploygit_auto_deploy dir main servicePull + build + restart automatizado
git bisect rungit bisect run ./test.shBúsqueda binaria automatizada
git bundlegit bundle create repo.bundle --allBackup completo del repositorio en un archivo
git worktreegit worktree add ../dir ramaMúltiples ramas checkout al mismo tiempo
git submodulegit submodule update --recursiveGestión de submódulos
Shellcheck hookshellcheck -x script.shLinter para scripts Bash en pre-commit
CI/CD pipelineci_pipeline.shPipeline portable: lint → test → build → deploy
core.hooksPathgit config core.hooksPath .githooksHooks compartidos y versionables
git fsckgit fsck --no-danglingVerificar integridad del repositorio
git gcgit gc --autoLimpiar objetos no referenciados

Mas información,

  • «Pro Git» de Scott Chacon y Ben Straub — La biblia de Git, completamente gratuita en git-scm.com
  • «Git Pocket Guide» de Richard E. Silverman — Referencia compacta para el día a día
  • «Bash Cookbook» de Carl Albing — Recetas que incluyen automatización con Git
  • «Learn Git in a Month of Lunches» de Rick Umali — Enfoque práctico con scripts
  • «The Linux Programming Interface» de Michael Kerrisk — Conceptos subyacentes de procesos y señales que explican cómo funcionan los hooks
  • «Engineering DevOps» de Marc Hornbeek — Automatización de pipelines CI/CD desde la infraestructura

Deja una respuesta