Rápida introducción a Git

Cómo empezar a usar Git y no morir en el intento

Autor:Fernando J. Pereda <ferdy () gentoo ! org>
Licencia:http://creativecommons.org/licenses/by-nc-sa/2.5/es/

Tabla de contenidos

Introducción a Git

¿ Git ?

Git es un sistema de control de versiones distribuido. Es decir, nos ayudará a mantener ficheros en directorios.

Es parecido en cierto modo a los archiconocidos CVS y SVN (Subversion) pero por suerte es distinto. Es más parecido a darcs, mercurial, bzr y demás sistemas de versiones distribuidos.

¿Distribuidos? Si. No existe el concepto de cliente y servidor, si no de repositorios. Cada repositorio tiene toda la historia del proyecto o del conjunto de ficheros que queremos controlar. Así que de primeras, es normal que una copia del repositorio ocupe más. Sin embargo esto tiene sus ventajas:

Primeros pasos

Empezaremos por crear un repositorio y trabajaremos sobre varios ficheros. Más adelante veremos cómo varias personas podrían trabajar sobre esos ficheros sin muchos problemas.

Para crear un repositorio haremos lo siguiente:

$ mkdir git-intro
$ cd git-intro
$ git init-db
defaulting to local storage area

Con esto Git ha creado toda una estructura de directorios donde guardará su información interna, normalmente no tocaremos esto a mano, pero es importante saber qué es cada cosa:

$ tree .git/
.git/
|-- HEAD
|-- config
|-- description
|-- hooks
|   |-- applypatch-msg
|   |-- commit-msg
|   |-- post-commit
|   |-- post-update
|   |-- pre-applypatch
|   |-- pre-commit
|   `-- update
|-- info
|   `-- exclude
|-- objects
|   |-- info
|   `-- pack
`-- refs
    |-- heads
    `-- tags

8 directories, 11 files

Brevemente: (todo tendrá más sentido en un rato)

HEAD
Contiene la rama actual sobre la que estamos trabajando.
config
Configuración del repositorio. El formato es tipo INI.
description
Descripción del repositorio, usado por gitweb.
hooks/
Contiene diferentes scripts y filtros que se ejecutarán en distintas fases de Git. Están todos desactivados por defecto, para activarlos hay que darles permisos de ejecución. Los hooks por defecto son bastante útiles en muchos casos y además contienen documentación para construir hooks que nosotros podamos necesitar.
info/
Otros ficheros.
objects/
Contiene los objetos de Git. Un objeto puede ser de varios tipos (commit, blob, merge, ...) y es inmutable, una vez creado no se puede modificar.
objects/packs/
Los packs son objetos especiales, como su nombre indica son paquetes con varios objetos, útiles para ahorrar espacio y por razones de rendimiento.
refs/heads/
Las ramas que creemos se crearán aquí, realmente una rama no es más que un hash SHA1 indicando dónde está el commit que inicia la rama.
refs/tags/
Aquí se guardan las etiquetas, una etiqueta marca un punto en la historia del proyecto, útil para marcar las versiones o momentos especiales del desarrollo. Una etiqueta es igual que una rama solo que la etiqueta no cambia, marca un momento exacto.

Ahora añadiremos un fichero chorra y haremos el primer commit en Git. Para eso tenemos que hacer una pequeña configuración en nuestro repositorio. Tenemos que poner nuestro nombre y nuestro email:

$ git repo-config user.name "Ego mi mei"
$ git repo-config user.email emm@example.com

Esto es necesario para que nuestros cambios queden bien archivados y sean fáciles de compartir, hay que recordar que parte de la idea de usar un sistema distribuido ¡es compartir nuestros cambios!

$ cp /piribi/piribi/OSL-2.1 COPYING
$ git add COPYING ;# Con esto le decimos a Git que controle COPYING
$ git commit -s -m "Initial commit" -a
Committing initial tree 7e86d6c3d9d46e7741ca30dc39328661b22bf119

Las opciones de commit que hemos utilizado son:

-s
Firmar el commit. Esto añade una línea 'Signed-off-by:' al mensaje, el significado de esta línea puede variar de proyecto a proyecto, pero básicamente significará que tu tienes la propiedad intelectual sobre el parche, o que tienes permiso para enviarlo (ya sea implícito por la licencia o explícito por parte del autor original). Muchos proyectos no aceptan parches y cambios que no lleven esta línea.
-m
Para indicarle un mensaje en el mismo comando, de no ser así Git abrirá nuestro EDITOR.
-a
Le decimos que envíe todos los ficheros que hayan cambiado desde el último commit.

El hecho de que el mensaje esté en inglés no es una coincidencia. Es de buena educación hacerlo así si pretendemos compartir nuestro trabajo con otros. Al comando anterior Git responderá con algo similar a lo que me contestó a mi.

Todo esto ocurre en nuestra máquina, en local, sin necesidad de acceder a la red. Incluso si el repositorio es una copia (clone) de uno remoto, el commit es siempre local.

¿ Y ya está ?

Empezaremos por añadir más ficheros:

$ git add main.c
$ git commit -s -m "My new shiny main." -a

Por defecto trabajamos en la rama master para obtener el parche correspondiente a nuestro último commit podemos hacerlo de varias formas, lo más simple es hacer:

$ git diff HEAD^
diff --git a/main.c b/main.c
new file mode 100644
index 0000000..6002919
--- /dev/null
+++ b/main.c
@@ -0,0 +1,13 @@
+#include <stdio.h>
+
+int main(int argc, char *argv[])
+{
+        int n;
+
+        n = atoi(argv[1]);
+
+        while (n-- > 0)
+                puts("Cuack!");
+
+        return 0;
+}

Donde HEAD^ significa 'el primer antecesor de la rama actual'. HEAD siempre se refiere a la rama actual, en este caso será master ya que no la hemos cambiado.

Así que lo que le estamos pidiendo a Git es la diferencia entre el estado actual y el estado anterior.

Además podemos conseguir un log de todos los cambios del proyecto usando log:

$ git log
commit f7faea740d7841e58eb349fdfbfe287ed82c6382
Author: Fernando J. Pereda <ferdy@posidon.ferdyx.org>
Date:   Wed Apr 12 19:56:58 2006 +0200

    My new shiny main.

    Signed-off-by: Ego mi mei <emm@example.com>

commit 69345fadb85fcdbed6082071c3bfc918bc930ee1
Author: Fernando J. Pereda <ferdy@posidon.ferdyx.org>
Date:   Wed Apr 12 18:55:49 2006 +0200

    Initial commit.

    Signed-off-by: Ego mi mei <emm@example.com>

Y podemos incluir un resumen de todos los cambios usando una utilidad muy maja llamada shortlog:

$ git log | git shortlog
Fernando J. Pereda:
      Initial commit.
      My new shiny main.

No te vayas por las ramas

Antes he mencionado las ramas, en Git trabajamos con ramas. Las ramas son distintos caminos del desarrollo; las ramas en Git son baratas así que crearemos muchas ramas a lo largo de la vida del proyecto.

Una rama no es más que una referencia al commit que hace de cima de esa rama. Para crear una rama utilizamos el comando checkout, crearemos una rama llamada (sorpresa sorpresa) mirama:

$ git checkout -b mirama

Ahora hemos creado una nueva rama llamada mirama y hemos cambiado a ella, para ver la rama en la que estamos usaremos el comando branch:

$ git branch
  master
* mirama

Como mirama es un nombre malísimo, la borraremos y le daremos un nombre mejor:

$ git branch -D mirama
Cannot delete the branch you are on.

Oops, no podemos borrar la rama en la que estamos, volvamos a master y la borraremos desde allí:

$ git checkout master
$ git branch -D mirama
Deleted branch mirama.
$ git checkout -b makeify

Ahora podemos trabajar en esta rama, en la que lo que haremos será añadir un Makefile poco a poco:

$ vim Makefile
$ git add Makefile
$ git commit -s -m "Add a simple Makefile." -a

En un momento dado de la vida de esta rama podemos querer un parche entre el estado actual de la rama y donde dejamos master, podemos usar, una vez más, el comando diff:

$ git diff master
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..d3a108a
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,6 @@
+SRCS=main.c
+
+OBJS=$(SRCS:.c=.o)
+
+ducky: $(OBJS)
+       $(CC) -o ducky $(OBJS)

Mmmm, revisando main.c nos damos cuenta de que tenemos un problema, hay que arreglarlo. Cambiaremos a esa rama, lo arreglaremos y podremos seguir trabajando en la rama que necesitemos:

$ git checkout master
$ vim main.c
$ git diff
diff --git a/main.c b/main.c
index 6002919..3a7599d 100644
--- a/main.c
+++ b/main.c
@@ -4,6 +4,11 @@ int main(int argc, char *argv[])
 {
        int n;

+       if (argc < 2) {
+               fprintf(stderr,"%s: Necesito un número!\n",argv[0]);
+               return 1;
+       }
+
        n = atoi(argv[1]);

        while (n-- > 0)

Cuando estamos contentos con nuestro trabajo, podemos incluirlo en la rama:

$ git commit -s -m "Fix a potential segfault when no args are provided" -a

Con este commit, la situación actual de nuestro repositorio es la siguiente:

o---o---o "master"
     \
      \
       x "makeify"

Hemos creado en master un cambio que no hay en makeify. Seguiremos trabajando en makeify para mejorar un poco el Makefile y luego veremos cómo incluir los cambios de una rama, en la otra.

$ git checkout makeify
$ vim Makefile
$ git diff
diff --git a/Makefile b/Makefile
index d3a108a..b43f232 100644
--- a/Makefile
+++ b/Makefile
@@ -4,3 +4,6 @@ OBJS=$(SRCS:.c=.o)
 
 ducky: $(OBJS)
        $(CC) -o ducky $(OBJS)
+
+clean:
+       rm *.o ducky
$ git commit -s -m "Add a clean target." -a

Nuestra situación actual sería:

o---o---o "master"
     \
      \
       x---x "makeify"

Ahora tenemos dos commits en makeify que no hay en master y creemos que es hora de ir incluyendo los cambios.

Aquí tenemos dos opciones, un merge de makeify en master o hacer un rebase de makeify sobre master y luego un merge. Lo mejor es lo segundo, aunque es importante explicar las diferencias.

En el primer caso, haríamos:

$ git checkout master
$ git pull . makeify
[ ... información del merge ... ]

Y la situación sería:

o---o---o---@ "master"
     \      |
      \     /
       x---x "makeify"

Ese commit marcado con @ es un commit especial, es un merge. Realmente no hay grandes problemas con esto, el único problema es que la historia no queda del todo limpia, lo mejor es hacer el rebase:

$ git checkout makeify
$ git rebase master
[ ... información del rebase ... ]

Y nos dejaría la siguiente situación:

o---o---o "master"
         \
          \
           x---x "makeify"

Ahora no hay commits en master que no existan en makeify así que si ahora hacemos un merge, realmente haremos un merge especial, en la jerga de Git se llama fast-forward:

$ git checkout master
$ git pull . makeify
Updating from 13b0a5cdc355012d8fade4ec408d6ead4ecd1dd6 to  4a2986b1179541e2067025d183510bab85a5cf1f.
Fast forward
 Makefile |    9 +++++++++
 1 files changed, 9 insertions(+), 0 deletions(-)
 create mode 100644 Makefile

Y nos deja una historia limpia y lineal:

o---o---o---x---x "master" = "makeify"

Tanto el primer pull como el rebase pueden crear conflictos si las ramas tocan los mismos ficheros en zonas muy cercanas en ese caso haríamos lo siguiente:

$ vim ficheros-que-han-colisionado
$ git update-index ficheros-que-han-colisionado
$ git am --resolved

Explicar estos comandos bien requeriría explicar una de las partes más oscuras de Git, el index file. Eso es carne para cortar en otro momento.

Publicar nuestro trabajo

Para publicar nuestro trabajo podemos hacerlo de varias formas, usaremos el modelo más simple, lo publicaremos en un servidor donde tenemos cuenta:

$ ssh miserver
miserver$ mkdir git-intro.git
miserver$ GIT_DIR=git-intro.git git init-db
miserver$ chmod +x git-intro.git/hooks/post-update
$ cat << EOF > .git/remotes/miserver
> URL: miserver:git-intro.git
> EOF

Ahora podemos subir nuestra flamante rama master a miserver:

$ git push miserver master
[ ... información del push ... ]

Podríamos compartir este repositorio vía web por ejemplo para que otros lo clonaran y nos enviaran sus mejoras.

Trucos variados

Parches a la Git

En los proyectos que usan Git está muy de moda utilizar en las listas de correo los parches formateados por varias razones. Una de ellas es por la facilidad de ver qué ficheros han cambiado rápidamente y otra porque utilizando el comando applymailbox o el comando am es muy fácil aplicar el parche utilizando la información del autor y la fecha del parche en cuestión.

Estos parches los generamos con el comando format-patch, por ejemplo nuestro último parche sería:

$ git format-patch --stdout HEAD^
0001-Add-a-clean-target.txt
From nobody Mon Sep 17 00:00:00 2001
From: Fernando J. Pereda <ferdy@posidon.ferdyx.org>
Date: Wed Apr 12 21:15:05 2006 +0200
Subject: [PATCH] Add a clean target.

Signed-off-by: Ego mi mei <emm@example.com>

---

 Makefile |    3 +++
 1 files changed, 3 insertions(+), 0 deletions(-)

4a2986b1179541e2067025d183510bab85a5cf1f
diff --git a/Makefile b/Makefile
index d3a108a..b43f232 100644
--- a/Makefile
+++ b/Makefile
@@ -4,3 +4,6 @@ OBJS=$(SRCS:.c=.o)

 ducky: $(OBJS)
        $(CC) -o ducky $(OBJS)
+
+clean:
+       rm *.o ducky
--
1.2.6

Deshaciendo un commit

Imaginemos que hacemos un commit y nos damos cuenta de que la hemos liado. No pasa nada. Lo primero que haremos será echar la rama un commit hacia atrás:

$ git reset HEAD^
Makefile: needs update

Git nos indica que Makefile no corresponde con el estado actual de la rama. Lo editamos para arreglar el commit y lo volvemos a incluir en el repositorio con el mensaje que tenía en un principio:

$ vim Makefile
$ git commit -C ORIG_HEAD -a

Es importante ver que esto CAMBIA el commit ya que como he comentado antes, los objetos son inmutables. Dado que no hemos borrado la rama makeify esto ha quedado así:

o---o---o---o---o "master"
             \
              o "makeify"

Esto es porque los últimos cambios los hemos hecho sobre master y esto demuestra que efectivamente, los objetos son inmutables. Como no queremos confusión borraremos makeify.

$ git branch -D makeify
Deleted branch makeify.

Manteniendo el repositorio en orden

Cuando deshacemos muchos commit s o rebase s se nos quedarán objetos perdidos por el repositorio. En esta mini-sección veremos unos comandos para mantener el repositorio en orden.

Lo primero será comprobar si hay algún objeto perdido:

$ git fsck-objects
dangling commit 4a2986b1179541e2067025d183510bab85a5cf1f
dangling commit ea4448c6f9142f3472a2bf21c0a696cb740151c9

Pues es que si. Lo que haremos será crear un pack con todos los objetos actuales que nos sirven para algo y borrar los objetos que no nos interesan. Además esto reducirá el tamaño del repositorio:

$ du -hs
169K
$ git count-objects
22 objects, 88 kilobytes
$ git repack -a -d
Generating pack...
Done counting 15 objects.
Deltifying 15 objects.
 100% (15/15) done
Writing 15 objects.
 100% (15/15) done
Total 15, written 15 (delta 0), reused 0 (delta 0)
Pack pack-e1b94904c2a83b7b0ea2bcb7add651bb6beb0453 created.
$ git count-objects
22 objects, 88 kilobytes
$ git prune-packed
$ git count-objects
7 objects, 28 kilobytes
$ git prune
$ git count-objects
0 objects, 0 kilobytes
$ du -hs
100K

Hemos limpiado el repositorio de objetos y hemos ahorrado algo de espacio:

$ tree .git/objects
.git/objects/
|-- info
|   `-- packs
`-- pack
    |-- pack-e1b94904c2a83b7b0ea2bcb7add651bb6beb0453.idx
    `-- pack-e1b94904c2a83b7b0ea2bcb7add651bb6beb0453.pack

2 directories, 3 files

Deshacer un commit sin perder trabajo a medias

Es realmente fácil hacer esto también. Supongamos que estamos trabajando en una nueva feature de ducky:

$ vim main.c
$ git diff
diff --git a/main.c b/main.c
index 3a7599d..2c743ae 100644
--- a/main.c
+++ b/main.c
@@ -3,16 +3,19 @@
 int main(int argc, char *argv[])
 {
        int n;
+       char *msg = "Cuack!";

        if (argc < 2) {
                fprintf(stderr,"%s: Necesito un número!\n",argv[0]);
                return 1;
+       } else if (argc == 3) {
+               msg = argv[2];
        }

        n = atoi(argv[1]);

        while (n-- > 0)
-               puts("Cuack!");
+               puts(msg);

        return 0;
 }

Pero nos damos cuenta de que el commit anterior está mal, ya que clean debería llamar a rm -f en lugar de rm para que no de error si algún fichero no existe. Con lo que hemos comentado antes sería fácil, sin embargo perderíamos lo que hemos hecho hasta ahora y eso no es interesante.

Para no perder lo que tenemos hecho crearemos una rama de usar y tirar, haremos un commit de usar y tirar, arreglaremos el commit anterior y recuperaremos el de usar y tirar:

$ git checkout -b usarytirar
$ git commit -s -m "usar y tirar" -a
$ git checkout master
$ git reset HEAD^
Makefile: needs update
$ vim Makefile
$ git commit -C ORIG_HEAD -a
$ git cherry-pick -n -r usarytirar
First trying simple merge strategy to cherry-pick.
Finished one cherry-pick.
$ vim main.c
$ git commit -s -m "Allow users to specify output message." -a
$ git branch -D usarytirar
Deleted branch usarytirar.
$ git prune

Diff avanzado

A medida que el proyecto crece y tenemos muchas ramas nos interesará obtener las diferencias (diff) entre una rama y otra. Es decir, queremos los commit que hay en la rama b y no hay en la rama a:

o---o---o---o---o "a"
         \
          x---x---x---x---x "b"

Es decir, queremos los commit marcados con x. Podríamos usar una notación especial en Git: a..b o ^a b.

$ git diff a..b

Si estamos actualmente en la rama b podemos abreviar:

$ git diff a..

Ser bueno con el mundo

Cualquier proyecto que se precie no permitirá parches chapuzas. Para asegurarte de que tus parches no son muy chapuzas utiliza hooks/pre-commit que hará varias comprobaciones antes de incluir el cambio en el repositorio:

$ chmod +x .git/hooks/pre-commit