Las cinco lecciones anteriores resolvían problemas con nombre: he perdido commits, mi rama ha divergido, el repositorio está corrupto. Esta se ocupa de la categoría que no tiene nombre y que, en la práctica, es la más frecuente:

Git está haciendo algo que no entiendo, y no sé ni por dónde empezar a mirar.

Un fichero que aparece como modificado y nadie lo ha tocado. Un .gitignore que ignora lo que no debe. Un push que tarda cuarenta segundos en un proyecto pequeño. Un hook que no se ejecuta. Una configuración que dice una cosa y un comportamiento que dice otra. Un git log que oculta commits que existen.

No son averías: son desajustes entre lo que crees que hay y lo que hay de verdad. Y para eso Git tiene una caja de herramientas que apenas hemos rozado: variables de traza que muestran lo que ocurre por debajo, comandos que dicen de dónde sale cada ajuste, y la capa de fontanería (plumbing) que consulta la base de datos sin interpretaciones.

Más importante que las herramientas es el método, porque sin él las herramientas producen ruido. Cerraremos con un método de diagnóstico en cuatro pasos y con una investigación real sobre gestor-tareas que combina bisect y blame para ir del síntoma al commit, y del commit a la línea y su porqué.

Contenido

  1. El método de diagnóstico, en cuatro pasos
  2. Variables de traza: ver lo que Git hace por debajo
  3. Depurar la configuración efectiva
  4. Depurar rutas y patrones: check-ignore, check-attr, ls-files
  5. Fontanería para inspeccionar el grafo
  6. Acotar con git diff --stat y git log
  7. Una investigación real: de bisect a blame
  8. Un catálogo de misterios y sus comandos

  1. El método de diagnóstico, en cuatro pasos

Antes de las herramientas, el procedimiento. Es lo que separa una investigación de veinte minutos de una tarde perdida.

flowchart TD
    A["1. REPRODUCIR<br/>El comando mínimo que provoca el síntoma"]
    B["2. AISLAR<br/>Quitar variables: repo limpio, sin config,<br/>sin hooks, otra máquina"]
    C["3. CONSULTAR EL ESTADO REAL<br/>No el recordado. Fontanería y trazas"]
    D["4. COMPROBAR LA HIPÓTESIS<br/>Una predicción concreta y falsable"]
    A --> B --> C --> D
    D -->|"No se cumple"| C
    D -->|"Se cumple"| E["Arreglar la causa,<br/>no el síntoma"]

Paso 1: reproducir

Reduce el síntoma al comando mínimo que lo provoca.

# Mal: "el push falla"
# Bien:
git push origin GT-241

Si no puedes reproducirlo a voluntad, no puedes verificar que lo has arreglado. Y muchas veces reducir el caso ya revela la causa: "solo falla con esta rama", "solo con este fichero", "solo desde el portátil de Carla".

Paso 2: aislar

Quita variables una a una:

# Sin configuración global ni de sistema
GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null git status

# Sin hooks (lección 06-01)
git -c core.hooksPath=/dev/null commit -m "prueba"
git commit --no-verify -m "prueba"

# Sin alias ni configuración del repositorio
git -c include.path= <comando>

# En un repositorio recién clonado, en /tmp
git clone <url> /tmp/prueba-limpia && cd /tmp/prueba-limpia

Si el problema desaparece sin la configuración global, ya sabes dónde mirar. Si persiste en un clon limpio, el problema está en el repositorio o en el servidor, no en tu máquina.

Paso 3: consultar el estado real, no el recordado

Este es el paso que más veces se salta y el que más veces resuelve.

No preguntes... Pregunta...
"Yo creo que ese fichero está ignorado" git check-ignore -v <fichero>
"Tengo user.email bien configurado" git config --list --show-origin --show-scope
"Ese fichero está en el índice" git ls-files --stage <fichero>
"Esa rama apunta a ese commit" git rev-parse <rama>
"El fichero es texto normal" git ls-files --eol <fichero>
"El remoto está en ese commit" git ls-remote origin <rama>
"Ese hook se está ejecutando" GIT_TRACE=1 git commit ...

La memoria y la documentación mienten; los comandos no.

Paso 4: comprobar la hipótesis

Formula una predicción concreta y falsable antes de tocar nada:

"Si la causa es que .gitattributes marca *.css como text eol=crlf, entonces git check-attr eol estilos.css debe decir crlf, y git ls-files --eol estilos.css debe mostrar w/crlf."

Ejecuta, comprueba, y solo entonces arregla. Si la predicción falla, la hipótesis era mala: vuelve al paso 3. Cambiar cosas al azar hasta que "funciona" deja el problema latente y te impide saber qué lo causaba.

  1. Variables de traza: ver lo que Git hace por debajo

Git tiene un sistema de trazas que se activa con variables de entorno. Son la forma más directa de ver qué está pasando realmente.

Variable Qué muestra Cuándo usarla
GIT_TRACE=1 Cada subcomando que Git ejecuta, con sus argumentos Alias raros, hooks, comandos que llaman a otros
GIT_TRACE_SETUP=1 Cómo Git localiza el repositorio: .git, raíz, prefijo "No es un repositorio", worktrees, submódulos
GIT_TRACE_PERFORMANCE=1 Tiempos por etapa Comandos lentos (08-06)
GIT_TRACE_PACKET=1 Cada paquete del protocolo con el servidor fetch/push que fallan o van lentos
GIT_TRACE_PACK_ACCESS=1 Accesos a packfiles Rendimiento de la base de objetos
GIT_CURL_VERBOSE=1 Diálogo HTTP completo Problemas con remotos HTTPS
GIT_SSH_COMMAND="ssh -v" Diálogo SSH completo Permission denied (publickey)
GIT_TRACE2_PERF=1 Trazas modernas, estructuradas Análisis fino
GIT_TRACE_SHALLOW=1 Lógica de clones superficiales --depth (10-04)

Todas aceptan una ruta absoluta en lugar de 1, para escribir a fichero:

GIT_TRACE=/tmp/traza.log git status

GIT_TRACE: qué está ejecutando Git realmente

GIT_TRACE=1 git status
10:14:22.104 git.c:463               trace: built-in: git status
10:14:22.108 run-command.c:657       trace: run_command: 'gpg' '--status-fd=2' '-bsau' '[email protected]'

Ahí se ve, por ejemplo, si Git está llamando a gpg (firma de commits, lección 08-05), a un hook, o a un credential helper.

Es especialmente útil con alias que hacen cosas raras (lección 06-04):

GIT_TRACE=1 git lg
10:15:03.221 git.c:750               trace: alias expansion: lg => 'log' '--graph' '--abbrev-commit' '--date=relative'
10:15:03.222 git.c:463               trace: built-in: git log --graph --abbrev-commit --date=relative

Y con hooks que no se ejecutan:

GIT_TRACE=1 git commit -m "prueba" 2>&1 | grep -i hook

Si no aparece nada, el hook no se está lanzando. Causas habituales: no es ejecutable (chmod +x), core.hooksPath apunta a otro sitio, o el nombre del fichero tiene extensión.

ls -l .git/hooks/pre-commit
git config --get core.hooksPath

GIT_TRACE_SETUP: dónde cree Git que está

GIT_TRACE_SETUP=1 git status
10:16:41.003 trace.c:318  setup: git_dir: /home/ana/proyectos/gestor-tareas/.git
10:16:41.003 trace.c:319  setup: git_common_dir: /home/ana/proyectos/gestor-tareas/.git
10:16:41.003 trace.c:320  setup: worktree: /home/ana/proyectos/gestor-tareas
10:16:41.003 trace.c:321  setup: cwd: /home/ana/proyectos/gestor-tareas/src
10:16:41.003 trace.c:322  setup: prefix: src/

