Usando Git(3): Internals


Anteriormente vimos lo más básico de Git, en dos tutoriales separados: lo más básico de git y cómo trabajar con varias working copies.

En esta ocasión me gustaría compartir cómo funciona Git desde dentro. Y muchas veces, la mejor manera de saber cómo funciona algo es construirlo. Por esa razón vamos a diseñar nuestro propio Git.

Veremos que no es tan complejo como pueda parecer, y conocer cómo funciona nos permitirá saber qué podemos hacer. Siempre es interesante saber cómo funcionan las cosas.

Y demostraré que Git no tiene ramas.

Qué es Git

Hay DVCS, como Mercurial, que guardan las diferencias entre versiones. Git sólo guarda ficheros completos, por lo que yo lo veo más bien como un sistema de ficheros sobre el que han montado un sistema de control de versiones. Precisamente por eso me gustaría comenzar contando cómo funciona un sistema de ficheros, Ext2, y por qué no es adecuado para resolver los problemas que resuelve Git.

No tengáis miedo a este artículo, es más sencillo de lo que parece por el título. Mi profesor de sistemas operativos, Eduardo D., siempre nos decía: "Aquí no hay magia, sólo programas".

Ext2

Un sistema de ficheros tiene que tener ficheros. Y habitualmente directorios. Éstos varían frecuentemente, por lo que es importante gestionar esta característica.

Por eso, los diseñadores de Ext2 vieron el sistema de ficheros como un conjunto de bloques de dos tipos: Archivos y Directorios.

Un Archivo contiene una serie de características (nombre, permisos, dueño,...), y un contenido arbitrariamente grande. Por eso decidieron poner una pequeña cantidad de datos junto a las características y una lista de direcciones donde continúa el contenido del archivo. Es decir: podemos decir que el Archivo contiene características y que hay otro tipo de objetos llamados "Datos" con el contenido. Cuando un Archivo es demasiado grande, las últimas direcciones apuntarán a otros nodos que contienen más direcciones de bloques de Datos.

Un Directorio funciona igual, gestionando una serie de características y una lista de Archivos y de otros Directorios.

Finalmente, la gestión de la memoria libre se hace de una manera similar, mediante bloques de memoria libre que apuntan a otros bloques de memoria libres. Sí, utiliza la propia memoria libre para gestionar lo que está libre.

Ext2 se basa en conocer la posición de dos elementos especiales del sistema: la raíz del árbol de directorios y la raíz del espacio libre. Al fin y al cabo, necesitamos tener un nodo origen del que parten todos los demás.

Si tuviéramos que implementar Ext2 a alto nivel, probablemente necesitaríamos estos cuatro objetos: Directorio, Fichero, Datos y EspacioLibre.

Ext2 es mucho más que esto, con un sistema de bitácora que lo hace semi-transacional, y otras características necesarias para recuperarse de problemas, pero éste es un artículo sobre Git y con esto es suficiente.

Problemas de Ext2

Ext2 está pensado para archivos que se modifican con frecuencia. En un DVCS los archivos no se modifican nunca. Esto puede suponer una pérdida importante de eficiencia.

Además, un DVCS tiene una dimensión más: el tiempo. Un mismo archivo tiene historia.

Estas dos características hacen necesario pensar otra solución en lugar de utilizar Ext2.

Git

Git es un sistema en 4 dimensiones. Veremos cuáles son éstas y cómo no se diferencia tanto de Ext2, al menos conceptualmente.

Algo que no debemos olvidar es que Git es una implementación de alto nivel. Eso significa que donde Ext2 utiliza direcciones de memoria, Git tiene que utilizar algo más grande: nombres de archivos.

Dimensión uno: Bloques de datos (Blobs)

Igual que Ext2, Git tiene bloques de datos, que se llaman Blobs. Estos Blobs se tienen que guardar en archivos, por lo que aquí surge el primer problema: ¿Cómo direccionar de forma unívoca un Blob? ¿Cuál es el equivalente de una "posición de memoria"?

La solución es muy sencilla: Se calcula el Sha1 del Blob y se utiliza como nombre de archivo. Recordemos que el contenido de un archivo no cambia nunca, por lo que es algo perfectamente válido.

Estos Blob tendrán un tamaño máximo, de manera que sean sencillos de manejar. Si dos Blob comparten la misma Sha1, se asume que tienen el mismo contenido y esto permite ahorrar espacio, guardándolo en memoria una única vez. Las probabilidades de que un bloque de tamaño fijo genere el mismo Sha1 que otro diferente son, en la práctica, nulas.

Así mismo, para ahorrar espacio se guarda la información comprimida en Zip.

A los archivos cuyo nombre coincide con el Sha1 y contienen algún tipo de elemento de Git se les denomina objetos y se guardarán en el directorio .git/objects.

