Skip to content

Instantly share code, notes, and snippets.

@danidiaz
Last active November 26, 2023 21:06
Show Gist options
  • Save danidiaz/9363236911972464dbb04c35941a7669 to your computer and use it in GitHub Desktop.
Save danidiaz/9363236911972464dbb04c35941a7669 to your computer and use it in GitHub Desktop.
Truquillos Git

conflictos modify/delete

(NOTA: Los primeros "truquillos Git" fueron redactados antes de que en Git la rama por defecto pasase a ser main, y he usado --initial-branch=master como manera fácil de adaptar los ejemplos para que siguiesen funcionado. Los "truquillos" posteriores ya usan main.)

Ejecutar este script en Git Bash crea un repositorio y lo deja en un estado de conflicto de merge:

mkdir conflicto.01
cd conflicto.01
git init --initial-branch=master
echo "zzz" > foo.txt
printf "aaa\nbbb\nccc" > bar.txt
git add .
git commit -m "first commit"
git checkout -b rama
sed -i -s 's/bbb/zzz/' bar.txt
git commit --all -m "bar modificado en rama"
git checkout master
git rm bar.txt
git commit --all -m "bar borrado en master"
git merge rama
echo "Ready!"

En concreto, el conflicto es el siguiente:

CONFLICT (modify/delete): bar.txt deleted in HEAD and modified in rama. Version rama of bar.txt left in tree.
Automatic merge failed; fix conflicts and then commit the result.

O sea, hemos borrado bar.txt en master, pero en rama lo habíamos modificado. Esto es un conflicto porque las líneas que han cambiado en rama pueden ser importantes.

El problema

Utilizando la línea de comandos de Git, cómo podemos averiguar cuáles fueron las líneas cambiadas en rama?

En nuestro working tree, tenemos la versión de bar.txt proveniente de rama:

$ cat bar.txt
aaa
zzz
ccc

Pero esto no resulta demasiado útil, porque en situaciones reales el archivo puede ser grande y los cambios no van a ser obvios. Necesitamos algún tipo de diff explícito!

Solución usando git log

El comando git log tiene la opción --patch que lista las diferencias introducidas por una serie de commits.

También tiene una opción --merge especialmente pensada para conflictos de merge. Según la documentación:

--merge
After a failed merge, show refs that touch files having a conflict and don’t exist on all heads to merge.

--merge lista commits que se refieran a archivos que estén en conflicto y que además estén ausentes en una de las ramas del merge.

Combinando ambas opciones:

$ git log --patch --merge
commit d9563ffcc0d35c632b38cb0f1211c141aabcccfa (HEAD -> master)
Author:
Date:  

    bar borrado en master

diff --git a/bar.txt b/bar.txt
deleted file mode 100644
index 8cc5896..0000000
--- a/bar.txt
+++ /dev/null
@@ -1,3 +0,0 @@
-aaa
-bbb
-ccc
\ No newline at end of file

commit aec567a05c768d86c27bca2e4ff763d12a616807 (rama)
Author:
Date:  

    bar modificado en rama

diff --git a/bar.txt b/bar.txt
index 8cc5896..6536f0e 100644
--- a/bar.txt
+++ b/bar.txt
@@ -1,3 +1,3 @@
 aaa
-bbb
+zzz
 ccc
\ No newline at end of file

Ahora ya tenemos un diff explícito que nos dice que la línea zzz fue añadida en el commit aec567a.

Si hubiese habido más de un archivo en conflicto, podríamos habernos quedado exclusivamente con los commits que afectasen a bar.txt:

git log --patch --merge -- bar.txt

El -- aislado le dice a Git que lo que viene a continuación es el path de un archivo. No siempre es necesario, pero a veces hace falta para desambiguar entre nombres de archivo y ramas.

Queda otro posible refinamiento. El log nos mostró el commit d9563ff que fue el que borró el archivo en HEAD. Pero en realidad no nos interesaba; ya sabíamos que el archivo se había borrado en HEAD. Ese commit es ruido.

Podemos usar la sintaxis ^HEAD, que le va a decir a git log que excluya de su salida todos los commits que sean HEAD o ancestros de HEAD:

$ git log --patch --merge ^HEAD -- bar.txt
commit aec567a05c768d86c27bca2e4ff763d12a616807 (rama)
Author: 
Date:  

    bar modificado en rama

diff --git a/bar.txt b/bar.txt
index 8cc5896..6536f0e 100644
--- a/bar.txt
+++ b/bar.txt
@@ -1,3 +1,3 @@
 aaa
-bbb
+zzz
 ccc
\ No newline at end of file

El commit que añade las líneas no se ve afectado porque reside en otra rama.

La sintaxis que hemos usado para excluir commits está explicada aquí.

Solución usando git diff

También podemos recurrir directamente a git diff.

En git diff, la notación de rango <commit>...<commit> significa: "encuentra el ancestro común de los dos commits, y muéstrame las diferencias entre dicho ancestro y el segundo commit".

Que es justo lo que necesitamos; mostrar los cambios en rama desde el punto en el cual HEAD y rama divergieron:

$ git diff HEAD...rama -- bar.txt
diff --git a/bar.txt b/bar.txt
index 8cc5896..6536f0e 100644
--- a/bar.txt
+++ b/bar.txt
@@ -1,3 +1,3 @@
 aaa
-bbb
+zzz
 ccc
\ No newline at end of file

Vuelve a resultar evidente que fue añadida la línea zzz.

Otras maneras equivalentes de invocar el diff serían:

$ git diff ...rama -- bar.txt # Aquí HEAD se asume por defecto
$ git diff ...MERGE_HEAD -- bar.txt # La referencia MERGE_HEAD apunta a la rama que estamos mergeando

En cambio comparar directamente HEAD y MERGE_HEAD usando .. en vez de ... no nos habría servido de nada, porque todas las líneas de bar.txt se mostrarían como añadidas:

$ git diff HEAD..MERGE_HEAD -- bar.txt
diff --git a/bar.txt b/bar.txt
new file mode 100644
index 0000000..6536f0e
--- /dev/null
+++ b/bar.txt
@@ -0,0 +1,3 @@
+aaa
+zzz
+ccc
\ No newline at end of file

