DevOps, CI/CD y contenedores

Fundamentos de Git: commits, working tree, remotos y cómo deshacer cambios

Principiante10 min de lecturaActualizado

Esta página se tradujo del inglés con ayuda de IA y se cotejó con el original. Leer la versión en inglés

Git se usa mucho mejor cuando entiendes el modelo que hay debajo de los comandos: un grafo de snapshots inmutables, tres lugares donde puede estar un cambio y ramas que no son más que punteros móviles. Esta guía construye ese modelo y después recorre el flujo de trabajo diario —clone, add, commit, fetch, pull, push— y las distintas formas de deshacer cambios, para que puedas recuperarte de los errores sin miedo.

Lo que aprenderás

  • Explicar cómo guarda Git el historial como un grafo de snapshots direccionados por contenido
  • Mover cambios de forma deliberada entre el working tree, el índice y el repositorio
  • Sincronizarte con un remoto usando fetch, pull y push e interpretar lo que muestran
  • Deshacer errores con restore, reset, revert y el reflog, eligiendo cada vez la opción segura
  • Configurar Git, ignorar archivos correctamente y escribir mensajes de commit que ayuden a quien los lea después

La mayoría de los tutoriales de Git enseñan una secuencia de comandos para memorizar. Eso funciona hasta que algo sale mal, y con Git tarde o temprano algo sale mal: un commit en la rama equivocada, un pull que genera un merge que no esperabas, un reset que parece haberse tragado una tarde de trabajo. Para salir de ahí hay que entender las pocas ideas sobre las que se construyen los comandos. El modelo de Git es más sencillo de lo que sugiere su línea de comandos, y en cuanto eres capaz de imaginar qué le hace un comando al grafo de commits, el manual de referencia se vuelve legible y el pánico desaparece. Esta guía está pensada para Git 2.4x y usa los comandos modernos switch y restore allí donde el material antiguo usaba checkout para todo.

Qué guarda Git

Un repositorio Git es una base de datos de cuatro tipos de objeto, cada uno identificado por el hash SHA-1 (o SHA-256 en los repositorios más recientes) de su contenido:

  • Un blob es el contenido de un archivo, sin nombre ni metadatos.
  • Un tree es un listado de directorio: nombres, permisos y los hashes de los blobs y subárboles que contiene.
  • Un commit apunta a un tree (el snapshot completo del proyecto en ese momento) y a cero o más commits padre, y lleva el autor, el committer, las marcas de tiempo y el mensaje.
  • Un objeto tag asocia a un commit un nombre, un mensaje y, opcionalmente, una firma.

De aquí se derivan dos consecuencias. La primera: un commit es un snapshot, no un diff. Git guarda el árbol completo cada vez, deduplicado por hash, así que los archivos que no cambian no cuestan nada y puedes hacer checkout de cualquier commit sin reproducir el historial. Los diffs se calculan bajo demanda. La segunda: todo es inmutable y direccionado por contenido. Cambia un solo byte en cualquier punto del historial y cambian todos los hashes que hay por encima; por eso «reescribir el historial» significa crear commits nuevos, y por eso un hash es un identificador fiable de un único estado del código.

Los commits, unidos por sus enlaces a los padres, forman un grafo dirigido acíclico. Una rama no es más que un archivo que contiene el hash de un commit: un puntero móvil que avanza cuando haces commit en ella. HEAD es un puntero a la rama que tienes activa. Crear una rama consiste en escribir 41 bytes en un archivo; por eso es instantáneo, y por eso quienes usan Git crean ramas con mucha más libertad que quienes usaban sistemas anteriores.

Las tres áreas

Un cambio pasa por tres lugares, y casi toda la confusión con Git viene de no saber a cuál de ellos afecta un comando.

ÁreaQué esComandos que escriben en ella
Working treeLos archivos en disco que editaseditor, git restore, git switch, git merge
Índice (staging area)El siguiente commit propuesto, guardado en .git/indexgit add, git rm, git restore --staged, git reset
RepositorioLa base de datos de objetos y las refs bajo .gitgit commit, git fetch, git merge, git reset

