Compare commits

..
142 Commits
Author SHA1 Message Date
bitbucket-pipelines e87571af8b chore: bump version and update CHANGELOG.md [skip ci] 2026-02-11 10:47:35 +00:00
JonathanGitFlowandjt e504887f2f Merged in feature/update-project-name-in-lock (pull request #64)
feature: update project name in lock

Approved-by: Jonathan Teran
2026-02-11 10:47:04 +00:00
Jonathan Teran Carballo 9cd0b2514b feature: actualizar nombre de proyecto en uv.lock 2026-02-11 07:46:38 -03:00
bitbucket-pipelines 7d246fd4a2 chore: bump version and update CHANGELOG.md [skip ci] 2026-02-11 10:41:14 +00:00
JonathanGitFlowandjt e9ef77d9fc Merged in bugfix/test-pipeline-change (pull request #63)
bugfix: test pipeline change

Approved-by: Jonathan Teran
2026-02-11 10:40:39 +00:00
Jonathan Teran Carballo f2eb5af5f2 bugfix: test pipeline change 2026-02-11 07:38:24 -03:00
bitbucket-pipelines 7c938f207f chore: bump version and update CHANGELOG.md [skip ci] 2026-02-11 00:06:32 +00:00
JonathanGitFlowandjt d571267966 Merged in feature/update-project-name (pull request #62)
feature: actualizo nombre y descripcion del proyecto

Approved-by: Jonathan Teran
2026-02-11 00:06:09 +00:00
jt 41d6957901 feature: actualizo nombre y descripcion del proyecto 2026-02-10 21:05:41 -03:00
bitbucket-pipelines 81a0578830 chore: bump version and update CHANGELOG.md [skip ci] 2026-02-11 00:02:13 +00:00
JonathanGitFlowandjt d035c02cf7 Merged in bugfix/tag-all-commits-locally (pull request #61)
bugfix: taggeo todos los commits localmente, incluso en ci

Approved-by: Jonathan Teran
2026-02-11 00:01:47 +00:00
jt 5d23bb6e8c bugfix: taggeo todos los commits localmente, incluso en ci 2026-02-10 21:00:18 -03:00
bitbucket-pipelines 04c1b47551 chore: bump version and update CHANGELOG.md [skip ci] 2026-02-10 23:57:03 +00:00
JonathanGitFlowandjt 5b91c44c41 Merged in bugfix/test-pipeline-change (pull request #60)
bugfix: test pipeline change

Approved-by: Jonathan Teran
2026-02-10 23:56:38 +00:00
jt 98bf7e7f9c bugfix: test pipeline change 2026-02-10 20:54:06 -03:00
JonathanGitFlowandjt c7d25203c7 Merged in chore/test-git-status-after-tag (pull request #59)
test: muestro git status y log despues de taggear el commit en pipeline

Approved-by: Jonathan Teran
2026-02-10 23:49:02 +00:00
jt 7ac8376247 test: muestro git status y log despues de taggear el commit en pipeline 2026-02-10 20:48:24 -03:00
bitbucket-pipelines 8b8b9337ac chore: bump version and update CHANGELOG.md [skip ci] 2026-02-10 23:39:27 +00:00
JonathanGitFlowandjt da8b27ce88 Merged in bugfix/add-build-step-to-pipeline (pull request #58)
bugfix: agrego paso de build al pipeline

Approved-by: Jonathan Teran
2026-02-10 23:38:32 +00:00
jt ade86f1350 bugfix: agrego paso de build al pipeline 2026-02-10 20:38:02 -03:00
bitbucket-pipelines 1eee08f60d chore: bump version and update CHANGELOG.md [skip ci] 2026-02-10 23:36:17 +00:00
JonathanGitFlowandjt f20f8c811c Merged in feature/use-uv-package-manager (pull request #57)
feature: cambio pipelines para usar uv package manager

Approved-by: Jonathan Teran
2026-02-10 23:35:35 +00:00
jt 0f700b026a feature: cambio pipelines para usar uv package manager 2026-02-10 20:34:04 -03:00
JonathanGitFlowandjt 9564339218 Merged in docs/create-access-token (pull request #56)
docs: arreglo pasos para crear Access Token

Approved-by: Jonathan Teran
2026-01-14 12:28:46 +00:00
Jonathan Teran Carballo dfd96e62bb docs: arreglo pasos para crear Access Token 2026-01-14 09:27:03 -03:00
bitbucket-pipelines 65c901e5b8 chore: bump version and update CHANGELOG.md [skip ci] 2025-12-22 02:33:28 +00:00
JonathanGitFlowandjt 5759f67aa2 Merged in feature/2025-12-21 (pull request #55)
feature: 2025 12 21

Approved-by: Jonathan Teran
2025-12-22 02:32:42 +00:00
jt 726c73af39 feature: al agrupar release, mergear cambios en rama group 2025-12-21 23:30:46 -03:00
jt 057b32a2f9 feature: usar commit message cuando el pr solo tiene un commit 2025-12-21 23:10:21 -03:00
jt c0e2e596aa feature: eliminar prompt de ticket en creacion de rama 2025-12-21 22:32:54 -03:00
jt 77e338dcc8 feature: deshacer rebase cuando este falla en el pasaje de cambios 2025-12-21 22:31:16 -03:00
jt 1aa2263a38 bugfix: uso titulo personalizado de PR 2025-12-21 22:27:14 -03:00
bitbucket-pipelines 8c4037f702 chore: bump version and update CHANGELOG.md [skip ci] 2025-12-15 02:03:36 +00:00
JonathanGitFlowandjt f3980d6c78 Merged in feature/merge-command-improvements (pull request #54)
feature: merge command improvements

Approved-by: Jonathan Teran
2025-12-15 02:03:11 +00:00
Jonathan Teran Carballo df8998a092 feature: agrego opcion para cambiar a rama objetivo y obtener cambios 2025-12-14 23:01:49 -03:00
Jonathan Teran Carballo c87b14121c feature: agrego opcion para personalizar nombre de PR 2025-12-14 22:53:26 -03:00
bitbucket-pipelines 7fb0ba0c39 chore: bump version and update CHANGELOG.md [skip ci] 2025-12-15 01:40:00 +00:00
JonathanGitFlowandjt 5b6beb7c08 Merged in bugfix/list-branchs-on-environment (pull request #53)
bugfix: list branchs on environment

Approved-by: Jonathan Teran
2025-12-15 01:39:35 +00:00
Jonathan Teran Carballo c54cb95ed7 bugfix: uso rama actual cuando esta es un entorno 2025-12-14 22:38:25 -03:00
bitbucket-pipelines a2731e3400 chore: bump version and update CHANGELOG.md [skip ci] 2025-12-15 01:11:57 +00:00
JonathanGitFlowandjt b20787d69d Merged in feature/branch-command-improvements (pull request #52)
feature: branch command improvements

Approved-by: Jonathan Teran
2025-12-15 01:11:32 +00:00
Jonathan Teran Carballo 5d33b29abd bugfix: uso env actual en vez de rama actual para listar ramas 2025-12-14 22:10:00 -03:00
Jonathan Teran Carballo 104b3214d4 feature: listar ramas de todos los entornos con flag --all, y del entorno actual por defecto 2025-12-14 22:00:14 -03:00
Jonathan Teran Carballo 774cf9ade6 feature: agrego opcion para listar ramas wip 2025-12-14 21:56:18 -03:00
Jonathan Teran Carballo 74c333aa1a refactor: cambio opcion para listar ramas trash 2025-12-14 21:48:44 -03:00
Jonathan Teran Carballo 499babcaec bugfix: elimino : extra de comando new 2025-12-14 21:40:54 -03:00
bitbucket-pipelines 379e0f3257 chore: bump version and update CHANGELOG.md [skip ci] 2025-12-15 00:32:39 +00:00
JonathanGitFlowandjt 4a57dfd121 Merged in feature/new-command-improvements (pull request #51)
feature: new command improvements

Approved-by: Jonathan Teran
2025-12-15 00:32:13 +00:00
Jonathan Teran Carballo 01e938ebd9 feature: agrego opcion para incluir ticket en nombre de rama 2025-12-14 21:29:48 -03:00
Jonathan Teran Carballo 97b4a56ff7 feature: mostrar warning si la rama actual no es un entorno configurado 2025-12-14 13:46:09 -03:00
Jonathan Teran Carballo 24be50111d feature: hacer pull en rama actual antes de crear nueva rama 2025-12-14 13:43:23 -03:00
bitbucket-pipelines 897e9aa53b chore: bump version and update CHANGELOG.md [skip ci] 2025-12-14 16:39:20 +00:00
JonathanGitFlowandjt e4800d1054 Merged in bugfix/arreglos-pendientes (pull request #50)
bugfix: arreglos pendientes

Approved-by: Jonathan Teran
2025-12-14 16:38:55 +00:00
Jonathan Teran Carballo 0d95a978bf bugfix: verificar si la rama grupo trackea una rama remota antes de hacer pull 2025-12-14 13:32:23 -03:00
Jonathan Teran Carballo 0af5102fae bugfix: capturo interrupion por teclado para no mostrar stack trace 2025-12-14 13:25:31 -03:00
bitbucket-pipelines 2a21922a63 chore: bump version and update CHANGELOG.md [skip ci] 2025-12-14 15:22:19 +00:00
JonathanGitFlowandjt 12b2bc9a39 Merged in bugfix/merge-pr-title-generation (pull request #49)
bugfix: merge pr title generation

Approved-by: Jonathan Teran
2025-12-14 15:21:54 +00:00
Jonathan Teran Carballo b24b22a178 bugfix: generación de PR de ramas release 2025-12-14 12:20:32 -03:00
bitbucket-pipelines c5f1be346c chore: bump version and update CHANGELOG.md [skip ci] 2025-12-14 15:05:04 +00:00
JonathanGitFlowandjt 6c32b3e751 Merged in bugfix/release-not-listing-branches (pull request #48)
bugfix: release not listing branches

Approved-by: Jonathan Teran
2025-12-14 15:04:39 +00:00
Jonathan Teran Carballo 5600e5ec83 bugfix: listado de ramas para elegir grupo release no devolvia resultados 2025-12-14 11:59:42 -03:00
bitbucket-pipelines 57142afa40 chore: bump version and update CHANGELOG.md [skip ci] 2025-12-11 20:08:25 +00:00
JonathanGitFlowandjt 4375af35d9 Merged in bugfix/release-switch-to-group (pull request #47)
bugfix: release switch to group

Approved-by: Jonathan Teran
2025-12-11 20:07:56 +00:00
Jonathan Teran Carballo 44b8efd953 bugfix: al hacer un release, cambio a la rama grupo si esta disponible 2025-12-11 17:07:13 -03:00
bitbucket-pipelines f4b47000a6 chore: bump version and update CHANGELOG.md [skip ci] 2025-12-11 18:18:23 +00:00
JonathanGitFlowandjt b12d2a1930 Merged in bugfix/first-fork-point-getter (pull request #46)
bugfix: first fork point getter

Approved-by: Jonathan Teran
2025-12-11 18:17:53 +00:00
Jonathan Teran Carballo 7b4fb34ef3 bugfix: cambio la forma en la que se obtiene el first fork point entre 2 ramas 2025-12-11 15:15:45 -03:00
bitbucket-pipelines 6a758cdb94 chore: bump version and update CHANGELOG.md [skip ci] 2025-12-11 12:55:31 +00:00
JonathanGitFlowandjt b148a9f71d Merged in feature/show-commit-list-on-release (pull request #45)
feature: show commit list on release

Approved-by: Jonathan Teran
2025-12-11 12:55:01 +00:00
Jonathan Teran 2e65cb7a3e feature: muestro el listado de commits a pasar al siguiente entorno en release 2025-12-11 09:53:41 -03:00
bitbucket-pipelines 9c8df34a09 chore: bump version and update CHANGELOG.md [skip ci] 2025-12-10 18:29:40 +00:00
JonathanGitFlowandjt 89f8bc58ba Merged in bugfix/commit-list-for-merge (pull request #44)
bugfix: commit list for merge

Approved-by: Jonathan Teran
2025-12-10 18:29:07 +00:00
Jonathan Teran 4e089dc4d6 bugfix: cambio la lista de commits a mostrar en el merge 2025-12-10 15:06:21 -03:00
Jonathan Teran c8e1a7cc88 chore: bump version and update CHANGELOG.md [skip ci] 2025-12-04 10:19:54 -03:00
JonathanGitFlowandjt 65be7a01f0 Merged in bugfix/tag-name-scheme (pull request #43)
bugfix: tag name scheme

Approved-by: Jonathan Teran
2025-12-04 13:17:52 +00:00
Jonathan Teran 7ce6addf07 bugfix: revertir guion entre version y entorno, agregar guion a sufijo 2025-12-04 10:17:26 -03:00
bitbucket-pipelines 824b5f06de chore: bump version and update CHANGELOG.md [skip ci] 2025-12-04 13:16:27 +00:00
JonathanGitFlowandjt 9937f4b6bc Merged in bugfix/tag-name-scheme (pull request #42)
bugfix: tag name scheme

Approved-by: Jonathan Teran
2025-12-04 13:15:56 +00:00
Jonathan Teran 053b8b102b bugfix: agrego guion entre version y nombre de entorno 2025-12-04 10:15:32 -03:00
bitbucket-pipelines a2475195b2 chore: bump version and update CHANGELOG.md [skip ci] 2025-12-04 13:13:42 +00:00
JonathanGitFlowandjt 603519bf39 Merged in bugfix/create-changelog-when-missing (pull request #41)
bugfix: create changelog when missing

Approved-by: Jonathan Teran
2025-12-04 13:13:14 +00:00
Jonathan Teran 0c8e7bb37a bugfix: crear changelog cuando no existe 2025-12-04 10:11:10 -03:00
bitbucket-pipelines 6643271747 chore: bump version and update CHANGELOG.md [skip ci] 2025-11-29 16:41:43 +00:00
JonathanGitFlowandjt 68e11c66d1 Merged in bugfix/group-release-confirmation-and-commits-squash-count (pull request #40)
bugfix: group release confirmation and commits squash count

Approved-by: Jonathan Teran
2025-11-29 16:41:19 +00:00
jt f1f6e6724c bugfix: arreglo mensaje de confirmacion de squash, muevo prompt de commit message antes del squash 2025-11-29 13:40:34 -03:00
jt 83df7e9553 bugfix: cambio default en confirmacion para agrupar release 2025-11-29 13:35:25 -03:00
bitbucket-pipelines f5e683a1da chore: bump version and update CHANGELOG.md [skip ci] 2025-11-29 16:22:03 +00:00
JonathanGitFlowandjt fb9b8b57f6 Merged in feature/release-branch-groups (pull request #39)
feature: release branch groups

Approved-by: Jonathan Teran
2025-11-29 16:21:39 +00:00
jt c47afffa14 feature: agrego opcion para agrupar ramas release 2025-11-29 13:20:33 -03:00
bitbucket-pipelines 341e6239b2 chore: bump version and update CHANGELOG.md [skip ci] 2025-11-28 18:48:40 +00:00
JonathanGitFlowandjt 4fb00e786f Merged in bugfix/command-init-merge-errors (pull request #38)
bugfix: command init merge errors

Approved-by: Jonathan Teran
2025-11-28 18:48:15 +00:00
Jonathan Teran Carballo 2ad747111f bugfix: arreglo split de ramas de flow.branches 2025-11-28 15:44:05 -03:00
Jonathan Teran Carballo 5c3a3171fd bugfix: arreglo iteracion de items de flowconfig en set_config 2025-11-28 15:43:46 -03:00
Jonathan Teran Carballo 50979e2c3d docs: agrego venv a lista de carpetas ignoradas 2025-11-28 15:42:54 -03:00
bitbucket-pipelines a670b90899 chore: bump version and update CHANGELOG.md [skip ci] 2025-11-27 02:35:54 +00:00
JonathanGitFlowandjt 532a93e95e Merged in refactor/move-constants (pull request #37)
refactor: move constants

Approved-by: Jonathan Teran
2025-11-27 02:35:28 +00:00
jt 29757ccfb5 refactor: muevo constantes a modulo git_flow para centralizar uso 2025-11-26 23:35:00 -03:00
jt 7f857fdd68 bugfix: ignorar carpetas generadas en build de lsp 2025-11-26 23:34:39 -03:00
bitbucket-pipelines 432885e107 chore: bump version and update CHANGELOG.md [skip ci] 2025-11-27 01:53:24 +00:00
JonathanGitFlowandjt a7ea4a6149 Merged in feature/branch-list-format (pull request #36)
feature: branch list format

Approved-by: Jonathan Teran
2025-11-27 01:53:01 +00:00
jt 717708469a feature: modifico formato de listado de ramas 2025-11-26 22:51:58 -03:00
bitbucket-pipelines 542c629489 chore: bump version and update CHANGELOG.md [skip ci] 2025-11-23 21:51:20 +00:00
JonathanGitFlowandjt 3c271be415 Merged in feature/add-branch-command (pull request #35)
feature: add branch command

Approved-by: Jonathan Teran
2025-11-23 21:50:57 +00:00
jt c74f1a3093 feature: agrego comando branch para listar ramas 2025-11-23 18:50:00 -03:00
jt eafb3f1b31 feature: agrego soporte para parametros repetidos en llamadas a Git 2025-11-23 18:49:42 -03:00
JonathanGitFlowandjt 82b9a70400 Merged in docs/agregar-seccion-instalacion (pull request #34)
docs: agregar seccion instalacion

Approved-by: Jonathan Teran
2025-11-23 07:05:38 +00:00
jt 6f90a37ba2 style: arreglo sublistas en seccion configuracion 2025-11-23 04:04:48 -03:00
jt 5a641938c0 docs: agrego seccion con pasos de instalacion 2025-11-23 04:02:35 -03:00
bitbucket-pipelines d378c27546 chore: bump version and update CHANGELOG.md [skip ci] 2025-11-23 06:54:16 +00:00
JonathanGitFlowandjt d9015cc5ab Merged in feature/add-release-command (pull request #33)
feature: add release command

Approved-by: Jonathan Teran
2025-11-23 06:53:25 +00:00
jt 43d7e5a057 feature: agrego comando release, elimino codigo no utilizado 2025-11-23 03:52:47 -03:00
JonathanGitFlowandjt 27147232fe Merged in docs/agregar-documentacion-comandos (pull request #32)
docs: agregar documentacion comandos

Approved-by: Jonathan Teran
2025-11-22 21:30:25 +00:00
jt aad1a66641 docs: actualizo readme 2025-11-22 18:29:42 -03:00
jt d89856e5f0 docs: agregar descripcion a texto de ayuda de cada comando 2025-11-22 12:19:23 -03:00
jt f716eead72 docs: agregar tipo rama docs 2025-11-22 12:19:04 -03:00
bitbucket-pipelines 9c5ec26529 chore: bump version and update CHANGELOG.md [skip ci] 2025-11-22 15:09:27 +00:00
JonathanGitFlowandjt e6386ed252 Merged in feature/add-git-exec-options (pull request #31)
feature: add git exec options

Approved-by: Jonathan Teran
2025-11-22 15:08:46 +00:00
jt 34aba71d32 bugfix: deshabilito check en merge --abort al buscar conflictos 2025-11-22 12:07:46 -03:00
jt 4135a789e3 feature: agregando descripcion a ejecucion de comandos importantes en new, tag y merge 2025-11-22 12:02:39 -03:00
jt faea8c2d2a feature: agregando descripcion a ejecucion de comandos importantes en init y commit 2025-11-22 11:49:12 -03:00
jt 11e2a9daa9 bugfix: cambiando tipo de print para agregar soporte de titulos 2025-11-22 11:48:19 -03:00
jt d133c480c5 bugfix: descartar output cuando el comando git se ejecuta con _run 2025-11-22 11:28:51 -03:00
jt 7346cfd1a8 feature: muevo opciones para check y print a metodos para ejecutar git 2025-11-22 11:23:24 -03:00
JonathanGitFlowandjt 37a7c101af Merged in refactor/read-flowconfig-before-run (pull request #30)
refactor: read flowconfig before run

Approved-by: Jonathan Teran
2025-11-21 23:48:58 +00:00
jt edf4948719 refactor: realizo parseo de flowconfig antes de ejecutar run, no en __init__ 2025-11-21 20:48:20 -03:00
bitbucket-pipelines 4dbef58e90 chore: bump version and update CHANGELOG.md [skip ci] 2025-11-21 23:44:00 +00:00
JonathanGitFlowandjt da385154d4 Merged in bugfix/tag-on-update-changelog (pull request #29)
bugfix: tag on update changelog

Approved-by: Jonathan Teran
2025-11-21 23:42:01 +00:00
jt 222c819b36 bugfix: creo tag en el commit que actualiza el changelog 2025-11-21 20:41:37 -03:00
jt 0e75a8ec1f style: ignorar carpeta dist/ 2025-11-21 20:33:55 -03:00
bitbucket-pipelines 58401bf6e1 chore: bump version and update CHANGELOG.md [skip ci] 2025-11-21 17:43:26 +00:00
JonathanGitFlowandjt acf1c0fdc8 Merged in refactor/separate-command-files (pull request #28)
refactor: separate command files

Approved-by: Jonathan Teran
2025-11-21 17:42:59 +00:00
jt bf829a114b bugfix: separar etapa de lectura y escritura de changelog 2025-11-21 14:41:49 -03:00
JonathanGitFlowandjt b8e43f065c Merged in refactor/separate-command-files (pull request #27)
refactor: separate command files

Approved-by: Jonathan Teran
2025-11-20 00:06:34 +00:00
jt 8d973f0b2b bugfix: separar etapa de lectura y escritura de changelog 2025-11-19 21:06:09 -03:00
JonathanGitFlowandjt bac1f04d9a Merged in refactor/separate-command-files (pull request #26)
refactor: separate command files

Approved-by: Jonathan Teran
2025-11-20 00:03:06 +00:00
jt 8f79a6a288 bugfix: agrego http:// como protocolo soportado para pipelines 2025-11-19 21:02:33 -03:00
JonathanGitFlowandjt ccda277674 Merged in refactor/separate-command-files (pull request #25)
refactor: separate command files

Approved-by: Jonathan Teran
2025-11-19 23:57:52 +00:00
jt 4a0356c229 bugfix: uso base para generar contenido del PR y muestro mensajes de exito 2025-11-19 20:57:14 -03:00
jt be87c2098b feature: cambio merge y tag para que el changelog se genere al crear un tag nuevo 2025-11-19 20:43:22 -03:00
bitbucket-pipelines 30c6c36beb chore: bump version and update CHANGELOG.md [skip ci] 2025-11-08 00:28:27 +00:00
JonathanGitFlowandjt 0495f96f8d Merged in bugfix/pipeline-changelog-generation (pull request #24)
bugfix: pipeline changelog generation

Approved-by: Jonathan Teran
2025-11-08 00:28:02 +00:00
jt 14beea150d bugfix: cambio el commit que se usa para generar las entradas del changelog 2025-11-07 21:26:59 -03:00
25 changed files with 1873 additions and 711 deletions
+7 -1
View File
@@ -1,5 +1,11 @@
# Bitbucket token
.repository-token
**/__pycache__/
# Virtual environment
venv/
# Build folders and artifacts
dist/
build/
**/__pycache__/
**/*.egg-info/
+1
View File
@@ -0,0 +1 @@
3.10
+280
View File
@@ -1,3 +1,283 @@
## v1.14.0 - 2026-02-11
- **feature**: actualizar nombre de proyecto en uv.lock [[jteran](mailto:jteran@renatre.org.ar)] (9cd0b25)
---
## v1.13.1 - 2026-02-11
- **bugfix**: test pipeline change [[jteran](mailto:jteran@renatre.org.ar)] (f2eb5af)
---
## v1.13.0 - 2026-02-11
- **feature**: actualizo nombre y descripcion del proyecto [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (41d6957)
---
## v1.12.3 - 2026-02-11
- **bugfix**: taggeo todos los commits localmente, incluso en ci [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (5d23bb6)
---
## v1.12.2 - 2026-02-10
- **bugfix**: test pipeline change [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (98bf7e7)
---
## v1.12.1 - 2026-02-10
- **bugfix**: agrego paso de build al pipeline [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (ade86f1)
---
## v1.12.0 - 2026-02-10
- **feature**: cambio pipelines para usar uv package manager [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (0f700b0)
---
## v1.11.0 - 2025-12-22
- **feature**: al agrupar release, mergear cambios en rama group [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (726c73a)
- **feature**: usar commit message cuando el pr solo tiene un commit [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (057b32a)
- **feature**: eliminar prompt de ticket en creacion de rama [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (c0e2e59)
- **feature**: deshacer rebase cuando este falla en el pasaje de cambios [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (77e338d)
- **bugfix**: uso titulo personalizado de PR [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (1aa2263)
---
## v1.10.0 - 2025-12-15
- **feature**: agrego opcion para cambiar a rama objetivo y obtener cambios [[jteran](mailto:jteran@renatre.org.ar)] (df8998a)
- **feature**: agrego opcion para personalizar nombre de PR [[jteran](mailto:jteran@renatre.org.ar)] (c87b141)
---
## v1.9.1 - 2025-12-15
- **bugfix**: uso rama actual cuando esta es un entorno [[jteran](mailto:jteran@renatre.org.ar)] (c54cb95)
---
## v1.9.0 - 2025-12-15
- **bugfix**: uso env actual en vez de rama actual para listar ramas [[jteran](mailto:jteran@renatre.org.ar)] (5d33b29)
- **feature**: listar ramas de todos los entornos con flag --all, y del entorno actual por defecto [[jteran](mailto:jteran@renatre.org.ar)] (104b321)
- **feature**: agrego opcion para listar ramas wip [[jteran](mailto:jteran@renatre.org.ar)] (774cf9a)
- **refactor**: cambio opcion para listar ramas trash [[jteran](mailto:jteran@renatre.org.ar)] (74c333a)
- **bugfix**: elimino : extra de comando new [[jteran](mailto:jteran@renatre.org.ar)] (499babc)
---
## v1.8.0 - 2025-12-15
- **feature**: agrego opcion para incluir ticket en nombre de rama [[jteran](mailto:jteran@renatre.org.ar)] (01e938e)
- **feature**: mostrar warning si la rama actual no es un entorno configurado [[jteran](mailto:jteran@renatre.org.ar)] (97b4a56)
- **feature**: hacer pull en rama actual antes de crear nueva rama [[jteran](mailto:jteran@renatre.org.ar)] (24be501)
---
## v1.7.5 - 2025-12-14
- **bugfix**: verificar si la rama grupo trackea una rama remota antes de hacer pull [[jteran](mailto:jteran@renatre.org.ar)] (0d95a97)
- **bugfix**: capturo interrupion por teclado para no mostrar stack trace [[jteran](mailto:jteran@renatre.org.ar)] (0af5102)
---
## v1.7.4 - 2025-12-14
- **bugfix**: generación de PR de ramas release [[jteran](mailto:jteran@renatre.org.ar)] (b24b22a)
---
## v1.7.3 - 2025-12-14
- **bugfix**: listado de ramas para elegir grupo release no devolvia resultados [[jteran](mailto:jteran@renatre.org.ar)] (5600e5e)
---
## v1.7.2 - 2025-12-11
- **bugfix**: al hacer un release, cambio a la rama grupo si esta disponible [[jteran](mailto:jteran@renatre.org.ar)] (44b8efd)
---
## v1.7.1 - 2025-12-11
- **bugfix**: cambio la forma en la que se obtiene el first fork point entre 2 ramas [[jteran](mailto:jteran@renatre.org.ar)] (7b4fb34)
---
## v1.7.0 - 2025-12-11
- **feature**: muestro el listado de commits a pasar al siguiente entorno en release [[jteran](mailto:jteran@renatre.org.ar)] (2e65cb7)
---
## v1.6.4 - 2025-12-10
- **bugfix**: cambio la lista de commits a mostrar en el merge [[jteran](mailto:jteran@renatre.org.ar)] (4e089dc)
---
## v1.6.3 - 2025-12-04
- **bugfix**: revertir guion entre version y entorno, agregar guion a sufijo [[jteran](mailto:jteran@renatre.org.ar)] (7ce6add)
- **bugfix**: agrego guion entre version y nombre de entorno [[jteran](mailto:jteran@renatre.org.ar)] (053b8b1)
---
## v1.6.3- - 2025-12-04
- **bugfix**: agrego guion entre version y nombre de entorno [[jteran](mailto:jteran@renatre.org.ar)] (053b8b1)
---
## v1.6.2 - 2025-12-04
- **bugfix**: crear changelog cuando no existe [[jteran](mailto:jteran@renatre.org.ar)] (0c8e7bb)
---
## v1.6.1 - 2025-11-29
### Commits
- **bugfix**: arreglo mensaje de confirmacion de squash, muevo prompt de commit message antes del squash [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (f1f6e67)
- **bugfix**: cambio default en confirmacion para agrupar release [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (83df7e9)
---
## v1.6.0 - 2025-11-29
### Commits
- **feature**: agrego opcion para agrupar ramas release [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (c47afff)
---
## v1.5.2 - 2025-11-28
### Commits
- **bugfix**: arreglo split de ramas de flow.branches [[jteran](mailto:jteran@renatre.org.ar)] (2ad7471)
- **bugfix**: arreglo iteracion de items de flowconfig en set_config [[jteran](mailto:jteran@renatre.org.ar)] (5c3a317)
- **docs**: agrego venv a lista de carpetas ignoradas [[jteran](mailto:jteran@renatre.org.ar)] (50979e2)
---
## v1.5.1 - 2025-11-27
### Commits
- **refactor**: muevo constantes a modulo git_flow para centralizar uso [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (29757cc)
- **bugfix**: ignorar carpetas generadas en build de lsp [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (7f857fd)
---
## v1.5.0 - 2025-11-27
### Commits
- **feature**: modifico formato de listado de ramas [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (7177084)
---
## v1.4.0 - 2025-11-23
### Commits
- **feature**: agrego comando branch para listar ramas [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (c74f1a3)
- **feature**: agrego soporte para parametros repetidos en llamadas a Git [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (eafb3f1)
---
## v1.3.0 - 2025-11-23
### Commits
- **feature**: agrego comando release, elimino codigo no utilizado [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (43d7e5a)
---
## v1.2.0 - 2025-11-22
### Commits
- **bugfix**: deshabilito check en merge --abort al buscar conflictos [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (34aba71)
- **feature**: agregando descripcion a ejecucion de comandos importantes en new, tag y merge [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (4135a78)
- **feature**: agregando descripcion a ejecucion de comandos importantes en init y commit [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (faea8c2)
- **bugfix**: cambiando tipo de print para agregar soporte de titulos [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (11e2a9d)
- **bugfix**: descartar output cuando el comando git se ejecuta con _run [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (d133c48)
- **feature**: muevo opciones para check y print a metodos para ejecutar git [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (7346cfd)
---
## v1.1.5 - 2025-11-21
### Commits
- **bugfix**: creo tag en el commit que actualiza el changelog [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (222c819)
- **style**: ignorar carpeta dist/ [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (0e75a8e)
---
## v1.1.4 - 2025-11-21
### Commits
- **bugfix**: separar etapa de lectura y escritura de changelog [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (bf829a1)
- **bugfix**: agrego http:// como protocolo soportado para pipelines [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (8f79a6a)
---
## Saturday, 8 de November de 2025, 00:28
- Autor: [bitbucket-pipelines](mailto:commits-noreply@bitbucket.org)
- Rama: `14beea150de16e06f7b4e54361584dae5d5ae713`
### Commits
- **bugfix**: cambio el commit que se usa para generar las entradas del changelog [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (14beea1)
---
## Viernes, 7 de Noviembre de 2025, 11:42
- Autor: [Jonathan Teran](mailto:jteran@renatre.org.ar)
+90 -1
View File
@@ -1,3 +1,92 @@
# Git Flow
Herramienta para establecer un workflow y versionar un repositorio
Herramienta para establecer un workflow siguiendo [Conventional Commits
(CC)](https://www.conventionalcommits.org), [Conventional Branch
(CB)](https://conventional-branch.github.io/), y [SemVer](https://semver.org/).
Esta diseñada para facilitar la creación de commits y branches en un formato estandar, que permite
derivar versiones automaticamente de los merges sobre ramas principales.
## Instalación
Para instalar Git Flow, se sugiere utilizar [pipx](https://github.com/pypa/pipx) (seguir la
documentación para instalarlo). Una vez instalado `pipx`, simplemente clonamos el repositorio en una
carpeta local, y lo instalamos:
```sh
cd ~/Downloads
git clone git@bitbucket.org:sushi2717/git-flow.git
cd git-flow
pipx install .
```
Luego podemos verificar que se haya instalado correctamente ejecutando: `git-flow -h`
Para actualizarlo, hacemos un pull de los cambios recientes del repositorio, y hacemos el upgrade
mediante `pipx`:
```sh
cd ~/Downloads/git-flow
git pull
pipx upgrade git-flow
```
## Configuración
Para poder usar la herramienta en un repositorio, necesitamos inicializarlo:
```sh
git-flow init
```
El comando solicitará 2 opciones:
- **Ramas principales**: Ramas que representan los entornos principales del proyecto. Por ejemplo:
`dev,test,prod`.
- **Remoto** (opcional): Nombre del remoto que se usará para crear PRs, y taggear merges
automáticamente. Por el momento, se soportan los siguientes remotos:
- [x] Bitbucket
- [ ] Github: planeado
La configuración del remoto requiere tener un Access Token para poder crear los PRs automáticamente.
El mismo se puede crear desde el repositorio al que se quiere dar acceso: *"Repository settings" >
"Access Tokens" > "Create access token"*. El token necesita permiso para *Write* en *Pull Requests*.
Luego se debe guardar el token dentro del repositorio a configurar, en el archivo
`.repository-token`. De no especificarse, los merges y tags se harán de forma local.
## Utilización
Git Flow cuenta con 5 comandos principales para guiar el workflow (ver `git-flow <command> -h` para
más información):
- `new`: Crea una nueva rama siguiendo CB.
- `commit`: Crea un commit siguiendo CC.
- `merge`: Mergea la rama actual a una de las ramas principales.
- `tag`: Crea un tag sobre el ultimo merge siguiendo SemVer.
- `release`: Prepara la rama actual para pasarse al siguiente entorno configurado
El workflow para el cual se penso la herramienta es el siguiente:
1. Sobre la primer rama principal configurada (por ejemplo, `dev`), se crea una nueva rama con
`git-flow new`
2. Se realizan cambios y se commitean los mismos con `git-flow commit` (se repite hasta que se
considere que la rama esté lista para mergear)
3. Se ejecuta `git-flow merge` para mergear la rama al entorno que corresponda (en este caso,
`dev`). En caso de tener un remoto configurado, el comando crea un PR para integrar el cambio. Si
no, simplemente se ejecuta un `git merge` simple.
4. Para versionar este último merge, se ejecuta `git-flow tag`, ya sea localmente o desde un
pipeline para que se ejecute en cada merge.
5. Una vez que la rama fue correctamente integrada a un entorno (por ejemplo, `dev`), se usa
`git-flow release` para crear una nueva rama para integrar unicamente los cambios de esa rama
sobre el siguiente entorno (por ejemplo, `test`). Si la rama original era
`feature/my-new-feature`, se crea la rama `release/test/feature/my-new-feature` sobre `test` que
tiene los cambios de la rama original.
## Taggeo automático por pipeline
Éste repositorio utiliza Git Flow para el taggeo automático usando pipelines, se puede ver el
archivo `bitbucket-pipelines.yml` como ejemplo. El comando `git-flow tag` soporta el pasaje del
token mediante la opción `--token=<TOKEN>`. Ver la sección [Configuración](#configuracion) para las
instrucciones de como generar este token. El mismo necesita scope `write:repository:bitbucket`.
+8 -6
View File
@@ -1,14 +1,16 @@
image: python:3.12
image: python:3.10
pipelines:
branches:
'main':
- step:
name: "Tag main"
name: "Tag and publish package"
caches:
- pip
script:
- cd $BITBUCKET_CLONE_DIR
- pip install --upgrade pip
- pip install .
- git-flow tag $BEARER
- pip install uv
- uv run git-flow tag --token=$BEARER
- git status
- git log --oneline -5
- uv build
- uv publish --token=$PYPI_TOKEN
+3 -3
View File
@@ -3,10 +3,10 @@ requires = ["setuptools >= 80", "setuptools-scm[simple] >= 8" ]
build-backend = "setuptools.build_meta"
[project]
name = "git-flow"
description = "Git workflow automation to follow best practices"
name = "git-flow-envs"
description = "Git workflow automation to follow best practices when working with multiple environments"
dynamic = ["version"]
requires-python = ">= 3.12"
requires-python = ">= 3.10"
dependencies = [
"requests"
]
+6
View File
@@ -0,0 +1,6 @@
{
"exclude": [
"build",
"**/*.egg-info"
]
}
+63
View File
@@ -0,0 +1,63 @@
GitFlowError = ValueError
BRANCH_TYPES = [
"feature",
"refactor",
"bugfix",
"hotfix",
"chore",
"docs",
]
COMMIT_TYPES = [
"feature",
"bugfix",
"hotfix",
"refactor",
"perf",
"docs",
"style",
"test",
"wip",
]
WIP_BRANCH_PREFIX = "wip/"
TRASH_BRANCH_PREFIX = "trash/"
# Commits con estos valores incrementan el componente correspondiente
SEMVER_MAJOR = 3
SEMVER_MINOR = 2
SEMVER_PATCH = 1
# Commits con estos tipos no alteran la versión
SEMVER_SKIP = 0
COMMIT_TYPE_INCREMENT = {
"feature": SEMVER_MINOR,
"bugfix": SEMVER_PATCH,
"hotfix": SEMVER_PATCH,
"refactor": SEMVER_SKIP,
"perf": SEMVER_SKIP,
"docs": SEMVER_SKIP,
"style": SEMVER_SKIP,
"test": SEMVER_SKIP,
}
SUPPORTED_REMOTE_APIS = [
"bitbucket.org",
"github.com",
]
REPOSITORY_TOKEN_FILENAME = ".repository-token"
COLOR_BLACK = "\033[30m"
COLOR_RED = "\033[31m"
COLOR_GREEN = "\033[32m"
COLOR_YELLOW = "\033[33m"
COLOR_BLUE = "\033[34m"
COLOR_5 = "\033[35m"
COLOR_6 = "\033[36m"
COLOR_7 = "\033[37m"
COLOR_BLACK_BOLD = "\033[1;30m"
COLOR_WHITE_BOLD = "\033[1;37m"
COLOR_RESET = "\033[0m"
+59
View File
@@ -0,0 +1,59 @@
from datetime import datetime
from os.path import isfile
from git_flow.git import Git
class Changelog:
FILENAME = "CHANGELOG.md"
COMMIT_MESSAGE = "chore: bump version and update CHANGELOG.md [skip ci]"
def update(self, title: str, base: str, branch: str):
self._prepend(self.generate_entry(title, base, branch))
def generate_header(self, title):
now = datetime.now()
return f"""## {title} - {now.strftime("%F")}
"""
def generate_content(self, base: str, branch: str):
commits = Git(
"log", base + ".." + branch, first_parent=True, format="%h", merges=False
).lines()
content = ""
for commit in commits:
message = Git("show", commit, patch=False, format="%s").firstline()
email = Git("show", commit, patch=False, format="%ae").firstline()
username = email[: email.index("@")]
index = message.index(":")
commit_type = message[:index]
commit_message = message[index + 1 :]
content += f"- **{commit_type}**: {commit_message} [[{username}](mailto:{email})] ({commit})\n"
return content
def generate_footer(self):
return """
---
"""
def generate_entry(self, title: str, base: str, branch: str):
return (
self.generate_header(title)
+ self.generate_content(base, branch)
+ self.generate_footer()
)
def _prepend(self, content: str):
if isfile(self.FILENAME):
with open(self.FILENAME, "r") as f:
content += f.read()
with open(self.FILENAME, "w") as f:
f.write(content)
View File
+236
View File
@@ -0,0 +1,236 @@
from argparse import Namespace, ArgumentParser
from abc import ABC, abstractmethod
from os.path import isfile
from git_flow import (
BRANCH_TYPES,
COLOR_BLUE,
COLOR_GREEN,
COLOR_RED,
COLOR_RESET,
COLOR_YELLOW,
REPOSITORY_TOKEN_FILENAME,
GitFlowError,
)
from git_flow.git import FLOWCONFIG_FILE, Git
from git_flow.remote.base import RemoteAPI
from git_flow.remote.bitbucket import BitbucketRemoteAPI
from git_flow.remote.github import GithubRemoteAPI
TYPE_SUCCESS = 0
TYPE_WARNING = 1
TYPE_ERROR = 2
TYPE_INFO = 3
class Command(ABC):
flowconfig: dict[str, str]
@abstractmethod
def name(self) -> str:
pass
@abstractmethod
def description(self) -> str:
pass
@abstractmethod
def run(self, args: Namespace = Namespace()):
pass
def init(self):
self.flowconfig = (
Git.get_config(FLOWCONFIG_FILE) if isfile(FLOWCONFIG_FILE) else {}
)
def success(self, msg: str):
self._print(TYPE_SUCCESS, msg)
def warning(self, msg: str):
self._print(TYPE_WARNING, msg)
def error(self, msg: str):
self._print(TYPE_ERROR, msg)
def info(self, msg: str):
self._print(TYPE_INFO, msg)
def prompt(
self,
prompt: str,
default: str | None = None,
persistent: bool = False,
strip: bool = True,
) -> str:
onetime = not persistent
while onetime or persistent:
if default:
prompt += f" [{default}]"
prompt += ": "
result = input(prompt)
result = result.strip() if strip else result
if result:
return result
elif default is not None:
return default
elif persistent:
self.error("Debe ingresar un valor no vacío.")
else:
return result
def confirm(self, question: str, default: bool = True):
suffix = " [Y/n]: " if default else " [y/N]: "
answer = input(question + suffix)
return default if len(answer) == 0 else answer.startswith("y")
def choice(self, prompt: str, options: list[str]):
print(prompt)
for i, option in enumerate(options):
print(f"\t{i+1}. {option}")
selection = None
while selection is None:
answer = input(f"Seleccione una opción [1-{len(options)}] o escribala: ")
if answer.isdigit():
answer = int(answer)
if 1 <= answer and answer <= len(options):
selection = options[answer - 1]
if not self.confirm(
f"Seleccionó la opción {answer} ({selection}), ¿es correcto?"
):
selection = None
else:
self.error(f"La opción {answer} está fuera del rango permitido.")
elif answer in options:
selection = answer
else:
self.error(f"La opción '{answer}' es inválida.")
return selection
def setup_parser(self, parser: ArgumentParser) -> ArgumentParser:
return parser
def ensure_initialized(self):
initialized = self.flowconfig["flow.initialized"] if self.flowconfig else None
if not initialized:
raise GitFlowError(
"El repositorio no fue inicializado, debe ejecutar el comando `init`."
)
elif initialized != "true":
raise GitFlowError(
f"El valor de `flow.initialized` ({initialized}) es inválido."
)
def ensure_right_branch(self):
branch = Git.get_current_branch()
if branch == "HEAD":
raise GitFlowError("No se encuentra parado sobre una rama.")
elif self.confirm(f"Se encuentra sobre la rama '{branch}'. ¿Es correcto?"):
return branch
else:
raise GitFlowError("Ejecución cancelada.")
def ensure_repository_token(self):
if not isfile(REPOSITORY_TOKEN_FILENAME):
raise GitFlowError(
"No existe un token para el repositorio en " + REPOSITORY_TOKEN_FILENAME
)
with open(REPOSITORY_TOKEN_FILENAME) as f:
return f.readline().strip()
def ensure_clean_worktree(self, has_remote: bool):
status = Git.status()
if not status:
return
not_empty = any(map(lambda s: s.index != " " or s.worktree != " ", status))
if not_empty:
if not has_remote:
self.warning("Existen cambios en tu entorno de trabajo sin commitear.")
else:
raise GitFlowError("No se puede continuar con cambios pendientes.")
if not self.confirm("¿Desea continuar?", False):
raise GitFlowError("Ejecución abortada")
def get_branch_env_and_type(self, branch: str) -> tuple[str, str]:
components = branch.split("/")
target_branches = self.flowconfig["flow.branches"].split(",")
if len(components) == 2:
if components[0] not in BRANCH_TYPES:
raise GitFlowError(
f"Branch inválida, el tipo '{components[0]}' no es válido."
)
return (target_branches[0], components[0])
elif len(components) == 4:
target_branches = target_branches[1:]
if (
components[0] != "release"
or components[1] not in target_branches
or components[2] not in BRANCH_TYPES
):
raise GitFlowError(
"Branch release inválido, debe tener el siguiente formato: "
"release/<env>/<type>/<name>, pero es: " + branch
)
return (components[1], components[2])
else:
raise GitFlowError("Branch inválido: " + branch)
def get_remote_api(self, token: str) -> RemoteAPI:
if "flow.remote" not in self.flowconfig:
raise GitFlowError("El repositorio no tiene configurado un remoto.")
[host, repository] = RemoteAPI.parse(self.flowconfig["flow.remote"])
if host == "bitbucket.org":
return BitbucketRemoteAPI(repository, token)
elif host == "github.com":
return GithubRemoteAPI(repository, token)
else:
raise GitFlowError("El host del repositorio remoto es inválido")
def _print(self, type: int, msg: str):
print(self._get_color(type) + self._get_tag(type) + " " + msg + COLOR_RESET)
def _get_color(self, type: int) -> str:
if type == TYPE_SUCCESS:
return COLOR_GREEN
elif type == TYPE_WARNING:
return COLOR_YELLOW
elif type == TYPE_ERROR:
return COLOR_RED
elif type == TYPE_INFO:
return COLOR_BLUE
return ""
def _get_tag(self, type: int) -> str:
if type == TYPE_SUCCESS:
return "[success]"
elif type == TYPE_WARNING:
return "[warning]"
elif type == TYPE_ERROR:
return "[error]"
elif type == TYPE_INFO:
return "[info]"
return ""
+117
View File
@@ -0,0 +1,117 @@
from argparse import ArgumentParser, Namespace
from git_flow import COLOR_WHITE_BOLD, COLOR_GREEN, COLOR_RED, COLOR_RESET, TRASH_BRANCH_PREFIX, WIP_BRANCH_PREFIX, GitFlowError
from git_flow.command.base import Command
from git_flow.git import Git
BRANCH_FORMAT = "%(refname:short)"
class BranchCommand(Command):
def name(self) -> str:
return "branch"
def description(self) -> str:
return """Lista ramas del repositorio, agrupandolas por entorno objetivo"""
def setup_parser(self, parser: ArgumentParser) -> ArgumentParser:
parser.add_argument(
"environment",
nargs="?",
help="Entorno de las ramas a listar. Por defecto, es el entorno actual.",
)
parser.add_argument(
"--trash",
action="store_true",
help=f"Listar ramas en la 'papelera de reciclaje' (ramas que empiezan con '{TRASH_BRANCH_PREFIX}')"
)
parser.add_argument(
"--wip",
action="store_true",
help=f"Listar ramas 'en progreso' (ramas que empiezan con '{WIP_BRANCH_PREFIX}')"
)
parser.add_argument(
"--all",
action="store_true",
help=f"Listar ramas de todos los entornos"
)
return parser
def run(self, args: Namespace = Namespace()):
self.ensure_initialized()
self.current_branch = Git.get_current_branch()
self.environments = self.flowconfig["flow.branches"].split(",")
if self.current_branch in self.environments:
current_environment = self.current_branch
else:
(current_environment, _) = self.get_branch_env_and_type(self.current_branch)
if args.trash:
self.show_branches(TRASH_BRANCH_PREFIX)
elif args.wip:
self.show_branches(WIP_BRANCH_PREFIX)
elif args.all:
self.show_envs()
self.show_all_branches()
elif args.environment is None:
self.show_envs()
self.show_env_branches(current_environment)
elif args.environment in self.environments:
self.show_envs()
self.show_env_branches(args.environment)
else:
raise GitFlowError(f"'{args.environment}' no es un entorno válido.")
def show_branches(self, prefix: str):
branches = self.get_branches(prefix)
if not branches:
print("No hay ramas a mostrar.")
return
for branch in branches:
prefix = (COLOR_RED + "*") if branch == self.current_branch else " "
print(prefix + " " + branch + COLOR_RESET)
def get_branches(self, prefix: str):
return Git("for-each-ref", "refs/heads/" + prefix, format=BRANCH_FORMAT).lines()
def show_all_branches(self):
for environment in self.environments:
self.show_env_branches(environment, len(self.environments) > 1)
trash = len(self.get_branches(TRASH_BRANCH_PREFIX))
if trash:
print()
print(COLOR_WHITE_BOLD + "Papelera: " + COLOR_RESET + str(trash) + " rama" + ("" if trash == 1 else "s"))
def show_envs(self):
print(COLOR_WHITE_BOLD + "Entornos" + COLOR_RESET)
for environment in self.environments:
prefix = (COLOR_GREEN + "*") if environment == self.current_branch else " "
print(prefix + " " + environment + COLOR_RESET)
print()
def show_env_branches(self, environment: str, show_env: bool = False):
is_first_env = environment == self.environments[0]
pattern = "refs/heads/" if is_first_env else "refs/heads/release/" + environment + "/"
pattern += "*/*"
exclude = ["refs/heads/" + TRASH_BRANCH_PREFIX, "refs/heads/release/"] if is_first_env else []
branches = Git("for-each-ref", pattern, exclude=exclude, format=BRANCH_FORMAT).lines()
print(COLOR_WHITE_BOLD + (environment if show_env else "Ramas") + COLOR_RESET)
if not branches:
print("No hay ramas a mostrar.")
return
for branch in branches:
prefix = (COLOR_GREEN + "*") if branch == self.current_branch else " "
print(prefix + " " + branch + COLOR_RESET)
+62
View File
@@ -0,0 +1,62 @@
from argparse import Namespace
from datetime import datetime
from git_flow import COMMIT_TYPES, TRASH_BRANCH_PREFIX, WIP_BRANCH_PREFIX, GitFlowError
from git_flow.command.base import Command
from git_flow.git import Git
class CommitCommand(Command):
def name(self) -> str:
return "commit"
def description(self) -> str:
return """Crea un commit siguiendo el formato de Conventional Commits"""
def run(self, args: Namespace = Namespace()):
self.ensure_initialized()
branch = self.ensure_right_branch()
if not self._has_files_staged():
self.warning("No hay cambios en el indice para commitear.")
if self.confirm("¿Desea agregar la carpeta actual?"):
Git("add", ".").exec(print="Agregando cambios")
else:
raise GitFlowError(
"Debe agregar algún cambio al indice para continuar."
)
commit_type = self.choice("Tipo de commit", COMMIT_TYPES)
commit_message = self.prompt(
"Mensaje (máximo recomendado: 100 caracteres)", persistent=True
)
message = commit_type + ": " + commit_message
if branch.startswith(WIP_BRANCH_PREFIX):
original_branch = branch.removeprefix(WIP_BRANCH_PREFIX)
Git("commit", m=message).exec(print="Creando commit en rama WIP")
if commit_type != "wip":
suffix = datetime.now().strftime("%Y%m%dT%H%M")
Git("switch", original_branch).exec(print="Volviendo a rama original")
Git("merge", branch, squash=True).exec(print="Squasheando commits WIP en uno solo")
Git("commit", m=message).exec(print="Creando commit final de rama WIP")
Git("branch", branch, TRASH_BRANCH_PREFIX + branch + "/" + suffix, move=True).exec(print="Backup de rama WIP")
else:
if commit_type == "wip":
Git("switch", WIP_BRANCH_PREFIX + branch, create=True).exec(print="Creando nueva rama WIP")
Git("commit", m=message).exec(print="Creando commit")
def _has_files_staged(self):
status = Git.status()
if not status:
raise GitFlowError("No hay cambios para commitear.")
files_in_index = []
for s in status:
if s.worktree != "?" and s.worktree != " ":
files_in_index.append(s.file)
return len(files_in_index) > 0
+115
View File
@@ -0,0 +1,115 @@
from argparse import Namespace
from git_flow import GitFlowError
from git_flow.command.base import Command
from git_flow.git import FLOWCONFIG_FILE, Git
class InitCommand(Command):
def name(self) -> str:
return "init"
def description(self) -> str:
return """Inicializa el repositorio para utilizar git-flow"""
def run(self, args: Namespace = Namespace()):
self._ensure_is_repository()
self._ensure_not_already_initialized()
branches = self._setup_flow_branches()
remote = self._setup_flow_remote()
flowconfig = {
"flow.initialized": "true",
"flow.branches": ",".join(branches),
}
if remote:
flowconfig["flow.remote"] = remote
Git.set_config(flowconfig, FLOWCONFIG_FILE)
Git("add", FLOWCONFIG_FILE).exec()
Git("commit", message="feature: initialize git-flow").exec()
self._ensure_all_flow_branches_exist(branches, remote)
def _ensure_is_repository(self):
if not Git.is_repository():
self.warning("El directorio actual no es un repositorio.")
if not self.confirm("¿Desea inicializarlo?"):
raise GitFlowError(
"No se puede continuar sin inicializar el repositorio"
)
Git("init").exec()
def _ensure_not_already_initialized(self):
if not self.flowconfig:
return
elif self.flowconfig.get("flow.initialized") == "true":
raise GitFlowError("El repositorio ya fue inicializado para usar git-flow")
else:
raise GitFlowError("Valor de 'flow.initialized' es inválido")
def _setup_flow_branches(self):
self.info(
"""Ingrese las ramas que representan los entornos de deploy del proyecto en
orden creciente de cercanía al entorno productivo, y separados por coma.
Por ejemplo: "dev,test,prod"."""
)
branches = list(
map(lambda b: b.strip(), self.prompt("Ramas", "main").split(","))
)
if not all(branches):
raise GitFlowError("No puede ingresar una rama vacia")
return branches
def _setup_flow_remote(self):
self.info(
"""Puede elegir o crear un repositorio remoto para generar automáticamente PRs"""
)
remote = None
if self.confirm("¿Configurar repositorio remoto?"):
remotes = Git("remote").lines(print="Listando remotos disponibles")
if not remotes:
self.info("No tiene ningún repositorio remoto, se creará uno.")
url = self.prompt(
"Ingrese la URL del repositorio (e.g. https://github.com/user/repo.git)",
persistent=True,
)
remote = "origin"
Git("remote", "add", remote, url).exec(print="Agregando remoto")
elif len(remotes) == 1:
remote = remotes[0]
else:
self.info("Tiene más de un remoto, seleccione el que va a utilizar.")
remote = self.choice("Remoto", remotes)
return remote
def _ensure_all_flow_branches_exist(self, branches: list[str], remote: str | None):
existing_branches = Git.get_branches()
if not all(map(lambda b: b in existing_branches, branches)):
self.warning("Algunas de las ramas de entornos expecificadas no existen.")
self.info("Debe indicar sobre que rama se crearán las ramas de entorno.")
target_branch = self.choice("Rama", existing_branches)
for branch in branches:
if branch not in existing_branches:
Git("branch", branch, target_branch).exec(
print="Creando rama inexistente"
)
if remote:
Git("push", remote, branch, set_upstream=True).exec(
print="Creando rama en remoto"
)
+111
View File
@@ -0,0 +1,111 @@
from argparse import Namespace
from git_flow import GitFlowError
from git_flow.changelog import Changelog
from git_flow.command.base import Command
from git_flow.git import Git
class MergeCommand(Command):
def name(self) -> str:
return "merge"
def description(self) -> str:
return """Realiza el merge de la rama, o crea el PR para hacerlo si tiene un remoto configurado"""
def run(self, args: Namespace = Namespace()):
self.ensure_initialized()
branch = self.ensure_right_branch()
(target, _) = self.get_branch_env_and_type(branch)
if "flow.remote" in self.flowconfig:
remote = self.flowconfig["flow.remote"]
token = self.ensure_repository_token()
self.run_remote(remote, token, branch, target)
else:
self.run_local(branch, target)
def run_remote(self, remote: str, token: str, branch: str, target: str):
self.ensure_clean_worktree(True)
Git("switch", target).exec(print="Cambiando a rama destino")
Git("pull").exec(print="Obteniendo ultimos cambios del remoto")
Git("switch", "-").exec(print="Volviendo a rama a mergear")
self.check_merge_conflicts(target)
self.show_commits_to_merge(target)
if self.confirm(f"¿Crear PR de '{branch}' a '{target}'?"):
Git("push", remote, branch, set_upstream=True).exec(
print="Subiendo rama al remoto para crear PR"
)
self.create_pull_request(token, branch, target)
def run_local(self, branch: str, target: str):
self.ensure_clean_worktree(False)
self.check_merge_conflicts(target)
self.show_commits_to_merge(target)
if self.confirm(f"¿Mergear rama '{branch}' a '{target}'?"):
Git("switch", target).exec(print="Cambiando a rama destino")
Git("merge", branch, ff=False).exec(print="Mergeando")
def show_commits_to_merge(self, target):
commits = Git("log", target + "..", format="%s").lines()
if not commits:
raise GitFlowError("No hay cambios a mergear.")
print("Cambios a mergear:")
for commit in commits:
print("- " + commit)
def check_merge_conflicts(self, target: str):
merge_conflicts = Git("merge", target, ff=False, commit=False).code(
print="Realizando merge de prueba para verificar conflictos"
)
Git("merge", abort=True).exec(print="Abortando merge de prueba", check=False)
if merge_conflicts:
self.error(f"La rama actual tiene conflictos con {target}")
self.info(
f"""Se recomienda mergear {target} a la rama actual, resolver los conflictos localmente, y ejecutar nuevamente este comando"""
)
raise GitFlowError("Ejecución abortada.")
else:
self.success("No se detectaron merge conflicts.")
def create_pull_request(self, token: str, branch: str, target: str):
changelog = Changelog()
commits = Git("log", target + "..", format="%s").lines()
if len(commits) == 1:
title = commits[0]
else:
title = branch.replace("/", ": ").replace("-", " ")
if title.startswith("release: "):
title = title[title.index(' ', title.index(' ') + 1) + 1:]
self.info("Título del PR por defecto: " + title)
opt_title = self.prompt("[Opcional] Ingrese otro titulo para el PR")
if opt_title:
title = opt_title
message = self.get_remote_api(token).create_pull_request(
branch,
target,
title,
changelog.generate_content(target, branch),
)
self.success(message)
if self.confirm("¿Desea cambiar a la rama objetivo y bajar los cambios?"):
Git("switch", target).exec(print="Cambiando a rama objetivo")
Git("pull").exec(print="Obteniendo cambios")
+56
View File
@@ -0,0 +1,56 @@
from argparse import Namespace
from git_flow import BRANCH_TYPES
from git_flow.command.base import Command
from git_flow.git import Git
class NewCommand(Command):
def name(self) -> str:
return "new"
def description(self) -> str:
return """Crea una nueva rama siguiendo conventional branches"""
def run(self, args: Namespace = Namespace()):
self.ensure_initialized()
branch = self.ensure_right_branch()
envs = self.flowconfig["flow.branches"].split(",")
if branch not in envs:
self.warning("La rama actual no corresponde a un entorno configurado.")
self.info("Se creará una nueva rama de trabajo sobre la rama actual.")
self.info("Debe seleccionar el tipo de cambio a realizar.")
branch_type = self.choice("Tipos de cambio", BRANCH_TYPES)
new_branch = self._get_unique_branch_name(branch_type)
if "flow.remote" in self.flowconfig and Git.get_tracking_branch(branch):
Git("pull").exec(print="Actualizando rama actual")
if self.confirm(f"¿Crear rama '{new_branch}' sobre la rama actual?"):
Git("switch", new_branch, create=True).exec(print="Creando nueva rama")
def _get_unique_branch_name(self, branch_type: str) -> str:
self.info(
f"Ingrese las palabras clave que describan el cambio de tipo '{branch_type}'."
)
branch = None
branches = Git.get_branches()
while branch is None:
keywords = self.prompt(
"Palabras clave del cambio (por ejemplo: 'create worker form')",
persistent=True,
)
keywords = filter(lambda k: len(k) > 0, keywords.split(" "))
branch = branch_type + "/" + "-".join(keywords)
if branch in branches:
self.error(
f"La rama '{branch}' ya existe, cambie las palabras claves para generar otra"
)
branch = None
return branch
+105
View File
@@ -0,0 +1,105 @@
from argparse import ArgumentParser, Namespace
from git_flow import COMMIT_TYPES, GitFlowError
from git_flow.command.base import Command
from git_flow.git import Git
class ReleaseCommand(Command):
def name(self) -> str:
return "release"
def description(self) -> str:
return """Realiza el merge de la rama, o crea el PR para hacerlo si tiene un remoto configurado"""
def setup_parser(self, parser: ArgumentParser) -> ArgumentParser:
parser.add_argument(
"--group",
nargs="?",
help="Agrupar release",
)
return parser
def run(self, args: Namespace = Namespace()):
self.ensure_initialized()
branch = self.ensure_right_branch()
envs = self.flowconfig["flow.branches"].split(",")
(env, _) = self.get_branch_env_and_type(branch)
if env == envs[-1]:
raise GitFlowError(
"No se puede hacer release de una rama en el ultimo entorno."
)
has_remote = "flow.remote" in self.flowconfig
self.ensure_clean_worktree(has_remote)
next_env = envs[envs.index(env) + 1]
next_branch = (
branch.replace(f"/{env}/", f"/{next_env}/")
if branch.startswith("release/")
else f"release/{next_env}/{branch}"
)
base = Git.get_first_fork_point(branch, env)
commits = Git("log", base + ".." , format="%s").lines()
if not commits:
raise GitFlowError("No hay cambios a mergear.")
print("Cambios a pasar al proximo entorno:")
for commit in commits:
print("- " + commit)
group = args.group
grouping = group is not None
if not grouping:
if self.confirm("¿Desea agrupar este release con otra rama?", False):
grouping = True
group = self.choice("Grupo release: ", Git.get_branches("release/" + next_env + "/"))
else:
group = next_env
if has_remote and Git.get_tracking_branch(group):
Git("switch", group).exec(print="Cambiando a la rama objetivo")
Git("pull").exec(print="Sincronizando cambios la rama objetivo")
Git("switch", "-").exec(print="Volviendo a la rama original")
if len(commits) > 1 and self.confirm(
f"¿Desea reemplazar los {len(commits)} commits de la rama por uno solo?",
False
):
self.info("Debe ingresar el mensaje del commit a crear.")
commit_type = self.choice("Tipo de commit", COMMIT_TYPES[:-1])
commit_message = self.prompt(
"Mensaje (máximo recomendado: 100 caracteres)", persistent=True
)
message = commit_type + ": " + commit_message
Git("switch", next_branch, base, create=True).exec(
print="Creando rama release en base"
)
Git("merge", branch, squash=True).exec(
print="Squasheando commits en uno solo"
)
Git("commit", m=message).exec(print="Creando commit único")
else:
Git("switch", next_branch, create=True).exec(print="Creando rama release")
status = Git("rebase", base, next_branch, onto=group).code(
print="Moviendo cambios hacia el siguiente ambiente"
)
if status:
Git("rebase", abort=True).exec(
print="Deshaciendo cambios por conflictos"
)
raise GitFlowError("Error al realizar pasaje de cambios a rama objetivo")
elif grouping:
Git("switch", group).exec(print="Cambiando a rama objetivo")
Git("merge", next_branch, ff_only=True).exec(print="Mergeando cambios de la nueva rama")
+107
View File
@@ -0,0 +1,107 @@
from argparse import ArgumentParser, Namespace
from git_flow import (
COMMIT_TYPE_INCREMENT,
SEMVER_MAJOR,
SEMVER_MINOR,
SEMVER_PATCH,
SEMVER_SKIP,
GitFlowError,
)
from git_flow.changelog import Changelog
from git_flow.command.base import Command
from git_flow.git import Git
class TagCommand(Command):
def name(self) -> str:
return "tag"
def description(self) -> str:
return """Crea un nuevo tag para el último merge."""
def run(self, args: Namespace = Namespace()):
self.ensure_initialized()
envs = self.flowconfig["flow.branches"].split(",")
target = Git.get_current_branch()
if target not in envs:
raise GitFlowError("Solo se pueden taggear commits en ramas principales.")
branch = Git("show", self.get_last_merge_commit() + "^2", patch=False, format="%h").firstline()
base = Git.get_first_fork_point(branch, target)
commits = Git("log", base + ".." + branch, format="%s").lines()
next_tag = self.get_next_tag_from_commits(commits, target)
if not next_tag:
self.info(
"Los cambios realizados no implican un salto de versión, se mantiene la anterior."
)
return
(token, ci) = self.get_token_and_ci(args)
changelog = Changelog()
changelog.update(next_tag, base, branch)
Git("add", Changelog.FILENAME).exec(print="Agregando nuevo CHANGELOG.md")
Git("commit", message=Changelog.COMMIT_MESSAGE).exec(print="Creando commit")
tag_commit = Git("show", "HEAD", patch=False, format="%h").firstline()
if ci:
Git("push").exec(print="Subiendo commit al remoto para taggearlo")
self.success(self.get_remote_api(token).create_tag(next_tag, tag_commit))
Git("tag", next_tag, tag_commit).exec(print="Creando tag localmente")
def get_token_and_ci(self, args: Namespace):
try:
return (self.ensure_repository_token(), False)
except GitFlowError:
return (args.token, True)
def setup_parser(self, parser: ArgumentParser) -> ArgumentParser:
parser.add_argument("--token")
return parser
def get_last_merge_commit(self):
commit = Git(
"log", first_parent=True, merges=True, max_count=1, format="%h"
).firstline()
if not commit:
raise GitFlowError("No hay merges a taggear.")
return commit
def check_not_tagged(self, commit: str):
tag = Git("describe", commit, tags=True, exact_match=True).firstline(check=False)
if tag:
raise GitFlowError("El commit a taggear ya tiene tag: " + tag)
def get_next_tag_from_commits(self, commits: list[str], branch: str) -> str | None:
increment = SEMVER_SKIP
for commit in commits:
commit_type = commit[: commit.index(":")]
increment = max(
increment, COMMIT_TYPE_INCREMENT.get(commit_type, SEMVER_SKIP)
)
tag = Git.get_current_tag() or f"v0.0.0-{branch}"
until_dash = tag.index("-") if "-" in tag else None
suffix = tag[until_dash:] if until_dash is not None else ""
[major, minor, patch] = map(int, tag[1:until_dash].split("."))
if increment == SEMVER_MAJOR:
major += 1
minor = 0
patch = 0
elif increment == SEMVER_MINOR:
minor += 1
patch = 0
elif increment == SEMVER_PATCH:
patch += 1
return None if increment == SEMVER_SKIP else f"v{major}.{minor}.{patch}{suffix}"
+85 -49
View File
@@ -1,8 +1,17 @@
import subprocess
from .io import *
from git_flow import GitFlowError
from git_flow.io import *
FLOWCONFIG_FILE = ".flowconfig"
CHANGELOG_FILE = "CHANGELOG.md"
BUMP_VERSION = "chore: bump version and update CHANGELOG.md [skip ci]"
class Git:
class Status:
worktree: str
index: str
@@ -13,56 +22,73 @@ class Status:
self.index = line[1]
self.file = line[3:].strip("\n")
class Git:
FLOWCONFIG_FILE = ".flowconfig"
CHANGELOG_FILE = "CHANGELOG.md"
BUMP_VERSION = "chore: bump version and update CHANGELOG.md [skip ci]"
command: list[str]
check_returncode: bool = True
@staticmethod
def get_config(file: str | None = None):
config: dict[str, str] = {}
lines = Git("config", file=file, list=True).lines()
for line in lines:
pos = line.index("=")
key = line[:pos]
value = line[pos + 1 :]
config[key] = value
return config
@staticmethod
def set_config(config: dict[str, str], file: str | None = None):
for key, value in config.items():
Git("config", key, value, file=file).exec()
@staticmethod
def flow_config(*args, **kwargs):
return Git("config", *args, file=Git.FLOWCONFIG_FILE, **kwargs)
return Git("config", *args, file=FLOWCONFIG_FILE, **kwargs)
@staticmethod
def get_current_branch():
return Git("rev-parse", "HEAD", abbrev_ref=True).firstline()
@staticmethod
def get_branches():
return Git._get_references("heads")
def get_branches(prefix: str = ""):
return Git._get_references("heads/" + prefix)
@staticmethod
def status():
return list(map(Status, Git("status", porcelain=True).lines(False)))
return list(map(Git.Status, Git("status", porcelain=True).lines(False)))
@staticmethod
def get_current_tag():
return Git("describe", abbrev="0", tags=True).without_checking().firstline()
return Git("describe", abbrev="0", tags=True).firstline(check=False)
@staticmethod
def get_first_fork_point(branch: str, env: str):
boundary_commits = list(filter(
lambda c: c.startswith("-"), # boundary commits
Git("rev-list", env + "..." + branch, topo_order=True, boundary=True).lines()
))
# Lista de commits alcanzables por el entorno (por ejemplo, dev),
env_commits = Git("rev-list", env + "..." + branch, first_parent=True).lines()
if not boundary_commits:
if not env_commits:
raise RuntimeError("No se encontro un commit base para realizar el rebase.")
return boundary_commits[-1].removeprefix("-")
return Git("show", env_commits[-1] + "^", patch=False, format="%h").firstline()
@staticmethod
def _get_references(kind: str):
prefix = "refs/" + kind + "/"
return map(
lambda r: r.removeprefix(prefix),
Git("for-each-ref", prefix + "*", format="%(refname)").lines(),
)
prefix = "refs/" + kind
return Git("for-each-ref", prefix + "**", format="%(refname:short)").lines()
def __init__(self, subcommand: str, *args: str, **kwargs: str | int | bool) -> None:
@staticmethod
def is_repository() -> bool:
return Git("rev-parse", is_inside_work_tree=True).code() == 0
@staticmethod
def get_tracking_branch(branch: str) -> str | None:
try:
return Git("rev-parse", branch + "@{upstream}", abbrev_ref=True).firstline()
except subprocess.CalledProcessError:
return None
def __init__(self, subcommand: str, *args: str, **kwargs: str | int | bool | list[str] | None) -> None:
self.command = ["git", subcommand]
for option, value in kwargs.items():
@@ -74,6 +100,11 @@ class Git:
else:
prefix = "--" if value else "--no-"
self.command.append(prefix + option)
elif value is None:
continue
elif isinstance(value, list):
for v in value:
self.command.append("--" + option + "=" + v)
else:
value = value if isinstance(value, str) else str(value)
@@ -85,44 +116,47 @@ class Git:
self.command += args
def lines(self, strip: bool = True):
process = subprocess.run(self.command, capture_output=True, text=True)
self.__print_process(process.stdout, process.stderr)
if self.check_returncode:
process.check_returncode()
def lines(self, strip: bool = True, **kwargs):
process = self._get(**kwargs)
lines = process.stdout.splitlines()
return lines if not strip else list(map(lambda l: l.strip(), lines))
def firstline(self):
lines = self.lines()
def firstline(self, **kwargs: bool):
lines = self.lines(**kwargs)
return lines[0] if lines else ""
def exec(self):
def code(self, **kwargs):
return self._run(check=False, **kwargs).returncode
def exec(self, **kwargs):
self._run(**kwargs)
def _get(self, print: str|None = None, check: bool = True):
process = subprocess.run(self.command, capture_output=True, text=True)
self.__print_process(process.stdout, process.stderr)
if print is not None:
self._print_process(process.stdout, process.stderr, print)
if self.check_returncode:
if check:
process.check_returncode()
def code(self):
process = subprocess.run(self.command, capture_output=True, text=True)
return process
self.__print_process(process.stdout, process.stderr)
return process.returncode
def _run(self, print: str|None = None, check: bool = True):
process = subprocess.run(self.command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def without_checking(self):
self.check_returncode = False
if print is not None:
self._print_process("", "", title=print)
return self
if check:
process.check_returncode()
def __print_process(self, stdout: str, stderr: str):
return process
def _print_process(self, stdout: str, stderr: str, title: str):
command = []
for arg in self.command:
@@ -134,10 +168,12 @@ class Git:
command.append(arg)
print(COLOR_YELLOW + "> " + " ".join(command) + COLOR_RESET)
title = title or "Ejecutando"
print(COLOR_YELLOW + "| " + title + COLOR_RESET)
print(COLOR_YELLOW + "| $ " + " ".join(command) + COLOR_RESET)
for line in stdout.splitlines():
print(COLOR_BLUE + "< " + line + COLOR_RESET)
print(COLOR_YELLOW + "| " + COLOR_RESET + "[out] " + line)
for line in stderr.splitlines():
print(COLOR_RED + "! " + line + COLOR_RESET)
print(COLOR_YELLOW + "| " + COLOR_RED + "[err] " + line + COLOR_RESET)
+1 -9
View File
@@ -1,14 +1,6 @@
import sys
COLOR_BLACK = '\033[30m'
COLOR_RED = '\033[31m'
COLOR_GREEN = '\033[32m'
COLOR_YELLOW = '\033[33m'
COLOR_BLUE = '\033[34m'
COLOR_BLACK_BOLD = '\033[1;30m'
COLOR_RESET = '\033[0m'
from git_flow import COLOR_BLACK_BOLD, COLOR_BLUE, COLOR_GREEN, COLOR_RED, COLOR_RESET, COLOR_YELLOW
def confirm(prompt, default: bool = True):
user_input = input(prompt + (" [Y/n]: " if default else " [y/N]: "))
+67 -635
View File
@@ -1,17 +1,61 @@
#!/usr/bin/env python3
import os
import sys
import datetime
import requests
import locale
from argparse import Namespace, ArgumentParser
from .git import Git
from .io import *
from git_flow import GitFlowError
from git_flow.command.base import Command
from git_flow.command.branch import BranchCommand
from git_flow.command.commit import CommitCommand
from git_flow.command.init import InitCommand
from git_flow.command.merge import MergeCommand
from git_flow.command.new import NewCommand
from git_flow.command.release import ReleaseCommand
from git_flow.command.tag import TagCommand
from git_flow.io import *
REPOSITORY_TOKEN_PATH = ".repository-token"
LOCALE = 'es_AR.UTF-8'
LOCALE = "es_AR.UTF-8"
class GitFlowCommand(Command):
commands: tuple[Command, ...]
def __init__(self, *args: Command) -> None:
self.commands = args
def name(self) -> str:
return "git-flow"
def description(self) -> str:
return """Git flow es una herramienta para automatizar un workflow siguiendo conventional commits, conventional branch y semver."""
def setup_parser(self, parser: ArgumentParser) -> ArgumentParser:
subparsers = parser.add_subparsers(
title="comandos", dest="command", required=True
)
for command in self.commands:
subparser = subparsers.add_parser(
command.name(), description=command.description()
)
command.setup_parser(subparser)
return parser
def get_parser(self) -> ArgumentParser:
parser = ArgumentParser(self.name(), description=self.description())
return self.setup_parser(parser)
def run(self, args: Namespace = Namespace()):
args = self.get_parser().parse_args()
for command in self.commands:
if command.name() == args.command:
command.init()
return command.run(args)
def main():
@@ -20,639 +64,27 @@ def main():
except locale.Error as e:
print_warning(f"No se pudo configurar el locale '{LOCALE}': {e}")
args = sys.argv
if len(args) < 2:
print_error("missing command")
return
command = args[1]
command = GitFlowCommand(
InitCommand(),
NewCommand(),
CommitCommand(),
MergeCommand(),
TagCommand(),
ReleaseCommand(),
BranchCommand()
)
try:
if command == "new":
new_command()
elif command == "init":
init_command()
elif command == "commit":
commit_command()
elif command == "merge":
merge_command()
elif command == "tag":
tag_command()
elif command == "release":
release_command()
elif command == "help":
help_command()
else:
help_command(command)
command.run()
except GitFlowError as e:
command.error(str(e))
except Exception as e:
print_error(f"Ocurrio un error al ejecutar el comando: {e}")
command.error("Ocurrió un error inesperado: " + str(e))
except KeyboardInterrupt as e:
print()
command.error("Ejecución abortada")
BRANCH_TYPES = [
"feature",
"refactor",
"bugfix",
"hotfix",
"perf",
"docs",
"typo",
"testing",
"breaking",
]
BRANCH_TYPES_INCREMENT = {
"feature": "minor",
"refactor": "minor",
"bugfix": "patch",
"hotfix": "patch",
"perf": "minor",
"docs": "patch",
"typo": "patch",
"testing": "patch",
"breaking": "major",
}
COMMIT_TYPES = [
"feature",
"bugfix",
"hotfix",
"refactor",
"perf",
"docs",
"typo",
"testing",
"ignore",
"wip"
]
DEFAULT_REMOTE = "origin"
def new_command():
current_branch = ensure_right_branch()
print_info("Se creará una nueva rama de trabajo sobre la rama actual.")
print_info("Seleccione el tipo de cambio a realizar.")
branch_type = choose("Tipos de cambio", BRANCH_TYPES)
branch = get_unique_branch_name(branch_type)
if confirm(f"Crear rama '{branch}' sobre rama '{current_branch}'"):
Git("switch", branch, create=True).exec()
else:
raise RuntimeError("Abortando operación")
def init_command():
if not Git("rev-parse", is_inside_work_tree=True).code() == 0:
print_warning("El directorio actual no es un repositorio.")
if not confirm("¿Desea inicializarlo?"):
raise RuntimeError("Abortando operación")
Git("init").exec()
initialized = Git.flow_config("flow.initialized").without_checking().firstline()
if initialized == "true":
print_error("El repositorio ya fue inicializado")
return
elif initialized != "":
print_error("El valor de 'flow.initialized' ({initialized}) es inválido")
return
print_info("Ingrese las ramas principales del repositorio separadas por espacio.")
branches = input("Ramas [dev main]: ").strip()
branches = branches.split(" ") if len(branches) > 0 else ["dev", "main"]
base = None
if confirm("¿Desea configurar un repositorio remoto?"):
remote = DEFAULT_REMOTE
remotes = Git("remote").lines()
if len(remotes) == 0:
print("El repositorio no tiene ningún remoto.")
url = None
while url is None:
url = input("Ingrese la URL del repositorio (https://github.com/user/repo.git): ").strip()
if len(url) == 0:
print_error("Debe ingresar una URL")
url = None
elif len(remotes) == 1:
remote = remotes[0]
else:
print("Tiene más de un remoto, seleccione el que desea utilizar.")
remote = choose("Remotos: ", remotes)
Git.flow_config("flow.remote", remote).exec()
Git.flow_config("flow.branches", " ".join(branches)).exec()
Git.flow_config("flow.initialized", "true").exec()
Git("add", Git.FLOWCONFIG_FILE).exec()
Git("commit", m="feature: initialize git-flow").exec()
existing_branches = Git.get_branches()
while base is None:
default = Git("rev-parse", "HEAD", abbrev_ref=True).firstline()
base = input(f"Ingrese la rama sobre la que desea crear las ramas principales [{default}]: ")
if not base:
base = default
elif base not in existing_branches:
print_error("Debe ingresar una rama existente.")
base = None
if confirm(f"¿Crear las ramas ingresadas sobre '{base}'?"):
for branch in branches:
if branch in existing_branches:
continue
if base == "HEAD":
Git("branch", branch).exec()
else:
Git("branch", branch, base).exec()
def commit_command():
ensure_initialized()
branch = ensure_right_branch()
status = Git.status()
if not status:
print_error("No hay cambios para commitear.")
return
files_in_index = []
for s in status:
if s.worktree != "?" and s.worktree != " ":
files_in_index.append(s.file)
if not files_in_index:
print_warning("No hay cambios en el indice para commitear.")
if confirm("¿Desea agregar la carpeta actual (`git add .`)?"):
Git("add", ".").exec()
else:
print_error("Debe agregar algún cambio al indice para continuar.")
return
commit_type = choose("Tipo de commit", COMMIT_TYPES)
message = commit_type + ": " + get_commit_message()
if branch.startswith("wip/"):
original_branch = branch.removeprefix("wip/")
Git("commit", m=message).exec()
if commit_type != "wip":
now = datetime.datetime.now()
suffix = now.strftime("%Y-%m-%d_%H.%M.%S")
Git("switch", original_branch).exec()
Git("merge", branch, squash=True).exec()
Git("commit", m=message).exec()
Git("branch", branch, branch + "/" + suffix, move=True).exec()
else:
if commit_type == "wip":
Git("switch", "wip/" + branch, create=True).exec()
Git("commit", m=message).exec()
def merge_command():
ensure_initialized()
branch = ensure_right_branch()
if branch.startswith("wip/"):
print_error("No se pueden mergear ramas de tipo 'wip'")
return
(target, _) = get_branch_env_and_type(branch)
status = Git.status()
remote = Git.flow_config("flow.remote").firstline()
if status:
not_empty = any(map(lambda s: s.index != " " or s.worktree != " ", status))
if not_empty:
print_warning("Existen cambios en tu entorno de trabajo sin commitear.")
if not confirm("¿Desea continuar?", False):
return
if remote:
Git("switch", target).exec()
Git("pull").exec()
Git("switch", "-").exec()
commits = Git("log", target + "..", format="%s").lines()
if not commits:
print_error("No hay cambios a mergear.")
return
print("Cambios a mergear:")
for commit in commits:
if commit != Git.BUMP_VERSION:
print("- " + commit)
merge_conflicts = Git("merge", target, ff=False, commit=False).exec()
if merge_conflicts:
Git("merge", abort=True).exec()
print_error(f"La rama actual tiene conflictos con '{target}'. "
"Se recomienda mergear la rama a la actual, resolver los conflictos, "
"y ejecutar nuevamente este comando.")
return
if remote:
Git("push", remote, branch, set_upstream=True).exec()
url = Git("remote", "get-url", remote).firstline()
if url is not None:
[service, repository] = parse_url(url)
create_pull_request(service, repository, branch, target)
else:
if confirm(f"Mergear rama '{target}' <= '{branch}'?"):
Git("switch", target).exec()
Git("merge", branch, ff=False).exec()
def parse_url(url: str) -> list[str]:
if "@" in url:
# git@github.com:username/repository.git
start = url.find("@") + 1
end = url.find(":")
service = url[start : end]
start = end + 1
end = url.find(".git") if ".git" in url else len(url)
repository = url[start : end]
elif "://" in url:
# https://github.com/username/repository.git
start = url.index("://") + 3
end = url.index("/", start)
service = url[start : end]
start = end + 1
end = url.find(".git") if ".git" in url else len(url)
repository = url[start : end]
else:
raise RuntimeError("Invalid url: " + url)
# Esto permite utilizar un Host ficticio si tenemos 2 cuentas de bitbucket (personal y trabajo)
if service.endswith(".bitbucket.org"):
service = "bitbucket.org"
return [service, repository]
def get_branch_env_and_type(branch: str) -> tuple[str, str]:
components = branch.split("/")
target_branches = Git.flow_config("flow.branches").firstline().split(" ")
if len(components) == 2:
if components[0] not in BRANCH_TYPES:
raise ValueError(f"Branch inválida, el tipo '{components[0]}' no es válido.")
return (target_branches[0], components[0])
elif len(components) == 4:
target_branches = target_branches[1:]
if (
components[0] != "release"
or components[1] not in target_branches
or components[2] not in BRANCH_TYPES
):
raise ValueError(
"Branch release inválido, debe tener el siguiente formato: "
"release/<env>/<type>/<name>, pero es: " + branch
)
return (components[1], components[2])
else:
raise ValueError("Branch inválido: " + branch)
def tag_command():
ensure_initialized()
ci = len(sys.argv) == 3 # ./git-flow tag <BEARER>
token = sys.argv[2] if ci else None
if token is None:
with open(REPOSITORY_TOKEN_PATH, "r") as file:
token = file.readline().strip()
output = Git("log", first_parent=True, merges=True, max_count=1, format="%H,%s").lines()
if not output:
print_error("No hay merges a taggear")
return
output = output[0]
pos = output.index(',')
commit = output[:pos]
message = output[pos+1:]
merged_branch = None
output = Git("describe", commit, tags=True, exact_match=True).without_checking().firstline()
if output:
print_error("El ultimo merge ya tiene tag: " + output)
return
if message.startswith("Merged in "):
merged_branch = message.removeprefix("Merged in ")
merged_branch = merged_branch[:merged_branch.index(' ')]
elif message.startswith("Merge branch '"):
merged_branch = message.removeprefix("Merge branch '")
merged_branch = merged_branch[:merged_branch.index("'")]
else:
print_error("No se pudo obtener el nombre de la rama mergeada")
return
remote = Git.flow_config("flow.remote").firstline()
branch = Git.get_current_branch()
last_version = Git.get_current_tag() or "v1.0.0"
from_major = 1
until_dash = last_version.index("-") if "-" in last_version else None
[major, minor, patch] = map(int, last_version[from_major:until_dash].split("."))
(_, merged_branch_type) = get_branch_env_and_type(merged_branch)
increment = BRANCH_TYPES_INCREMENT[merged_branch_type]
if increment == "major":
major = major + 1
minor = 0
patch = 0
elif increment == "minor":
minor = minor + 1
patch = 0
elif increment == "patch":
patch = patch + 1
last_branch = Git.flow_config("flow.branches").firstline().split(" ")[-1]
suffix = "-" + branch if branch != last_branch else ""
new_version = f"v{major}.{minor}.{patch}" + suffix
if ci:
url = Git("remote", "get-url", remote).firstline()
if url:
[service, repository] = parse_url(url)
create_tag(service, repository, token, new_version, commit)
if update_changelog(merged_branch, branch):
Git("add", Git.CHANGELOG_FILE).exec()
Git("commit", message=Git.BUMP_VERSION).exec()
Git("push").exec()
else:
Git("tag", new_version, commit).exec()
def release_command():
ensure_initialized()
branch = ensure_right_branch()
env_branches = Git.flow_config("flow.branches").firstline().split(" ")
(env, _) = get_branch_env_and_type(branch)
if env == env_branches[-1]:
raise RuntimeError("No se puede hacer release de una rama en el ultimo entorno.")
base = Git.get_first_fork_point(branch, env)
next_env = env_branches[env_branches.index(env) + 1]
next_branch = branch.replace(f"/{env}/", f"/{next_env}/") if branch.startswith("release/") else f"release/{next_env}/{branch}"
Git("switch", next_branch, create=True).exec()
commits = int(Git("rev-list", base + ".." + next_branch, count=True).firstline())
if commits > 1 and confirm(f"Desea reemplazar los {commits} commits de la rama por uno solo?"):
Git("reset", base, soft=True).exec()
commit_type = choose("Tipo de commit", COMMIT_TYPES[:-1]) # Solo permitir commits NO wip
Git("commit", m=commit_type + ": " + get_commit_message()).exec()
Git("rebase", base, next_branch, onto=next_env).exec()
def help_command(name: str|None = None):
if name is not None:
print_error(f"Comando inválido: {name}")
script = sys.argv[0]
print(f"""USO:
{script} <COMANDO>
COMANDOS:
init inicializa el repositorio por única vez para utilizar git-flow
new crea una nueva rama para realizar cambios
commit crea un commit siguiendo el formato de Conventional Commit
merge actualiza el changelog y crea un nuevo PR para mergear la rama
tag crea un nuevo tag para el ultimo merge
release crea una nueva rama release para pasar la rama actual al siguiente entorno
help muestra este texto de ayuda""", file=sys.stderr)
def ensure_initialized():
initialized = Git.flow_config("flow.initialized").firstline()
if initialized == "":
raise RuntimeError("El repositorio no fue inicializado, antes de continuar ejecute `git flow init`.")
elif initialized != "true":
raise RuntimeError(f"El valor de `flow.initialized` ({initialized}) es inválido.")
def get_commit_message():
message = None
while message is None:
message = input("Mensaje (recomendado: 100 caracteres): ").strip()
if not message:
print_error("El mensaje no puede ser vacío.")
message = None
return message
def ensure_right_branch():
branch = Git.get_current_branch()
if branch != "HEAD":
if confirm(f"Se encuentra sobre la rama '{branch}'. ¿Es correcto?"):
return branch
raise RuntimeError("Abortando operación")
def get_base_branch(branch: str):
if branch.startswith("release/"):
start = len("release/")
end = branch.index("/", start)
return branch[start:end]
else:
return Git.flow_config("flow.branches").firstline().split(" ")[0]
def get_unique_branch_name(branch_type):
print_info(f"Ingrese las palabras clave que describan el cambio de tipo '{branch_type}'.")
branch = None
branches = Git.get_branches()
while branch is None:
keywords = input("Palabras clave (por ej. 'edit worker form'): ")
if len(keywords) == 0:
print_error("Debe ingresar al menos una palabra clave.")
else:
keywords = list(filter(lambda k: len(k) > 0, keywords.split(" ")))
branch = branch_type + "/" + "-".join(keywords)
if branch in branches:
print_error(
f"La rama '{branch}' ya existe, cambie las palabras claves para generar otra"
)
branch = None
return branch
def update_changelog(branch: str, target: str):
last_commit = Git("show", "HEAD", no_patch=True, format="%s").firstline()
if last_commit == Git.BUMP_VERSION:
return False
else:
content = generate_changelog_entry(branch, target)
if os.path.isfile(Git.CHANGELOG_FILE):
with open(Git.CHANGELOG_FILE, "r") as changelog:
content = content + changelog.read()
with open(Git.CHANGELOG_FILE, "w") as changelog:
changelog.write(content)
return True
def get_changelog_header_lines(branch: str):
name = Git("config", "user.name").firstline()
email = Git("config", "user.email").firstline()
now = datetime.datetime.now()
weekday = now.strftime("%A").capitalize()
month = now.strftime("%B").capitalize()
timestamp = now.strftime(f"{weekday}, %e de {month} de %Y, %H:%M")
return [
f"## {timestamp}",
"",
f"- Autor: [{name}](mailto:{email})",
f"- Rama: `{branch}`",
"",
"### Commits",
""
]
def get_changelog_content_lines(branch: str, target: str):
lines = []
base = Git.get_first_fork_point(branch, target)
commits = Git("log", base + ".." + branch, first_parent=True, format="%h", merges=False).lines()
for commit in commits:
message = Git("show", commit, patch=False, format="%s").firstline()
email = Git("show", commit, patch=False, format="%ae").firstline()
username = email[:email.index('@')]
if message == Git.BUMP_VERSION:
continue
index = message.index(":")
commit_type = message[:index]
commit_message = message[index+1:]
lines.append(f"- **{commit_type}**: {commit_message} [[{username}](mailto:{email})] ({commit})")
return lines
def get_changelog_footer_lines():
return [
"",
"---",
"",
""
]
def generate_changelog_entry(branch: str, target: str):
entry_lines = get_changelog_header_lines(branch)
entry_lines += get_changelog_content_lines(branch, target)
entry_lines += get_changelog_footer_lines()
return "\n".join(entry_lines)
def create_pull_request(service: str, repository: str, source: str, destination: str):
endpoint = get_api_endpoint(service, repository, "/pullrequests")
title = source.replace("/", ": ").replace("-", " ")
description = "\n".join(get_changelog_content_lines(source, destination))
headers = get_headers()
json = {
"title": title,
"description": description,
"source": {"branch": {"name": source}},
"destination": {"branch": {"name": destination}},
"close_source_branch": True
}
request = requests.post(endpoint, headers=headers, json=json)
if request.ok:
json = request.json()
print_success("PR creado exitosamente: " + json['links']['html']['href'])
else:
print_error("Ocurrió un error al crear el PR: " + request.text)
def create_tag(service: str, repository: str, token: str, tag: str, commit: str):
endpoint = get_api_endpoint(service, repository, "/refs/tags")
headers = get_headers(token)
json = {
"name": tag,
"target": {
"hash": commit
}
}
response = requests.post(endpoint, headers=headers, json=json)
if response.ok:
print("Tag creado exitosamente.")
else:
print_error("Ocurrió un error al crear el tag: " + response.text)
def get_headers(token: str | None = None):
if not token:
with open(REPOSITORY_TOKEN_PATH) as file:
token = file.readline().strip()
return {
"Accept": "application/json",
"Content-Type": "application/json",
"Authorization": "Bearer " + token,
}
def get_api_endpoint(service: str, repository: str, path: str):
if service == "bitbucket.org":
return "https://api.bitbucket.org/2.0/repositories/" + repository + path
return ""
if __name__ == "__main__":
main()
+75
View File
@@ -0,0 +1,75 @@
from abc import ABC, abstractmethod
from git_flow import SUPPORTED_REMOTE_APIS, GitFlowError
from git_flow.git import Git
class RemoteAPI(ABC):
def __init__(self, repository: str, token: str) -> None:
self.repository = repository
self.token = token
@staticmethod
def parse(remote: str):
url = Git("remote", "get-url", remote).firstline()
invalid_url_error = GitFlowError(f"La URL del remoto {remote} es inválida: {url}")
url_type = None
if url.startswith("git@"):
url_type = "ssh"
elif url.startswith("http://") or url.startswith("https://"):
url_type = "https"
else:
raise invalid_url_error
if url_type == "ssh":
# git@host:user/repo[.git]
at_pos = url.index("@")
colon_pos = url.index(":")
host = url[at_pos+1:colon_pos]
repository = url[colon_pos+1:]
else:
# https://host/user/repo[.git]
host_start = url.index("://") + 3
uri_start = url.index("/", host_start)
host = url[host_start:uri_start]
repository = url[uri_start+1:]
# Esto permite configurar hosts "imaginarios" para permitir el uso de multiples claves ssh
if host.endswith(".bitbucket.org"):
host = "bitbucket.org"
elif host.endswith(".github.com"):
host = "github.com"
if host not in SUPPORTED_REMOTE_APIS:
raise GitFlowError("El host del repositorio remoto es inválido")
repository.removesuffix(".git")
return [host, repository]
@abstractmethod
def create_pull_request(
self,
source: str,
destination: str,
title: str | None = None,
description: str | None = None,
) -> str:
pass
@abstractmethod
def create_tag(self, tag: str, commit: str) -> str:
pass
@abstractmethod
def get_endpoint(self, resource: str) -> str:
pass
def get_headers(self):
return {
"Accept": "application/json",
"Content-Type": "application/json",
"Authorization": "Bearer " + self.token,
}
+47
View File
@@ -0,0 +1,47 @@
import requests
from git_flow import GitFlowError
from git_flow.remote.base import RemoteAPI
class BitbucketRemoteAPI(RemoteAPI):
API_ENDPOINT = "https://api.bitbucket.org/2.0/repositories"
def create_pull_request(
self,
source: str,
destination: str,
title: str | None = None,
description: str | None = None,
):
request = requests.post(
self.get_endpoint("/pullrequests"),
headers=self.get_headers(),
json={
"title": title,
"description": description,
"source": {"branch": {"name": source}},
"destination": {"branch": {"name": destination}},
"close_source_branch": True,
},
)
if request.ok:
json = request.json()
return "PR creado exitosamente: " + json["links"]["html"]["href"]
else:
raise GitFlowError("Ocurrió un error al crear el PR: " + request.text)
def create_tag(self, tag: str, commit: str):
response = requests.post(
self.get_endpoint("/refs/tags"),
headers=self.get_headers(),
json={"name": tag, "target": {"hash": commit}},
)
if response.ok:
return "Tag creado exitosamente: " + tag
else:
raise GitFlowError("Ocurrió un error al crear el tag: " + response.text)
def get_endpoint(self, resource: str) -> str:
return f"{BitbucketRemoteAPI.API_ENDPOINT}/{self.repository}/{resource}"
+21
View File
@@ -0,0 +1,21 @@
from git_flow import GitFlowError
from git_flow.remote.base import RemoteAPI
class GithubRemoteAPI(RemoteAPI):
API_ENDPOINT = "https://github.com"
def create_pull_request(
self,
source: str,
destination: str,
title: str | None = None,
description: str | None = None,
) -> str:
raise GitFlowError("not implemented yet!")
def create_tag(self, tag: str, commit: str) -> str:
raise GitFlowError("not implemented yet!")
def get_endpoint(self, resource: str) -> str:
return GithubRemoteAPI.API_ENDPOINT
Generated
+144
View File
@@ -0,0 +1,144 @@
version = 1
revision = 3
requires-python = ">=3.10"
[[package]]
name = "certifi"
version = "2026.1.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" },
]
[[package]]
name = "charset-normalizer"
version = "3.4.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1f/b8/6d51fc1d52cbd52cd4ccedd5b5b2f0f6a11bbf6765c782298b0f3e808541/charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", size = 209709, upload-time = "2025-10-14T04:40:11.385Z" },
{ url = "https://files.pythonhosted.org/packages/5c/af/1f9d7f7faafe2ddfb6f72a2e07a548a629c61ad510fe60f9630309908fef/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", size = 148814, upload-time = "2025-10-14T04:40:13.135Z" },
{ url = "https://files.pythonhosted.org/packages/79/3d/f2e3ac2bbc056ca0c204298ea4e3d9db9b4afe437812638759db2c976b5f/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", size = 144467, upload-time = "2025-10-14T04:40:14.728Z" },
{ url = "https://files.pythonhosted.org/packages/ec/85/1bf997003815e60d57de7bd972c57dc6950446a3e4ccac43bc3070721856/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", size = 162280, upload-time = "2025-10-14T04:40:16.14Z" },
{ url = "https://files.pythonhosted.org/packages/3e/8e/6aa1952f56b192f54921c436b87f2aaf7c7a7c3d0d1a765547d64fd83c13/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", size = 159454, upload-time = "2025-10-14T04:40:17.567Z" },
{ url = "https://files.pythonhosted.org/packages/36/3b/60cbd1f8e93aa25d1c669c649b7a655b0b5fb4c571858910ea9332678558/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", size = 153609, upload-time = "2025-10-14T04:40:19.08Z" },
{ url = "https://files.pythonhosted.org/packages/64/91/6a13396948b8fd3c4b4fd5bc74d045f5637d78c9675585e8e9fbe5636554/charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", size = 151849, upload-time = "2025-10-14T04:40:20.607Z" },
{ url = "https://files.pythonhosted.org/packages/b7/7a/59482e28b9981d105691e968c544cc0df3b7d6133152fb3dcdc8f135da7a/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", size = 151586, upload-time = "2025-10-14T04:40:21.719Z" },
{ url = "https://files.pythonhosted.org/packages/92/59/f64ef6a1c4bdd2baf892b04cd78792ed8684fbc48d4c2afe467d96b4df57/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", size = 145290, upload-time = "2025-10-14T04:40:23.069Z" },
{ url = "https://files.pythonhosted.org/packages/6b/63/3bf9f279ddfa641ffa1962b0db6a57a9c294361cc2f5fcac997049a00e9c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", size = 163663, upload-time = "2025-10-14T04:40:24.17Z" },
{ url = "https://files.pythonhosted.org/packages/ed/09/c9e38fc8fa9e0849b172b581fd9803bdf6e694041127933934184e19f8c3/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", size = 151964, upload-time = "2025-10-14T04:40:25.368Z" },
{ url = "https://files.pythonhosted.org/packages/d2/d1/d28b747e512d0da79d8b6a1ac18b7ab2ecfd81b2944c4c710e166d8dd09c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", size = 161064, upload-time = "2025-10-14T04:40:26.806Z" },
{ url = "https://files.pythonhosted.org/packages/bb/9a/31d62b611d901c3b9e5500c36aab0ff5eb442043fb3a1c254200d3d397d9/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", size = 155015, upload-time = "2025-10-14T04:40:28.284Z" },
{ url = "https://files.pythonhosted.org/packages/1f/f3/107e008fa2bff0c8b9319584174418e5e5285fef32f79d8ee6a430d0039c/charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", size = 99792, upload-time = "2025-10-14T04:40:29.613Z" },
{ url = "https://files.pythonhosted.org/packages/eb/66/e396e8a408843337d7315bab30dbf106c38966f1819f123257f5520f8a96/charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", size = 107198, upload-time = "2025-10-14T04:40:30.644Z" },
{ url = "https://files.pythonhosted.org/packages/b5/58/01b4f815bf0312704c267f2ccb6e5d42bcc7752340cd487bc9f8c3710597/charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", size = 100262, upload-time = "2025-10-14T04:40:32.108Z" },
{ url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" },
{ url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" },
{ url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" },
{ url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" },
{ url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" },
{ url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" },
{ url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" },
{ url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" },
{ url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" },
{ url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" },
{ url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" },
{ url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" },
{ url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" },
{ url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" },
{ url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" },
{ url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" },
{ url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" },
{ url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" },
{ url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" },
{ url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" },
{ url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" },
{ url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" },
{ url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" },
{ url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" },
{ url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" },
{ url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" },
{ url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" },
{ url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" },
{ url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" },
{ url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" },
{ url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" },
{ url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" },
{ url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" },
{ url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" },
{ url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" },
{ url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" },
{ url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" },
{ url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" },
{ url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" },
{ url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" },
{ url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" },
{ url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" },
{ url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" },
{ url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" },
{ url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" },
{ url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" },
{ url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" },
{ url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" },
{ url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" },
{ url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" },
{ url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" },
{ url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" },
{ url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" },
{ url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" },
{ url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" },
{ url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" },
{ url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" },
{ url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" },
{ url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" },
{ url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" },
{ url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" },
{ url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" },
{ url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" },
{ url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" },
{ url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
]
[[package]]
name = "git-flow-envs"
source = { editable = "." }
dependencies = [
{ name = "requests" },
]
[package.metadata]
requires-dist = [{ name = "requests" }]
[[package]]
name = "idna"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "requests"
version = "2.32.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
]
[[package]]
name = "urllib3"
version = "2.6.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
]