Volver a las novedades

¿Se puede deshacer lo que un agente de IA hizo en tu repositorio?

Git devuelve exactamente lo que llegó a ver al menos una vez, y la línea divisoria es el índice, no el commit. El 15 de agosto de 2026, con git 2.50.1, destruimos el mismo trozo de trabajo de seis maneras distintas, en seis repositorios desechables, e intentamos recuperarlo. Cuatro volvieron y dos se perdieron. Los dos que se perdieron nunca habían pasado por un git add. Esa es la regla entera: un solo git add que ni siquiera llegaste a commitear basta para recuperar el archivo, y lo que un agente de IA creó y destruyó sin pasar por el índice nunca existió desde el punto de vista de git.

El script que destruye el trabajo que un agente de IA podría destruir

Escribimos un script que crea un repositorio nuevo para cada escenario, mete la misma cadena en un archivo, la destruye con un comando distinto en cada caso y después intenta recuperarla. Nada aquí te pide creer en nuestra palabra: este es el script entero, y ejecutarlo es el punto.

#!/usr/bin/env bash
# What git gives back after an AI coding agent destroys work.
# The six scenarios live in one temporary directory, created once and
# removed on exit. The check after mktemp is not ceremony: git -C ""
# does not fail, it falls back to the current directory.
set -uo pipefail

LAB=$(mktemp -d)
[ -n "$LAB" ] && [ -d "$LAB" ] || { printf 'could not create a temporary directory, aborting\n' >&2; exit 1; }
trap 'rm -rf "$LAB"' EXIT

new_repo() {
  d="$LAB/$1"
  git init -q "$d"
  git -C "$d" config user.email lab@example.com
  git -C "$d" config user.name lab
  printf 'base\n' > "$d/tracked.txt"
  git -C "$d" add tracked.txt
  git -C "$d" commit -qm base
  printf '%s' "$d"
}

GOLD='work worth keeping'
check() { [ "$2" = "$GOLD" ] && printf '%-44s %s\n' "$1" "RECOVERED" || printf '%-44s %s\n' "$1" "LOST"; }

# 1. The agent committed, then reset --hard threw the commit away.
r=$(new_repo one)
printf '%s\n' "$GOLD" > "${r}/tracked.txt"
git -C "$r" commit -qam work
git -C "$r" reset -q --hard HEAD~1
git -C "$r" reset -q --hard 'HEAD@{1}'
check "committed, killed by reset --hard" "$(cat "${r}/tracked.txt" 2>/dev/null)"

# 2. The agent edited a tracked file, never staged it, checkout threw it away.
r=$(new_repo two)
printf '%s\n' "$GOLD" > "${r}/tracked.txt"
git -C "$r" checkout -q -- tracked.txt
check "edited, never staged, git checkout --" "$(cat "${r}/tracked.txt" 2>/dev/null)"

# 3. Same edit, but it reached the index once before being thrown away.
r=$(new_repo three)
printf '%s\n' "$GOLD" > "${r}/tracked.txt"
git -C "$r" add tracked.txt
git -C "$r" reset -q --hard HEAD
b=$(git -C "$r" fsck --lost-found 2>/dev/null | awk '/dangling blob/ {print $3; exit}')
check "edited, git add once, reset --hard" "$(git -C "$r" cat-file -p ${b} 2>/dev/null)"

# 4. The agent created a new file, never added it, git clean removed it.
r=$(new_repo four)
printf '%s\n' "$GOLD" > "${r}/newfile.txt"
git -C "$r" clean -qfd
check "created, never added, git clean -fd" "$(cat "${r}/newfile.txt" 2>/dev/null)"

# 5. The agent deleted the branch its commits lived on.
r=$(new_repo five)
git -C "$r" checkout -q -b feature
printf '%s\n' "$GOLD" > "${r}/tracked.txt"
git -C "$r" commit -qam feature-work
git -C "$r" checkout -q -
git -C "$r" branch -qD feature
s=$(git -C "$r" reflog 2>/dev/null | awk '/feature-work/ {print $1; exit}')
check "committed on a branch, branch -D" "$(git -C "$r" show ${s}:tracked.txt 2>/dev/null)"