Esto es un Blob:

-------------------------
| a1ad5...              |
|-----------------------|
| Blob           | size |
|------------------------
| Zipped content        |
| example               |
-------------------------

Ejemplo:

$ ls .git/objects/a1/ad5a63324b733e8caf056f5167d4ee9957bf79
.git/objects/a1/ad5a63324b733e8caf056f5167d4ee9957bf79
$ git cat-file -t a1ad5a63324b733e8caf056f5167d4ee9957bf79
blob
$ git cat-file -p a1ad5a63324b733e8caf056f5167d4ee9957bf79
Zipped content
example
$

Dimensión dos: Árboles (Trees)

De la misma manera que en Ext2 hay archivos y directorios, en git hay Trees.

Los Trees son unos bloques especiales que permiten dar un orden a los bloques de datos y almacenar cierta meta-información, como permisos, un nombre, etc.

Nuevamente nos encontramos con algo que no va a cambiar con el tiempo. Si un archivo o un directorio cambiase, sería otro diferente. Así que se puede guardar el Tree utilizando también su Sha1 como nombre. Igual que los Blobs, si dos archivos tienen el mismo Sha1 podemos asumir que son iguales. Es más: podemos guardarlos junto con los Blobs, ya que también son objetos.

Al contrario que en Ext2, los objetos Tree pueden contener referencias a Blobs y a otros Trees, por lo que no hay distinción real entre "Archivos" y "Directorios".

Como veis, lo único que tenemos de momento son un montón de archivos con nombres de 40 bytes (su Sha1), aunque el significado de éstos puede ser distinto para Git.

Esto es un Tree:

---------------------------
| a1975...                |
|-------------------------|
| Tree           | size   |
|--------------------------
| perms, type, sha1, name |
| perms, type, sha1, name |
| ...                     |
---------------------------

Ejemplo:

$ ls .git/objects/a1/a1975049511402aac1e2710bd4762ba7d15b74d1
.git/objects/a1/a1975049511402aac1e2710bd4762ba7d15b74d1
$ git cat-file -t a1975049511402aac1e2710bd4762ba7d15b74d1
tree
$ git cat-file -p a1975049511402aac1e2710bd4762ba7d15b74d1
040000 tree ef67b9c831b2a669b8d8508cc5a9ed052d05e613    folder1
040000 tree 0a546f747dd82b6b765cf272867f2d04c60fd8dd    folder2
040000 tree 9194aeb855a27f7aec885eeeff32c88e26c8ee1c    folder3
040000 tree 9ad174760352980f6c5d4222bb2e54484189a2d3    folder4
100644 blob 6dc5a02b1d0dcb1e7e187972033f05b07e9592fe    file1
040000 tree 08a180175c5df5e2d4f545fb15e65c50bb488c65    folder5

Dimensión tres: Commits

Y he aquí la tercera dimensión: el tiempo. Cada vez que hacemos git commit, Git guarda un nuevo tipo de nodo, el Commit, que consiste en una lista Trees con alguna característica, como la descripción, el usuario y la fecha.

La implementación es sencilla: Cuando alguien hace git add, Git se guarda el objeto(contenido + sha1) en un directorio distinto, de la forma que vimos arriba (bueno, esto no es del todo cierto... realmente guarda diffs, pero eso no importa ahora). Así, al hacer un git commit basta crear un nuevo objeto Commit con sus características y la lista de Trees que contuviera el commit anterior más los cambios almacenados. Después se cogen todos los objetos generados y se guardan con los demás.

Esta configuración hace que también sea muy sencillo implementar un git push o git pull, ya que basta con sincronizar el directorio con todos los objects (bueno... y alguna cosilla más).

Esto es un Commit:

---------------------------
| b52b8...                |
|-------------------------|
| Commit         | size   |
|--------------------------
| tree a2e96...           |
| parent 0e443...         |
| author MagMax           |
| timestamp               |
| commiter MagMax         |
| timestamp               |
| description             |
---------------------------

Ejemplo:

$ ls .git/objects/b5/2b8ec9e4784cc0ce458d13d8868bc5255e5ce7
.git/objects/b5/2b8ec9e4784cc0ce458d13d8868bc5255e5ce7
$ git cat-file -t b52b8ec9e4784cc0ce458d13d8868bc5255e5ce7
commit
$ git cat-file -p b52b8ec9e4784cc0ce458d13d8868bc5255e5ce7
tree a2e96967bbb39652ecb6a5d0fee882eeb8d7f829
parent 0e4432bf3cb667dbc9a99afcc3601696e8c110af
author MagMax <miguelangel@magmax.org> 1371385169 +0200
committer MagMax <miguelangel@magmax.org> 1371385169 +0200

The description of this commit.