Nota: La notación de rango de commits de dos puntos .. y también la de tres puntos ... hacen cosas diferentes en git diff y en git log, como explica la documentación:

"diff" is about comparing two endpoints, not ranges, and the range
notations ("<commit>..<commit>" and "<commit>...<commit>") do not mean a
range as defined in the "SPECIFYING RANGES" section in gitrevisions[7].

Lo cual me parece muy confuso, la verdad.

Solución usando GUIs

El plugin de Git para IntelliJ es lo bastante listo para mostrar las líneas cambiadas de bah.txt en rama mientras resolvemos el merge.

No he sido capaz de configurar lo mismo en Sourcetree. Si alguien sabe cómo hacerlo, agradecería que lo explicase en un comentario!

resets: soft, mixed, hard

git reset tiene dos modos de operación principales:

  • Cuando se especifica un commit (o se asume por defecto HEAD) y el path de un archivo, copia al índice la versión del archivo que existe en el commit, sobreescribiendo cualquier valor previo. El archivo permanece inalterado en el working tree.

  • Cuando solo se especifica el commit (o se asume por defecto HEAD) modifica la rama actual para que pase a apuntar a dicho commit, potencialmente "destruyendo historia". Este modo de operación viene en tres sabores: --soft, --mixed y --hard.

Ver la documentación del comando y también los diagramas explicativos en Reset Demystified.

Vamos a centrarnos en el segundo modo de operación. git reset --hard es fácil de entender por lo tajante que es: además de cambiar el commit al que apunta la rama actual, machaca los contenidos del working tree y del índice para ajustarlos al nuevo commit. Pero, qué hacen --soft y --mixed?

Para tener un repo en el que realizar nuestros experimentos, ejecutemos este script en Git Bash:

mkdir sandbox.02
cd sandbox.02
git init --initial-branch=master
echo "this is dummy" > dummy.txt
git add .
git commit -m "first commit"
echo "this is foo" > foo.txt
echo "this is faa" > faa.txt
echo "this is faa" > fee.txt
echo "this is bar" > bar.txt
echo "this is baz" > baz.txt
echo "this is qux" > qux.txt
echo "this is quz" > quz.txt
echo "this is waz" > waz.txt
git add .
git commit -m "commit to which reset"
git rm foo.txt
sed -i -s 's/bar/zzz/' bar.txt
git add bar.txt
sed -i -s 's/yyy/zzz/' baz.txt
git mv qux.txt qux_ren.txt 
git mv quz.txt quz_ren.txt 
git add .
git commit -m "commit to be resetted"
git rm faa.txt
rm fee.txt
sed -i -s 's/baz/yyy/' baz.txt
git add baz.txt
sed -i -s 's/quz/qqq/' quz_ren.txt
echo "this is new" > new.txt
git add new.txt
echo "this is new_untracked" > new_untracked.txt
git mv waz.txt waz_ren.txt
echo "Ready!"

El repositorio consta de varios commits, uno de ellos todavía en reparación con cambios ya añadidos al índice y otros sin añadir:

$ git status -s -b
## master
M  baz.txt
D  faa.txt
 D fee.txt
A  new.txt
 M quz_ren.txt
R  waz.txt -> waz_ren.txt
?? new_untracked.txt

(Los estados en la columna izquierda son los del archivo en el índice, los de la derecha en el working tree.)

Reset soft a HEAD

La opción --soft cambia el commit al que apunta la rama actual, pero deja en el índice la versión de los archivos anterior al reset. El working tree también permanece inalterado.

Qué pasará si hacemos un soft reset a HEAD?

$ git reset --soft HEAD

La respuesta: absolutamente nada. Como la rama actual sigue apuntando al mismo sitio y el soft reset no modifica ni el índice ni el working tree, la salida de git status seguirá siendo la misma.

Reset mixed a HEAD

La opción --mixed cambia el commit al que apunta la rama actual, y machaca el índice para que se ajuste a los contenidos del nuevo commit, pero deja en el working tree la versión de los archivos anterior al reset.

Qué pasa si—después de borrar sandbox.02 y recrearlo con el script—hacemos un mixed reset a HEAD?

$ git reset --mixed HEAD
Unstaged changes after reset:
M       baz.txt
D       faa.txt
D       fee.txt
M       quz_ren.txt
D       waz.txt

$ git status -s -b
## master
 M baz.txt
 D faa.txt
 D fee.txt
 M quz_ren.txt
 D waz.txt
?? new.txt
?? new_untracked.txt
?? waz_ren.txt

Vemos que los cambios a baz.txt (modificación) y faa.txt (borrado), antes añadidos al índice, pasan a estar en el working tree. fee.txt, que había sido borrado pero cuyo borrado no había sido registrado en el índice, sigue como estaba.

El archivo new.txt, que había sido creado y añdido al índice, ahora aparece como "untracked". El archivo new_untracked.txt, que había sido creado pero todavía no estaba añadido al índice, sigue como estaba.

Un caso interesante es el de waz.txt. Había sido renombrado a waz_ren.txt usando git mv. El reset --mixed hace que se pierda la conexión: tenemos un borrado de waz.txt, y waz_ren.txt aparece por separado como "untracked". Pero, si los volvemos a añadir al índice:

$ git add waz.txt waz_ren.txt

Git reconoce de nuevo el renombrado:

$ git status -s -b
...
R  waz.txt -> waz_ren.txt
...

Git no guarda explícitamente información de renombrado en cada commit ("este archivo pasa a llamarse así"). En vez de eso, la detección de renombrados se basa en analizar los contenidos de los archivos a través de los commits ("hay un archivo añadido y otro eliminado, los contenidos son similares en un 90%...voy a asumir que se trata de un renombrado").

Reset soft a HEAD^

Hacer un reset soft a HEAD no tuvo efecto alguno. Qué pasa si—después de borrar sandbox.02 y recrearlo con el script—hacemos un reset soft al commit anterior a HEAD?

$ git reset --soft HEAD^

$ git status -s -b
## master
M  bar.txt
M  baz.txt
D  faa.txt
 D fee.txt
D  foo.txt
A  new.txt
R  qux.txt -> qux_ren.txt
RM quz.txt -> quz_ren.txt
R  waz.txt -> waz_ren.txt
?? new_untracked.txt

