Ir al contenido

De Git a Jujutsu, paso a paso

Si conoces bien Git, jj te resultará familiar en algunos aspectos y del revés en otros. Este tutorial recorre el camino flujo a flujo, mostrando siempre lo que harías en Git junto a lo que haces en jj y, cuando ayuda, el movimiento equivalente en Wyrm.

No necesitas memorizar comandos nuevos de entrada. Empieza por el cambio de mentalidad y luego sigue las fases en orden.

Un puñado de ideas explica casi todas las diferencias con las que te vas a encontrar:

  • No hay staging area. Cada cambio en un archivo forma parte inmediatamente del commit actual.
  • La copia de trabajo es un commit, marcado como @ en el log. Cada comando de jj toma automáticamente una instantánea de tus archivos dentro de él.
  • Los commits son baratos y desechables. No haces squash para «limpiar antes del push»; haces squash cuando quieres, y jj se encarga del efecto en cascada.
  • Reescribir el historial es el flujo de trabajo normal, no una vía de escape peligrosa.
  • Las ramas anónimas son lo normal. El temido «detached HEAD» de Git es el día a día en jj.
  • Los bookmarks son etiquetas de envío, no espacios de trabajo. No trabajas «sobre un bookmark»: trabajas sobre commits y etiquetas uno cuando llega el momento de hacer push.

En Wyrm, la fila superior del grafo del log es @. A medida que editas archivos, esa fila se actualiza en directo: no hay un panel aparte de «cambios sin confirmar» que gestionar.

En Git creas una rama, editas, preparas los cambios (stage) y haces commit. En jj simplemente empiezas a editar.

En Git…