# 6. The agent stashed the work and then dropped the stash.
r=$(new_repo six)
printf '%s\n' "$GOLD" > "${r}/tracked.txt"
git -C "$r" stash -q
git -C "$r" stash drop -q
c=$(git -C "$r" fsck --lost-found 2>/dev/null | awk '/dangling commit/ {print $3; exit}')
check "stashed, then git stash drop" "$(git -C "$r" show ${c}:tracked.txt 2>/dev/null)"

printf '\ngit %s\n' "$(git --version | awk '{print $3}')"

Cada uno de los seis bloques destruye la misma cadena, work worth keeping, de una manera distinta, y después intenta leerla de vuelta. El script imprime RECOVERED cuando el contenido recuperado coincide exactamente con el original, y LOST cuando no coincide, así que cada veredicto es una comparación y no una opinión. Las dos líneas que validan el directorio temporal antes que nada no son ceremonia, y la sección sobre git -C más abajo explica contra qué defienden.

Lo que muestran los seis escenarios sobre recuperar trabajo de agente de IA

Esta es la salida transcrita de aquel script, en git 2.50.1 en macOS. Lo ejecutamos bajo bash, zsh y sh, y la salida resultó idéntica byte a byte en los tres, así que el resultado de abajo no es un artefacto de un shell:

committed, killed by reset --hard            RECOVERED
edited, never staged, git checkout --        LOST
edited, git add once, reset --hard           RECOVERED
created, never added, git clean -fd          LOST
committed on a branch, branch -D             RECOVERED
stashed, then git stash drop                 RECOVERED

git 2.50.1

Lee la lista por lo que los seis escenarios tienen en común, y no uno por uno. Cada línea RECOVERED es un caso en que el contenido llegó a la base de objetos de git: el commit escribe ahí, el git add escribe ahí, y el git stash escribe ahí porque un stash es un commit con otro nombre. Cada línea LOST es un caso en que el contenido solo existió en el directorio de trabajo. El comando que destruyó no es lo que decide el resultado, y esa es la parte que casi todo el mundo entiende al revés: reset --hard suena mucho más violento que checkout --, y aun así es el que se sobrevive.

¿Por qué un solo git add salva un trabajo que nunca commiteaste?

Porque git add no es anotación, es escritura. Cuando pones un archivo en el índice, git calcula el hash de ese contenido exacto y graba un objeto blob dentro de .git/objects en el acto. El índice entonces apunta al blob. Un git reset --hard posterior devuelve el índice y el árbol de trabajo al estado anterior, pero no sale a buscar el blob que acaba de dejar huérfano. El objeto sigue en el disco sin nadie apuntándole, que es exactamente lo que git fsck --lost-found reporta como dangling.

Por eso el tercer escenario recupera. Un agente de IA puso el archivo en el índice, alguien corrió git reset --hard, y el contenido sigue ahí, bajo un hash que nadie recuerda. Lo traes de vuelta con dos comandos, sin necesitar saber el hash de antemano:

git fsck --lost-found          # lista blobs y commits sueltos
git cat-file -p <hash>         # imprime el contenido de uno de ellos

La consecuencia práctica para quien trabaja con agentes de IA merece decirse con todas las letras, porque es barata y no es obvia: poner en el índice es respaldo. Si un agente está a punto de hacer algo amplio y tienes trabajo sin commitear que te importa, git add -A no cuesta nada, no exige mensaje de commit, y mueve tu trabajo de la categoría irrecuperable a la categoría recuperable.

¿Qué no recuperas nunca después de que un agente de IA lo borra?