Resuelve una familia entera de misterios:

  • "No es un repositorio Git" estando dentro de uno: te dice qué .git encuentra (o que no encuentra ninguno).
  • Trabajas en un repositorio que no es el que crees: típico con submódulos (lección 06-05) y worktrees (06-06), donde git_dir y git_common_dir difieren.
  • Los patrones de .gitignore no funcionan como esperas: el prefix explica desde dónde se interpretan las rutas.

GIT_SSH_COMMAND y GIT_CURL_VERBOSE: problemas de red

Retomando el apartado 5.8 de la lección 09-01, aquí está la versión completa.

GIT_SSH_COMMAND="ssh -v" git fetch 2>&1 | head -40
debug1: Reading configuration data /home/ana/.ssh/config
debug1: /home/ana/.ssh/config line 4: Applying options for git.ejemplo.es
debug1: Connecting to git.ejemplo.es [192.0.2.10] port 22.
debug1: Offering public key: /home/ana/.ssh/id_rsa RSA SHA256:xxxx agent
debug1: Authentications that can continue: publickey
debug1: Offering public key: /home/ana/.ssh/id_ed25519 ED25519 SHA256:yyyy agent
debug1: Server accepts key: /home/ana/.ssh/id_ed25519 ED25519 SHA256:yyyy agent
debug1: Authentication succeeded (publickey).

Cada línea es diagnóstico:

Línea Qué te dice
Reading configuration data ~/.ssh/config Qué fichero de configuración se aplica
Applying options for <host> Qué bloque Host ha coincidido
Connecting to <ip> port 22 A dónde va realmente (¿el host correcto?)
Offering public key: <ruta> Qué claves ofrece y en qué orden
Server accepts key Cuál aceptó
Authentications that can continue: publickey Rechazó la anterior y sigue probando

El caso clásico: tienes varias claves, SSH ofrece la equivocada primero y el servidor corta tras N intentos. La solución es un bloque en ~/.ssh/config:

Host git.ejemplo.es
    User git
    IdentityFile ~/.ssh/id_ed25519_ejemplo
    IdentitiesOnly yes

IdentitiesOnly yes es la clave: fuerza a ofrecer solo esa.

Para HTTPS:

GIT_CURL_VERBOSE=1 git fetch 2>&1 | head -40
* Connected to git.ejemplo.es (192.0.2.10) port 443
> GET /equipo/gestor-tareas.git/info/refs?service=git-upload-pack HTTP/2
> User-Agent: git/2.45.0
< HTTP/2 401
< www-authenticate: Basic realm="Git"
* Issue another request to this URL
> Authorization: Basic YW5hOnh4eA==
< HTTP/2 200

Diagnostica proxies corporativos, certificados, redirecciones y credenciales. Un 401 persistente apunta al credential helper (lección 04-03):

git config --get credential.helper
git credential-cache exit          # vaciar credenciales cacheadas
printf 'protocol=https\nhost=git.ejemplo.es\n\n' | git credential fill

Cuidado: GIT_CURL_VERBOSE puede mostrar cabeceras Authorization. No pegues su salida en un ticket público sin revisarla (lección 08-05).

GIT_TRACE_PACKET: el protocolo, paquete a paquete

El nivel más profundo. Muestra el diálogo exacto con el servidor.

GIT_TRACE_PACKET=1 git fetch 2>&1 | head -30
packet:        git< version 2
packet:        git< agent=git/2.45.0
packet:        git< ls-refs=unborn
packet:        git< fetch=shallow wait-for-done
packet:        git< object-format=sha1
packet:        git> command=ls-refs
packet:        git> peel
packet:        git> ref-prefix refs/heads/
packet:        git< b52c9d1e4a7f3c8b6d2e9a5f1c7b3d8e4a6f2c9b refs/heads/main
packet:        git< 7d3a8f4a2c6e9b1d5f3a8c6e2b9d4f7a1c5e8b3d refs/heads/GT-241

Para qué sirve de verdad:

  • Ver qué referencias anuncia el servidor y qué versión del protocolo se negocia.
  • Diagnosticar un fetch lento: si el servidor anuncia 40.000 referencias, ahí está el problema (lección 08-06, higiene de referencias).
  • Entender un push rechazado por un hook de servidor (lección 07-06): el mensaje del hook viaja en estos paquetes.
  • Comprobar si se usa el protocolo v2, mucho más eficiente:
git config --get protocol.version      # debería ser 2 en Git moderno
git -c protocol.version=2 fetch

Trazas para todos los comandos, temporalmente

# En la sesión actual
export GIT_TRACE=1
export GIT_TRACE_SETUP=1
# ...reproducir el problema...
unset GIT_TRACE GIT_TRACE_SETUP

Y un guion para capturar todo de golpe cuando hay que enviar un informe:

#!/usr/bin/env bash
# capturar-traza.sh <comando git...>
LOG=/tmp/git-traza-$(date +%s).log
GIT_TRACE="$LOG" \
GIT_TRACE_SETUP="$LOG" \
GIT_TRACE_PERFORMANCE="$LOG" \
GIT_TRACE_PACKET="$LOG" \
  "$@"
echo "Traza en: $LOG"
echo "REVISA el fichero antes de compartirlo: puede contener credenciales."

  1. Depurar la configuración efectiva

Retomando la lección 01-05. Git lee la configuración de varios sitios y el último gana. Cuando algo se comporta de forma inesperada, la configuración es sospechosa número uno.

git config --list --show-origin --show-scope
system  /etc/gitconfig          core.autocrlf=false
global  /home/ana/.gitconfig    user.name=Ana Ferrer
global  /home/ana/.gitconfig    [email protected]
global  /home/ana/.gitconfig    pull.rebase=true
local   .git/config             remote.origin.url=git.ejemplo.es:equipo/gestor-tareas.git
local   .git/config             [email protected]
local   .git/config             core.autocrlf=input

Dos columnas nuevas frente al --list de siempre:

  • --show-scope: en qué nivel está (system, global, local, worktree, command).
  • --show-origin: el fichero exacto, incluidos los que vienen por include.path.

En el ejemplo, user.email aparece dos veces: el local gana. Ahí está la explicación del "he configurado bien el correo y los commits salen con otro" (lección 09-01, apartado 5.3).

El valor efectivo de un ajuste

# Qué valor gana
git config --get user.email

# TODOS los valores definidos, en orden de precedencia (el último gana)
git config --get-all user.email

# Con su origen
git config --get-all --show-origin user.email

# Todo lo que empiece por un prefijo
git config --get-regexp '^remote\.'
git config --get-regexp '^alias\.'
git config --get-regexp '^advice\.'

Ese último merece atención: si alguien copió una configuración que silencia los advice.*, estás perdiendo las sugerencias que resuelven la mitad de los problemas del módulo (lección 09-01, apartado 6).

Precedencia y anulaciones

Nivel Fichero Prioridad
system /etc/gitconfig Más baja
global ~/.gitconfig o ~/.config/git/config Media
local .git/config Alta
worktree .git/config.worktree Más alta (si extensions.worktreeConfig)
-c en la línea de comandos Máxima
Variables GIT_* Entorno Depende del ajuste
# Probar un valor sin cambiar nada, solo para este comando
git -c core.autocrlf=false status
git -c diff.noprefix=false diff

# Ver qué pasa SIN configuración de usuario
GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null git status

La última es la prueba definitiva de "¿es cosa de mi configuración?".

Configuración condicional, la fuente de sorpresas

# ~/.gitconfig
[includeIf "gitdir:~/proyectos/trabajo/"]
    path = ~/.gitconfig-trabajo