Ventana de terminal
# 1. You're on develop
git checkout -b feature/new-ui # branch BEFORE you work
# 2. Edit files (now in a dirty, unsaved state)
# 3. Stage and commit
git add src/ui/*.ts && git commit -m "feat: new ui"

En jj…

Ventana de terminal
# 1. You're on develop (same)
# 2. Just start editing. That's it.
# No branch. No staging. Every save is captured in @.
# 3. Name the work when you're ready:
jj describe -m "feat: new ui"
# 4. (Optional, only when pushing) add a bookmark:
jj bookmark create feature/new-ui

Tu trabajo está a salvo en cuanto guardas un archivo. No necesitas ninguna rama hasta que hagas push, y no hay «staging»: todo vive en @.

Tienes un commit antiguo con buenos cambios, pero develop ha avanzado 20 commits. ¿Cómo te pones al día?

El truco: el orden da igual. Haz primero el rebase o edita primero: a jj le da lo mismo, y los conflictos no te van a bloquear.

Opción A — primero rebase, luego editar (lo más habitual):

Ventana de terminal
# Move your old commit onto the latest develop:
jj rebase -r old_change_id -d develop
# Then continue working on it:
jj edit old_change_id
# Now @ IS that commit; edits amend it directly.
# The change ID stays the same even after the rebase.

Opción B — primero editar, rebase después:

Ventana de terminal
jj edit old_change_id # jump to it right now
# ...work...
jj rebase -r @ -d develop # rebase when you're ready

En Git esto es «checkout → rebase → resolver conflictos → continue», y un conflicto te deja congelado a mitad del rebase. En jj puedes hacer el rebase, ver un conflicto, ponerte con otra cosa y volver más tarde para resolverlo: el conflicto es simplemente un estado en el que se encuentra el commit.

¿Has hecho doble clic en un commit antiguo y te ha salido «Revision is immutable»? En Git puedes hacer checkout de cualquier sitio, así que parece que jj se interpone en tu camino. No es así.

jj protege los commits que ya son públicos, es decir, compartidos con el mundo:

Inmutable (◆) Mutable (●)
Commits en main@origin Tu copia de trabajo @
Commits en cualquier *@origin Commits solo locales
Commits con tag Todo lo que aún no has subido con push

Por qué es bueno: en Git puedes hacer git push --force sobre main o enmendar un commit compartido y destrozar un historial del que dependen otros, y nada te lo impide. jj te dice «esto ya es público; no puedes reescribir aquello en lo que confía el equipo». Eso elimina toda una categoría de momentos de «ay, no».

Qué hacer en su lugar: no edites el commit inmutable; construye uno nuevo encima:

Ventana de terminal
jj new immutable_change_id

Tu nuevo @ contiene todos los archivos de ese commit; editas con total libertad y el original queda intacto.

Commit mutable (●) Commit inmutable (◆)
jj edit change_id jj new change_id
@ se mueve a ese commit Crea un nuevo commit hijo
Las ediciones lo enmiendan en el sitio @ es el nuevo hijo

Referencia rápida: new vs edit vs describe vs commit

Sección titulada «Referencia rápida: new vs edit vs describe vs commit»
Comando Qué hace Equivalente más cercano en Git Cuándo usarlo
jj new Nuevo commit hijo vacío; mueve @ allí git checkout -b «Terminé con esto, a lo siguiente».
jj edit Mueve @ a un commit mutable existente git rebase -i → edit Volver atrás para arreglar un commit anterior.
jj describe Pone o cambia el mensaje de @ git commit --amend -m Ponerle nombre a lo que estás haciendo.
jj commit describe + new de una vez git commit -m Terminar este trabajo y empezar el siguiente.

Has editado 10 archivos. Solo 7 pertenecen a este commit; 3 son basura de pruebas. Algunos archivos incluso mezclan cambios reales con líneas desechables.

Git es opt-in: preparas lo que quieres y descartas el resto. jj es opt-out: todo está ya en @, así que quitas lo que no quieres. Mismo destino, dirección opuesta.

Método 1 — jj restore (descartar archivos enteros):

Ventana de terminal
# Revert these files to the parent's version:
jj restore test-setup.ts dev-nav-hack.svelte .env.test
# @ now has only the 7 real files. Name it:
jj describe -m "fix: navigation and auth"

jj restore es el equivalente de «Descartar cambios»: devuelve un archivo a su estado en el commit padre.

Método 2 — jj split (quedarte con una parte y separar el resto):

Ventana de terminal
jj split # pick what goes into the FIRST commit
# Result:
# ○ @ — leftover (3 test files)
# ● fix: navigation and auth (7 real files)
# ◆ develop
jj bookmark create nav-fix -r @-
jj git push --bookmark nav-fix
jj abandon @ # throw away the test-junk commit

Archivos mezclados (a nivel de hunk): cuando un archivo tiene 10 líneas reales y 5 apaños de prueba:

Ventana de terminal
jj split -i

Esto abre un diff interactivo con cada hunk modificado; eliges qué hunks van al primer commit y cuáles se quedan en el segundo. Es el git add -p de jj.

¿Basura de pruebas repartida en varios commits? No necesitas un split por commit:

Ventana de terminal
# Option A — squash everything, then split once:
jj squash --from "commit1::commit5" --into commit1
jj split -i -r that_commit
jj abandon junk_commit
Ventana de terminal
# Option B — jj absorb:
# 1. In @, simply UNDO the test changes (delete the files,
# remove the test lines). jj snapshots those "undo" edits.
# 2. Route them home automatically:
jj absorb

jj absorb examina cada hunk de deshacer en @, encuentra el commit ancestro que añadió esas líneas e integra la corrección en ese commit: sin rebase interactivo, con un solo comando. Más sobre esto en el Paso 8.

En Git…

Ventana de terminal
git rebase -i HEAD~5
# Editor opens; change "pick" → "squash" on 4 lines,
# write the combined message, save.

En jj…

Ventana de terminal
# One at a time, into the parent:
jj squash -r commit5
jj squash -r commit4
# …or a whole range in one command:
jj squash --from "commit1::commit5" --into commit1

Los descendientes se rebasan automáticamente después de cada squash. Sin editor, sin --continue, sin ceremonias.

En Git…

Ventana de terminal
git checkout feature/new-ui
git fetch origin
git rebase origin/develop
# On conflict: STOP, fix files, git add ., git rebase --continue
# …repeated for EACH conflicting commit.

En jj…

Ventana de terminal
jj git fetch
jj rebase -r your_change -d develop
# On conflict: nothing stops. The commit is just marked CONFLICT.
jj edit your_change # fix the markers when you're ready — that's it.

Qué commits se mueven depende del flag:

Flag Qué mueve Análogo en Git
-r (revision) Solo este commit; los hijos se quedan donde están (sin equivalente directo)
-s (source) Este commit y todos sus descendientes git rebase --onto
-b (branch) Todo lo que hay desde que divergió del destino git rebase develop

Resolver uno es simplemente editar un archivo:

Ventana de terminal
jj edit conflicted_change
# Open the file. jj's markers show the base plus what each
# side intended, not two opaque full versions:
#
# <<<<<<< Conflict 1 of 1
# %%%%%%% diff from base to side #1
# context
# -old line
# +your change
# +++++++ side #2
# their change
# >>>>>>> Conflict 1 of 1 ends
#
# Fix it, save. jj snapshots. Resolved. No git add. No --continue.

Como los marcadores describen la intención (la base + cada diff) en lugar de dos blobs finales, los conflictos suelen ser más fáciles de razonar. Consulta Resolución de conflictos para ver el flujo en Wyrm.

Una funcionalidad completa, de principio a fin:

  1. Empieza a trabajar — jj describe -m "feat: new navigation UI"
  2. Captura hitos sobre la marcha — jj commit -m "wip: nav routing done"
  3. Límpialo en un solo commit — jj squash --from "first::last" --into first
  4. Quita la basura de pruebas — jj restore test-setup.ts (o jj split -i)
  5. Haz rebase sobre el develop más reciente — jj git fetch && jj rebase -r feature -d develop
  6. Etiqueta y haz push — jj bookmark create feature/x && jj git push --bookmark feature/x
  7. Abre la PR y consigue la revisión
  8. Atiende los comentarios — jj edit feature_change → haz los cambios → jj git push --bookmark feature/x

Sin commits de fixup, sin un segundo squash. Edita el commit y haz push: jj hace el force-push de forma segura por su cuenta.

Un bookmark de jj no es una rama de Git. Una rama de Git sigue a HEAD: haces commit y se mueve contigo; siempre estás «en» una rama. Un bookmark de jj es una nota adhesiva pegada a un commit concreto. No se mueve cuando creas commits nuevos y nunca estás «en» él. Siempre estás en @.

Git: escribe la dirección en la caja antes de empaquetarla. jj: empaqueta la caja y pégale la etiqueta de envío cuando vayas a mandarla.

Así que primero trabajas y después etiquetas. Tres situaciones habituales cuando ya existe un bookmark antiguo:

A — el trabajo antiguo está obsoleto (tus nuevos commits lo sustituyen):

Ventana de terminal
jj bookmark set feature/nav-redesign -r commit3
jj git push --bookmark feature/nav-redesign # safe force-push

B — conservar el trabajo antiguo y construir encima:

Ventana de terminal
jj rebase -s commit1 -d feature/nav-redesign # your commits on top
jj bookmark set feature/nav-redesign -r commit3
jj git push --bookmark feature/nav-redesign

C — combinar el trabajo antiguo y el nuevo (archivos distintos, quieres ambos):

Ventana de terminal
jj squash --from old_work_change_id --into commit1
jj bookmark set feature/nav-redesign -r commit3
jj git push --bookmark feature/nav-redesign
Comportamiento Rama de Git Bookmark de jj
¿Se mueve al hacer commit? Sí — sigue a HEAD automáticamente No — se queda quieto; lo mueves a mano
¿Hace falta para trabajar? Sí — siempre estás «en» una No — puedes trabajar sin ninguno
¿Hace falta para hacer push? Sí Sí — un push necesita un nombre
¿Hay uno «actual»? Sí — HEAD apunta a él No existe el concepto; @ es siempre el actual
¿Sigue a un rebase? A veces Sí — sigue el change ID a través de las reescrituras
¿Miedo al detached HEAD? Sí Ninguno — eso es lo normal
Force push Manual, da miedo Automático, seguro (with-lease)

Como los bookmarks siguen el change ID (no el SHA del commit), sobreviven a las reescrituras: después de un rebase basta con hacer push, sin necesidad de un force-push manual para actualizar el remoto.

Git tiene varias formas de push (-u la primera vez, push a secas, --force-with-lease, --delete). jj las canaliza todas por un único comando:

Ventana de terminal
jj git push --bookmark my-thing # first push
jj git push --bookmark my-thing # every push after
jj git push --all # all local bookmarks
jj git push --delete old-bookmark # delete a remote bookmark

No existe el flag --force. jj se comporta siempre como --force-with-lease: si alguien ha hecho push a ese bookmark desde tu último fetch, se niega y te pide que hagas fetch primero. Es literalmente imposible machacar por accidente el trabajo de un compañero.

Ventana de terminal
jj git fetch # downloads remote state; does NOT merge,
# rebase, or touch your working copy.
# Then YOU decide:
jj rebase -d main@origin # rebase your work onto the update — or don't.

No existe jj pull. El fetch y el rebase siempre van por separado, así que nunca te encontrarás con un commit de merge inesperado.

Atiende los comentarios editando el commit en el sitio y volviendo a hacer push, sin commits de fixup:

Ventana de terminal
jj edit feature_change
# ...make the requested changes...
jj git push --bookmark feature/x # auto force-push, safely

Algunas capacidades de jj que no tienen un equivalente limpio en Git.

jj absorb — envía cada corrección a su commit automáticamente

Sección titulada «jj absorb — envía cada corrección a su commit automáticamente»

El revisor dice: «la línea 42 del commit 2 está mal, y la línea 88 del commit 4 tiene una errata». En Git eso supone un git commit --fixup por cada punto más un git rebase -i --autosquash.

Ventana de terminal
# 1. In @, just fix BOTH lines wherever they live now.
# jj snapshots; @ holds both fixes.
# 2. Route them home:
jj absorb

Para cada línea modificada, jj encuentra el ancestro más reciente que la tocó, lleva la corrección allí y rebasa los descendientes. Ambos errores quedan corregidos en sus commits correspondientes y @ vuelve a estar vacío.

Tu jefe dice: «déjalo todo y arregla esto en caliente». En Git eso es git stash / rama / arreglo / git stash pop y cruzar los dedos.

Ventana de terminal
jj new develop -m "hotfix: prod is down"
# Your previous work is still there as a sibling commit.
# Fix, push, then return:
jj edit feature_a_id # right back where you left off

No hay stash porque no hay nada que guardar en él: tu trabajo nunca estuvo sin confirmar.

Entrega cuatro PR pequeñas y fáciles de revisar que se construyen una sobre otra, en lugar de una gigante:

● commit 4 ← bookmark feature/tests
● commit 3 ← bookmark feature/ui
● commit 2 ← bookmark feature/api
● commit 1 ← bookmark feature/auth-refactor
◆ develop

En Git, arreglar un error en la PR base implica hacer rebase y force-push de cada PR que tiene encima. En jj, editas el commit base, dejas que todos los descendientes se rebasen solos y actualizas todas las PR a la vez:

Ventana de terminal
jj edit feature/auth-refactor # fix the base
# ...descendants auto-rebase...
jj git push --all # all four PRs update together

Ciclo de vida del trabajo

Objetivo jj git
Estado jj status git status
Log jj log git log --graph
Diff del trabajo actual jj diff git diff HEAD
Empezar un commit nuevo jj new git checkout -b
Poner el mensaje del commit jj describe -m "…" git commit --amend -m
Hacer commit y seguir jj commit -m "…" git commit -m

Editar commits

Objetivo jj git
Editar un commit mutable antiguo jj edit <rev> git rebase -i (edit)
Hijo de cualquier commit jj new <rev> git checkout -b
Integrar en el padre jj squash fixup + autosquash
Integrar de forma interactiva jj squash -i git add -p + amend
Dividir un commit jj split git rebase -i (split)
Enviar correcciones automáticamente jj absorb (ninguno)
Descartar un commit jj abandon git reset --hard HEAD~1
Restaurar un archivo jj restore <file> git checkout HEAD -- <file>

Operaciones con el remoto

Objetivo jj git
Fetch jj git fetch git fetch
Push jj git push --bookmark <name> git push
Primer push jj git push --bookmark <name> git push -u
Force push (automático) jj git push --bookmark <name> git push --force-with-lease
Push de todo jj git push --all git push --all
Borrar en el remoto jj git push --delete <name> git push --delete

Bookmarks

Objetivo jj git
Crear jj bookmark create <name> git branch <name>
Mover jj bookmark set <name> -r <rev> git branch -f <name>
Borrar jj bookmark delete <name> git branch -d <name>
Listar jj bookmark list git branch -a

Seguridad y selectores

Objetivo jj git
Deshacer cualquier cosa jj undo git reflog + reset
Historial de operaciones jj op log git reflog
«Stash» del trabajo jj new @- y luego jj edit de vuelta git stash
Revset Significado
@ Copia de trabajo
@- Padre de la copia de trabajo
trunk() Rama principal en origin
trunk()..@ Tu trabajo local que aún no está en trunk
mine() Tus propios commits
conflicts() Todos los commits en conflicto
bookmarks() Todos los commits con bookmark
description("fix") Commits con «fix» en el mensaje