Dos cosas, y las dos tienen la misma causa. La primera es un cambio en un archivo rastreado que nunca pasó al índice y fue descartado por git checkout -- archivo, o por su grafía moderna git restore archivo. La segunda es un archivo que el agente creó y nunca agregó, eliminado por git clean -fd. En los dos casos git no tenía copia alguna del contenido, porque nada le pidió nunca que hiciera una.

No existe recuperación por el lado del repositorio para ninguno de los dos, y vale ser exactos sobre el alcance de esa frase: quiere decir que git no tiene nada que devolverte, no que los bytes hayan desaparecido necesariamente de tu máquina. Lo que sea que todavía guarde una copia vive enteramente fuera de git, en un respaldo, en un snapshot del sistema de archivos o en un editor que mantenga su propio historial local, y nada de eso es lo que medimos aquí. Si alguna de esas cosas existe en tu máquina es una pregunta que este artículo no responde por ti.

¿Cómo se hace la recuperación en la práctica?

Cada escenario recuperable tiene un comando que hace el trabajo, y vale tener esos comandos delante de los ojos antes de necesitarlos, no después. Un commit destruido por reset --hard vuelve con git reset --hard 'HEAD@{1}', que es la entrada del reflog de donde apuntaba la rama un movimiento atrás. Una rama borrada con branch -D vuelve encontrando su último commit en git reflog y corriendo git branch <nombre> <sha>. Un stash descartado vuelve por git fsck --lost-found, que lo reporta como dangling commit, que inspeccionas con git show y reaplicas con git stash apply <sha>.

Lo primero que hay que hacer, antes de cualquiera de esas, es dejar de escribir en el repositorio. Toda recuperación de arriba depende de objetos que están sin referencia y todavía no fueron recolectados, y el comando que recolecta es git gc, que el propio git también dispara solo: la página de manual dice que las operaciones comunes de porcelana comprueban si el repositorio creció de forma sustancial desde el último mantenimiento y, si creció, ejecutan git gc automáticamente. Un agente de IA que sigue trabajando en ese directorio está corriendo exactamente los comandos capaces de cerrar la ventana por la que estás intentando pasar.

¿Por qué git -C con una ruta vacía borra archivos en el repositorio equivocado?

Porque git -C "" no falla. Cae en el directorio en el que estás parado, y medimos cuánto cuesta eso. En un repositorio desechable con dos commits, un archivo rastreado y uno no rastreado, git -C "" log --oneline imprimió el historial de ese repositorio en vez de dar error. Después git -C "" clean -qfd borró el archivo no rastreado, y git -C "" reset -q --hard HEAD~1 tiró el commit más nuevo y revirtió el archivo rastreado a su contenido anterior. Dos commits se volvieron uno, y nada de eso ocurrió donde apuntaba la ruta vacía, porque una ruta vacía no apunta a ningún lado y git la resolvió como aquí.

Por eso el script de arriba valida su directorio temporal antes de hacer nada, y la falla contra la que se defiende está más cerca de lo que parece: cualquier variable de shell que termine vacía convierte todo git -C "$dir" en git -C "". Dos hábitos que parecen protección no protegen aquí. set -u rechaza una variable no definida, no una vacía, así que se queda callado. Y exit dentro de una función cuya salida capturas con $(...) solo termina el subshell, así que el script sigue adelante con la variable vacía. Es el mismo accidente del que trata el resto de este artículo, llegando por un script en vez de por un agente.

¿Por qué el primer commit inalcanzable es el equivocado para confiar?

Este costó un resultado equivocado antes de volverse hallazgo. Nuestra primera versión del escenario del stash reportó LOST, y el problema no era el dato: era la llamada. git stash crea dos commits, no uno. Uno guarda tu árbol de trabajo ("WIP on main") y el otro guarda el índice de ese instante ("index on main"). Nuestro script tomaba el primer commit que git fsck --unreachable imprimía, que resultó ser el del índice, cuyo contenido es la versión vieja. Comparó la versión vieja con el contenido esperado, los halló distintos, y reportó honestamente una pérdida que no había ocurrido.