Observamos que el working tree no ha cambiado. fee.txt sigue borrado, new_untracked.txt sigue untracked, quz_ren sigue modificado.

Pero, además de modificado, quz_ren.txt aparece ahora como renombrado en el índice. Esto es así porque hemos descartado el commit en el cual se había renombrado quz.txt a quz_ren, y git status muestra la diferencia entre los contenidos del índice y el nuevo HEAD.

Algo similar pasa con qux.txt, aunque sin modificaciones pendientes en el working tree.

bar.txt aparece como modificado en el índice, porque había sido modificado por el commit que hemos olvidado.

foo.txt aparece como borrado en el índice, porque había sido borrado por el commit que hemos descartado.

Reset mixed a HEAD^

Por último, qué pasa si—después de borrar sandbox.02 y recrearlo con el script—hacemos un reset mixed al commit anterior a HEAD?

$ git reset --mixed HEAD^
Unstaged changes after reset:
M       bar.txt
M       baz.txt
D       faa.txt
D       fee.txt
D       foo.txt
D       qux.txt
D       quz.txt
D       waz.txt

$ git status -s -b
## master
 M bar.txt
 M baz.txt
 D faa.txt
 D fee.txt
 D foo.txt
 D qux.txt
 D quz.txt
 D waz.txt
?? new.txt
?? new_untracked.txt
?? qux_ren.txt
?? quz_ren.txt
?? waz_ren.txt

Vemos que el índice está queda limpio, los cambios que "recuerdan" el commit que hemos descartado han sido incorporados al working tree.

Si ahora hiciésemos un git reset --hard HEAD, todos esos archivos borrados (D) aparecerían de nuevo en el working tree!

Trabajando en varias ramas simultáneamente mediante git worktree

Normalmente, Git solo nos deja trabajar en una rama a la vez. Cuando cambiamos de rama usando git checkout o git switch, los contenidos de nuestro worktree—y la referencia HEAD—se ajustan automáticamente. Si queremos volver a trabajar con la rama en la que estábamos antes, tenemos que cambiar de nuevo.

Pero, ¿qué pasa si, por ejemplo, queremos tener varias versiones de nuestro proyecto abiertas simultáneamente en nuestro IDE? La solución más directa consiste en clonar el repositorio varias veces, pero tiene varios inconvenientes:

  • Te estás bajando la historia completa del repo con cada clon, malgastando espacio.

  • No puedes mergear o rebasear entre ramas sin publicar antes tus cambios en el repo remoto.

  • Tienes que hacer git fetch en cada clon por separado para estar al tanto de cambios en remoto.

Una solución mejor es usar el comando git worktree, que nos permite crear un worktree secundario para cualquier rama que no se encuentre actualmente "checkouteada". Tenemos que proporcionar al comando (git worktree add) el directorio del nuevo worktree, el cual (ojo!) tiene que estar situado fuera del directorio principal del repo.

Un repositorio y worktree de ejemplo

Ejecutar este script en Git Bash crea un repositorio repo_principal y un worktree secundario worktree_secundario:

mkdir repo_principal
cd repo_principal
git init --initial-branch=master
printf "aaa\nbbb\nccc" > foo.txt
git add .
git commit -m "first commit"
git checkout -b rama
sed -i -s 's/bbb/zzz/' foo.txt
git commit --all -m "foo modificado en rama"
git checkout master
sed -i -s 's/bbb/yyy/' foo.txt
git commit --all -m "foo modificado en master"
git worktree add ../worktree_secundario rama
echo "Ready!"

Podemos comprobar que cada worktree está en ramas distintas:

$ git branch
* master
+ rama
$ cd ../worktree_secundario/
$ git branch
+ master
* rama

Podemos hacer un commit en la rama y mergearlo en master, sin tener que publicarlo previamente:

$ touch bar.txt
$ git add bar.txt
$ git commit -m "bar added in branch"
$ cd ../repo_principal/
$ git merge rama

(Tendremos que resolver un conflicto en foo.txt para proseguir.)

Una cosa que NO podemos hacer—y que no tendría mucho sentido—es tener la misma rama checkouteada en worktrees distintos:

$ cd ../worktree_secundario/
$ git switch master
fatal: 'master' is already checked out at

Cuando ya no necesitemos más el worktree secundario, podemos notificar a Git usando git worktree remove:

$ cd ../repo_principal/
$ git worktree remove ../worktree_secundario

git worktree remove también puede usarse tras haber borrado nosotros mismos el worktree en el sistema de ficheros:

$ cd ../repo_principal/
$ rm -rf ../worktree_secundario/
$ git worktree remove ../worktree_secundario

En ese caso, se limita a borrar los metadatos asociados al worktree en Git.

Soporte en GUIs

No tengo ni idea si alguna interfaz gráfica para Git soporta los worktrees secundarios creados por git worktree.

Enlaces relacionados

switching is expensive, because in the meantime you completely restructured the repository and maybe build system. If you switch, your IDE will run mad trying to adapt the project settings.

Actually, with Git 2.17+ (Q2 2018), git worktree remove will be enough

Ignorando archivos y cambios en archivos

Vamos a ver unas cuantas maneras de ignorar archivos—y cambios en archivos—más allá de un uso básico de .gitignore.

Para tener un repo en el que realizar nuestros experimentos, ejecutemos este script en Git Bash:

mkdir _05_repo
cd _05_repo
git init --initial-branch=main
printf "aaa\nbbb\nccc" > foo.txt
mkdir subdir
printf "this is bar" > subdir/bar.txt
printf "this is baz" > subdir/baz.txt
git add .
git commit -m "first commit"
git checkout -b rama
printf "this was added in rama" > rama.txt
git add .
git commit -m "commit en rama"
git switch main
printf "this was added in main" > main.txt
git add .
git commit -m "commit en main"
echo "Ready!"

Ignorar archivos localmente

Si queremos que Git no "vea" un archivo, para así evitar comitearlo por error, una opción es añadirlo al .gitignore y comitear el cambio.

