Se me mostró una prueba: la salida de dos comandos. El primero es
git show deadbeef
- mostró cambios en el archivo, llamémoslo Page.php. Se le ha agregado el método canBeEdited y su uso.
Y en la salida del segundo comando:
git log -p Page.php
- no hubo ningún compromiso de deadbeef. Y en la versión actual del archivo Page.php no había ningún método canBeEdited.
Al no encontrar una solución rápidamente, hicimos otro parche para el maestro, presentamos los cambios y decidí que volvería al problema con una mente fresca.
"Fuera de contexto"
, Git. , , .
¿Fue hecho a propósito? ¿Se cambió el nombre del archivo?
Comencé a buscar el problema pidiendo ayuda en el chat del equipo de ingenieros de versiones. Son responsables de alojar repositorios y automatizar los procesos relacionados con Git, entre otras cosas. Para ser honesto, probablemente podrían haber eliminado el parche, pero lo habrían hecho sin dejar rastro.
Uno de los ingenieros de versiones sugirió ejecutar git log con la opción --follow. Quizás se haya cambiado el nombre del archivo y, por lo tanto, Git no muestra algunos de los cambios.
--follow
Continuar enumerando el historial de un archivo más allá de los cambios de nombre (funciona solo para un solo archivo).
(Muestra el historial de archivos después de cambiarle el nombre (solo funciona para archivos individuales))
Había
git log --follow Page.php
un deadbeef en la salida , pero no se eliminó ni se le cambió el nombre a ningún archivo. Y, sin embargo, no era visible que el método canBeEdited fuera eliminado en alguna parte. La opción de seguimiento parecía jugar un papel en esta historia, pero aún no estaba claro dónde fueron los cambios.
Desafortunadamente, el repositorio en cuestión es uno de los más grandes que tenemos. Desde el momento en que se introdujo el primer parche hasta que desapareció, hubo 21.000 confirmaciones. También fue una suerte que el archivo requerido se editara solo en diez de ellos. Los estudié todos y no encontré nada interesante.
¡Buscamos testigos! Necesitamos un oso vivo
¡Detener! ¿Solo buscábamos deadbeef? Pensemos lógicamente: debe haber un compromiso, llamémoslo livebear, después de lo cual deadbeef ya no se muestra en el historial del archivo. Quizás esto no nos dé nada, pero sí nos dará algunas reflexiones.
Hay un comando git bisect para buscar en el historial de Git. De acuerdo con la documentación , le permite encontrar la confirmación en la que apareció el error por primera vez. En la práctica, se puede utilizar para buscar cualquier momento de la historia si sabe cómo determinar si ese momento ha llegado. Nuestro error fue la falta de cambios en el código. Podría comprobarlo con otro comando: git grep. Después de todo, fue suficiente para mí saber si hay un método canBeEdited en Page.php. Un poco de depuración y lectura de la documentación:
livebear [build]: fusiona el origen de la rama / XXX en build_web_yyyy.mm.dd.hh
Parece una confirmación de fusión normal de una rama de tarea con una rama de lanzamiento. Pero con este compromiso logré reproducir el problema:
$ git checkout -b test livebear^1 2>/dev/null $ grep -c canBeEdited Page.php 2 $ git merge —-no-edit -—no-stat livebear^2 Removing … … Removing … Merge made by the ‘recursive’ strategy. $ grep -c canBeEdited Page.php 0 $ git log -p Page.php | grep -c canBeEdited 0
Es cierto que no encontré nada interesante en livebear, y su conexión con nuestro problema no quedó clara. Después de pensar un poco, envié los resultados de mis búsquedas al desarrollador: acordamos que, incluso si llegamos a la verdad, el esquema de reproducción será demasiado complicado y no podremos asegurarnos contra algo así en el futuro. Por lo tanto, decidimos oficialmente dejar de buscar.
Sin embargo, mi curiosidad quedó insatisfecha.
La persistencia no es un vicio, sino un gran repugnante
Varias veces volví al problema, ejecuté git bisect y encontré más y más confirmaciones. Todos son sospechosos, todos son fusiones, pero eso no me dio nada. Me parece que un compromiso se me ocurrió con más frecuencia que otros, pero no estoy seguro de que fuera él quien fuera el culpable al final.
Por supuesto, también probé otros métodos de búsqueda. Por ejemplo, varias veces pasé por 21,000 confirmaciones que se realizaron en el momento del problema. No fue muy emocionante, pero encontré un patrón interesante. Ejecuté el mismo comando:
git grep -c canBeEdited {commit} -- Page.php
Resultó que las confirmaciones "malas", que no tenían el código requerido, estaban en la misma rama. Y una búsqueda en este hilo me llevó rápidamente a una pista:
changekiller Fusiona la rama 'master' en TICKET-XXX_description
Esto también fue una fusión de dos ramas. Y al intentar repetirlo localmente, hubo un conflicto en el archivo requerido: Page.php. A juzgar por el estado del repositorio, el desarrollador dejó su versión del archivo, descartando los cambios del maestro (es decir, se perdieron). Pasó mucho tiempo y el desarrollador no recordaba qué sucedió exactamente, pero en la práctica la situación se reprodujo en una secuencia simple:
git checkout -b test changekiller^1 git merge -s ours changekiller^2
Queda por ver cómo una secuencia legítima de acciones podría conducir a tal resultado. Al no encontrar nada al respecto en la documentación, entré en el código fuente.
¿El asesino es Git?
La documentación dice que git log recibe múltiples confirmaciones como entrada y debería mostrar al usuario sus confirmaciones principales, excluyendo a los padres de las confirmaciones enviadas con un ^ delante de ellas. Resulta que git log A ^ B debería mostrar confirmaciones que son padres de A y no padres de B.
El código de comando resultó ser bastante complejo. Hubo muchas optimizaciones diferentes para trabajar con la memoria y, en general, leer el código C nunca me pareció una experiencia muy agradable. La lógica básica se puede representar con el siguiente pseudocódigo:
// , commit commit; rev_info revs; revs = setup_revisions(revisions_range); while (commit = get_revision(revs)) { log_tree_commit(commit); }
Aquí la función get_revision acepta revs, un conjunto de indicadores de control, como entrada. Cada una de sus llamadas debería dar la siguiente confirmación para su procesamiento en el orden correcto (o vacío, cuando llegamos al final). También hay una función setup_revisions que completa la estructura de revs y log_tree_commit, que muestra información en la pantalla.
Tenía la sensación de que había descubierto dónde buscar el problema. Pasé un archivo específico (Page.php) al comando, porque solo estaba interesado en sus cambios. Esto significa que el registro de git debe tener algún tipo de lógica para filtrar confirmaciones "adicionales". Las funciones setup_revisions y get_revision se han utilizado en muchos lugares, lo que no supone ningún problema. Eso dejó log_tree_commit.
Para mi indescriptible alegría, en esta función realmente había un código que calcula qué cambios se hicieron en una confirmación en particular. Pensé que la lógica general debería verse así:
void log_tree_commit(commit) { if (tree_has_changed(commit, commit->parents)) { log_tree_commit_1(commit); } }
Pero cuanto más miraba el código real, más me di cuenta de que estaba equivocado. Esta función solo imprime mensajes. ¡Así que crea en tus sentimientos después de eso!
Volví a las funciones setup_revisions y get_revision. La lógica de su trabajo era difícil de entender: la "niebla" de las funciones auxiliares interfería, algunas de las cuales eran necesarias para trabajar correctamente con punteros y memoria. Todo parecía como si la lógica principal fuera un simple recorrido en amplitud del árbol de confirmación, es decir, un algoritmo bastante estándar:
rev_info setup_revisions(revisions_range, ...) { rev_info rev; commit commit; // — for (commit = get_commit_from_range(revisions_range)) { revs->commits = commit_list_append(commit, revs->commits) } } commit get_revision(rev_info revs) { commit c; commit l; c = get_revision_1(revs); for (l = c->parents; l; l = l->next) { commit_list_insert(l, &revs->commits); } return c; } commit get_revision_1(rev_info revs) { return pop_commit(revs->commits); }
Se crea una lista (revs-> commits), el primer elemento (superior) del árbol de commit se coloca allí. Luego, las confirmaciones del principio se toman gradualmente de esta lista y sus padres se agregan al final.
Al leer el código, encontré que entre la "niebla" de las funciones de ayuda, hay una lógica compleja para filtrar confirmaciones, que he estado buscando durante tanto tiempo. Esto sucede en la función get_revision_1:
commit get_revision_1(rev_info revs) { commit commit; commit = pop_commit(revs->commits); try_to_sipmlify_commit(commit); return commit; } void try_to_simplify_commit(commit commit) { for (parent = commit->parents; parent; parent = parent->next) { if (rev_compare_tree(revs, parent, commit) == REV_TREE_SAME) { parent->next = NULL; commit->parents = parent; } } }
En el caso de que se fusionen varias ramas, si el estado del archivo sigue siendo el mismo que en una de ellas, no tiene sentido considerar otras ramas. Si el estado del archivo no ha cambiado en ninguna parte, solo saldremos de la primera rama.
Ejemplo. Denotemos con cero las confirmaciones en las que el archivo no ha cambiado, por uno - aquellas en las que el archivo ha cambiado, y X - la fusión de ramas.
En esta situación, el código no considerará la rama de la función, no hay cambios en ella. Si el archivo se cambió allí, entonces en X los cambios se "descartaron", lo que significa que su historial no es muy relevante: este código ya no está allí.
Con nosotros pasó algo parecido. Dos desarrolladores realizaron cambios en el mismo archivo: Page.php, uno en la rama maestra, en la confirmación de deadbeef y el segundo en su rama de tareas.
Cuando el segundo desarrollador fusionó los cambios de la rama maestra en la rama de tareas, se produjo un conflicto, en el proceso de resolución, que simplemente descartó los cambios de la rama maestra. Pasó el tiempo, completó el trabajo en la tarea y la rama de la tarea se cargó en el maestro, eliminando así los cambios de la confirmación de deadbeef.
El compromiso en sí se mantuvo. Pero si ejecuta git log con el parámetro Page.php, no verá la confirmación de deadbeef en la salida.
La optimización es un trabajo ingrato
Me apresuré a estudiar cuidadosamente las reglas para enviar cambios y errores al propio Git. Después de todo, pensé que había encontrado un problema realmente serio: solo piense, algunas confirmaciones simplemente desaparecen de la salida, ¡y este es el comportamiento predeterminado! Afortunadamente, las reglas resultaron ser voluminosas, el tiempo era tarde y a la mañana siguiente mi mecha se había ido.
Me di cuenta de que esta optimización acelera enormemente el rendimiento de Git en grandes repositorios como el nuestro. También hay documentación para ello en man git-rev-list , y este comportamiento se puede desactivar muy fácilmente.
Por cierto, ¿cómo está involucrado --follow en esta historia?
De hecho, hay muchas formas de influir en el funcionamiento de esta lógica. Específicamente, sobre la bandera de seguimiento en el código de Git, se encontró un comentario hace 13 años:
No se pueden eliminar las confirmaciones con el siguiente cambio de nombre: las rutas cambian.
(Traducción: no se pueden lanzar confirmaciones cuando el cambio de nombre está en curso: las rutas pueden cambiar)
PD:
Yo mismo he estado con el equipo de ingeniería de lanzamiento de Badoo durante varios años y muchos en la empresa creen que entendemos a Git.
(Traducción. Original: xkcd.com/1597 )
En este sentido, tenemos que lidiar con los problemas que surgen en este sistema, y algunos de ellos me parecen bastante curiosos, como, por ejemplo, los descritos en este artículo. Muy a menudo, los problemas se resuelven rápidamente: ya nos hemos encontrado con muchos, algo está bien descrito en la documentación. Este caso fue una excepción.
De hecho, la documentación tenía una sección de Simplificación de historial, pero era solo para el comando git rev-list y no pensé en buscar allí. Hace seis meses, esta sección se incluyó en el manual del comando git log, pero nuestro caso sucedió un poco antes, simplemente no tuve tiempo de terminar este artículo. (*)
Y por último, tengo un pequeño bono para los que hayan leído hasta el final. Tengo un repositorio muy pequeño donde se reproduce el problema:
$ git clone https://github.com/Md-Cake/lost-changes.git Cloning into 'lost-changes'... … $ git log --oneline test.php edfd6a4 master: print 3 between 1 and 2 096d4cf init $ git log --oneline --full-history test.php afea493 (HEAD -> master, origin/master, origin/HEAD) Merge branch 'changekiller' 57041b8 (origin/changekiller) print 4 between 1 and 2 edfd6a4 master: print 3 between 1 and 2 096d4cf init
¡Gracias por la atención!
(*) UPD: Resultó que la sección de Simplificación del historial había estado en la documentación del comando git log durante mucho más de seis meses, y simplemente la omití. Gracias tú Molasque llamó la atención sobre esto!