Dos cosas salieron del arreglo. El orden en que git fsck --unreachable imprime los objetos no es algo sobre lo que construir nada: entre nuestras ejecuciones, el mismo repositorio listó los dos commits en órdenes distintos. Y --lost-found es el mejor instrumento para esta tarea específica, porque reporta solo el commit del stash como dangling: el commit del índice es padre del commit del stash, así que es alcanzable desde él y correctamente no aparece. La lección generaliza más allá de git, y es la que reaprendemos siempre: cuando una medición devuelve un cero sorprendente, sospecha de tu propia llamada antes que del mundo.

¿Cuánto tiempo queda abierta la ventana de recuperación?

Tiempo suficiente para que el pánico sea el riesgo mayor, y no para siempre. Los valores por defecto están documentados en git help gc, que en esta máquina es git 2.50.1 (Apple Git-155): gc.reflogExpire elimina entradas de reflog más viejas que 90 días; gc.reflogExpireUnreachable elimina, después de 30 días, las entradas que no son alcanzables desde la punta actual, que es justamente el caso de los commits huérfanos por un reset; y gc.pruneExpire hace que git gc llame a prune --expire 2.weeks.ago, que es lo que de hecho borra los blobs y commits sueltos.

Así que la versión honesta es que un commit que el agente destruyó esta mañana queda recuperable por semanas, y la ventana de dos semanas del prune es la más apretada de las tres. Ninguno de esos relojes reinicia porque te diste cuenta. Si estás recuperando trabajo que un agente de IA destruyó el mes pasado, confronta los números de arriba con tu propia configuración usando git config --get gc.pruneExpire, porque un repositorio que los define explícitamente sigue su propia regla, y una plataforma alojada puede correr recolección de basura en su propio calendario.

¿El agente de IA que rompió debe ser el que arregla?

Nuestra respuesta es no para la recuperación en sí, y el motivo es mecánico, no es cuestión de confianza. Recuperar es una lectura corta contra la base de objetos: encontrar el objeto, imprimirlo, devolverlo a su lugar. El agente que causó el destrozo carga el contexto que produjo el destrozo, y el modo de falla es específico y malo. Mandado a arreglar un repositorio, un agente tira de los mismos comandos amplios que destruyen la evidencia que queda, y git clean y git checkout -- son exactamente las dos operaciones que nuestra medición muestra irrecuperables.

La secuencia que menos cuesta es detener al agente, correr git fsck --lost-found tú mismo, y solo entonces decidir qué restaurar. Delegar el arreglo después de eso es razonable, una vez que los objetos que necesitas ya están identificados y a salvo. Lo que no sobrevive al contacto con la realidad es pedirle a la cosa que acaba de borrar tu trabajo que vaya a descubrir cómo traerlo de vuelta, en el mismo directorio, con el reloj de dos semanas corriendo.

Dónde se detiene esta medición

Seis escenarios en repositorios desechables no son un relevamiento de cómo se pierde el trabajo. El script corre sobre un repositorio con un archivo y un commit, sin remoto, sin submódulos, sin hooks y sin LFS, y cada uno de esos cambia el cuadro: una rama que ya fue enviada es recuperable del remoto independientemente de todo lo de arriba, que es la red de seguridad más barata de todas y la razón por la que este artículo sirve menos para trabajo ya publicado. Lo corrimos en git 2.50.1 en macOS y confirmamos que la salida es idéntica en tres ejecuciones seguidas y en tres shells; no lo corrimos en versiones más viejas de git, y los valores de expiración que citamos vienen de la documentación instalada con esa versión, no de una medición de expiración.

Lo que no medimos en absoluto es con qué frecuencia cada una de esas seis cosas ocurre de hecho cuando hay agentes de IA involucrados. Podemos decir qué es recuperable. No podemos decir, a partir de esto, qué error tiene más probabilidad de cometer tu agente.