Hace un tiempo vengo estudiando este sistema de control de versiones distribuído, hecho principalmente en Python, y que lo utilizan algunos grandes proyectos como Mozilla, OpenJDK, NetBeans, etc. En este post, quería compararlo con Subversion, con respecto a mantener distintas ramas de desarrollo.
Aclaro que si bien no tengo una experiencia real con ambos sistemas en el tema, como la podría tener algún desarrollador de un proyecto grande de Software Libre, leyendo los manuales, viendo los ejemplos y utilizándolo en pequeños proyectos con pocos desarrolladores uno puede darse una idea de cómo son las cosas a gran escala.
En muchos lugares se habla de las bondades de los sistemas de control de versiones distribuidos. Una de las ventajas que tienen (entre muchas otras), según se dice, es la facilidad para manejar branches, donde se mencionan las deficiencias de Subversion para manejar varias ramas de desarrollo.
Este post puede servir para alguien que esta pensando en alguna herramienta de versionado, donde se esperan utilizar varios branches en el proyecto, como para poder ver de forma sencilla cómo manejan esto ambos sistemas. Esa “forma sencilla” de verlo podría significar también una divergencia importante con la práctica. Así que si piensan que algo es incorrecto, los invito a dejar un comentario.
En fin, ¿cómo se realizan estas operaciones en ambos sistemas?
Aquí con branch me refiero a una línea de desarrollo para agregar un nuevo feature o corregir un bug, etc. La aclaración es importante, porque en Mercurial casi siempre trabajamos con branches. Si hago un pull y traigo changesets (es decir commits) puede que surjan nuevas ramas. Este no es el significado al que me refiero aquí.
Subversion
En Subversion, no existe el concepto de branch. Un branch es simplemente una copia de un directorio a otro lugar. Uno mismo es el que le da ese significado.
Puedo hacer la copia directamente en el repositorio (donde automáticamente hago un commit):
$ svn copy http://svn.example.com/repos/calc/trunk \ http://svn.example.com/repos/calc/branches/my-calc-branch \ -m "Creating a private branch of /calc/trunk." Committed revision 341.
Si tengo la copia de trabajo correspondiente a todo el repositorio en mi máquina (incluyendo las típicas carpetas trunk, branches y tags):
$ svn copy trunk branches/my-calc-branch $ svn status A + branches/my-calc-branch $ $ svn commit -m "Creating a private branch of /calc/trunk." Adding branches/my-calc-branch Committed revision 341.
Luego simplemente comienzo a trabajar en esa carpeta, en mi nuevo branch. Sin embargo, hay una serie de inconvenientes. No podemos dejar que la línea principal de desarrollo se aleje demasiado de nuestro branch (supongamos que estamos agregando un nuevo feature), ya que al intentar fusionar todo luego de un largo tiempo, podemos terminar por frustrarnos. Esto, obviamente, no se aplica sólo a Subversion.
Entonces, periódicamente, traemos los cambios hechos en trunk a nuestra copia de trabajo, que corresponde a la rama en la que estamos trabajando:
$ svn merge -r 100:200 http://svn.example.com/repos/trunk my-working-copy ... $ svn commit -m "Sync with trunk"
El comando “svn merge” es muy similar a “svn diff”, pero aplica los cambios a mi copia de trabajo.
Como dice en el libro de Subversion, fusionar los cambios suena fácil, pero puede resultar en un dolor de cabeza. Yo, el usuario, soy el que tengo que controlar los merges que hago. Subversion (las versiones menores a las 1.5) no lo hacen. Esto significa que puedo fusionar los cambios dos veces, por ejemplo, o puedo comparar dos cosas sin ningún tipo de relación e intentar aplicar los cambios resultantes a mi copia de trabajo. Cuando ejecuto un comando parecido al de arriba, Subversion no sabe qué significan esos cambios. Simplemente se aplican a la copia de trabajo.
Supongamos que el día al fin ha llegado, y quiero traer a trunk mi nuevo feature, desarrollado en mi branch. Como dice en el libro, si están pensando en comparar la última revisión de trunk con la última del branch, se equivocan: ¿qué pasaría con los cambios hechos en trunk que nunca sucedieron en el branch? Se borrarían, y eso no es lo que queremos.
Lo que hay que hacer es comparar el estado inicial del branch con su estado final. De esta forma adiciono a trunk los cambios que he realizado en mi branch. Para esto tengo que saber en qué revisión creé mi branch. Una forma fácil de averiguar esto con Subversion:
$ svn log --verbose --stop-on-copy \ http://svn.example.com/repos/calc/branches/my-calc-branch … ------------------------------------------------------------------------ r341 | user | 2002-11-03 15:27:56 -0600 (Thu, 07 Nov 2002) | 2 lines Changed paths: A /calc/branches/my-calc-branch (from /calc/trunk:340) $
El procedimiento completo, sacado del libro, es este:
$ cd calc/trunk $ svn update At revision 405. $ svn merge -r 341:405 http://svn.example.com/repos/calc/branches/my-calc-branch U integer.c U button.c U Makefile $ svn status M integer.c M button.c M Makefile # ...examine the diffs, compile, test, etc... $ svn commit -m "Merged my-calc-branch changes r341:405 into the trunk." Sending integer.c Sending button.c Sending Makefile Transmitting file data ... Committed revision 406.
Noten lo descriptivos que son los mensajes de commit. Esto es necesario hacerlo así, para saber luego, por ejemplo, que ya se han aplicado los cambios del branch, y qué cambios involucran. Es información crítica: supongan que siguen trabajando en el branch y quieren traer los nuevos cambios hechos. Necesitan saber qué cambios ya han sido aplicados para no volver a traerlos a trunk. Para hacer eso, básicamente se ejecutan los comandos anteriores, alterando el rango de las revisiones según corresponda.
Cuando estaba aprendiendo Subversion, leí muy por arriba estas cosas, ya que no eran las que iba a utilizar habitualmente, y además me pareció un poco complicado, con varias advertencias de posibles problemas al fusionar cambios, etc. Por eso estoy copiando los ejemplos del libro, y ahora, en realidad, estoy viendo bien cómo funciona.
Sin embargo una de las cosas que sí aprendí a hacer ya que me parecieron muy útiles, que tiene relación con esto, es “deshacer” un commit, o sea, anular sus cambios. La forma de hacerlo es la siguiente:
$ svn merge -r 303:302 http://svn.example.com/repos/calc/trunk U integer.c $ svn status M integer.c $ svn diff … # verify that the change is removed … $ svn commit -m "Undoing change committed in r303." Sending integer.c Transmitting file data . Committed revision 350.
Simplemente se invierten, como habrán notado, el orden de las revisiones a comparar, resultando en un patch contrario al que aplica, en este caso, el commit 303, provocando la anulación de sus modificaciones. Sin embargo, si quiero traer un objeto eliminado de vuelta, no utilizo el comando anterior, sino “svn copy”.
Todas estas cosas que pueden parecer complicadas (y de hecho lo son), parecen estar comenzando a cambiar a partir de Subversion 1.5. Por ejemplo, algunos de los nuevos features, que podemos leer en las release notes, son merge tracking y la resolución interactiva de conflictos. Sin embargo el primero, como se menciona, posee una funcionalidad básica.
Mercurial
Veamos ahora cómo es esto en Mercurial. Aquí un desarrollador no posee únicamente la copia de trabajo, como en Subversion, sino el repositorio completo. Una forma fácil de crear un branch en Mercurial es clonar mi repositorio actual. Supongamos que tengo uno en la carpeta myproject:
$ hg clone myproject myproject-1.0 updating working directory 2 files updated, 0 files merged, 0 files removed, 0 files unresolved
De esta forma puedo comenzar a trabajar en el nuevo branch creado:
$ cd myproject-1.0 $ echo 'Alguna nueva funcionalidad' > algunArchivo $ hg commit -A -m 'Nuevo feature!' adding algunArchivo
Con esta manera de crear ramas, hay una relación uno-a-uno entre los branches en los que estoy trabajando y las carpetas existentes. Si necesito centralizar el branch porque voy a trabajar con otros desarrolladores y queremos tener un lugar común donde subir los cambios, entonces puedo subir el repositorio por FTP por ejemplo, a la carpeta donde el script hgwebdir.cgi verifique la existencia de repositorios. Con copiar la carpeta allí y realizar unas simples configuraciones en el archivo .hg/hgrc ya estamos. Otra es, si tenemos acceso shell a la máquina servidor, clonar el repositorio allí mismo.
Estos son los repositorios Mercurial de Mozilla. Allí se pueden ver varios. En uno de ellos se hace un merge con otro, como se puede ver en este changeset.
Si corrijo algún error en mi branch myproject-1.0, seguramente también queramos llevar esas correcciones en la linea principal de desarrollo (digamos, trunk), en el cual, supongamos, también se han hecho modificaciones. Para hacer esto, nos ubicamos en el directorio en donde queremos llevar los cambios (la linea principal en este caso), y ejecutamos un pull indicando la carpeta del branch:
$ cd myproject/ $ hg pull ../myproject-1.0 pulling from ../myproject-1.0 searching for changes adding changesets adding manifests adding file changes added 2 changesets with 2 changes to 2 files (+1 heads) (run 'hg heads' to see heads, 'hg merge' to merge) $ hg merge 2 files updated, 0 files merged, 0 files removed, 0 files unresolved (branch merge, don't forget to commit)
Con el comando pull lo que hago es traer los cambios (changesets) hechos en el repositorio que le paso como argumento. Cada changeset sabe cuál es su padre, por lo tanto al pullear, se armarán las ramas que sean necesarias. Después del pull, yo sigo en la revisión en la que estaba. Nada ha cambiado en mi copia de trabajo, simplemente se sincronizó mi repositorio actual con el otro.
Notar que Mercurial me avisa que han quedado varios heads: un head es un changeset sin hijos. Con el comando “hg view” puedo ver el panorama actual del repositorio:
El comando “hg merge” fusiona, justamente, mi copia de trabajo con otra revisión. Lo que hace es aplicar a mi copia de trabajo todos los cambios de la otra rama, según corresponda.
Notar que si bien estamos diciendo que utilizamos un branch, myproject-1.0, estos dos heads aquí, también representan branches, pero del tipo “little picture” (como se explica en el libro de Mercurial). Este tipo de branches aparecen todos los días, cuando pulleamos cambios de otro repositorio: son parte del trabajo cotidiano. En cambio el branch myproject-1.0 es del tipo “big picture”, ya que vamos a trabajar en la versión 1.0, corrigiendo errores, etc.
Si “hg merge” detecta conflictos, se ejecuta algún visor de diferencias, como meld o kdiff3. Mercurial se encarga de buscarlo en el sistema, o podemos setearlo nosotros en el archivo ~/.hgrc. Estos visores tienen tres vistas: a la izquierda están mis cambios, en el medio el resultado, y a la derecha los cambios del otro. Entonces es muy sencillo resolver el conflicto.
Como Mercurial nos recuerda después de ejecutar el merge, no debemos olvidarnos de hacer un commit aquí. Este commit tendrá como padres a los dos heads actuales:
Si bien estamos hablando de un sistema distribuído, obviamente que si queremos compartir cambios, la manera sencilla de trabajar es poner un repositorio central visto por todos los desarrolladores, al cual todos puedan subir sus cambios, y del cual puedan bajar los cambios de otros. Si realizo commits en mi repositorio local y quiero subirlos al remoto (con un push), Mercurial me avisará si es que estoy por generar heads remotamente. Si esto sucede, tengo que hacer un pull, mergear los dos heads, y luego subir el resultado.
Esta situación es similar en Subversion a cuando intentamos hacer un commit y nos dice que tenemos que ejecutar un update antes: entonces aplica los cambios en nuestra copia de trabajo, resolvemos los conflictos si es necesario, y realizamos un commit.
Otra forma de crear un branch en Mercurial, es hacerlo en el mismo repositorio, con Named Branches. Así nos ahorramos el tener que subir algo al servidor. Por defecto, si no hemos cambiado nada, trabajamos en el branch llamado default.
Con el comando “branches” puedo ver cuáles hay en mi repositorio:
$ hg branches default 0:34b3e0e21c40
Y con el comando “branch” veo en qué branch estoy trabajando ahora:
$ hg branch default
Supongamos que hemos hecho unos commits en la rama default:
$ hg log changeset: 1:7c0e8fbd1fb1 user: Milton Pividori <mimail@proveedor.com> date: Sun Oct 12 13:52:18 2008 -0300 summary: Agrego chau.cs changeset: 0:936587b0804a user: Milton Pividori <mimail@proveedor.com> date: Sun Oct 12 13:52:05 2008 -0300 summary: Agrego hola.cs
Entonces, para comenzar a trabajar en un nuevo branch, digamos myproject-1.0, hacemos:
$ hg branch myproject-1.0 marked working directory as branch myproject-1.0 $ hg branch myproject-1.0
Como nos indica el comando “branch”, ahora estamos trabajando en nuestro nuevo branch recién creado. El comando por sí sólo no hace nada. Simplemente le indica a Mercurial que a partir de ahora, vamos a comenzar a trabajar en este branch. Si hacemos un commit, Mercurial marcará el changeset como perteneciente a ese branch, y así con todos los changesets siguientes. Supongamos que hemos realizado tres commits más en este nuevo branch:
$ hg log -b myproject-1.0 changeset: 4:188979745f99 branch: myproject-1.0 tag: tip user: Milton Pividori <mimail@proveedor.com> date: Sun Oct 12 14:05:53 2008 -0300 summary: Agrego comoestas.cs changeset: 3:06231db27675 branch: myproject-1.0 user: Milton Pividori <mimail@proveedor.com> date: Sun Oct 12 14:01:02 2008 -0300 summary: Nuevo feature en chau.cs changeset: 2:d2d1fd5f5b39 branch: myproject-1.0 user: Milton Pividori <mimail@proveedor.com> date: Sun Oct 12 14:00:48 2008 -0300 summary: Nuevo feature en hola.cs
Y supongamos que el trabajo en la rama default continúa con más commits. Entonces el panorama en el repositorio luce como esto:
La rama de la izquierda corresponde a default, y la de la derecha a myproject-1.0. Para moverme entre los distintos branches de un repositorio, utilizo el comando “hg update nombreBranch”. Si después de eso realizo más commits, obviamente, pertenecerán al branch al que me he movido.
Supongamos que mi trabajo en el branch myproject-1.0 ha terminado, y quiero fusionar los cambios con default. Para eso me muevo al branch default (notar que llamo branch a la linea principal de trabajo), o sea a donde quiero llevar mis cambios, y utilizo, nuevamente, el comando “hg merge” seguido del nombre del branch del que quiero traer los cambios:
$ hg update default 2 files updated, 0 files merged, 1 files removed, 0 files unresolved $ hg merge myproject-1.0 merging chau.cs merging hola.cs 1 files updated, 2 files merged, 0 files removed, 0 files unresolved (branch merge, don't forget to commit) $ hg ci -m "Merge de myproject-1.0 a default"
El nuevo commit pertenecerá al branch default. Este comportamiento es el esperado: fusiono los cambios de un branch dentro del que yo estoy trabajando.
Aquí pueden ver una captura de meld cuando se presenta un conflicto, y a continuación el estado final del repositorio, luego de fusionar los cambios de mi branch a default:
Veamos cómo deshacer un commit con Mercurial. Supongamos que éste es el estado actual del repositorio:
Si me doy cuenta que el changeset 4 (“Bug fix en bar”) es incorrecto, entonces lo deshago con el comando “hg backout”:
$ hg backout --merge 4 reverting bar created new head changeset 7:cad3793b208a backs out changeset 4:e56015c82591 merging with changeset 7:cad3793b208a 1 files updated, 0 files merged, 0 files removed, 0 files unresolved (branch merge, don't forget to commit)
El comando “backout” crea una nueva revisión teniendo como padre aquella que quiere deshacer. Por lo tanto se generará un nuevo head. La opción “–merge” automáticamente fusiona los cambios con la otra cabeza.
Después del commit, el repositorio queda así:
Al mensaje de log lo genera Mercurial.
Conclusiones finales
Creo que realizar branches y luego fusionarlos es bastante más fácil en Mercurial. No tengo que ver en qué revisión comencé el branch y calcular el rango como en Subversion. No tengo que preocuparme de no aplicar dos veces los mismos cambios. Si sigo trabajando en mi branch, traer los nuevos cambios es muy sencillo, ya que Mercurial es el que controla esto. No hace falta ser descriptivo con los logs de los commits, porque con “hg view” puedo ver gráficamente el panorama y saber qué commits fueron realizados para fusionar cambios.
Revertir el cambio de una revisión parece sencillo en ambos, salvo que en Mercurial el comando para resucitar un objeto eliminado, que en definitiva es anular un commit, sigue siendo “backout”. En Subversion había que utilizar “copy”. De todas formas habría que probarlos en situaciones reales en contextos más complicados.