Pero qué pasa si solo queremos ignorar el archivo localmente, sin imponer esa norma a otras personas que trabajen con el repo? En ese caso tenemos que tener cuidado de no comitear por error nuestro cambio al .gitignore. No hemos arreglado nada; volvemos a la casilla 0!

La verdadera solución consiste en añadir el archivo al $GIT_DIR/info/exclude del repositorio local, donde $GIT_DIR es el directorio de metadatos de Git, en nuestro caso _05_repo/.git. Al contrario que .gitignore, el fichero exclude es puramente local.

$ echo "should be ignored" > ignore_this.txt
$ git status
On branch main
Untracked files:
  (use "git add <file>..." to include in what will be committed)
        ignore_this.txt
nothing added to commit but untracked files present (use "git add" to track)
$ echo "ignore_this.txt" > .git/info/exclude
$ git status
On branch main
nothing to commit, working tree clean

.gitignore secundarios en subdirectorios

Es posible tener archivos .gitignore en subdirectorios de un repositorio. Los archivos del subdirectorio se verán afectados por la combinación de los .gitignore.

$ touch subdir/ignorado1.txt
$ touch subdir/ignorado2.txt
$ git status --short
?? subdir/ignorado1.txt
?? subdir/ignorado2.txt
$ echo "ignorado1.txt" > .gitignore
$ git status --short
?? .gitignore
?? subdir/ignorado2.txt
$ echo "ignorado2.txt" > subdir/.gitignore
$ git status -- short
?? .gitignore
?? subdir/.gitignore

En repositorios complejos que alberguen subproyectos con tecnologías diferentes (Java, Node...) puede tener tener sentido usar .gitignore especializados para cada subproyecto.

Quién me ignora?

En cuanto empezamos a usar más de un .gitignore surge la cuestión de qué .gitignore es el que está afectando a un archivo determinado.

Lo podemos saber usando el comando git check-ignore:

$ git check-ignore -v subdir/ignorado1.txt
.gitignore:1:ignorado1.txt      subdir/ignorado1.txt
$ git check-ignore -v subdir/ignorado2.txt
subdir/.gitignore:1:ignorado2.txt       subdir/ignorado2.txt

Ignorar cambios en archivos ya comiteados

Es frecuente realizar modificaciones locales a archivos ya bajo en control de Git, cambios que no queremos de ninguna manera comitear. Por ejemplo al debuguear o realizar pruebas exploratorias.

De nuevo tenemos el problema de cómo evitar comitear los cambios por accidente. Sin embargo, aquí el .gitignore no resulta de ayuda, porque Git ya está siguiendo el archivo:

$ echo "foo.txt" >> .gitignore
$ sed -i -s 's/bbb/zzz/' foo.txt
$ git status --short
 M foo.txt

Vamos a eliminar ese .gitignore y buscar otra solución.

$ rm .gitignore

Hay un comando alternativo (y parece que un tanto experimental) llamado git update-index --skip-worktree que le dice a Git "ignora cambios en este archivo; no lo muestres en la lista de archivos con cambios pendientes de comitear".

$ git update-index --skip-worktree -- foo.txt
$ git status --short
# ahora no aparece foo.txt, a pesar de sus cambios!

Si empezamos a "skipear" archivos a diestro y siniestro, en algún momento nos olvidaremos de cuáles eran. Una manera de identificarlos es mediante el comando git ls-files:

$ git ls-files -v
S foo.txt

Los archivos "skipeados" aparecerán precedidos de una S. Para repositorios con muchos archivos, podemos combinar el comando con grep:

$ git ls-files -v . | grep "^S"
S foo.txt

Cómo comenzar de nuevo a seguir las modificaciones? Mediante git update-index --no-skip-worktree:

$ git update-index --no-skip-worktree -- foo.txt
$ git status --short
 M foo.txt

--skip-worktree no es una panacea

--skip-worktree tiene algunas desventajas. Por ejemplo, vamos a aplicarlo a main.txt, un archivo que existe solo en main:

$ git update-index --skip-worktree -- main.txt

Luego vamos a modificarlo e intentar cambiar de rama:

$ echo "change" > main.txt
$ git switch rama
error: Your local changes to the following files would be overwritten by checkout:
        main.txt
Please commit your changes or stash them before you switch branches.
Aborting

Este error también nos habría pasado sin --skip-worktree. Git impide que nos movamos a otra rama porque eso destruiría cambios pendientes de comitear o stashear. Incluso cambios que han sido ocultados mediante --skip-worktree!

Como no queremos comitear, porque nuestros cambios son locales, vamos a probar a stashear:

$ git stash
No local changes to save

Oops. Cambios que han sido "escondidos" con --skip-worktree no pueden ser stasheados. Nos vemos obligados a usar --no-skip-worktree para poder stashear:

$ git update-index --no-skip-worktree -- main.txt
$ git stash
Saved working directory and index state WIP on main: 8ad241d commit en main
$ git switch rama
Switched to branch 'rama'

Lo cual resulta un tanto engorroso.

Enlaces relacionados

Patterns which are specific to a particular repository but which do not need to be shared with other related repositories (e.g., auxiliary files that live inside the repository but are specific to one user’s workflow) should go into the $GIT_DIR/info/exclude file.

Sobreescribiendo ramas

Recientemente me vi en la situación de tener que "sobreescribir" una rama con los contenidos de otra rama procedente de un repositorio no relacionado. Pero—importante!—sin destruir historia ni tener que recurrir a git push --force.

Para crear repositorios que reproduzcan el escenario, ejecutemos este script en Git Bash:

git init --bare --initial-branch=main source_remote.git
git init --initial-branch=main source 
cd source/
git remote add origin ../source_remote.git
touch foo.txt
git add .
git commit -m "first commit"
git checkout -b machaca
echo "foo" >> foo.txt
git add .
git commit -m "second commit"
git push --set-upstream origin machaca
cd ..
git init --bare --initial-branch=main destination_remote.git
git init --initial-branch=main destination 
cd destination
git remote add origin ../destination_remote.git
touch bar.txt
git add .
git commit -m "commit to be overwritten"
touch baz.txt
git add .
git commit -m "another commit to be overwritten"
git push --set-upstream origin main
echo "Ready!"

Nuestra misión: sobreescribir la rama main del repositorio destination con los contenidos de la rama machaca del repositorio source_remote.git.

