Worktrunk, auditado: 153 versiones en 351 días, un hook post-start que falla mientras wt switch devuelve 0 y puertos hash_port que chocan entre repositorios

Worktrunk tiene 10.479 estrellas y 153 versiones en 351 días. Ejecuté el binario: un hook post-start que falla devuelve 0 y merge borra tu directorio.

Worktrunk es el gestor de worktrees de git con más estrellas que existe. Diez mil cuatrocientas setenta y nueve estrellas, 376 forks, un binario en Rust que se instala con un solo comando de Homebrew y un README cuya promesa principal es que los worktrees pasan a ser tan fáciles como las ramas, de modo que puedes tener «5-10+» agentes de programación en paralelo sin que se pisen entre sí.

También publicó su primera versión hace exactamente un año —la 0.1.0, el 3 de noviembre de 2025— y sigue en la 0.81.0. En esos 351 días publicó 153 versiones. Su changelog pesa 537 KB. La versión más reciente contiene siete cambios incompatibles distintos y llegó después del periodo de calma más largo de la historia del proyecto.

Nada de eso lo descalifica. Lo que importa para el público al que apunta el README —agentes que se ejecutan en subshells, no personas frente a un intérprete— es una pregunta más estrecha: cuando la herramienta dice que tuvo éxito, ¿lo tuvo?

Descargué el binario oficial worktrunk-x86_64-unknown-linux-musl de la v0.81.0 (29.099.264 bytes), lo ejecuté sobre ext4 sin integración de shell instalada y probé cada comando principal en una veintena de repositorios de prueba. La herramienta está genuinamente bien construida. Y también está construida sobre una suposición que deja de cumplirse en silencio en el momento en que la invoca un programa en lugar de una persona: la misma clase de hueco que encontré auditando el emparejador adaptativo de Scrapling, donde una puntuación del 47 % también llegaba sin aviso.

Lo que resistió el contacto

Empecemos por lo que aguanta, porque la crítica posterior solo tiene valor si esta parte es cierta.

La protección de datos es real. wt remove sobre un worktree con un archivo sin seguimiento se niega, nombra el archivo y devuelve 1:

$ wt remove dirty
✗ Cannot remove worktree: dirty has uncommitted changes
  ?? untracked.txt
↳ Commit or run git stash -u in the dirty worktree first, or to lose uncommitted changes, run wt remove --force dirty
$ echo $?
1

El worktree y el archivo sobreviven. Una modificación preparada en un archivo rastreado se comporta igual. Incluso --force se niega a ser vago sobre lo que destruye: enumera cada cambio descartado antes de borrar.

◎ Removing forceme worktree (--force) & branch in background (same commit as main, _)
▲ Discarding uncommitted changes (--force):
  ?? notes.txt

Ese es el diseño correcto, y coincide con el propio AGENTS.md del proyecto, que fija la regla para quien contribuye: «Prefer a clear failure over losing untracked files, uncommitted changes, or user data. Make cleanup a separate action, never a destructive side effect of an unrelated command.»

La ingeniería no es un script envoltorio. Un clon superficial da 212 archivos Rust y 170.364 líneas en src/, más 75 archivos y 110.461 líneas en tests/, con 2.763 atributos #[test] y 2.340 #[rstest] (32 ignorados). El archivo fuente más grande no es el selector interactivo ni la lógica de merge: es src/config/deprecation.rs, con 5.650 líneas, la maquinaria para migrar configuraciones que se han movido.

La higiene de incidencias es sólida. Sesenta y cinco incidencias abiertas frente a 10.479 estrellas son 6,2 por cada 1.000 estrellas. Herramientas comparables de git en Rust triplican o sextuplican esa cifra:

ProyectoEstrellasSeguidoresSeguidores/EstrellasIncidencias abiertasPor 1.000 estrellas
Worktrunk10.479230,22 %656,2
delta32.6501110,34 %50215,4
lazygit83.6353370,40 %1.06312,7
jj (Jujutsu)32.348990,31 %1.25838,9
git-town3.390320,94 %5415,9
git-absorb5.756180,31 %315,4
gitui22.620930,41 %35715,8

Esperaba que la ratio de seguidores del 0,22 % fuera la historia: veintitrés personas han pedido que se les avise de un repositorio con diez mil estrellas. No lo es. Todas las CLI comparables aquí se sitúan entre el 0,31 % y el 0,94 %, así que worktrunk está en el extremo bajo de una clase que ya es baja, no fuera de ella. Estrellas y seguidores son monedas distintas para las herramientas de línea de comandos, y una herramienta que se difunde por artículos y gestores de paquetes acumula la primera mucho más rápido que la segunda.

La lectura honesta de las señales del repositorio es esta: un proyecto bien mantenido, con un autor inusualmente receptivo, no un proyecto inflado a base de estrellas.

El hallazgo que importa: un hook post-start fallido sigue devolviendo 0

Aquí hay un hook post-start, el tipo que la documentación llama «the most common creation hook» porque «runs background tasks (dev servers, file copying, builds) without blocking worktree creation»:

# .config/wt.toml
[post-start]
setup = "echo doing setup; exit 7"

Ejecutado como tiene que ejecutarlo un agente —de forma no interactiva, con los hooks aprobados por bandera—:

$ wt switch --create bg7 -y; echo "EXIT=$?"
✓ Created branch bg7 from main and worktree @ .../repo.bg7
↳ To customize worktree locations, run wt config create
▲ Cannot change directory — shell integration not installed
↳ To enable automatic cd, run wt config shell install
◎ Running post-start: setup (project) @ .../repo.bg7
EXIT=0

El hook falló. El código de salida es cero. Nada en stderr lo dice. El worktree existe, parece correcto y todas las comprobaciones que puede hacer un script supervisor —código de salida, existencia del directorio, wt list— informan de éxito.

El fallo sí queda registrado, pero solo en un archivo:

$ cat .git/wt/logs/bg7/project/post-start/runner.log
✗ post-start project:setup failed (exit code 7)

Y el registro central de auditoría tampoco lo recoge:

{"ts":"2026-11-04T00:53:43Z","wt":".../wt switch --create bg7 -y",
 "label":"post-start project runner","cmd":".../wt hook run-pipeline",
 "exit":null,"dur_ms":null}

exit es null, y cmd nombra al runner, no al comando que se ejecutó. El archivo setup.log que está al lado contiene la salida del propio hook —aquí una sola línea, doing setup—, así que entre los dos archivos se puede reconstruir lo ocurrido. Nada los une y ningún código de salida lleva la señal.

Esto no es un descuido: post-start está documentado como no bloqueante, y los documentos te dicen que «prefer post-start over pre-start unless a later step needs the work completed first». Pero mira lo que la gente pone realmente en post-start. El ejemplo de aprobación del propio proyecto es:

○ pre-start install: npm ci
○ pre-start build: cargo build --release
○ pre-start env: echo 'PORT={{ branch | hash_port }}' > .env.local

La instalación de dependencias es exactamente lo que vive ahí. Los tres modos de hook se comportan de forma completamente distinta para quien los llama, y solo uno es visible en un código de salida:

Tipo de hook¿Primer plano?El hook falla con salida 7¿Se crea el worktree?Señal que ve un script
pre-startsí (bloqueante)wt switch devuelve 3sí, sin revertircódigo de salida
post-startno (segundo plano)wt switch devuelve 0síninguna
post-start + --no-hooksse omite por completowt switch devuelve 0, el hook no se ejecutasíninguna

La fila de pre-start merece su propia nota: el hook falla, el código de salida es 3, el mensaje apunta a la solución (↳ To skip pre-start hooks, re-run with --no-hooks) y el worktree se queda igualmente en su sitio. Una puerta que falla no deshace aquello que estaba filtrando.

El segundo: wt merge borra el directorio en el que estás

La mejora ergonómica más publicitada es que nunca vuelves a hacer cd a main:

«No cd back to main — wt merge runs from the feature worktree and merges into the target, like GitHub’s merge button.»

Es exacto, y también es la razón por la que el hecho siguiente sorprende. Ejecutado desde dentro del worktree de la funcionalidad:

$ cd ~/repo.feat
$ wt merge main; echo "EXIT=$?"
◎ Merging 1 commit to main @ 12e4123 (no commit/squash/rebase needed)
  * 12e4123 unique work
   d.txt | 1 +
   1 file changed, 1 insertion(+)
✓ Merged to main (1 commit, 1 file, +1)
◎ Removing feat worktree & branch in background (same commit as main, _)
▲ Worktree for main @ ~/repo, but cannot change directory — shell integration not installed
EXIT=0

$ git status
fatal: Unable to read current working directory: No such file or directory

El merge funcionó. El commit está en main. Y el directorio en el que estaba el shell se borró aproximadamente un segundo después, dejando al shell en un estado en el que git —y casi cualquier otra herramienta— no puede ejecutarse. pwd seguirá imprimiendo la ruta borrada sin pestañear, porque bash la tiene en caché.

wt remove invocado desde dentro del worktree que elimina hace lo mismo. Y wt switch también cuando no puede cambiar de directorio: crea el worktree, imprime un aviso marcado con triángulo y devuelve 0.

Para una persona con la integración de shell instalada esto es correcto: la función envoltorio evalúa el cd por ti y la herramienta imprime «Switched to worktree for main». Para un script bash -c, un paso de CI o la llamada de una herramienta desde un agente no hay envoltorio, no hay cd y no hay señal de fallo. Los tres desenlaces de wt switch que devuelven 0:

SituaciónQué ocurreCódigo de salida
Sin integración de shellse crea el worktree, no hay cd, se imprime un aviso0
Binario invocado por ruta absolutaigual, con el mensaje «shell integration wraps wt»0
Dentro del worktree cuando merge lo eliminase borra el directorio actual, la siguiente llamada a git es fatal0

La herramienta detecta las tres condiciones con precisión y describe cada una correctamente en prosa. Simplemente no considera que ninguna sea un fallo.

El tercero: los hooks con -y se ejecutan y las aprobaciones nunca se escriben

Los hooks de proyecto —todo lo que hay en el .config/wt.toml del repositorio— requieren aprobación en la primera ejecución, porque son código arbitrario de un repo que quizá clonaste hace cinco minutos. Esa puerta existe de verdad:

$ wt switch --create victim2        # no interactivo, hook sin aprobar
▲ repoA needs approval to execute 1 command:
○ post-start exfil:
  touch /tmp/hook_marker && date +%s >> /tmp/hook_marker
✗ Cannot prompt for approval in non-interactive environment
↳ To skip prompts in CI/CD, add --yes; to pre-approve commands, run wt config approvals add --yes
$ echo $?
1

Fíjate en el daño colateral: salida 1 y el worktree no se crea en absoluto. En un bucle automatizado no se ejecuta nada hasta que interviene una persona, que es el valor por defecto correcto para un humano y un muro para una tubería.

Las salidas documentadas son --yes y --no-hooks. No son equivalentes:

Bandera¿Se ejecuta el hook?¿Se escribe approvals.toml?Entrada en commands.jsonl¿Seguro en un repo clonado?
(ninguna, sin TTY)no, abortanonosí (bloquea todo)
--no-hooksnononosí
--yessí, de inmediatonosí, con exit:nullconfía en el repo
wt config approvals add --yessísí, con el texto exactosísí, tras revisarlo

wt config approvals add escribe el texto literal del comando en ~/.config/worktrunk/approvals.toml, indexado por la ruta del proyecto:

[projects."/home/you/repo"]
approved-commands = [
    "echo HOOK_RAN_IN=$(pwd)",
]

Es un buen diseño: cambia un carácter del hook y vuelve a exigir aprobación. Pero -y se salta la puerta entera: no registra lo que ejecutó, solo que se ejecutó una tubería. Si tu bucle de agentes usa -y (y todo bucle no interactivo tiene que elegir una de estas cuatro), entonces cualquier repositorio que clones puede ejecutar código en tu máquina en el momento del wt switch, sin registro de aprobación y con una entrada de auditoría cuyo campo exit es null.

--no-hooks es la bandera que debería usar un equipo cuidadoso, y está documentada en una sola línea dentro de la sección de seguridad de hooks, no en el inicio rápido.

El cuarto: hash_port da un puerto por worktree, no uno único

El README incluye «Dev server per worktree — hash_port template filter gives each worktree a unique port». La tabla de filtros documenta el rango de salida como 10000–19999. Midiéndolo:

$ wt switch feat-a -x echo -- '{{ branch | hash_port }}'
15882
$ wt switch feat-b -x echo -- '{{ branch | hash_port }}'
18703

Estable entre ejecuciones, distinto por rama. Ahora el mismo nombre de rama en dos repositorios diferentes:

repoA/shared-name = 14075
repoB/shared-name = 14075

Idéntico. El hash cubre el nombre de la rama y nada más, así que main, develop, fix/ci o feature/login —los nombres que se repiten en todos los repositorios jamás creados— apuntan a un único número en todos ellos. Dos proyectos con una rama llamada shared-name que lancen un servidor de desarrollo se pelearán por el puerto 14075, y el síntoma es un error de bind en el que arranque segundo, que no parece obviamente un problema de Worktrunk.

La solución sí está en los documentos, en un comentario de código bajo «Hash any string, including concatenations»:

# Unique port per repo+branch combination
[post-start]
dev = "npm run dev --port {{ (repo ~ '-' ~ branch) | hash_port }}"

Medido, eso da 17635 y 19522 para los dos repositorios. Es, por tanto, un hallazgo de colocación de la documentación y no un defecto sin arreglar: la receta correcta existe, media página por debajo de la que copiará la mayoría.

153 versiones en 351 días, y lo que cuesta

La cadencia de publicación es el número más legible del repositorio, y no es una línea recta:

MesVersionesMesVersiones
2025-11102026-059
2025-12252026-069
2026-01352026-079
2026-02132026-084
2026-03112026-095
2026-04222026-101

Medias: 3,05 versiones por semana, intervalo mediano entre versiones de 32,5 horas, el más corto 0,3 horas y el más largo 552 horas (23 días) justo antes de la v0.81.0. El pico de enero —35 versiones en un mes— se ha enfriado hasta aproximadamente una al mes, que es el aspecto que tiene un proyecto que madura más allá de su fase de descubrimiento.

El changelog cuenta la historia más útil. A lo largo de 140 versiones documentadas hay 1.393 viñetas de funcionalidades y 146 apariciones de «Breaking», repartidas en 33 versiones. Solo la v0.81.0 contiene siete cambios incompatibles distintos, entre ellos:

  • Los hooks ya no reciben su contexto como JSON por stdin (hay que pasar argumentos de plantilla o tojson).
  • El renderizado de plantillas pasa a MiniJinja 3, así que los objetos y listas se imprimen al estilo Python ({'port': 3000}) y no como JSON, round redondea al par más cercano y la división por cero da error.
  • Filtros como sanitize ahora rechazan una entrada indefinida, así que {{ upstream | sanitize }} necesita una guarda en una rama sin upstream.
  • wt step prune ejecuta los hooks pre-remove en paralelo, de modo que los hooks que escriben estado compartido tienen que coordinarse.
  • wt switch - y wt remove - se resuelven de otra manera, también en repositorios bare.

El propio AGENTS.md del proyecto fija el límite que pretende mantener: «Config formats and CLI arguments are protected interfaces; internal APIs and output formatting are flexible.» La semántica de plantillas, el contrato de stdin de los hooks y la concurrencia de prune no son ninguna de esas tres cosas: son comportamiento en tiempo de ejecución, justo la categoría que un script de agente codifica de forma rígida. Un archivo de instrucciones escrito contra la 0.79 no se equivoca sobre la CLI; se equivoca sobre lo que la CLI hace.

El coste silencioso de esa política se ve en el árbol de fuentes: src/config/deprecation.rs tiene 5.650 líneas, el archivo más grande de un código de 170.364. Y los nombres de hook obsoletos se aceptan como alias sin decir nada:

$ cat .config/wt.toml
[post-create]
setup = "echo old-name-hook-ran"

$ wt hook show
PROJECT HOOKS @ .../repo/.config/wt.toml
❯ post-start setup: (requires approval)
  echo old-name-hook-ran

$ wt config update
○ No deprecated settings found