Tan solo falta saber por dónde vamos. Para eso, Git utiliza una serie de archivos en los que se guarda "referencias", que consisten en el Sha1 de los objetos. Así, el HEAD que apareció en el tutorial anterior no es más que un archivo (.git/HEAD) donde se guarda la referencia en la que nos encontramos. Y la referencia no es más que otro archivo (.git/refs/heads/master ) que contendrá el Sha1 del commit actual. Así de fácil. Os invito a que comprovéis el contenido de estos archivos en vuestro repositorio local:

  • .git/HEAD contiene: ref: refs/heads/master
  • .git/refs/heads/master contiene: 00ce77b5f13e7750ba87e091e8df5df0956101fb
  • Existe el archivo `.git/objects/00/ce77b5f13e7750ba87e091e8df5df0956101fb
  • Ejecutando git show comprobaremos que el último commit fue 00ce77b5f13e7750ba87e091e8df5df0956101fb

Dimensión cuatro: Tags

Consisten en referencias a commits, de manera que se puede establecer un nombre alternativo para estos commits.

Los veremos más adelante; de momento baste saber que estos Tags también son objetos y se guardan junto con todos los demás.

Su composición es similar a los commits, salvo que guardan referencias a otros commits en lugar de trees.

Ramas

Las ramas no son objetos.

A Git le gustan las cosas inmutables, lo que no cambia. Un Tag no cambia nunca. Podemos borrarlo y crear otro diferente, pero el mismo Tag es inmutable. Lo mismo ocurre con Archivos, Árboles y Commits.

Sin embargo, en Git el concepto de "rama" es efímero. Consiste en una referencia a un commit, pero irá cambiando frecuentemente, con cada commit. Además, no es un concepto indispensable. Así, una working copy por defecto sólo trackea una rama (habitualmente, "master").

Las ramas son, en realidad, referencias. Es decir, punteros. Sólo eso y nada más. Y puedo demostrarlo:

$ cat .git/refs/heads/master
00ce77b5f13e7750ba87e091e8df5df0956101fb

Vaya... Está apuntando al mismo changeset que vimos antes :D

Por lo tanto, en Git las ramas no son objetos. Al no ser objetos, no se copian con un git fetch.

Pensemos en los punteros de C o C++: podemos tener dos punteros apuntando a la misma variable. Cada puntero tendrá un nombre, y puede ser diferente o igual (en el caso de C y C++, sólo si están en distinto ámbito). Con Git ocurre lo mismo: lo que importa es el commit, no el nombre que se le ha puesto. Por eso los nombres de las ramas son locales. Podemos referirnos a una rama en el servidor, indicando que está allí:

$ cat .git/refs/remotes/origin/master
e2bf849e1c09ad79e04e7f6c0fcdcea8e5dd5175

Hmmm.... vaya. Mi copia local no está sincronizada con el servidor o el changeset volvería a coincidir. Necesito hacer un git push para sincronizarlas.

Simplicidad

Estoy tratando de demostrar algo más: lo que se hace con las órdenes de Git no es más que consultar archivos, a menudo de texto plano, que podríamos consultar a mano. Es una manera simple de gestionar un sistema complejo.

  • Cada fetch trae un montón de objetos y actualiza las referencias remotas que se encuentran en .git/refs/remotes.
  • Cada commit genera nuevos objetos y actualiza las referencias en .git/refs y algunos archivos como .git/HEAD y .git/
  • ...

Ahora es fácil entender qué significa Fast Forward: Podemos actualizar la rama sin necesidad de generar nuevos objetos. Es decir: basta actualizar las referencias.

También es fácil entender que un add guarda diffs en un archivo llamado .git/index (por eso a veces se llama index al stage). Y es aquí donde también se guarda la información del stash.

Sabiendo cómo funciona Git, resulta fácil saber si se puede o no se puede hacer algo. Basta imaginar cómo se haría a mano.

Eficiencia

Pero no todo es tan sencillo. Git realizar algunas operaciones para ser más eficiente. En concreto, realiza empaquetados de objetos (por defecto, 6700) para facilitar operaciones. Estos objetos empaquetados pueden estar repetidos: empaquetados y sin empaquetar.

Los paquetes permiten facilitar los envíos de archivos. Así un clone será mucho más rápido, ya que basta enviar un único archivo en lugar de 6700. Además, la copia local ocupará menos. Y es poco probable que nadie necesite acceder a estos archivos antiguos.

Cuando alguien accede, se desempaqueta y así el archivo está duplicado: empaquetado y sin empaquetar. De esta manera se conserva la eficiencia a costa de un poco de espacio. Periódicamente, Git puede decidir borrar los objetos que ya estaban empaquetados.

Más información

Todo, absolutamente todo lo contado en este post se encuentra en el libro progit, de Scott Chacon. Probablemente, también en la web de Git.


Comentarios

Comments powered by Disqus