(Aunque los repositorios source_remote.git y destination_remote.git sean locales, imaginemos que se tratan de repositorios remotos en Github o Bitbucket, que hemos clonado en source y destination, respectivamente.)

Primer paso: traernos la rama

Si listamos los remotes de destination:

$ git remote --verbose
origin  ../destination_remote.git (fetch)
origin  ../destination_remote.git (push)

vemos que aparece destination_remote.git, como cabría esperar. Pero un repositorio puede tener más de un remoto. Vamos a añadir source_remote.git bajo el nombre temp:

$ git remote add temp ../source_remote.git
$ git remote --verbose
origin  ../destination_remote.git (fetch)
origin  ../destination_remote.git (push)
temp    ../source_remote.git (fetch)
temp    ../source_remote.git (push)

y, habiendo añadido el remote, vamos a traernos la rama machaca a local:

$ git fetch temp
From ../source_remote
 * [new branch]      machaca    -> temp/machaca
$ git branch machaca temp/machaca
Branch 'machaca' set up to track remote branch 'machaca' from 'temp'.
$ git branch
  machaca
* main

No vamos a necesitar el remoto para nada más, así que lo borramos:

$ git remote remove temp

Segundo paso: sobreescribir main

Podemos sentirnos tentados de, estando en main, hacer algo como

$ git reset --hard machaca

para hace que main pase a apuntar a machaca. Pero eso destruiría historia y requeriría un "force push", porque los commits de main ya han sido publicados en destination_remote.git. Necesitamos encontrar otra manera.

Cuál es una manera no destructiva de combinar dos ramas? Un merge, por supuesto. Pero no nos vale un merge normal, ya que queremos quedarnos tan solo con los contenidos de machaca. Los archivos bar.txt y baz.txt no deben existir en el commit de merge!

Resulta que git merge admite la opción --strategy que nos permite configurar de qué manera se man a combinar las ramas. Y hay una estrategia de merge que parece prometedora: ours.

This resolves any number of heads, but the resulting tree of the merge is always that of the current branch head, effectively ignoring all changes from all other branches. It is meant to be used to supersede old development history of side branches.

Esto sería perfecto... excepto que funciona en sentido contrario al que necesitamos! Queremos sobreescribir la rama actual con la rama que mergeamos. ours en cambio, descarta completamente el contenido de la rama que mergeamos. Y parece que en Git no existe la estrategia simétrica theirs!

La ausencia de theirs no es un problema irresoluble, simplemente nos fuerza a dar un pequeño rodeo. Vamos a movernos a la rama machaca, hacer el git merge --strategy=ours allí con main, luego volver a main y mergear machaca. La idea es que este segundo merge será un mero fast-forward, o sea, que no habrá dos merge commits distintos.

Vamos allá:

$ git switch machaca
Switched to branch 'machaca'
$ git merge --strategy=ours main
fatal: refusing to merge unrelated histories

Oops... tenemos un problema adicional. Las ramas main y machaca no comparten un ancestro común (nada sorprendente, ya que provienen de repos completamente distintos) y eso no le gusta a Git.

$ git log --graph --all --format=short
* commit 13b5fe8513a5848687f25084807043c1de2b7c79 (HEAD -> machaca)
|
|     second commit
|
* commit e5996f34741ca35b42caac0deffde3cb417f3658

      first commit

* commit 088e4c50e808d4162f7485425cb378b1b77ce66d (origin/main, main)
|
|     another commit to be overwritten
|
* commit 6e0be8e29f1b675f829411959990439f95f843d3

      commit to be overwritten

Afortunadamente, podemos aplacar a git merge con la opción --allow-unrelated-histories, que no está activa por defecto. La añadimos y continuamos con el plan:

$ git merge --strategy=ours --allow-unrelated-histories -m "nos quedamos con machaca" main
Merge made by the 'ours' strategy.
$ git switch main
$ git merge machaca
Updating 088e4c5..6ae06f6
Fast-forward

Como esperábamos, el segundo merge ha sido un fast-forward. Qué hay en el working tree?

$ ls
foo.txt

Perfecto, bar.txt y baz.txt han desaparecido. Y cómo quedó el log?

$ git log --oneline --decorate
6ae06f6 (HEAD -> main, origin/main, machaca) nos quedamos con machaca
13b5fe8 second commit
e5996f3 first commit
088e4c5 another commit to be overwritten
6e0be8e commit to be overwritten

Finalmente, comprobamos que podemos hacer git push sin --force:

$ git push
To ../destination_remote.git
   088e4c5..6ae06f6  main -> main

Enlaces relacionados

git merge: fast-forward o no fast-forward

Cuando mergeamos una rama en nuestra rama actual, y ambas ramas tienen cambios propios, tras resolver el merge se creará un "merge commit" en la rama actual. El primer padre (HEAD^ ó HEAD^1) del merge commit será el commit previo de la rama actual, mientras que el segundo padre (HEAD^2) será el último commit de la rama que mergeamos.

Sin embargo, cuando la rama actual no tiene cambios propios con respecto a la rama que mergeamos, se realizará un tipo de merge llamado "fast-forward". En ese caso no se crea un merge commit con dos padres: simplemente, el "puntero" de la rama actual pasa a apuntar al último commit de la rama que mergeamos.

git merge tiene una opción llamada --no-ff que fuerza la creación de un merge commit incluso en merges para los cuales se podría hacer un fast-forward. Pero, por qué querríamos usar esa opción? Acaso no son los merges fast-forward más "limpios" y sencillos, en cierto sentido?

Para tener una situación en la que --no-ff resulte útil, ejecutemos este script en Git Bash que crea un repositorio de ejemplo:

mkdir _07_repo
cd _07_repo
git init --initial-branch=main
printf "aaa\nbbb\n----------\nccc\nddd" > foo.txt
git add .
git commit -m "first commit"
git switch -c rama
sed -i -s 's/aaa/xxx/' foo.txt
git add .
git commit -m "commit en rama 1"
git switch main
sed -i -s 's/ccc/zzz/' foo.txt
git add .
git commit -m "commit en main 1"
git switch rama
git merge --no-edit main
sed -i -s 's/bbb/yyy/' foo.txt
git add .
git commit -m "commit en rama 2"
git switch main
echo "Ready!"