El índice es la pieza que más desconcierta a quien empieza. Su propósito es permitirte construir un commit de forma deliberada: editas cinco archivos, preparas los tres que van juntos, haces commit y luego preparas los otros dos en un commit aparte. git add -p va más allá y prepara hunks concretos dentro de un archivo. git status siempre muestra tanto lo que está preparado como lo que está modificado sin preparar; git diff compara el working tree con el índice, y git diff --staged compara el índice con el último commit.

git status                # what is staged, modified, untracked
git add src/Order.java    # stage one file
git add -p                # stage hunks interactively
git diff --staged         # review exactly what the commit will contain
git commit -m "Reject orders with a negative quantity"

El flujo de trabajo diario

Partiendo de un proyecto existente:

git clone [email protected]:acme/shop.git
cd shop
git switch -c fix-negative-quantity     # create and switch to a branch
# ... edit, test ...
git add -A
git commit
git push -u origin fix-negative-quantity  # -u sets the upstream so later pushes need no arguments

clone copia el repositorio entero —todo el historial, todas las ramas— y configura un remoto llamado origin que apunta al lugar de origen. Por cada rama del remoto obtienes una rama de seguimiento remoto: origin/main, origin/release-2.4, etc. Son marcadores de solo lectura que reflejan cómo estaba el remoto la última vez que hablaste con él; nunca haces commit directamente sobre ellas.

git fetch actualiza las ramas de seguimiento remoto y descarga los objetos nuevos, sin cambiar nada de tus propias ramas. git pull es un fetch seguido de la integración de origin/<branch> en tu rama actual, mediante merge o rebase según la configuración. Como un pull puede generar un commit de merge que no pretendías, muchos desarrolladores configuran git config --global pull.rebase true, de modo que los commits locales se reaplican encima de los descargados y el historial se mantiene lineal; la guía de flujos de trabajo con ramas analiza ese compromiso.

git push envía tus commits al remoto y hace avanzar allí la rama. Se rechaza si la rama remota tiene commits que tú no tienes —un non-fast-forward—, lo que te protege de sobrescribir el trabajo de otra persona. Lo correcto es hacer fetch, integrar y volver a hacer push; --force debería reservarse para ramas en las que solo trabajas tú, e incluso entonces es preferible --force-with-lease, que falla si el remoto ha cambiado desde tu último fetch.

Leer el historial

git log --oneline --graph --decorate -20   # compact graph with branch and tag labels
git log -p -- src/Order.java               # every change to one file, with diffs
git log --author=alice --since=2.weeks
git show a1b2c3d                           # one commit: metadata plus diff
git blame src/Order.java                   # which commit last touched each line
git diff main...feature                    # what feature adds relative to the merge base

Merece la pena aprender la forma de tres puntos de la última línea: A..B significa «commits alcanzables desde B pero no desde A», mientras que A...B en git diff significa «cambios en B desde que se separó de A», que es justo lo que quieres al revisar una rama.

Deshacer cambios con seguridad

Git ofrece varias maneras de deshacer, y elegir bien depende de dos preguntas: ¿el cambio ya está en un commit? ¿Se ha compartido ya?

Descartar ediciones sin commit en el working tree. git restore <file> sustituye el archivo por la versión del índice. El cambio se pierde para siempre —nunca llegó a estar en un commit—, así que revisa antes git diff.

Sacar del índice sin perder las ediciones. git restore --staged <file> vuelve a copiar en el índice la versión del último commit y no toca el working tree.

Corregir el último commit. git commit --amend sustituye el commit de la punta por uno nuevo con el índice actual (y, si quieres, un mensaje editado). Sin problema antes del push; después del push reescribe historial compartido.

Mover una rama hacia atrás. git reset mueve el puntero de la rama actual a otro commit. Con --soft, el índice y el working tree conservan los cambios posteriores (útil para unir los últimos commits en uno solo); con --mixed, la opción por defecto, se reinicia el índice pero los archivos no se tocan; con --hard, todo queda igual que en el commit de destino y el trabajo sin commit se destruye.