La clave se renombró —pre-create/post-create pasaron a pre-start/post-start—, el archivo no se reescribió y wt config update informa de que no hay nada que actualizar. Quien copie un bloque de hooks de un artículo anterior al cambio obtiene un hook que se ejecuta con otro nombre del que aparece en su archivo, para siempre, sin aviso de migración.

En contraste, el mismo comando trata bien las entradas inválidas: una sección desconocida imprime ▲ Project config has unknown field bogus-section (will be ignored) y devuelve 0, mientras que un TOML mal formado devuelve 1 con un unclosed table, expected ']' preciso en línea y columna.

Dos números que cambian decisiones de adopción

El copy-on-write no es gratis en ext4. El README dice que diez worktrees obtienen target/, node_modules/ «without building or copying them (on APFS, btrfs, and XFS)». Las FAQ son más directas: los reflinks funcionan en APFS, btrfs y XFS, «not ext4 or NTFS». Medí el caso de reserva en ext4 con un node_modules de 191 MB:

$ wt step copy-ignored --to cow
✓ Copied 1 file · 190.7 MiB (full copy)

187 ms, y el espacio disponible en df bajó de 29.532 MB a 29.341 MB: un duplicado real de 191 MB. Un cp -a normal del mismo árbol tardó 219 ms. La herramienta sí etiqueta el caso de reserva como «(full copy)», lo cual es honesto; pero diez worktrees en el sistema de archivos por defecto de Linux son 1,9 GB de caché de compilación duplicada, y la expresión «full copy» pasa de largo en una línea de marcas verdes.

wt list es suficientemente rápido para un prompt y suficientemente lento para notarse. En un repositorio de 400 archivos:

Worktreeswt listgit worktree list
143 ms—
234 ms—
565 ms—
1094 ms—
20138 ms6 ms

138 ms con veinte worktrees está bien para un comando que tecleas. No está bien para nada que se invoque por pulsación, y por eso el proyecto incluye un subcomando aparte, wt list statusline, para integraciones de prompt.

El detalle del README que merece más atención

A media página del README, dentro de un comentario HTML encima del gráfico de star-history, hay esto:

«The token is fine-grained and reaches only this repo. Its Contents permission has to be read and write — write access is what GitHub accepts as proof of collaborator status, so rotating to a read-only token brings the placeholder straight back. Publishing the ciphertext is safe on its own; what the scope buys is a bound on star-history, which decrypts it and so holds a credential that can push here until it expires 2027-08-16 — at which point the chart reverts to the placeholder with nothing else to signal it.»

Léelo despacio. Para que se dibuje un gráfico de estrellas, GitHub exige prueba de que eres colaborador, y la prueba que encontró el proyecto es un token de grano fino con permiso de escritura sobre el contenido del repositorio. Ese token está cifrado con la clave pública de star-history y pegado en el README de un repositorio público. Star-history guarda la credencial descifrada hasta el 16 de agosto de 2027. No hay revocación por este lado, y el README avisa de que no habrá ninguna señal cuando caduque.

El razonamiento está declarado, el alcance se limita a un repositorio y un hueco en un gráfico de terceros es un problema trivial en el conjunto de las cosas. Pero el intercambio —una credencial con escritura sobre la rama principal, en manos ajenas durante dos años, publicada como texto cifrado, para arreglar un fallo de renderizado en una imagen— es el tipo de decisión que se toma en cinco minutos y luego sobrevive a todos los que recuerdan haberla tomado. Si vas a adoptar una herramienta cuyo mantenedor acepta ese trato por un gráfico, lee el resto de su postura de seguridad con esa idea en la cabeza. Aquí el resto de la postura aguanta bien, lo que convierte al token en la anomalía y no en el patrón.

Qué haría yo

La recomendación no es «evita Worktrunk». Las partes deterministas —creación de worktrees, resolución de ramas, rechazo de estado sucio, transparencia de --force— son mejores que los scripts de shell que la mayoría de los equipos escriben a mano, y la batería de pruebas es lo bastante grande como para que los cambios de comportamiento sean probablemente intencionados y no accidentales.

La recomendación es dejar de tratarlo como una comodidad de shell y empezar a tratarlo como un componente cuyo contrato de código de salida no cubre los casos interesantes.

1. No des por hecho que wt switch cambió tu directorio. Resuelve la ruta y haz cd tú mismo, o pasa la ruta explícitamente a cada comando posterior. Si necesitas la ruta en una plantilla, el ayudante documentado es expected_worktree_path_of_branch.

2. No llames nunca a wt merge ni a wt remove desde dentro del worktree que van a borrar. Ve primero al worktree principal. Es una línea en un envoltorio y elimina toda una clase de fallos «fatal: Unable to read current working directory». Si prefieres no depender de ningún envoltorio, la vía más fina de git-worktree-runner hace el mismo intercambio de aislamiento con mucho menos comportamiento que reaprender.

3. Fija la versión y lee los cambios incompatibles al actualizar. 153 versiones significan que brew upgrade es un cambio de comportamiento unas tres veces por semana. Fija la versión exacta en CI y busca la palabra «Breaking» en el changelog antes de moverte.

4. Usa --no-hooks con repositorios que no controlas y aprueba comandos exactos con los que sí. -y es la bandera que hace desaparecer la puerta de consentimiento sin dejar registro de lo que permitió.

5. Lleva los fallos de post-start a tu propia señal. Después de wt switch, revisa el registro del runner antes de empezar a trabajar:

wt switch --create "$branch" -y >/dev/null || exit 1
log=".git/wt/logs/$branch/project/post-start/runner.log"
if [ -s "$log" ] && grep -q '✗' "$log"; then
  echo "worktree setup failed:"; cat "$log"; exit 1
fi

6. Mide tu sistema de archivos antes de presupuestar disco. Si df -T dice ext4, copy-ignored es una copia, y diez agentes son diez copias.

Preguntas frecuentes

¿Es seguro Worktrunk con agentes de programación en paralelo? Para crear y cambiar worktrees, sí: esa parte es determinista y rechaza acciones destructivas. El riesgo está en los dos lugares donde supone que hay una persona: los cambios de directorio y los hooks en segundo plano. Ambos funcionan correctamente con integración de shell y con pre-start, y ambos fallan de forma invisible sin ellas.

¿wt switch cambia mi directorio actual? Solo si la integración de shell está instalada y el comando se invoca a través de la función envoltorio. Llamado como binario desde un script, crea el worktree, imprime un aviso y devuelve 0 sin moverte.

¿Por qué falló mi hook post-start sin ningún error? Los hooks post-start se ejecutan en segundo plano para no bloquear la creación del worktree. Su código de salida no se propaga a wt switch y la consola solo muestra que el hook empieza. El texto del fallo queda en .git/wt/logs/<branch>/project/post-start/runner.log.

¿-y es lo mismo que aprobar los hooks? No. -y evita el aviso en esa invocación y no escribe en approvals.toml. Usa wt config approvals add --yes para registrar una aprobación persistente, o --no-hooks para omitir los hooks de proyecto por completo.

¿Por qué dos de mis proyectos reciben el mismo puerto de servidor de desarrollo? hash_port solo aplica el hash al nombre de la rama. Usa {{ (repo ~ '-' ~ branch) | hash_port }} para incluir el repositorio, como muestran los documentos bajo «Hash any string, including concatenations».

¿Funciona la caché copy-on-write de Worktrunk en Linux? En btrfs y XFS sí. En ext4 y NTFS no hay soporte de reflink, así que wt step copy-ignored hace una copia completa: 190,7 MiB duplicados medidos para un node_modules de 191 MB, a 187 ms por worktree.

¿Está sano el proyecto? En las señales que predicen mantenimiento, sí: 82 contribuyentes, una batería de pruebas grande y una densidad de incidencias abiertas unas cuatro veces menor que la de herramientas comparables de git en Rust. El coste real es la rotación, no el abandono: 146 menciones de cambios incompatibles en 140 versiones documentadas, en un proyecto que aún se etiqueta como 0.x.

La versión en una línea

Worktrunk resuelve un problema real lo bastante bien como para haberse ganado sus estrellas, y está diseñado para una persona frente a un intérprete con una función envoltorio cargada. Apunta un agente hacia él sin ese envoltorio y la herramienta seguirá teniendo éxito en un paso que no realizó, que es el único modo de fallo que un bucle supervisor no puede detectar, porque al bucle le han dicho que todo fue bien.