La idea es que la rama rama contiene una nueva feature, pero ha hecho frecuentes merges de la rama main para mantenerse al día y no tener dificultades a la hora de llevar sus propios cambios a main.

De hecho, la rama está tan al día que main no tiene ningún cambio nuevo desde que fue mergeada por última vez en rama.

Vamos a verlo en la historia. La situación en main es la siguiente:

$ git switch main
$ git log --graph --oneline --decorate --summary
* cca1cb4 (HEAD -> main) commit en main 1
* 3f5b903 first commit
   create mode 100644 foo.txt

y la situación en rama es:

$ git switch rama
$ git log --graph --oneline --decorate --summary
* 958a24f (HEAD -> rama) commit en rama 2
*   f9022fc Merge branch 'main' into rama
|\
| * cca1cb4 (main) commit en main 1
* | a49bb9a commit en rama 1
|/
* 3f5b903 first commit
   create mode 100644 foo.txt

Ok, ahora supongamos que con el commit 958a24f queda completada la feature que estábamos desarrollando en rama, y pasamos a mergear rama en main:

$ git switch main
$ git merge rama
Updating cca1cb4..958a24f
Fast-forward
 foo.txt | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

Perfecto, un fast-forward! Vamos a ver el historial:

$ git log --graph --oneline --decorate --summary
* 958a24f (HEAD -> main, rama) commit en rama 2
*   f9022fc Merge branch 'main' into rama
|\
| * cca1cb4 commit en main 1
* | a49bb9a commit en rama 1
|/
* 3f5b903 first commit
   create mode 100644 foo.txt

Buf... pues no tan perfecto, después de todo. Hay un problema con esta historia: si seguimos la cadena de "primeros padres" de cada commit, estaremos pasando por los cambios de la rama, no por los de main. En cierta manera, la rama se ha "apoderado" de la historia principal, relegando los cambios de main. Esto puede hacer la historia más difícil de entender.

Por qué ha sucedido esto? Recordemos que los merges fast-forward no crean commits de merge. Por lo tanto, main va a pasar a apuntar directamente al último commit de rama. Si seguimos la histoira de ese commit, nos encontraremos con los merges de main en rama que se hicieron para mantener rama al día. Y en esos merges, el primer padre va a ser siempre un commit de rama, porque fue allí donde se hiceron los merges.

No queremos eso. Suponiendo que no hayamos hecho git push todavía, vamos a deshacer el merge fast-forward haciendo que main apunte al valor que tenía justo antes del merge:

$ git reset --hard main@{1}
$ git log --graph --oneline --decorate --summary
* cca1cb4 (HEAD -> main) commit en main 1
* 3f5b903 first commit
   create mode 100644 foo.txt

Y vamos a intentar intentar mergear rama de nuevo, esta vez con --no-ff:

$ git merge --no-ff --no-edit rama
Merge made by the 'recursive' strategy.
 foo.txt | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

$ git log --graph --oneline --decorate --summary
*   1dc2c85 (HEAD -> main) Merge branch 'rama'
|\
| * 958a24f (rama) commit en rama 2
| *   f9022fc Merge branch 'main' into rama
| |\
| |/
|/|
* | cca1cb4 commit en main 1
| * a49bb9a commit en rama 1
|/
* 3f5b903 first commit
   create mode 100644 foo.txt

Mucho mejor! Ahora la cadena de "primeros padres" pasa por los commits de main.

Qué ha cambiado con respecto al merge fast-forward? Pues que ahora sí se ha creado un merge commit en main. Y los merge commit siempre tienen como primer padre el commit previo de la rama en la que el merge tiene lugar.

Enlaces relacionados