git reset --soft HEAD~3     # collapse the last three commits into staged changes
git commit -m "Implement discount codes"

Deshacer un commit compartido. git revert <hash> crea un commit nuevo que aplica el cambio inverso. El historial se conserva, nada de lo que otros hayan descargado queda invalidado, y es la única forma correcta de deshacer algo en main una vez hecho el push.

Recuperar commits «perdidos». Cada vez que HEAD se mueve, Git anota dónde estaba en el reflog. Tras un reset desafortunado o una rama borrada, git reflog lista las posiciones recientes y git reset --hard HEAD@{2} o git switch -c rescued <hash> recupera los commits. Los objetos inalcanzables se conservan al menos 30 días por defecto, así que casi cualquier cosa que esté en un commit se puede recuperar dentro de ese plazo.

Stash

git stash guarda en una pila los cambios del working tree y del índice y deja limpio el working tree, para que puedas cambiar de rama, arreglar algo urgente y volver. git stash pop restaura el stash más reciente y lo elimina; git stash list muestra lo que tienes aparcado; git stash push -m "wip: pricing" -- src/pricing guarda solo algunas rutas y les pone una etiqueta. Los stashes también son commits, y un pop que genera conflictos deja en su sitio tanto el stash como los marcadores de conflicto, así que no se pierde nada.

Ignorar archivos y configurar Git

Un archivo .gitignore enumera patrones de archivos que Git no debe seguir: salida de compilación, directorios de dependencias, ajustes del editor, secretos. Los patrones se evalúan respecto al directorio del propio archivo; una / inicial ancla el patrón a ese directorio, una / final solo coincide con directorios y ! niega el patrón.

# Build output
/target/
/build/
node_modules/

# IDE and OS noise
.idea/
*.iml
.vscode/
.DS_Store

# Never commit secrets
.env
*.pem

Hay dos cosas que suelen pillar a la gente. .gitignore no afecta a los archivos que ya están bajo seguimiento: primero tienes que quitarlos con git rm --cached. Y una entrada de ignore no es un control de seguridad: un archivo que tuvo commit una sola vez sigue en el historial hasta que este se reescriba, y cualquier secreto que contuviera debe rotarse.

La configuración vive en tres niveles —sistema, global (~/.gitconfig) y repositorio (.git/config)—, y cada uno prevalece sobre el anterior. Los ajustes que merece la pena aplicar en cada máquina nueva:

git config --global user.name "Alice Martin"
git config --global user.email [email protected]
git config --global init.defaultBranch main
git config --global pull.rebase true
git config --global push.autoSetupRemote true   # first push needs no -u
git config --global core.autocrlf input          # on Linux/macOS; "true" on Windows
git config --global rerere.enabled true          # remember how you resolved conflicts

Tags

Un tag marca un commit como relevante, normalmente una release. Los tags ligeros (git tag v2.4.0) son simples punteros; los tags anotados (git tag -a v2.4.0 -m "Release 2.4.0") son objetos con autor, fecha y mensaje, y se pueden firmar. Usa tags anotados para las releases: registran su procedencia y git describe los prefiere. Los tags no se suben por defecto —git push origin v2.4.0 o git push --tags— y, a diferencia de las ramas, están pensados para ser permanentes: mover un tag que otros ya han descargado provoca confusión duradera.

Escribir commits que merezca la pena leer

Un commit es una unidad de cambio y un mensaje para quien lea el historial un año después, a menudo mientras busca un bug con bisect. Limita cada commit a un único cambio lógico, para que pueda revertirse o aplicarse con cherry-pick por separado. Escribe la línea de asunto en imperativo («Add retry to payment client»), con unos 50 caracteres como máximo, y pon el porqué en el cuerpo: qué problema había, por qué este enfoque, qué alternativas se descartaron. El diff ya muestra qué cambió; el mensaje existe para explicar lo que el diff no puede.

git bisect es la recompensa de los commits pequeños y bien descritos. Dado un commit que sabes que está bien y otro que sabes que está mal, hace checkout del punto intermedio, tú (o un script) indicas si está bien o mal, y va cercando el commit culpable en un número logarítmico de pasos. Solo resulta útil si cada commit compila y hace una sola cosa.

Errores comunes

  • Hacer commit de archivos generados y dependencias. target/, node_modules/ y los archivos .class inflan el repositorio y provocan conflictos sin fin. Ignóralos desde el primer commit.
  • git add . desde el directorio equivocado. Prepara todo lo que hay por debajo del directorio actual, incluidos archivos que no pretendías añadir. Revisa git status antes de hacer commit.
  • Hacer force push a una rama compartida. Los push de tus compañeros se rechazan, sus ramas locales divergen y alguien acaba volviendo a subir el historial antiguo. Usa --force-with-lease y solo en ramas personales.
  • Hacer pull con cambios sin commit. Un merge o un rebase sobre archivos modificados produce conflictos confusos. Haz commit o stash antes.
  • Tratar reset --hard como la solución para todo. Destruye el trabajo sin commit; usa stash en su lugar y el reflog para volver a commits anteriores.
  • Hacer commit de secretos «temporalmente». Se quedan en el historial. Rota la credencial de inmediato y usa git filter-repo si hay que purgar el historial.
  • Commits gigantes. Un commit de 60 archivos titulado «changes» es imposible de revisar, revertir o usar con bisect. Prepara los cambios en bloques lógicos.

Ideas clave

  • Git guarda snapshots inmutables y direccionados por contenido, enlazados en un grafo; las ramas son punteros móviles dentro de ese grafo y HEAD marca dónde estás.
  • Los cambios viven en el working tree, en el índice o en el repositorio; status, diff y diff --staged te dicen en cuál, y add construye el siguiente commit de forma deliberada.
  • fetch actualiza las ramas de seguimiento remoto sin tocar tu trabajo; pull además integra; push se niega a sobrescribir commits que no has visto.
  • Deshaz el trabajo sin commit con restore, reorganiza los commits locales con reset y amend, deshaz commits compartidos con revert y recupérate de casi cualquier cosa con reflog.
  • Deja fuera los archivos generados con .gitignore, configura globalmente pull.rebase y las opciones de fin de línea, y usa tags anotados para las releases.
  • Los commits pequeños, con un único propósito y mensajes que explican el motivo, son lo que hace que el historial, bisect y la revisión de código sirvan de algo más adelante.

Fuentes y lecturas recomendadas

Preguntas frecuentes

¿Qué diferencia hay entre git fetch y git pull?
fetch descarga los commits nuevos y actualiza las ramas de seguimiento remoto (como origin/main) sin tocar tu rama ni tu working tree. pull es un fetch seguido de un merge o un rebase sobre tu rama actual. Haz primero fetch cuando quieras revisar qué ha cambiado antes de integrarlo, y pull cuando estés listo para integrarlo directamente.
He hecho commit en la rama equivocada. ¿Cómo lo arreglo?
Si no has hecho push, apunta el hash del commit con git log, cámbiate a la rama correcta y ejecuta git cherry-pick con ese hash; después vuelve a la rama equivocada y ejecuta git reset --hard HEAD~1 para quitar allí el commit. Si ya has hecho push de la rama equivocada, mejor usa git revert para no reescribir un historial que otros quizá ya hayan descargado.
¿Es peligroso git reset --hard?
Descarta de forma permanente los cambios sin commit del working tree y del índice: nunca estuvieron en un commit, así que nada puede recuperarlos. Los commits que el reset deja atrás no se borran de inmediato: git reflog los sigue listando durante al menos 30 días por defecto y puedes volver a ellos con otro reset. Antes de un reset --hard, ejecuta git status y guarda con git stash cualquier cosa que puedas necesitar.
¿Por qué Git dice «detached HEAD»?
Normalmente HEAD apunta a un nombre de rama, y al hacer commit la rama avanza. Si haces checkout de un commit o de un tag concreto, HEAD apunta directamente a ese commit. Los commits que crees en ese estado no pertenecen a ninguna rama y acabará eliminándolos el garbage collector, salvo que ejecutes git switch -c alguna-rama para crear una en ese punto.