[includeIf "gitdir:~/proyectos/personal/"]
    path = ~/.gitconfig-personal

Es una funcionalidad excelente para separar identidades, pero produce el desconcierto de "aquí funciona y allí no". --show-origin la desenreda en un segundo, porque nombra el fichero incluido.

Y ojo con la barra final: gitdir:~/proyectos/trabajo/ (con /) coincide con el directorio y sus descendientes; sin ella, el comportamiento cambia.

  1. Depurar rutas y patrones: check-ignore, check-attr, ls-files

Aquí viven los misterios más frecuentes del día a día.

git check-ignore -v: por qué se ignora (o no) un fichero

Retomando la lección 08-03:

git check-ignore -v datos/volcado.sql
.gitignore:12:*.sql	datos/volcado.sql

Lectura: fichero de reglas : línea : patrón que decide, y el fichero afectado. No hay ambigüedad posible.

# Varios ficheros a la vez
git check-ignore -v app.js datos/volcado.sql node_modules/x/y.js

# Todo lo que está siendo ignorado en el proyecto
git status --ignored --short

# Y el caso desconcertante: ¿por qué NO se ignora?
git check-ignore -v --no-index config-local.json
echo $?

Si check-ignore no devuelve nada y el código de salida es 1, ninguna regla lo ignora. Y si el fichero aparece igualmente en git status pese a estar ignorado, la causa es casi siempre la misma:

git ls-files --error-unmatch config-local.json
config-local.json

Está en el índice. .gitignore solo afecta a ficheros sin seguimiento; uno que ya tiene seguimiento se sigue vigilando aunque el patrón coincida. La solución es la de la lección 08-03:

git rm --cached config-local.json
git commit -m "chore: dejar de versionar la configuración local"

Otras causas menos evidentes, que check-ignore -v revela al nombrar el fichero de reglas:

Fichero que puede estar decidiendo Dónde vive
.gitignore del repositorio En cualquier directorio del proyecto
.git/info/exclude Local, no versionado
core.excludesFile Global, típicamente ~/.gitignore_global
Un .gitignore de un directorio padre Uno más específico gana
Una regla de negación !patrón Reactiva algo ignorado antes

Y la trampa clásica de los directorios:

git check-ignore -v capturas/pantalla.png
.gitignore:8:capturas/	capturas/pantalla.png

Si un directorio está ignorado, Git ni siquiera entra en él, así que una regla de negación para un fichero de dentro no funciona:

capturas/
!capturas/importante.png     # NO funciona: Git no entra en capturas/
capturas/*
!capturas/importante.png     # SÍ funciona

git check-attr: qué atributos se aplican

Retomando la lección 08-04:

git check-attr -a estilos.css
estilos.css: text: set
estilos.css: eol: lf
estilos.css: diff: css
# Un atributo concreto
git check-attr eol text diff -- estilos.css index.html app.js

# De dónde sale cada regla
git check-attr -a --source=HEAD estilos.css

# Para todos los ficheros seguidos
git ls-files | git check-attr --stdin -a | grep -v unspecified

Esto explica de golpe una familia de misterios:

Síntoma Comando Causa habitual
El diff de un fichero sale como binario git check-attr diff -- <f> -diff o binary en .gitattributes
Un fichero no se fusiona nunca git check-attr merge -- <f> merge=binary o merge=ours
Los finales de línea cambian solos git check-attr text eol -- <f> text, eol=crlf, o core.autocrlf
Un fichero se transforma al confirmar git check-attr filter -- <f> Un filter (LFS, clean/smudge)
git blame da resultados raros .git-blame-ignore-revs (06-03)

git ls-files: qué hay en el índice de verdad

git status interpreta; git ls-files muestra. Es la ventana directa al índice.

# Lo seguido
git ls-files

# Con metadatos: modo, hash y etapa
git ls-files --stage app.js
100644 6f2b9d4a8c1e5f3b7d9a2c6e4f8b1d3a5c7e9f2b 0	app.js
Campo Significado
100644 Fichero normal. 100755 = ejecutable. 120000 = enlace simbólico. 160000 = submódulo
Hash El blob que hay en el índice
0 La etapa. 0 = normal. 1/2/3 = conflicto (lección 03-05)

Ese último campo es oro durante un conflicto:

git ls-files --stage | awk '$3 != 0'
100644 4f8a2e6... 1	app.js
100644 b52c9d1... 2	app.js
100644 7d3a8f4... 3	app.js

Tres etapas: base común, la nuestra y la suya. Exactamente lo que vimos en la lección 03-05, ahora visible en crudo.

Los modos que resuelven misterios:

# ¿Por qué Git dice que este script ha cambiado si solo le di permisos?
git ls-files --stage build.sh
100644 8a1f6c3... 0	build.sh
ls -l build.sh
-rwxr-xr-x 1 ana ana 412 jul 28 10:02 build.sh

Índice 100644, disco ejecutable: ese es el cambio. Se corrige con:

git update-index --chmod=+x build.sh

Y el modo --eol, que resuelve el misterio de los finales de línea:

git ls-files --eol app.js estilos.css index.html
i/lf    w/crlf  attr/text=auto  	app.js
i/lf    w/lf    attr/            	estilos.css
i/crlf  w/crlf  attr/-text       	index.html
Columna Significado
i/ Cómo está en el índice (lo que se guarda en el repositorio)
w/ Cómo está en el directorio de trabajo (en disco)
attr/ Qué atributo se le aplica

La primera línea es el caso sano de Carla en Windows: LF en el repositorio, CRLF en su disco (lección 08-04). La tercera es un problema: i/crlf significa que se han guardado CRLF dentro del repositorio, que es justo lo que no se quiere.

Otros modos útiles:

git ls-files --others                        # sin seguimiento
git ls-files --others --exclude-standard      # sin seguimiento y no ignorados
git ls-files --ignored --exclude-standard     # ignorados
git ls-files --deleted                        # borrados del disco pero aún en el índice
git ls-files --modified                       # modificados
git ls-files --unmerged                       # en conflicto

--others --exclude-standard es exactamente la lista de "ficheros nuevos" que muestra git status, sin el resto de la salida. Y --deleted explica el "borré el fichero y Git sigue quejándose".

  1. Fontanería para inspeccionar el grafo

Cuando la pregunta es sobre la estructura del repositorio, la respuesta está en la capa de fontanería que vimos en la lección 01-04.

git rev-parse: traducir cualquier cosa a un hash

git rev-parse HEAD
git rev-parse main
git rev-parse HEAD~3
git rev-parse GT-241@{2}
git rev-parse v1.5.0^{commit}      # la etiqueta anotada apunta a un tag; esto da el commit

Y sus modos informativos, que resuelven preguntas de entorno:

git rev-parse --show-toplevel        # raíz del proyecto
git rev-parse --git-dir              # dónde está el .git
git rev-parse --git-common-dir       # el .git compartido (worktrees, 06-06)
git rev-parse --abbrev-ref HEAD      # nombre de la rama actual
git rev-parse --is-inside-work-tree  # true/false
git rev-parse --is-bare-repository   # true/false
git rev-parse --symbolic-full-name @{u}   # el upstream completo
/home/ana/proyectos/gestor-tareas
/home/ana/proyectos/gestor-tareas/.git
GT-241
true
refs/remotes/origin/GT-241

Son la base de cualquier guion que tenga que trabajar con repositorios de forma robusta.

git rev-list: contar y listar commits

# Cuántos commits hay
git rev-list --count HEAD

# Cuántos por delante y por detrás (lección 09-03)
git rev-list --left-right --count main...origin/main

# Commits que tocaron un fichero
git rev-list HEAD -- app.js | head

# Todos los objetos alcanzables (lección 08-06)
git rev-list --objects --all | wc -l

# Los commits raíz (¿hay más de uno? → historias no relacionadas)
git rev-list --max-parents=0 HEAD

# Solo fusiones, o solo lo que no son fusiones
git rev-list --merges HEAD | head
git rev-list --no-merges HEAD | head

Ese --max-parents=0 es el diagnóstico exacto de las unrelated histories de la lección 09-03: si devuelve dos hashes, hay dos raíces.

git cat-file --batch-check: inspección masiva

# Tipo y tamaño de un objeto
echo 4f8a2e6 | git cat-file --batch-check
4f8a2e6c9b1d5e3a7f2c8b6d9e4a1f5c3b7d2e9a commit 241
# Comprobar si existe (sin volcar contenido)
git cat-file -e 4f8a2e6 && echo "existe" || echo "no existe"

# Un informe de todos los objetos por tipo
git rev-list --objects --all \
  | git cat-file --batch-check='%(objecttype)' \
  | sort | uniq -c
  18432 blob
   3204 commit
  21908 tree
     47 tag

Es la misma técnica del análisis de tamaños de la lección 08-06, aplicada aquí a entender la composición del repositorio.

git verify-pack: qué hay dentro de un packfile

git verify-pack -v .git/objects/pack/pack-*.idx | head -5
b52c9d1e4a7f3c8b6d2e9a5f1c7b3d8e4a6f2c9b commit 241 168 12
7d3a8f4a2c6e9b1d5f3a8c6e2b9d4f7a1c5e8b3d tree   118  94 180
4f8a2e6c9b1d5e3a7f2c8b6d9e4a1f5c3b7d2e9a blob  8241 2104 274
6f2b9d4a8c1e5f3b7d9a2c6e4f8b1d3a5c7e9f2b blob   412  87 2378 1 4f8a2e6c9b1d5e3a

Columnas: hash, tipo, tamaño real, tamaño comprimido, posición en el pack y —en la última línea— profundidad de delta y objeto base. Es la vista de bajo nivel de lo que explicaba la lección 08-06: la última entrada se guarda como diferencia de otra.

# Estadísticas de las cadenas de delta
git verify-pack -v .git/objects/pack/pack-*.idx | tail -20

git for-each-ref: todas las referencias con sus datos

git for-each-ref --sort=-committerdate --format='%(refname:short)  %(objectname:short)  %(committerdate:relative)  %(authorname)' refs/heads
GT-241     9c4e7b2  hace 2 horas    Ana Ferrer
GT-238     7d3a8f4  hace 3 días     Bruno Salas
main       b52c9d1  hace 4 días     Carla Vidal
# Ramas sin upstream: existen solo en esta máquina (lección 09-05)
git for-each-ref --format='%(refname:short) %(upstream)' refs/heads | awk '$2==""{print $1}'

# Referencias que apuntan a objetos inexistentes (lección 09-05)
git for-each-ref --format='%(refname) %(objectname)' | while read -r r s; do
  git cat-file -e "$s" 2>/dev/null || echo "ROTA: $r"
done

git ls-remote: qué hay en el servidor, sin descargar nada

git ls-remote origin
git ls-remote origin 'refs/heads/GT-*'
git ls-remote --heads origin main
b52c9d1e4a7f3c8b6d2e9a5f1c7b3d8e4a6f2c9b	refs/heads/main

Consulta el servidor directamente, sin fetch y sin tocar tu repositorio. Resuelve preguntas como "¿existe esa rama en el servidor?" o "¿mi origin/main está al día?":

[ "$(git ls-remote origin main | cut -f1)" = "$(git rev-parse origin/main)" ] \
  && echo "al día" || echo "mi copia de origin/main está anticuada"

  1. Acotar con git diff --stat y git log

Antes de una investigación fina, conviene acotar. Estas son las herramientas de grano grueso.

# Qué cambió entre dos versiones, por fichero
git diff --stat v1.4.0 v1.5.0
 app.js         |  84 ++++++++++++++++-----
 estilos.css    |  12 ++--
 index.html     |   6 +-
 README.md      |  31 ++++++++
 4 files changed, 108 insertions(+), 25 deletions(-)
# Solo los nombres, para ver el alcance de un vistazo
git diff --name-only v1.4.0 v1.5.0

# Con el tipo de cambio: Añadido, Modificado, Borrado, Renombrado
git diff --name-status v1.4.0 v1.5.0

# Resumen de renombrados y cambios de modo
git diff --summary v1.4.0 v1.5.0

# Comparar solo el efecto de una rama (lección 07-02)
git diff --stat main...GT-241

Y git log en modo investigación (lección 06-04):

# Commits que tocaron una función concreta
git log -L :calcularPendientes:app.js

# Commits que añadieron o quitaron una cadena (pickaxe, lección 02-06)
git log -S "localStorage" --oneline

# Commits cuyo diff coincide con una expresión regular
git log -G "gestor\.tareas\.v[0-9]" --oneline

# Quién y cuándo, en un rango de fechas
git log --since="2026-07-01" --until="2026-07-31" --format='%h %an %ad %s' --date=short

# Solo lo que entró por la primera línea de padres (lección 08-02)
git log --first-parent --oneline main

-L :función:fichero es especialmente potente y poco conocida: sigue una función concreta a lo largo del historial, incluso cuando se mueve dentro del fichero.

  1. Una investigación real: de bisect a blame

Vamos a juntarlo todo en un caso completo sobre gestor-tareas.

El síntoma. Carla informa: "El contador de tareas pendientes muestra un número de más cuando hay tareas ocultas por el filtro. En la versión 1.4 funcionaba."

Paso 1: reproducir

Lo mínimo que provoca el fallo, y escrito como una prueba automatizable:

cat > /tmp/prueba-contador.sh <<'EOF'
#!/usr/bin/env bash
# Sale 0 si el contador es correcto, 1 si no.
node -e '
  const fs = require("fs");
  const src = fs.readFileSync("app.js", "utf8");
  const ctx = { localStorage: { getItem: () => null, setItem: () => {} }, document: null };
  // ... arranque mínimo de la aplicación ...
  const tareas = [
    { texto: "a", completada: false, oculta: false },
    { texto: "b", completada: true,  oculta: false },
    { texto: "c", completada: false, oculta: true  }
  ];
  const esperado = 2;
  const obtenido = calcularPendientes(tareas);
  process.exit(obtenido === esperado ? 0 : 1);
' 2>/dev/null
EOF
chmod +x /tmp/prueba-contador.sh

Una prueba automatizable es lo que convierte bisect de tedioso en instantáneo.

Paso 2: acotar el rango

git tag --sort=-creatordate | head -3
git diff --stat v1.4.0 v1.5.0 -- app.js
v1.5.0
v1.4.0
v1.3.0
 app.js | 84 ++++++++++++++++-----
 1 file changed, 62 insertions(+), 22 deletions(-)
git rev-list --count v1.4.0..v1.5.0
34

34 commits entre las dos versiones. Revisarlos a mano son horas; bisect son cinco pasos.

Paso 3: git bisect run

Retomando la lección 06-02:

git bisect start
git bisect bad v1.5.0
git bisect good v1.4.0
git bisect run /tmp/prueba-contador.sh
Bisecting: 16 revisions left to test after this (roughly 4 steps)
[7d3a8f4a2c6e9b1d5f3a8c6e2b9d4f7a1c5e8b3d] GT-238 extraer la creación del elemento
running  /tmp/prueba-contador.sh
Bisecting: 8 revisions left to test after this (roughly 3 steps)
...
4f8a2e6c9b1d5e3a7f2c8b6d9e4a1f5c3b7d2e9a is the first bad commit
commit 4f8a2e6c9b1d5e3a7f2c8b6d9e4a1f5c3b7d2e9a
Author: Bruno Salas <[email protected]>
Date:   Wed Jul 15 11:23:04 2026 +0200

    GT-231 añadir el filtro de tareas ocultas

 app.js | 18 ++++++++++++------
 1 file changed, 12 insertions(+), 6 deletions(-)
git bisect reset

Del síntoma al commit en cinco pasos automáticos. Ahora sabemos cuándo se rompió.

Paso 4: entender el commit

git show 4f8a2e6
 function calcularPendientes(tareas) {
-  return tareas.filter(t => !t.completada).length;
+  return tareas.filter(t => !t.completada || t.oculta).length;
 }

Ahí está: el || t.oculta cuenta también las ocultas. Pero saber qué línea falla no es saber por qué está ahí, y arreglarla sin entenderlo puede romper lo que ese commit venía a resolver.

Paso 5: git blame para el porqué

Retomando la lección 06-03:

git blame -L 12,20 app.js
4f8a2e6c (Bruno Salas  2026-07-15 11:23:04 +0200 12) function calcularPendientes(tareas) {
4f8a2e6c (Bruno Salas  2026-07-15 11:23:04 +0200 13)   return tareas.filter(t => !t.completada || t.oculta).length;
4f8a2e6c (Bruno Salas  2026-07-15 11:23:04 +0200 14) }
b52c9d1e (Ana Ferrer   2026-06-28 09:14:22 +0200 15)
b52c9d1e (Ana Ferrer   2026-06-28 09:14:22 +0200 16) function renderizarTareas() {
# La historia completa de esa función, aunque se haya movido
git log -L :calcularPendientes:app.js --format='%h %an %ad %s' --date=short
commit 4f8a2e6 Bruno Salas 2026-07-15 GT-231 añadir el filtro de tareas ocultas
commit 8a1f6c3 Ana Ferrer  2026-05-02 GT-198 extraer el cálculo a su propia función
commit 2f8c6e1 Ana Ferrer  2026-04-11 GT-142 mostrar el contador de pendientes
# El mensaje completo del commit culpable: el "por qué"
git log -1 --format=%B 4f8a2e6
GT-231 añadir el filtro de tareas ocultas

Las tareas archivadas dejan de aparecer en el listado. Se añade la
marca `oculta` y se filtra en renderizarTareas().

El contador debe seguir incluyéndolas porque la especificación de
GT-231 dice que "pendientes" cuenta todas las tareas no completadas,
estén visibles o no.

Aquí está el hallazgo, y es lo que cambia todo el desenlace. El comportamiento no es un error: es una decisión deliberada y documentada de GT-231. Lo que hay es una contradicción entre dos especificaciones: GT-231 dice que cuenten todas las no completadas; Carla espera que cuente solo las visibles.

Si hubiéramos "arreglado" la línea nada más verla, habríamos roto GT-231 y el ciclo habría vuelto a empezar dentro de dos semanas.

# ¿Quién más depende de esto? (pickaxe, lección 02-06)
git log -S "calcularPendientes" --oneline
git grep -n "calcularPendientes" -- '*.js'

La conclusión de la investigación no es un parche, sino una pregunta para el equipo: ¿qué significa "pendientes"? Y la respuesta, sea cual sea, se documenta en el commit que la implemente.

El recorrido, en una tabla

Paso Pregunta Herramienta Lección
1 ¿Cuál es el síntoma exacto? Un guion de prueba reproducible
2 ¿En qué rango buscar? git diff --stat, git rev-list --count 02-05
3 ¿Qué commit lo introdujo? git bisect run 06-02
4 ¿Qué cambió ese commit? git show 02-06
5 ¿Por qué está esa línea? git blame, git log -L, %B 06-03, 08-01
6 ¿Quién depende de ello? git log -S, git grep 02-06

bisect te lleva del síntoma al commit. blame y el mensaje del commit te llevan del commit a la intención. El primero sin el segundo produce parches que rompen otra cosa. Y aquí se ve, retrospectivamente, por qué la lección 08-01 insistía tanto en explicar el porqué en los mensajes: ese párrafo de Bruno ha ahorrado un error.

  1. Un catálogo de misterios y sus comandos

La tabla de consulta rápida del "Git hace algo raro".

Misterio Comando de diagnóstico Causa habitual
Un fichero sale modificado y no lo he tocado git ls-files --eol <f>, git check-attr -a <f> Finales de línea, o filter (08-04)
Un fichero ignorado aparece en status git ls-files --error-unmatch <f> Ya tenía seguimiento (08-03)
Un fichero no se ignora aunque está en .gitignore git check-ignore -v <f> Regla en otro fichero, o negación mal puesta
Un fichero se ignora y no debería git check-ignore -v <f> Un .gitignore de un directorio padre, o el global
Un script pierde el permiso de ejecución git ls-files --stage <f> Modo 100644 en el índice; update-index --chmod=+x
Un hook no se ejecuta GIT_TRACE=1 git commit, ls -l .git/hooks/ No es ejecutable, o core.hooksPath (06-01)
El autor de los commits no es el que espero git config --list --show-origin | grep user Un user.email local pisando el global
"No es un repositorio Git" estando dentro GIT_TRACE_SETUP=1 git status Worktree, submódulo, o .git perdido
push/fetch fallan por autenticación GIT_SSH_COMMAND="ssh -v" git fetch Clave equivocada ofrecida primero (04-03)
push/fetch van muy lentos GIT_TRACE_PACKET=1, git ls-remote | wc -l Miles de referencias (08-06)
git status tarda segundos GIT_TRACE_PERFORMANCE=1 git status Recorrido de ficheros sin seguimiento (08-06)
git log no muestra un commit que existe git log --all, git reflog, git cat-file -t Está en otra rama, o es inalcanzable (09-04)
Un merge dice "Already up to date" sin serlo git merge-base <a> <b>, git log --graph --all Fusión revertida (05-06)
Dos ramas no se pueden fusionar git rev-list --max-parents=0 HEAD Historias no relacionadas (09-03)
Un submódulo aparece siempre como modificado git diff --submodule, git ls-files --stage Commit del submódulo distinto (06-05)
El diff sale como binario git check-attr diff -- <f> -diff o binary en .gitattributes
Un alias hace algo inesperado GIT_TRACE=1 git <alias> Expansión del alias (06-04)
Git se comporta distinto en dos carpetas git config --list --show-origin --show-scope includeIf condicional
El remoto está en un commit que no esperaba git ls-remote origin <rama> Alguien publicó, o forzó (09-03)

Y un guion de diagnóstico general, para cuando no sabes ni por dónde empezar:

#!/usr/bin/env bash
# diagnostico-git.sh — foto completa del estado real del repositorio
set -u
echo "=== ENTORNO ==="
git --version
git rev-parse --show-toplevel
git rev-parse --git-dir
echo "rama: $(git rev-parse --abbrev-ref HEAD)"
echo "upstream: $(git rev-parse --symbolic-full-name '@{u}' 2>/dev/null || echo 'ninguno')"

echo; echo "=== ESTADO ==="
git status -sb | head -20

echo; echo "=== DIVERGENCIA ==="
git rev-list --left-right --count HEAD...@{u} 2>/dev/null || echo "sin upstream"

echo; echo "=== CONFIGURACIÓN CLAVE ==="
git config --list --show-scope 2>/dev/null \
  | grep -E '(user\.|core\.(autocrlf|eol|hooksPath|excludesFile|fsmonitor)|pull\.|push\.|merge\.|diff\.)'

echo; echo "=== HOOKS ACTIVOS ==="
RUTA=$(git config --get core.hooksPath || echo "$(git rev-parse --git-dir)/hooks")
find "$RUTA" -maxdepth 1 -type f -perm -u+x ! -name '*.sample' 2>/dev/null

echo; echo "=== REFERENCIAS ==="
echo "ramas locales: $(git branch | wc -l) | remotas: $(git branch -r | wc -l) | etiquetas: $(git tag | wc -l)"
echo "sin publicar: $(git for-each-ref --format='%(refname:short) %(upstream)' refs/heads | awk '$2==""{print $1}' | tr '\n' ' ')"

echo; echo "=== INTEGRIDAD ==="
git fsck --connectivity-only --no-progress 2>&1 | grep -vE '^(dangling|notice|Checking)' || echo "sin errores"

echo; echo "=== ÚLTIMOS MOVIMIENTOS ==="
git reflog --date=relative -8

Guárdalo. El día que te haga falta, te ahorrará quince minutos de comandos sueltos.

Errores Comunes y Consejos

Error 1: cambiar cosas al azar hasta que funcione. Deja el problema latente y no aprendes nada. Formula una hipótesis falsable y compruébala.

Error 2: fiarse de la memoria en lugar de consultar el estado real. "Yo creo que ese fichero está ignorado" es distinto de git check-ignore -v.

Error 3: no aislar. Antes de investigar a fondo, comprueba si el problema persiste sin configuración global, sin hooks y en un clon limpio. Ese descarte cuesta un minuto.

Error 4: activar GIT_TRACE_PACKET de entrada. Es el nivel más profundo y produce mucho ruido. Empieza por GIT_TRACE y GIT_TRACE_SETUP.

Error 5: pegar la salida de GIT_CURL_VERBOSE en un ticket público. Puede contener cabeceras Authorization (08-05). Revísala antes.

Error 6: usar git config --list sin --show-origin --show-scope. Sin esas opciones no sabes qué valor gana ni de dónde sale, que es justo lo que investigas.

Error 7: arreglar la línea que bisect señala sin leer el mensaje del commit. Puedes romper la funcionalidad que ese commit venía a implementar, como habría pasado en el apartado 7.

Error 8: intentar bisect sin una prueba automatizable. Con git bisect run y un guion, treinta commits son cinco pasos automáticos; a mano, media hora de aburrimiento y errores.

Error 9: confundir git status con la realidad. status interpreta; ls-files --stage, check-attr y rev-parse muestran.

Consejo 1: el método antes que las herramientas. Reproducir, aislar, consultar el estado real, comprobar la hipótesis.

Consejo 2: git config --list --show-origin --show-scope como reflejo. Resuelve una fracción sorprendente de los "Git hace algo raro".

Consejo 3: git check-ignore -v y git check-attr -a. Dos comandos que dan la respuesta exacta a dos de las preguntas más frecuentes del día a día.

Consejo 4: git ls-files --eol en cualquier problema de finales de línea. La tabla i/ y w/ lo dice todo de un vistazo.

Consejo 5: guarda el guion de diagnóstico del apartado 8. Es la foto completa en treinta segundos.

Consejo 6: escribe la prueba antes de bisecar. El esfuerzo se recupera en el primer bisect run, y la prueba se queda en el proyecto.

Ejercicios

Ejercicio 1: el misterio del fichero siempre modificado

  1. Crea un repositorio con app.js, estilos.css e index.html, y confírmalos.
  2. Añade un .gitattributes con *.css text eol=crlf y confírmalo.
  3. Ejecuta git status y observa el resultado. Después ejecuta git ls-files --eol y git check-attr -a estilos.css.
  4. Explica exactamente qué está pasando usando las columnas i/ y w/.
  5. Resuélvelo con git add --renormalize . (lección 08-04) y comprueba con ls-files --eol que las columnas cuadran.
  6. Repite el ejercicio con un fichero al que .gitattributes marque -diff y comprueba qué cambia en git diff.

Ejercicio 2: configuración, ignorados y trazas

  1. Crea un repositorio y configura user.email distinto en el nivel local y en el global.
  2. Usa git config --list --show-origin --show-scope para determinar cuál gana, y confírmalo haciendo un commit y mirando %ae.
  3. Crea un .gitignore con *.log, un .git/info/exclude con !importante.log y un core.excludesFile global con temporal*. Crea los tres ficheros correspondientes y usa git check-ignore -v con cada uno para determinar qué regla decide.
  4. Crea un hook pre-commit que imprima algo, hazlo no ejecutable, e intenta confirmar. Usa GIT_TRACE=1 para demostrar que no se lanza.
  5. Hazlo ejecutable y repite, comprobando en la traza que ahora sí aparece.
  6. Ejecuta GIT_TRACE_SETUP=1 git status desde un subdirectorio y explica cada línea de la salida.

Ejercicio 3: la investigación completa

  1. Crea un repositorio con un app.js que contenga una función calcularPendientes correcta, y haz veinte commits que toquen otras partes del proyecto.
  2. En un commit intermedio, introduce el fallo del apartado 7 (añadir || t.oculta) con un mensaje de commit que explique el porqué.
  3. Haz diez commits más encima.
  4. Escribe un guion de prueba que salga con 0 si la función es correcta y con 1 si no.
  5. Encuentra el commit culpable con git bisect run y anota cuántos pasos ha necesitado.
  6. Usa git show, git blame -L y git log -L :calcularPendientes:app.js para reconstruir la historia completa de esa función.
  7. Lee el mensaje completo del commit culpable con git log -1 --format=%B y explica por qué la respuesta correcta no es "cambiar la línea".

Soluciones

Solución 1:

rm -rf /tmp/p9-06 && mkdir /tmp/p9-06 && cd /tmp/p9-06 && git init -q -b main
git config user.name "Carla Vidal"; git config user.email "[email protected]"
printf 'console.log(1);\n' > app.js
printf 'body { margin: 0; }\n' > estilos.css
printf '<h1>Gestor</h1>\n' > index.html
git add . && git commit -q -m "chore: estructura inicial"

# 2. El .gitattributes
printf '*.css text eol=crlf\n' > .gitattributes
git add .gitattributes && git commit -q -m "chore: forzar CRLF en los CSS"
# 3. El síntoma
git status --short
(sin salida)

Sorprendentemente, nada. El atributo se aplica al escribir en disco, y el fichero en disco aún tiene LF. Fuerza la actualización:

rm estilos.css && git checkout -- estilos.css
git status --short
file estilos.css
 M estilos.css
estilos.css: ASCII text, with CRLF line terminators

Ahí está el misterio típico: un fichero modificado que nadie ha tocado.

git ls-files --eol
git check-attr -a estilos.css
i/lf    w/crlf  attr/text eol=crlf 	estilos.css
i/lf    w/lf    attr/              	app.js
i/lf    w/lf    attr/              	index.html
i/lf    w/lf    attr/              	.gitattributes
estilos.css: text: set
estilos.css: eol: crlf
# 4. La explicación
git diff --stat estilos.css
 estilos.css | 2 +-

Lectura de las columnas:

  • i/lf: en el índice hay LF, que es lo que se guardó en el commit original.
  • w/crlf: en el disco hay CRLF, porque el atributo eol=crlf lo convierte al extraerlo.
  • Git compara índice y disco byte a byte, ve una diferencia en cada final de línea, y lo marca como modificado.

No es un error: es el atributo funcionando, sobre un fichero que se guardó antes de que el atributo existiera.

# 5. La solución
git add --renormalize .
git status --short
git commit -q -m "chore: renormalizar los finales de línea"
git ls-files --eol estilos.css
M  estilos.css
i/lf    w/crlf  attr/text eol=crlf 	estilos.css
git status --short
(sin salida)

Limpio. --renormalize reescribe el índice aplicando los atributos actuales: ahora el índice guarda LF con conocimiento del atributo, y la comparación cuadra. Es exactamente el procedimiento de la lección 08-04.

# 6. Con -diff
printf '*.css text eol=crlf\nindex.html -diff\n' > .gitattributes
git add .gitattributes && git commit -q -m "chore: index.html sin diff"
printf '<h1>Gestor de tareas</h1>\n' > index.html
git diff index.html
git check-attr diff -- index.html
diff --git a/index.html b/index.html
index 8a1f6c3..4f8a2e6 100644
Binary files a/index.html and b/index.html differ
index.html: diff: unset

-diff hace que Git trate el fichero como binario a efectos de diff. Es útil para minificados y generados, y desconcertante si no sabes que está puesto: git check-attr diff lo resuelve en un segundo.

Solución 2:

rm -rf /tmp/p9-06b && mkdir /tmp/p9-06b && cd /tmp/p9-06b && git init -q -b main
git config --global user.email "[email protected]" 2>/dev/null
git config user.name "Ana Ferrer"
git config user.email "[email protected]"      # local, distinto

# 2. Quién gana
git config --list --show-origin --show-scope | grep user.email
global	/home/ana/.gitconfig	[email protected]
local	.git/config	[email protected]
git config --get user.email
echo "x" > f.txt && git add . && git commit -q -m "prueba"
git log -1 --format='%ae'

El local gana, porque está más cerca del repositorio. Es exactamente el mecanismo del apartado 3, y la causa habitual del "he configurado bien el correo".

# 3. Las tres capas de ignorados
printf '*.log\n' > .gitignore
printf '!importante.log\n' > .git/info/exclude
printf 'temporal*\n' > /tmp/gitignore-global
git config core.excludesFile /tmp/gitignore-global

touch salida.log importante.log temporal-1.txt normal.txt

for f in salida.log importante.log temporal-1.txt normal.txt; do
  printf '%-18s ' "$f"
  git check-ignore -v "$f" || echo "(no ignorado)"
done
salida.log         .gitignore:1:*.log	salida.log
importante.log     .git/info/exclude:1:!importante.log	importante.log
temporal-1.txt     /tmp/gitignore-global:1:temporal*	temporal-1.txt
normal.txt         (no ignorado)

Cada uno decidido por un fichero distinto, y check-ignore -v lo nombra con su número de línea. Fíjate en importante.log: la regla que coincide es la negación, y por eso el fichero no está ignorado:

git status --short | grep importante
?? importante.log

Cuando check-ignore -v devuelve un patrón que empieza por !, significa "lo ha rescatado esta regla".

# 4. El hook que no se ejecuta
cat > .git/hooks/pre-commit <<'EOF'
#!/usr/bin/env bash
echo "HOOK EJECUTADO"
EOF
# sin chmod +x
echo "y" >> f.txt
GIT_TRACE=1 git commit -q -am "prueba hook" 2>&1 | grep -ci "pre-commit"
git log -1 --format=%s
0
prueba hook

Cero menciones al hook en la traza, y el commit se hizo. El hook no se lanzó.

ls -l .git/hooks/pre-commit
-rw-r--r-- 1 ana ana 46 jul 31 13:02 .git/hooks/pre-commit
# 5. Con permiso de ejecución
chmod +x .git/hooks/pre-commit
echo "z" >> f.txt
GIT_TRACE=1 git commit -am "prueba hook 2" 2>&1 | grep -i "pre-commit"
13:03:11.402 run-command.c:657  trace: run_command: '.git/hooks/pre-commit'
HOOK EJECUTADO

Ahí está: run_command lo lanza y el echo aparece. GIT_TRACE=1 es el diagnóstico definitivo para "mi hook no se ejecuta" (lección 06-01).

# 6. GIT_TRACE_SETUP desde un subdirectorio
mkdir -p src/componentes && cd src/componentes
GIT_TRACE_SETUP=1 git status 2>&1 | head -6
13:04:22.101 trace.c:318  setup: git_dir: /tmp/p9-06b/.git
13:04:22.101 trace.c:319  setup: git_common_dir: /tmp/p9-06b/.git
13:04:22.101 trace.c:320  setup: worktree: /tmp/p9-06b
13:04:22.101 trace.c:321  setup: cwd: /tmp/p9-06b/src/componentes
13:04:22.101 trace.c:322  setup: prefix: src/componentes/
Línea Qué dice
git_dir Dónde está el .git que se está usando
git_common_dir El .git compartido: distinto en un worktree (06-06)
worktree La raíz del proyecto
cwd Desde dónde has lanzado el comando
prefix La ruta relativa que Git antepone a los argumentos

Ese prefix explica por qué git add . desde un subdirectorio solo añade ese subdirectorio, y por qué los patrones se interpretan como se interpretan.

Solución 3:

rm -rf /tmp/p9-06c && mkdir /tmp/p9-06c && cd /tmp/p9-06c && git init -q -b main
git config user.name "Ana Ferrer"; git config user.email "[email protected]"

cat > app.js <<'EOF'
function calcularPendientes(tareas) {
  return tareas.filter(t => !t.completada).length;
}
module.exports = { calcularPendientes };
EOF
git add . && git commit -q -m "GT-142 mostrar el contador de pendientes"

# 1. Veinte commits de relleno
for i in $(seq 1 20); do echo "// cambio $i" >> otros.js; git add .; git commit -q -m "chore: cambio $i"; done
# 2. El commit culpable, con un mensaje que explica el porqué
sed -i 's/!t.completada)/!t.completada || t.oculta)/' app.js
git commit -q -am "GT-231 añadir el filtro de tareas ocultas

Las tareas archivadas dejan de aparecer en el listado. Se añade la
marca \`oculta\` y se filtra en renderizarTareas().

El contador debe seguir incluyéndolas porque la especificación de
GT-231 dice que \"pendientes\" cuenta todas las tareas no completadas,
estén visibles o no."
CULPABLE=$(git rev-parse --short HEAD)

# 3. Diez commits más
for i in $(seq 21 30); do echo "// cambio $i" >> otros.js; git add .; git commit -q -m "chore: cambio $i"; done
git rev-list --count HEAD
32
# 4. La prueba
cat > /tmp/prueba-contador.sh <<'EOF'
#!/usr/bin/env bash
node -e '
  const { calcularPendientes } = require(process.cwd() + "/app.js");
  const tareas = [
    { completada: false, oculta: false },
    { completada: true,  oculta: false },
    { completada: false, oculta: true  }
  ];
  process.exit(calcularPendientes(tareas) === 2 ? 0 : 1);
' 2>/dev/null
EOF
chmod +x /tmp/prueba-contador.sh
/tmp/prueba-contador.sh; echo "estado actual: $?"
estado actual: 1

La prueba falla en HEAD: reproducido.

# 5. Bisect
git bisect start
git bisect bad HEAD
git bisect good HEAD~31
git bisect run /tmp/prueba-contador.sh 2>&1 | tail -12
Bisecting: 15 revisions left to test after this (roughly 4 steps)
running  '/tmp/prueba-contador.sh'
Bisecting: 7 revisions left to test after this (roughly 3 steps)
running  '/tmp/prueba-contador.sh'
Bisecting: 3 revisions left to test after this (roughly 2 steps)
running  '/tmp/prueba-contador.sh'
Bisecting: 1 revision left to test after this (roughly 1 step)
running  '/tmp/prueba-contador.sh'
4f8a2e6c9b1d5e3a7f2c8b6d9e4a1f5c3b7d2e9a is the first bad commit
    GT-231 añadir el filtro de tareas ocultas
bisect found first bad commit
git bisect reset
echo "Culpable esperado: $CULPABLE"
Culpable esperado: 4f8a2e6

Cinco pasos automáticos para 31 commits. El logaritmo en base 2 de 31 es algo menos de 5: exactamente lo que predecía la lección 06-02.

# 6. La historia de la función
git show 4f8a2e6 -- app.js
 function calcularPendientes(tareas) {
-  return tareas.filter(t => !t.completada).length;
+  return tareas.filter(t => !t.completada || t.oculta).length;
 }
git blame -L 1,3 app.js
4f8a2e6c (Ana Ferrer 2026-07-31 13:12:04 +0200 1) function calcularPendientes(tareas) {
4f8a2e6c (Ana Ferrer 2026-07-31 13:12:04 +0200 2)   return tareas.filter(t => !t.completada || t.oculta).length;
8a1f6c3d (Ana Ferrer 2026-07-31 13:11:58 +0200 3) }
git log -L :calcularPendientes:app.js --format='%h %ad %s' --date=short | grep -E '^commit|^[0-9a-f]{7} '
4f8a2e6 2026-07-31 GT-231 añadir el filtro de tareas ocultas
8a1f6c3 2026-07-31 GT-142 mostrar el contador de pendientes

Dos commits en toda la vida de esa función. -L :función:fichero la sigue aunque se mueva de sitio dentro del fichero, cosa que blame -L 1,3 no haría.

# 7. El porqué
git log -1 --format=%B 4f8a2e6
GT-231 añadir el filtro de tareas ocultas

Las tareas archivadas dejan de aparecer en el listado. Se añade la
marca `oculta` y se filtra en renderizarTareas().

El contador debe seguir incluyéndolas porque la especificación de
GT-231 dice que "pendientes" cuenta todas las tareas no completadas,
estén visibles o no.

Por qué la respuesta correcta no es "cambiar la línea":

El comportamiento es intencionado y está justificado por escrito. No hay un error de programación: hay dos especificaciones que se contradicen. GT-231 dice que el contador incluya las ocultas; lo que Carla espera es lo contrario.

Si se quita el || t.oculta sin más:

  1. Se rompe GT-231, que alguien pidió y alguien validó.
  2. Nadie sabrá por qué, porque el commit que lo deshaga probablemente diga "corregir el contador".
  3. En dos semanas, quien pidió GT-231 abrirá un ticket idéntico en sentido contrario, y el ciclo empezará otra vez.

La salida correcta es llevar la contradicción a quien pueda resolverla, y que la decisión —sea cual sea— quede escrita en el mensaje del commit que la implemente (lección 08-01), con una referencia a los dos tickets.

El mensaje de Bruno ha ahorrado un error. Ese párrafo de tres líneas es la diferencia entre una corrección y un bucle.

Conclusión

Esta lección era sobre entender por qué Git está haciendo lo que hace.

  • El método va antes que las herramientas: reproducir con el comando mínimo, aislar quitando variables (configuración global, hooks, un clon limpio), consultar el estado real y no el recordado, y comprobar una hipótesis concreta antes de cambiar nada.
  • Las variables de traza enseñan lo que ocurre por debajo: GIT_TRACE para los subcomandos, alias y hooks; GIT_TRACE_SETUP para saber qué repositorio cree Git que está usando; GIT_SSH_COMMAND="ssh -v" y GIT_CURL_VERBOSE para autenticación y red; GIT_TRACE_PACKET para el protocolo. Empieza por las suaves y revisa la salida antes de compartirla.
  • git config --list --show-origin --show-scope resuelve una fracción sorprendente de los misterios, porque dice qué valor gana y de qué fichero sale, incluidos los includeIf condicionales.
  • git check-ignore -v y git check-attr -a responden con precisión a las dos preguntas más frecuentes del día a día: por qué se ignora (o no) un fichero, y qué atributos se le aplican.
  • git ls-files es la ventana al índice: --stage para modos y etapas de conflicto, --eol para los finales de línea con sus columnas i/ y w/, --others --exclude-standard para lo realmente nuevo.
  • La fontanería contesta sobre el grafo sin interpretaciones: rev-parse para traducir a hashes y conocer el entorno, rev-list para contar y listar, cat-file --batch-check para inspección masiva, verify-pack para el interior de un packfile, for-each-ref para todas las referencias y ls-remote para el servidor sin descargar nada.
  • Y la investigación completa: bisect lleva del síntoma al commit; blame y el mensaje del commit llevan del commit a la intención. Saltarse el segundo paso produce parches que rompen otra cosa.

El módulo, en una idea

El módulo 8 terminó anunciando desastres. Este los ha resuelto todos, y la lección de fondo es una sola:

En Git, casi nada se pierde de verdad. Lo que se pierde es la calma.

Los commits son objetos inmutables que sobreviven aunque ninguna referencia los apunte (09-01). reset mueve una rama, no destruye historia (09-02). Una divergencia es una decisión pendiente, no una avería (09-03). El reflog recuerda por dónde has pasado, y con él vuelve el reset --hard, la rama borrada, el rebase fallido y el stash eliminado (09-04). Un repositorio dañado es un problema de logística, porque el clon de cualquier compañero es una copia casi completa (09-05). Y cuando nada de eso encaja, hay trazas, fontanería y un método (09-06).

Lo verdaderamente frágil es lo que nunca llegó a ser un objeto: el cambio sin confirmar, el fichero sin seguimiento. Por eso el consejo más rentable de todo el módulo no es un comando, sino un hábito: confirma pronto, marca con git branch antes de lo arriesgado, y publica tus ramas.

Lo que viene

Ana, Bruno, Carla y Diego dominan Git en gestor-tareas. Saben construir el historial, manipularlo con criterio, colaborar con un proceso, mantener buenos hábitos y salir de los líos.

Pero gestor-tareas son cuatro ficheros y un equipo de cuatro personas. El mundo real es más grande y más raro.

Hay proyectos con cincuenta mil ficheros y veinte años de historia, donde un git status sin optimizar tarda medio minuto. Hay repositorios que guardan vídeos, modelos 3D y ficheros de diseño de cientos de megabytes, y que necesitan un sistema distinto para almacenarlos. Hay organizaciones donde Git no lo usa una persona en una terminal, sino cien tuberías de despliegue que clonan, etiquetan y publican sin intervención humana. Hay integraciones con editores, con sistemas de tickets, con plataformas de revisión y con herramientas de análisis que cambian por completo la experiencia diaria. Y hay un Git que sigue evolucionando: SHA-256, referencias empaquetadas en un nuevo formato, clones parciales, índices dispersos.

En el módulo 10: Git en el Mundo Real veremos estudios de caso de cómo usan Git proyectos y organizaciones reales, la integración con otras herramientas del día a día, Git LFS para ficheros grandes, cómo escalar Git en repositorios enormes y monorepos, el papel de Git en DevOps como pieza central de la entrega continua, y hacia dónde va Git en los próximos años.

Empezamos mirando cómo lo hacen otros, en la lección 10-01: Estudios de Caso.

Dominando Git: De Principiante a Avanzado

Módulo 1: Introducción a Git

Módulo 2: Operaciones Básicas de Git

Módulo 3: Ramas y Fusión

Módulo 4: Trabajando con Repositorios Remotos

Módulo 5: Operaciones Avanzadas de Git

Módulo 6: Herramientas y Técnicas de Git

Módulo 7: Estrategias de Colaboración y Flujo de Trabajo

Módulo 8: Mejores Prácticas y Consejos de Git

Módulo 9: Solución de Problemas y Depuración

Módulo 10: Git en el Mundo Real

© Copyright 2026. Todos los derechos reservados