occasionally you want to prevent this behavior from happening, typically because you want to maintain a specific branch topology (e.g. you're merging in a topic branch and you want to ensure it looks that way when reading history)

Fast-forward merging makes sense for short-lived branches, but in a more complex history, non-fast-forward merging may make the history easier to understand

The git merge no-ff flag helps create a more readable history.

submódulos

Los submódulos en Git son una manera de "anidar" repositorios, preservando la autonomía de cada repositorio.

Un caso de uso típico es incluir dentro del repositorio de nuestra aplicación el repositorio de una dependencia, apuntando a un commit concreto de la historia de la dependencia que sabemos funciona bien con nuestra aplicación.

Para crear unos repos que nos sirvan de ejemplo, ejecutemos este script en Git Bash:

mkdir _08_subrepo
cd _08_subrepo
git init --initial-branch=main
printf "xxx\nyyy\nzzz" > sub.txt
git add .
git commit -m "first commit in sub"
git switch -c rama_sub
printf "uuu\nvvv" > sub_rama.txt
git add .
git commit -m "commit in sub branch"
git switch main
cd ..
mkdir _08_repo
cd _08_repo
git init --initial-branch=main
printf "aaa\nbbb\nccc" > foo.txt
git add .
git commit -m "first commit"
echo "Ready!"

Dentro de _08_repo, vamos a añadir _08_subrepo como submódulo:

$ git submodule add ../_08_subrepo

(Podemos pasar un simple path porque _08_subrepo lo tenemos en local, típicamente tendríamos que especificar una URL.)

Si hacemos ls, vemos que ya aparece el submódulo:

$ ls
_08_subrepo/  foo.txt

Git también ha creado un archivo .gitmodules en el cual guarda el path que tiene el submódulo dentro de _08_repo y la URL a la cual ir a buscar los contenidos del submódulo:

[submodule "_08_subrepo"]
        path = _08_subrepo
        url = ../_08_subrepo

Un detalle importante a tener en cuenta (no entenderlo me confundió bastante a la hora de empezar a usar submódulos): un repositorio que contenga un submódulo siempre apunta a un commit concreto dentro de la historia del submódulo. La identidad de dicho commit no se guarda dentro de .gitmodules, sino como un objeto dentro del directorio .git, no directamente manipulable por el usuario.

  • Corolario #1: Los submódulos no se actualizan automáticamente cuando se añaden nuevos commits en el repo remoto al cual sigue el submódulo, aunque hagamos fetch y pull en el superproyecto

  • Corolario #2: Como el superproyecto apunta a un commit concreto dentro del submódulo, no a una rama, es normal y frecuente (aunque no obligatorio) que dentro de los submódulos nos encontremos en un estado de DETACHED HEAD. Para comprobarlo, podemos entrar en el submódulo y allí ejecutar git status.

Llegados a este punto, hemos añadido el submódulo, pero nos queda comitearlo:

$ git add .
$ git commit -m "añadido submódulo"
[main 390a824] añdadido submódulo
 2 files changed, 4 insertions(+)
 create mode 100644 .gitmodules
 create mode 160000 _08_subrepo

El comando git submodule, sin más argumentos, nos dará información básica sobre los submódulos presentes en el superproyecto, en particular a qué commit estamos apuntando para cada submódulo:

$ git submodule
 2e9ff5100c201ec4a95a86e89a50e87b5f8ed5c5 _08_subrepo (heads/main)

Comitear un submódulo no implica copiar los contenidos del submódulo en la historia del superproyecto (recordemos que la filosofía de los submódulos es preservar la autonomía de cada repositorio). En vez de eso, el commit del superproyecto se limita a incluir una referencia al commit actual del submódulo. Un puntero, vamos.

Qué pasa si alguien clona nuestro superproyecto?

Nos podemos preguntar, qué pasa si alguien clona ahora nuestro superproyecto? Se bajará automáticamente el submódulo? Vamos a hacer la prueba, saliendo de _08_subrepo y clonándolo localmente:

$ git clone ./_08_repo _08_repo_cloned
Cloning into '_08_repo_cloned'...
done.
$ cd _08_repo_cloned/
$ ls .
_08_subrepo/  foo.txt

Ok, parece que el clon ya tiene el subdirectorio del submódulo. Pero, qué hay dentro?

$ ls _08_subrepo/
-- nada!

Está vacío! Por qué?

Resulta que, cuando clonamos un superproyecto, por defecto no se clonan los submódulos. Hay que realizar el paso adicional de invocar git submódule init seguido de git module update:

$ git submodule init
Submodule '_08_subrepo' (.../_08_subrepo) registered for path '_08_subrepo'
$ git submodule update
Cloning into '.../github/_08_repo_cloned/_08_subrepo'...
done.
Submodule path '_08_subrepo': checked out '2e9ff5100c201ec4a95a86e89a50e87b5f8ed5c5'
$ ls _08_subrepo/
sub.txt
  • git submodule init internaliza en .git la información del archivo .gitmodules. No tuvimos que hacerlo antes porque parece que git submodule add lo hace automáticamente. Pero git init es necesario en superproyectos clonados.

  • git submodule update es el comando que realmente se encarga de clonar el submódulo y hacer que apunte al commit requerido por el superproyecto.

Una duda que tengo es por qué es necesario el archivo .gitmodules si luego vamos a tener que acordarnos de "internalizarlo" en .git usando git submodule init. Parece mucho rodeo. Quizás la respuesta sea que, para superproyectos con varios submódulos, git submodule init nos permite elegir cuáles de ellos queremos inicializar (argumento <path>) de manera que git submodule update se baje solamente esos módulos. Útil para evitar clonados innecesarios cuando solo estamos interesados en un subconjunto de los submódulos.

A vueltas con git submodule update

Hemos visto que git submodule update se encarga de clonar los submódulos cuando todavía no existen en el superproyecto. Pero, qué pasa si lo invocamos una segunda vez, cuando ya nos hemos bajado el contenido de los submódulos?

$ git submodule update
-- nada

No sucede nada, lo cual es razonable. Pero vamos a probar una cosa. Los submódulos son repositorios Git plenamente funcionales en sí mismos. Vamos a entrar en _08_subrepo y hacer un commit:

$ cd _08_subrepo/
$ touch "bar" > bar.txt
$ git add .
$ git commit -m "nuevo commit en submodulo"
[detached HEAD 736fe9f] nuevo commit en submodulo
 2 files changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 bar
 create mode 100644 bar.txt
$ cd ..

Si de vuelta en el superproyecto invocamos git status, nos avisa de que hay nuevos commits en el submódulo:

$ git status
Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
        modified:   _08_subrepo (new commits)

git submodule summary es otro comando que nos puede dar información sobre la divergencia entre el commit al que apunta el superproyecto y el commit que hay actualmente en el submódulo.

Si ahora invocamos

$ git submodule update
Submodule path '_08_subrepo': checked out '2e9ff5100c201ec4a95a86e89a50e87b5f8ed5c5'
$ ls _08_subrepo/
sub.txt

Interesante! Hemos vuelto al commit al que el superproyecto apuntaba originalmente, y bar.txt ha desaparecido.

Este comportamiento de git submodule update puede resultar útil para descartar modificaciones exploratorias que hayamos realizado en el código de un submódulo.

git submodule update también tiene opciones como rebase y merge que cambian el comportamiento por defecto.

git submodule update --remote

A la opción --remote de git submodule update hay que darle de comer aparte, porque cambia bastante el comportamiento de update.

Por qué digo esto? Normalmente, git submodule update no descubre nuevos commits en el remoto del submódulo. Lo que hace es intentar armonizar de alguna manera el estado actual del submódulo con el commit que espera el superproyecto. (La manera más fácil de harmonizarlo es, por supuesto, descartar los cambios en el submódulo.) Otra manera de decirlo es que normalmente el comando no te propone "oye, ya sé que estamos apuntando a este commit, pero me he dado cuenta de que en el remoto del submódulo hay muchos commits nuevos, quieres que nos los traigamos?"

Sin embargo, git submodule update --remote hace precisamente eso: busca nuevos commits en el remoto y se los trae automáticamente.

Para dar un ejemplo, vamos a salir del superproyecto _08_repo_cloned, entrar en el repositorio autónomo _08_subrepo, y hacer un commit allí:

$ cd ..
$ cd _08_subrepo/
$ echo baz > baz.txt
$ git add .
$ git commit -m "queremos apuntar a este commit en el subproyecto"
[main 8ffa0ee] queremos apuntar a este commit en el subproyecto
 1 file changed, 1 insertion(+)
 create mode 100644 baz.txt

volvemos a un superproyecto:

$ cd ..
$ cd _08_repo_cloned/

y ejecutamos lo siguiente:

$ git submodule update --remote
remote: Enumerating objects: 4, done.
remote: Counting objects: 100% (4/4), done.
remote: Compressing objects: 100% (2/2), done.
remote: Total 3 (delta 0), reused 0 (delta 0), pack-reused 0
Unpacking objects: 100% (3/3), 282 bytes | 2.00 KiB/s, done.
From .../_08_subrepo
   2e9ff51..8ffa0ee  main       -> origin/main
Submodule path '_08_subrepo': checked out '8ffa0eefe00a95f7fe9495c2626c42ea41c7dbe8'

Qué ha pasado aquí? git submodule update --remote ha comprobado que en el remoto del submódulo existen commits más recientes que aquel al que apunta actualmente el superproyecto, y se ha bajado esos commits.

Lo podemos ver con git submodule summary:

$ git submodule summary
* _08_subrepo 2e9ff51...8ffa0ee (1):
  > queremos apuntar a este commit en el subproyecto

Si estamos de acuerdo con estos nuevos commits, podemos añadirlos y comitearlos:

$ git add .
$ git commit -m "aceptados nuevos commits del remoto del submódulo"
[main b96a828] aceptados nuevos commits del remoto del submódulo
 1 file changed, 1 insertion(+), 1 deletion(-)

Por cierto que, de acuerdo a la documentación, git submodule update --remote no es la única manera de actualizar el commit al que apunta el superproyecto. Otra opción es realizar un git pull dentro del submódulo:

Use this option to integrate changes from the upstream subproject with your submodule’s current HEAD. Alternatively, you can run git pull from the submodule, which is equivalent except for the remote branch name: update --remote uses the default upstream repository and submodule..branch, while git pull uses the submodule’s branch..merge. Prefer submodule..branch if you want to distribute the default upstream branch with the superproject and branch..merge if you want a more native feel while working in the submodule itself.

Enlaces relacionados

  • La doc oficial:

    • Starting with Submodules

    • git-submodule

    • gitsubmodules

      La referencia en el superproyecto al commit del submódulo se llama "gitlink":

      The gitlink entry contains the object name of the commit that the superproject expects the submodule’s working directory to be at.

      Interesante: el directorio .git de los submódulos se guarda en el directorio .git/modules/ del superproyecto:

      On the filesystem, a submodule usually (but not always - see FORMS below) consists of (i) a Git directory located under the $GIT_DIR/modules/ directory of its superproject, (ii) a working directory inside the superproject’s working directory, and a .git file at the root of the submodule’s working directory pointing to (i).

      Sobre cómo borrar un módulo:

      A submodule can be deleted by running git rm && git commit. This can be undone using git revert.

      Curiosamente, borrar un módulo no borra la entrada correspondiente en el .git/modules/ del superproyecto. Parece que esto es así para evitar descargarlo de nuevo si lo volvemos a añadir (por ejemplo haciendo git revert):

      The submodule’s working directory is removed from the file system, but the Git directory is kept around as it to make it possible to checkout past commits without requiring fetching from another repository.

      To completely remove a submodule, manually delete $GIT_DIR/modules/<name>/.

      "Deinicializar" un módulo no es lo mismo que borrarlo. git submodule deinit no modifica la historia del superproyecto:

      A submodule can be deinitialized by running git submodule deinit. Besides emptying the working directory, this command only modifies the superproject’s $GIT_DIR/config file, so the superproject’s history is not affected.

      Se infiere que git submodule init tampoco modifica la historia; es una acción local que no tiene sentido publicar.

  • Dos buenas respuestas en Stack Overflow

    • How can I specify a branch/tag when adding a Git submodule?

      You have a submodule object [...] in your Git repository. GitHub shows these as "submodule" objects. Or do git submodule status from a command line. Git submodule objects are special kinds of Git objects, and they hold the SHA information for a specific commit.

      Whenever you do a git submodule update, it will populate your submodule with content from the commit. It knows where to find the commit because of the information in the .gitmodules.

    • git submodule update --init --force --remote

  • Otras preguntas en SO:

  • Vídeos:

splitting commits (under construction)

mkdir _09_split
cd _09_split
git init --initial-branch=main
FILE=foo.txt
echo '...' > $FILE
git add $FILE
git c -m 'Initial commit'
git tag -a 'rebasefromhere' -m 'Rebase from here'
for i in {a..z}
do
   ADD="${i}${i}${i}"
   sed -i "1s/^/${ADD}\n/" ${FILE}
   echo "${ADD}" >> ${FILE}
   git add $FILE
   git c -m "Commit that adds ${ADD}"
done
echo "Ready!"

related links

rebasing with --update-refs (under construction)

mkdir _10_update_refs
cd _10_update_refs
git init --initial-branch=main
FILE=foo.txt
FILE1=bar.txt
FILE2=baz.txt
FILE3=somefile.txt
echo 'aaa' > $FILE
git add $FILE
git commit -m 'Initial commit'
git switch -c branch_1
echo 'bbb' >> $FILE1
git add $FILE1
git commit -m 'Stacked branch 1'
echo 'bbb' >> $FILE1
git add $FILE1
git commit -m 'Stacked branch 1 - another commit'
git switch -c branch_2
echo 'ccc' >> $FILE2
git add $FILE2
git commit -m 'Stacked branch 2'
echo 'ccc' >> $FILE2
git add $FILE2
git commit -m 'Stacked branch 2 - another commit'
git switch -c branch_3
echo 'ddd' >> $FILE3
git add $FILE3
git commit -m 'Stacked branch 3'
echo 'ddd' >> $FILE3
git add $FILE3
git commit -m 'Stacked branch 3 - another commit'
git switch branch_1
touch changerequest.txt
git add changerequest.txt
git c -m 'change_request'
git switch branch_3
echo "Ready!"
  • It seems that --update-refs becomes useful once we have more than two PRs in the stack. With only two, it seems we can always do a normal rebase of the second PR into the first, if the first changes.
  • For a branch to be affected, the branch must directly point to a commit being rebased. It's not enough that the branch is based on a commit being rebased.

related links

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment