Compare commits

...
59 Commits
Author SHA1 Message Date
bitbucket-pipelines 59b9e1c419 chore: bump version and update CHANGELOG.md [skip ci] 2026-05-20 02:30:52 +00:00
JonathanGitFlowandjt 8793248dbe Merged in feature/add-ticket-support (pull request #69)
feature: agrego soporte para especificar tickets en nombre e rama

Approved-by: Jonathan Teran
2026-05-20 02:30:18 +00:00
jt f389118515 feature: agrego soporte para especificar tickets en nombre e rama 2026-05-19 23:29:22 -03:00
JonathanGitFlowandjt 5a7ed03801 Merged in refactor/move-constants-to-git_flow (pull request #68)
refactor: move constants to git_flow module

Approved-by: Jonathan Teran
2026-05-17 14:48:46 +00:00
Jonathan Teran Carballo 5f7db6cf5d refactor: move constants to git_flow module 2026-05-17 11:48:02 -03:00
bitbucket-pipelines 8929438fbf chore: bump version and update CHANGELOG.md [skip ci] 2026-05-17 14:38:22 +00:00
JonathanGitFlowandjt d35dab9046 Merged in feature/include-revlist-count-in-version (pull request #67)
feature: include revlist count in version tag when skipping bump

Approved-by: Jonathan Teran
2026-05-17 14:37:38 +00:00
jt d275ac08f3 feature: include revlist count in version tag when skipping bump 2026-05-17 11:37:04 -03:00
JonathanGitFlowandjt 9b5584bbfe Merged in refactor/migrate-from-class-to-scripts (pull request #66)
refactor: migrate from class to scripts

Approved-by: Jonathan Teran
2026-05-17 14:28:48 +00:00
jt 9ccffd502d refactor: remove unused constants, move io functions to module 2026-05-17 11:27:17 -03:00
jt 0ac04aee84 refactor: migrate from class commands to scripts commands 2026-05-17 11:19:30 -03:00
JonathanGitFlowandjt 9ffefafad0 Merged in refactor/migrate-to-typer-framework (pull request #65)
refactor: replace global command class to typer functions

Approved-by: Jonathan Teran
2026-05-16 22:01:32 +00:00
jt fec30cd1e8 refactor: replace global command class to typer functions 2026-05-16 18:59:45 -03:00
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
19 changed files with 1025 additions and 823 deletions
+3
View File
@@ -9,3 +9,6 @@ dist/
build/ build/
**/__pycache__/ **/__pycache__/
**/*.egg-info/ **/*.egg-info/
# MacOS
.DS_Store
+1
View File
@@ -0,0 +1 @@
3.14
+100
View File
@@ -1,3 +1,103 @@
## v1.16.0 - 2026-05-20
- **feature**: agrego soporte para especificar tickets en nombre e rama [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (f389118)
---
## v1.15.0 - 2026-05-17
- **feature**: include revlist count in version tag when skipping bump [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (d275ac0)
---
## 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 ## v1.8.0 - 2025-12-15
- **feature**: agrego opcion para incluir ticket en nombre de rama [[jteran](mailto:jteran@renatre.org.ar)] (01e938e) - **feature**: agrego opcion para incluir ticket en nombre de rama [[jteran](mailto:jteran@renatre.org.ar)] (01e938e)
+3 -3
View File
@@ -49,9 +49,9 @@ El comando solicitará 2 opciones:
- [x] Bitbucket - [x] Bitbucket
- [ ] Github: planeado - [ ] Github: planeado
La configuración del remoto requiere tener un API Token para poder crear los PRs automáticamente. La configuración del remoto requiere tener un Access Token para poder crear los PRs automáticamente.
El mismo se puede crear desde: *"Atlassian Account Settings" > "Security" > "Create and Manage API El mismo se puede crear desde el repositorio al que se quiere dar acceso: *"Repository settings" >
Tokens" > "Create API Tokens with scopes"*. El token necesita el scope `write:pullrequest:bitbucket` "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 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. `.repository-token`. De no especificarse, los merges y tags se harán de forma local.
+6 -5
View File
@@ -1,13 +1,14 @@
image: python:3.12 image: python:3.14
pipelines: pipelines:
branches: branches:
'main': 'main':
- step: - step:
name: "Tag main" name: "Tag and publish package"
caches: caches:
- pip - pip
script: script:
- cd $BITBUCKET_CLONE_DIR - pip install uv
- pip install . - uv run git-flow tag --token=$BEARER
- git-flow tag --token=$BEARER - uv build
- uv publish --token=$PYPI_TOKEN
+4 -4
View File
@@ -3,12 +3,12 @@ requires = ["setuptools >= 80", "setuptools-scm[simple] >= 8" ]
build-backend = "setuptools.build_meta" build-backend = "setuptools.build_meta"
[project] [project]
name = "git-flow" name = "git-flow-envs"
description = "Git workflow automation to follow best practices" description = "Git workflow automation to follow best practices when working with multiple environments"
dynamic = ["version"] dynamic = ["version"]
requires-python = ">= 3.12" requires-python = ">= 3.14"
dependencies = [ dependencies = [
"requests" "requests", "typer"
] ]
authors = [ authors = [
{name = "Alan Facundo Biglieri", email = "abiglieri@renatre.org.ar"}, {name = "Alan Facundo Biglieri", email = "abiglieri@renatre.org.ar"},
+4 -11
View File
@@ -21,6 +21,9 @@ COMMIT_TYPES = [
"wip", "wip",
] ]
WIP_BRANCH_PREFIX = "wip/"
TRASH_BRANCH_PREFIX = "trash/"
# Commits con estos valores incrementan el componente correspondiente # Commits con estos valores incrementan el componente correspondiente
SEMVER_MAJOR = 3 SEMVER_MAJOR = 3
SEMVER_MINOR = 2 SEMVER_MINOR = 2
@@ -47,14 +50,4 @@ SUPPORTED_REMOTE_APIS = [
REPOSITORY_TOKEN_FILENAME = ".repository-token" REPOSITORY_TOKEN_FILENAME = ".repository-token"
COLOR_BLACK = "\033[30m" FLOWCONFIG_FILENAME = ".flowconfig"
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"
+79 -188
View File
@@ -1,21 +1,15 @@
from argparse import Namespace, ArgumentParser
from abc import ABC, abstractmethod
from os.path import isfile
from git_flow import ( from git_flow import (
BRANCH_TYPES, BRANCH_TYPES,
COLOR_BLUE,
COLOR_GREEN,
COLOR_RED,
COLOR_RESET,
COLOR_YELLOW,
REPOSITORY_TOKEN_FILENAME, REPOSITORY_TOKEN_FILENAME,
FLOWCONFIG_FILENAME,
GitFlowError, GitFlowError,
) )
from git_flow.git import FLOWCONFIG_FILE, Git from git_flow.git import Git
from git_flow.remote.base import RemoteAPI from git_flow.remote.base import RemoteAPI
from git_flow.remote.bitbucket import BitbucketRemoteAPI from git_flow.remote.bitbucket import BitbucketRemoteAPI
from git_flow.remote.github import GithubRemoteAPI from git_flow.remote.github import GithubRemoteAPI
from git_flow.io import *
from os.path import isfile
TYPE_SUCCESS = 0 TYPE_SUCCESS = 0
TYPE_WARNING = 1 TYPE_WARNING = 1
@@ -23,214 +17,111 @@ TYPE_ERROR = 2
TYPE_INFO = 3 TYPE_INFO = 3
class Command(ABC): flowconfig = Git.get_config(FLOWCONFIG_FILENAME) if isfile(FLOWCONFIG_FILENAME) else {}
flowconfig: dict[str, str]
@abstractmethod
def name(self) -> str:
pass
@abstractmethod def ensure_initialized():
def description(self) -> str: initialized = flowconfig["flow.initialized"] if flowconfig else None
pass
@abstractmethod if not initialized:
def run(self, args: Namespace = Namespace()): raise GitFlowError(
pass "El repositorio no fue inicializado, debe ejecutar el comando `init`."
)
def init(self): elif initialized != "true":
self.flowconfig = ( raise GitFlowError(
Git.get_config(FLOWCONFIG_FILE) if isfile(FLOWCONFIG_FILE) else {} f"El valor de `flow.initialized` ({initialized}) es inválido."
) )
def success(self, msg: str):
self._print(TYPE_SUCCESS, msg)
def warning(self, msg: str): def ensure_right_branch():
self._print(TYPE_WARNING, msg) branch = Git.get_current_branch()
def error(self, msg: str): if branch == "HEAD":
self._print(TYPE_ERROR, msg) raise GitFlowError("No se encuentra parado sobre una rama.")
elif confirm(f"Se encuentra sobre la rama '{branch}'. ¿Es correcto?"):
return branch
else:
raise GitFlowError("Ejecución cancelada.")
def info(self, msg: str):
self._print(TYPE_INFO, msg)
def prompt( def get_branch_env_and_type(branch: str) -> tuple[str, str]:
self, components = branch.split("/")
prompt: str, target_branches = flowconfig["flow.branches"].split(",")
default: str | None = None,
persistent: bool = False,
strip: bool = True,
) -> str:
onetime = not persistent
while onetime or persistent: if len(components) == 2:
if default: if components[0] not in BRANCH_TYPES:
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( raise GitFlowError(
"El repositorio no fue inicializado, debe ejecutar el comando `init`." f"Branch inválida, el tipo '{components[0]}' no es válido."
)
elif initialized != "true":
raise GitFlowError(
f"El valor de `flow.initialized` ({initialized}) es inválido."
) )
def ensure_right_branch(self): return (target_branches[0], components[0])
branch = Git.get_current_branch() elif len(components) == 4:
target_branches = target_branches[1:]
if branch == "HEAD": if (
raise GitFlowError("No se encuentra parado sobre una rama.") components[0] != "release"
elif self.confirm(f"Se encuentra sobre la rama '{branch}'. ¿Es correcto?"): or components[1] not in target_branches
return branch or components[2] not in BRANCH_TYPES
else: ):
raise GitFlowError("Ejecución cancelada.")
def ensure_repository_token(self):
if not isfile(REPOSITORY_TOKEN_FILENAME):
raise GitFlowError( raise GitFlowError(
"No existe un token para el repositorio en " + REPOSITORY_TOKEN_FILENAME "Branch release inválido, debe tener el siguiente formato: "
"release/<env>/<type>/<name>, pero es: " + branch
) )
with open(REPOSITORY_TOKEN_FILENAME) as f: return (components[1], components[2])
return f.readline().strip() else:
raise GitFlowError("Branch inválido: " + branch)
def ensure_clean_worktree(self, has_remote: bool):
status = Git.status()
if not status: def ensure_repository_token():
return if not isfile(REPOSITORY_TOKEN_FILENAME):
raise GitFlowError(
"No existe un token para el repositorio en " + REPOSITORY_TOKEN_FILENAME
)
not_empty = any(map(lambda s: s.index != " " or s.worktree != " ", status)) with open(REPOSITORY_TOKEN_FILENAME) as f:
return f.readline().strip()
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): def ensure_clean_worktree(has_remote: bool):
raise GitFlowError("Ejecución abortada") status = Git.status()
def get_branch_env_and_type(self, branch: str) -> tuple[str, str]: if not status:
components = branch.split("/") return
target_branches = self.flowconfig["flow.branches"].split(",")
if len(components) == 2: not_empty = any(map(lambda s: s.index != " " or s.worktree != " ", status))
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]) if not_empty:
elif len(components) == 4: if not has_remote:
target_branches = target_branches[1:] warning("Existen cambios en tu entorno de trabajo sin commitear.")
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: else:
raise GitFlowError("Branch inválido: " + branch) raise GitFlowError("No se puede continuar con cambios pendientes.")
def get_remote_api(self, token: str) -> RemoteAPI: if not confirm("¿Desea continuar?", False):
if "flow.remote" not in self.flowconfig: raise GitFlowError("Ejecución abortada")
raise GitFlowError("El repositorio no tiene configurado un remoto.")
[host, repository] = RemoteAPI.parse(self.flowconfig["flow.remote"])
if host == "bitbucket.org": def get_remote_api(token: str) -> RemoteAPI:
return BitbucketRemoteAPI(repository, token) if "flow.remote" not in flowconfig:
elif host == "github.com": raise GitFlowError("El repositorio no tiene configurado un remoto.")
return GithubRemoteAPI(repository, token)
else:
raise GitFlowError("El host del repositorio remoto es inválido")
def _print(self, type: int, msg: str): [host, repository] = RemoteAPI.parse(flowconfig["flow.remote"])
print(self._get_color(type) + self._get_tag(type) + " " + msg + COLOR_RESET)
def _get_color(self, type: int) -> str: if host == "bitbucket.org":
if type == TYPE_SUCCESS: return BitbucketRemoteAPI(repository, token)
return COLOR_GREEN elif host == "github.com":
elif type == TYPE_WARNING: return GithubRemoteAPI(repository, token)
return COLOR_YELLOW else:
elif type == TYPE_ERROR: raise GitFlowError("El host del repositorio remoto es inválido")
return COLOR_RED
elif type == TYPE_INFO:
return COLOR_BLUE
return ""
def _get_tag(self, type: int) -> str: def is_valid_ticket(ticket: str) -> bool:
if type == TYPE_SUCCESS: """Check if the specified string is a valid ticket number (ABC-123)"""
return "[success]" components = ticket.split("-")
elif type == TYPE_WARNING:
return "[warning]"
elif type == TYPE_ERROR:
return "[error]"
elif type == TYPE_INFO:
return "[info]"
return "" if len(components) != 2:
return False
ticket_project = components[0]
ticket_number = components[1]
return ticket_project.isalpha() and ticket_project.isupper() and ticket_number.isnumeric()
+126 -74
View File
@@ -1,89 +1,141 @@
from argparse import ArgumentParser, Namespace from git_flow import (
from git_flow import COLOR_WHITE_BOLD, COLOR_GREEN, COLOR_RED, COLOR_RESET, GitFlowError TRASH_BRANCH_PREFIX,
from git_flow.command.base import Command WIP_BRANCH_PREFIX,
GitFlowError,
)
from git_flow.command.base import *
from git_flow.git import Git from git_flow.git import Git
from typing import Annotated
import typer
BRANCH_FORMAT = "%(refname:short)" BRANCH_FORMAT = "%(refname:short)"
app = typer.Typer()
class BranchCommand(Command):
def name(self) -> str:
return "branch"
def description(self) -> str: @app.command()
return """Lista ramas del repositorio, agrupandolas por entorno objetivo""" def branch(
environment: Annotated[
str | None,
typer.Argument(
help="Entorno de las ramas a listar. Por defecto, es el entorno actual."
),
] = None,
trash: Annotated[
bool,
typer.Option(
help=f"Listar ramas en la 'papelera de reciclaje' (ramas que empiezan con '{TRASH_BRANCH_PREFIX}')"
),
] = False,
wip: Annotated[
bool,
typer.Option(
help=f"Listar ramas 'en progreso' (ramas que empiezan con '{WIP_BRANCH_PREFIX}')"
),
] = False,
all: Annotated[
bool, typer.Option(help="Listar ramas de todos los entornos")
] = False,
):
"""Lista ramas del repositorio, agrupandolas por entorno objetivo"""
ensure_initialized()
def setup_parser(self, parser: ArgumentParser) -> ArgumentParser: current_branch = Git.get_current_branch()
parser.add_argument( environments = flowconfig["flow.branches"].split(",")
"environment",
nargs="?", if current_branch in environments:
help="Entorno de las ramas a listar. Puede ser vacio para listar ramas de todos los entornos, un entorno válido, o 'trash' para listar las ramas eliminadas.", current_environment = current_branch
else:
current_environment, _ = get_branch_env_and_type(current_branch)
if trash:
show_branches(TRASH_BRANCH_PREFIX, current_branch)
elif wip:
show_branches(WIP_BRANCH_PREFIX, current_branch)
elif all:
show_envs(environments, current_branch)
show_all_branches(environments, current_branch)
elif environment is None:
show_envs(environments, current_branch)
show_env_branches(current_environment, environments, current_branch)
elif environment in environments:
show_envs(environments, current_branch)
show_env_branches(environment, environments, current_branch)
else:
raise GitFlowError(f"'{environment}' no es un entorno válido.")
def show_branches(branch_prefix: str, current_branch: str):
branches = get_branches(branch_prefix)
if not branches:
print("No hay ramas a mostrar.")
return
for branch in branches:
is_current = branch == current_branch
prefix = "*" if is_current else " "
console.print(prefix + " " + branch, style="green" if is_current else None)
def get_branches(branch_prefix: str):
return Git("for-each-ref", "refs/heads/" + branch_prefix, format=BRANCH_FORMAT).lines()
def show_all_branches(environments: list[str], current_branch: str):
for environment in environments:
show_env_branches(
environment, environments, current_branch, len(environments) > 1
) )
return parser trash = len(get_branches(TRASH_BRANCH_PREFIX))
def run(self, args: Namespace = Namespace()): if trash:
self.ensure_initialized()
self.current_branch = Git.get_current_branch()
self.environments = self.flowconfig["flow.branches"].split(",")
if args.environment is None:
self.show_envs()
self.show_all_branches()
elif args.environment in self.environments:
self.show_envs()
self.show_env_branches(args.environment)
elif args.environment == "trash":
self.show_trash_branches()
else:
raise GitFlowError(f"'{args.environment}' no es un entorno válido.")
def show_trash_branches(self):
branches = self.get_trash_branches()
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_trash_branches(self):
return Git("for-each-ref", "refs/heads/trash/", 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_trash_branches())
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() print()
console.print(
"[bold]Papelera:[/bold] "
+ str(trash)
+ " rama"
+ ("" if trash == 1 else "s")
)
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/", "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) def show_envs(environments: list[str], current_branch: str):
console.print("Entornos", style="bold")
for environment in environments:
is_current = environment == current_branch
prefix = "*" if is_current else " "
console.print(prefix + " " + environment, style="green" if is_current else None)
print()
if not branches:
print("No hay ramas a mostrar.")
return
for branch in branches: def show_env_branches(
prefix = (COLOR_GREEN + "*") if branch == self.current_branch else " " environment: str,
print(prefix + " " + branch + COLOR_RESET) environments: list[str],
current_branch: str,
show_env: bool = False,
):
is_first_env = environment == 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()
console.print(environment if show_env else "Ramas", style="bold")
if not branches:
print("No hay ramas a mostrar.")
return
for branch in branches:
is_current = branch == current_branch
prefix = "*" if is_current else " "
console.print(prefix + " " + branch, style="green" if is_current else None)
+52 -50
View File
@@ -1,62 +1,64 @@
from argparse import Namespace import typer
from datetime import datetime from datetime import datetime
from git_flow import COMMIT_TYPES, GitFlowError 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 from git_flow.git import Git
from git_flow.command.base import *
app = typer.Typer()
class CommitCommand(Command): @app.command()
def name(self) -> str: def commit():
return "commit" """Crea un commit siguiendo el formato de Conventional Commits"""
ensure_initialized()
branch = ensure_right_branch()
def description(self) -> str: if not _has_files_staged():
return """Crea un commit siguiendo el formato de Conventional Commits""" warning("No hay cambios en el indice para commitear.")
if confirm("¿Desea agregar la carpeta actual?"):
def run(self, args: Namespace = Namespace()): Git("add", ".").exec(print="Agregando cambios")
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/"):
original_branch = branch.removeprefix("wip/")
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 + "/" + suffix, move=True).exec(print="Backup de rama WIP")
else: else:
if commit_type == "wip": raise GitFlowError("Debe agregar algún cambio al indice para continuar.")
Git("switch", "wip/" + branch, create=True).exec(print="Creando nueva rama WIP")
Git("commit", m=message).exec(print="Creando commit") commit_type = choice("Tipo de commit", COMMIT_TYPES)
commit_message = prompt(
"Mensaje (máximo recomendado: 100 caracteres)", persistent=True
)
message = commit_type + ": " + commit_message
def _has_files_staged(self): if branch.startswith(WIP_BRANCH_PREFIX):
status = Git.status() original_branch = branch.removeprefix(WIP_BRANCH_PREFIX)
Git("commit", m=message).exec(print="Creando commit en rama WIP")
if not status: if commit_type != "wip":
raise GitFlowError("No hay cambios para commitear.") 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"
)
files_in_index = [] Git("commit", m=message).exec(print="Creando commit")
for s in status:
if s.worktree != "?" and s.worktree != " ":
files_in_index.append(s.file)
return len(files_in_index) > 0 def _has_files_staged():
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
+94 -92
View File
@@ -1,115 +1,117 @@
from argparse import Namespace import typer
from git_flow import GitFlowError
from git_flow.command.base import Command from git_flow import FLOWCONFIG_FILENAME, GitFlowError
from git_flow.git import FLOWCONFIG_FILE, Git from git_flow.git import Git
from git_flow.command.base import *
app = typer.Typer()
class InitCommand(Command): @app.command()
def name(self) -> str: def init():
return "init" """Inicializa el repositorio para utilizar git-flow"""
def description(self) -> str: _ensure_is_repository()
return """Inicializa el repositorio para utilizar git-flow""" _ensure_not_already_initialized()
def run(self, args: Namespace = Namespace()): branches = _setup_flow_branches()
self._ensure_is_repository() remote = _setup_flow_remote()
self._ensure_not_already_initialized()
branches = self._setup_flow_branches() flowconfig = {
remote = self._setup_flow_remote() "flow.initialized": "true",
"flow.branches": ",".join(branches),
}
flowconfig = { if remote:
"flow.initialized": "true", flowconfig["flow.remote"] = remote
"flow.branches": ",".join(branches),
}
if remote: Git.set_config(flowconfig, FLOWCONFIG_FILENAME)
flowconfig["flow.remote"] = remote Git("add", FLOWCONFIG_FILENAME).exec()
Git("commit", message="feature: initialize git-flow").exec()
Git.set_config(flowconfig, FLOWCONFIG_FILE) _ensure_all_flow_branches_exist(branches, remote)
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): def _ensure_is_repository():
if not Git.is_repository(): if not Git.is_repository():
self.warning("El directorio actual no es un repositorio.") warning("El directorio actual no es un repositorio.")
if not self.confirm("¿Desea inicializarlo?"): if not confirm("¿Desea inicializarlo?"):
raise GitFlowError( raise GitFlowError("No se puede continuar sin inicializar el repositorio")
"No se puede continuar sin inicializar el repositorio"
)
Git("init").exec() Git("init").exec()
def _ensure_not_already_initialized(self):
if not self.flowconfig: def _ensure_not_already_initialized():
return if not flowconfig:
elif self.flowconfig.get("flow.initialized") == "true": return
raise GitFlowError("El repositorio ya fue inicializado para usar git-flow") elif 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():
panel(
"Configuración de entornos",
"""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(), prompt("Ramas", "main").split(",")))
if not all(branches):
raise GitFlowError("No puede ingresar una rama vacia")
return branches
def _setup_flow_remote():
panel(
"Configuración de remoto",
"Puede configurar un repositorio remoto para generar PRs automáticamente",
)
remote = None
if confirm("¿Configurar repositorio remoto?"):
remotes = Git("remote").lines(print="Listando remotos disponibles")
if not remotes:
info("No tiene ningún repositorio remoto, se creará uno.")
url = 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: else:
raise GitFlowError("Valor de 'flow.initialized' es inválido") info("Tiene más de un remoto, seleccione el que va a utilizar.")
remote = choice("Remoto", remotes)
def _setup_flow_branches(self): return remote
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): def _ensure_all_flow_branches_exist(branches: list[str], remote: str | None):
raise GitFlowError("No puede ingresar una rama vacia") existing_branches = Git.get_branches()
return branches if not all(map(lambda b: b in existing_branches, branches)):
warning("Algunas de las ramas de entornos expecificadas no existen.")
info("Debe indicar sobre que rama se crearán las ramas de entorno.")
def _setup_flow_remote(self): target_branch = choice("Rama", existing_branches)
self.info(
"""Puede elegir o crear un repositorio remoto para generar automáticamente PRs"""
)
remote = None for branch in branches:
if branch not in existing_branches:
if self.confirm("¿Configurar repositorio remoto?"): Git("branch", branch, target_branch).exec(
remotes = Git("remote").lines(print="Listando remotos disponibles") print="Creando rama inexistente"
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 if remote:
Git("push", remote, branch, set_upstream=True).exec(
def _ensure_all_flow_branches_exist(self, branches: list[str], remote: str | None): print="Creando rama en remoto"
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"
)
+99 -67
View File
@@ -1,95 +1,127 @@
from argparse import Namespace import typer
from git_flow import GitFlowError from git_flow import GitFlowError
from git_flow.changelog import Changelog from git_flow.changelog import Changelog
from git_flow.command.base import Command from git_flow.command.base import *
from git_flow.git import Git from git_flow.git import Git
app = typer.Typer()
class MergeCommand(Command):
def name(self) -> str:
return "merge"
def description(self) -> str: @app.command()
return """Realiza el merge de la rama, o crea el PR para hacerlo si tiene un remoto configurado""" def merge():
"""Realiza el merge de la rama, o crea el PR para hacerlo si tiene un remoto configurado"""
ensure_initialized()
def run(self, args: Namespace = Namespace()): branch = ensure_right_branch()
self.ensure_initialized() target, _ = get_branch_env_and_type(branch)
branch = self.ensure_right_branch() if "flow.remote" in flowconfig:
(target, _) = self.get_branch_env_and_type(branch) remote = flowconfig["flow.remote"]
token = ensure_repository_token()
run_remote(remote, token, branch, target)
else:
run_local(branch, target)
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): def run_remote(remote: str, token: str, branch: str, target: str):
self.ensure_clean_worktree(True) 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")
check_merge_conflicts(target)
show_commits_to_merge(target)
if confirm(f"¿Crear PR de '{branch}' a '{target}'?"):
Git("push", remote, branch, set_upstream=True).exec(
print="Subiendo rama al remoto para crear PR"
)
create_pull_request(token, branch, target)
def run_local(branch: str, target: str):
ensure_clean_worktree(False)
check_merge_conflicts(target)
show_commits_to_merge(target)
if confirm(f"¿Mergear rama '{branch}' a '{target}'?"):
Git("switch", target).exec(print="Cambiando a rama destino") Git("switch", target).exec(print="Cambiando a rama destino")
Git("pull").exec(print="Obteniendo ultimos cambios del remoto") Git("merge", branch, ff=False).exec(print="Mergeando")
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}'?"): def show_commits_to_merge(target):
Git("push", remote, branch, set_upstream=True).exec( commits = Git("log", target + "..", format="%s").lines()
print="Subiendo rama al remoto para crear PR"
)
self.create_pull_request(token, branch, target) if not commits:
raise GitFlowError("No hay cambios a mergear.")
def run_local(self, branch: str, target: str): print("Cambios a mergear:")
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}'?"): for commit in commits:
Git("switch", target).exec(print="Cambiando a rama destino") print("- " + commit)
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: def check_merge_conflicts(target: str):
raise GitFlowError("No hay cambios a mergear.") merge_conflicts = Git("merge", target, ff=False, commit=False).code(
print="Realizando merge de prueba para verificar conflictos"
)
print("Cambios a mergear:") Git("merge", abort=True).exec(print="Abortando merge de prueba", check=False)
for commit in commits: if merge_conflicts:
print("- " + commit) error(f"La rama actual tiene conflictos con {target}")
info(
def check_merge_conflicts(self, target: str): f"""Se recomienda mergear {target} a la rama actual, resolver los conflictos localmente, y ejecutar nuevamente este comando"""
merge_conflicts = Git("merge", target, ff=False, commit=False).code(
print="Realizando merge de prueba para verificar conflictos"
) )
raise GitFlowError("Ejecución abortada.")
else:
success("No se detectaron merge conflicts.")
Git("merge", abort=True).exec(print="Abortando merge de prueba", check=False)
if merge_conflicts: def create_pull_request(token: str, branch: str, target: str):
self.error(f"La rama actual tiene conflictos con {target}") changelog = Changelog()
self.info( commits = Git("log", target + "..", format="%s").lines()
f"""Se recomienda mergear {target} a la rama actual, resolver los conflictos localmente, y ejecutar nuevamente este comando""" title = commits[0] if len(commits) == 1 else _get_pr_title_from_branch(branch)
)
raise GitFlowError("Ejecución abortada.")
else:
self.success("No se detectaron merge conflicts.")
def create_pull_request(self, token: str, branch: str, target: str): panel("Título del PR", title)
changelog = Changelog()
title = branch.replace("/", ": ").replace("-", " ")
if title.startswith("release: "): if confirm("¿Desea cambiar el título del PR?", False):
title = title[title.index(' ', title.index(' ') + 1) + 1:] title = prompt("Título del PR")
message = self.get_remote_api(token).create_pull_request( message = get_remote_api(token).create_pull_request(
branch, branch,
target, target,
branch.replace("/", ": ").replace("-", " "), title,
changelog.generate_content(target, branch), changelog.generate_content(target, branch),
) )
self.success(message) success(message)
if 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")
def _get_pr_title_from_branch(branch: str) -> str:
components = branch.split("-") # <type>/<desc> or release/<env>/<type>/<desc>
branch_type = components[-2]
branch_desc = components[-1]
# detect if branch description has ticket as prefix: <desc> = ABC-123-my-branch-name
branch_ticket = None
first_dash = branch_desc.find("-")
second_dash = branch_desc.find("-", first_dash + 1)
if first_dash >= 0 and second_dash >= 0:
maybe_ticket = branch_desc[:second_dash]
if is_valid_ticket(maybe_ticket):
branch_ticket = maybe_ticket
branch_desc = branch_desc[:second_dash+1]
return " ".join(filter(None, [
f"[Pasaje a {components[1]}]" if len(components) == 4 else None,
branch_type + ":",
branch_ticket,
branch_desc.replace("-", " ")
]))
+49 -43
View File
@@ -1,59 +1,65 @@
from argparse import Namespace import typer
from git_flow import BRANCH_TYPES from git_flow import BRANCH_TYPES
from git_flow.command.base import Command from git_flow.command.base import *
from git_flow.git import Git from git_flow.git import Git
from typing import Optional
app = typer.Typer()
class NewCommand(Command): @app.command()
def name(self) -> str: def new():
return "new" """Crea una nueva rama siguiendo Conventional Branches"""
def description(self) -> str: ensure_initialized()
return """Crea una nueva rama siguiendo conventional branches""" branch = ensure_right_branch()
envs = flowconfig["flow.branches"].split(",")
def run(self, args: Namespace = Namespace()): if branch not in envs:
self.ensure_initialized() warning("La rama actual no corresponde a un entorno configurado.")
branch = self.ensure_right_branch()
envs = self.flowconfig["flow.branches"].split(",")
if branch not in envs: info("Se creará una nueva rama de trabajo sobre la rama actual.")
self.warning("La rama actual no corresponde a un entorno configurado.") info("Debe seleccionar el tipo de cambio a realizar.")
self.info("Se creará una nueva rama de trabajo sobre la rama actual.") branch_type = choice("Tipos de cambio", BRANCH_TYPES)
self.info("Debe seleccionar el tipo de cambio a realizar.") ticket = _get_optional_ticket()
new_branch = _get_unique_branch_name(branch_type, ticket)
branch_type = self.choice("Tipos de cambio", BRANCH_TYPES) if "flow.remote" in flowconfig and Git.get_tracking_branch(branch):
new_branch = self._get_unique_branch_name(branch_type) Git("pull").exec(print="Actualizando rama actual")
if "flow.remote" in self.flowconfig and Git.get_tracking_branch(branch): if confirm(f"¿Crear rama '{new_branch}' sobre la rama actual?"):
Git("pull").exec(print="Actualizando rama actual") Git("switch", new_branch, create=True).exec(print="Creando nueva rama")
if self.confirm(f"¿Crear rama '{new_branch}' sobre la rama actual?"): def _get_optional_ticket() -> Optional[str]:
Git("switch", new_branch, create=True).exec(print="Creando nueva rama") ticket = prompt("[Opcional] Ticket que respalda el cambio (en formato ABC-123)")
def _get_unique_branch_name(self, branch_type: str) -> str: if not ticket:
self.info( return None
f"Ingrese las palabras clave que describan el cambio de tipo '{branch_type}'." if not is_valid_ticket(ticket):
warning("Formato de ticket inválido, ignorando.")
return None
return ticket
def _get_unique_branch_name(branch_type: str, ticket: Optional[str]) -> str:
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 = prompt(
"Palabras clave del cambio (por ejemplo: 'create worker form')",
persistent=True,
) )
branch = None
branches = Git.get_branches()
while branch is None: keywords = filter(lambda k: len(k) > 0, keywords.split(" "))
keywords = self.prompt( branch = branch_type + "/" + (ticket + "-" if ticket else "") + "-".join(keywords)
"Palabras clave del cambio (por ejemplo: 'create worker form')",
persistent=True, if branch in branches:
error(
f"La rama '{branch}' ya existe, cambie las palabras claves para generar otra"
) )
branch = None
ticket = self.prompt("[Opcional] Ingrese el ID del ticket (por ejemplo: ABC-123 o #123):") return branch
ticket = ticket[1:] if ticket.startswith("#") else ticket
keywords = ticket + ' ' + keywords
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
+77 -79
View File
@@ -1,94 +1,92 @@
from argparse import ArgumentParser, Namespace
from git_flow import COMMIT_TYPES, GitFlowError from git_flow import COMMIT_TYPES, GitFlowError
from git_flow.command.base import Command from git_flow.command.base import *
from git_flow.git import Git from git_flow.git import Git
from typing import Optional
import typer
app = typer.Typer()
class ReleaseCommand(Command): @app.command()
def name(self) -> str: def release(group: Optional[str] = None):
return "release" """Realiza el merge de la rama, o crea el PR para hacerlo si tiene un remoto configurado"""
ensure_initialized()
def description(self) -> str: branch = ensure_right_branch()
return """Realiza el merge de la rama, o crea el PR para hacerlo si tiene un remoto configurado""" envs = flowconfig["flow.branches"].split(",")
env, _ = get_branch_env_and_type(branch)
def setup_parser(self, parser: ArgumentParser) -> ArgumentParser: if env == envs[-1]:
parser.add_argument( raise GitFlowError(
"--group", "No se puede hacer release de una rama en el ultimo entorno."
nargs="?",
help="Agrupar release",
) )
return parser has_remote = "flow.remote" in flowconfig
def run(self, args: Namespace = Namespace()): ensure_clean_worktree(has_remote)
self.ensure_initialized()
branch = self.ensure_right_branch() next_env = envs[envs.index(env) + 1]
envs = self.flowconfig["flow.branches"].split(",") next_branch = (
(env, _) = self.get_branch_env_and_type(branch) branch.replace(f"/{env}/", f"/{next_env}/")
if branch.startswith("release/")
else f"release/{next_env}/{branch}"
)
if env == envs[-1]: base = Git.get_first_fork_point(branch, env)
raise GitFlowError( commits = Git("log", base + "..", format="%s").lines()
"No se puede hacer release de una rama en el ultimo entorno."
if not commits:
raise GitFlowError("No hay cambios a mergear.")
print("Cambios a pasar al proximo entorno:")
for commit in commits:
print("- " + commit)
grouping = group is not None
if not group:
if confirm("¿Desea agrupar este release con otra rama?", False):
grouping = True
group = choice(
"Grupo release: ", Git.get_branches("release/" + next_env + "/")
) )
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
if not group:
if self.confirm("¿Desea agrupar este release con otra rama?", False):
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: else:
Git("switch", next_branch, create=True).exec(print="Creando rama release") group = next_env
Git("rebase", base, next_branch, onto=group).exec( if has_remote and Git.get_tracking_branch(group):
print="Moviendo cambios hacia el siguiente ambiente" 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 confirm(
f"¿Desea reemplazar los {len(commits)} commits de la rama por uno solo?", False
):
info("Debe ingresar el mensaje del commit a crear.")
commit_type = choice("Tipo de commit", COMMIT_TYPES[:-1])
commit_message = 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"
) )
+76 -73
View File
@@ -1,4 +1,3 @@
from argparse import ArgumentParser, Namespace
from git_flow import ( from git_flow import (
COMMIT_TYPE_INCREMENT, COMMIT_TYPE_INCREMENT,
SEMVER_MAJOR, SEMVER_MAJOR,
@@ -8,100 +7,104 @@ from git_flow import (
GitFlowError, GitFlowError,
) )
from git_flow.changelog import Changelog from git_flow.changelog import Changelog
from git_flow.command.base import Command from git_flow.command.base import *
from git_flow.git import Git from git_flow.git import Git
from typing import Optional
import typer
app = typer.Typer()
class TagCommand(Command): @app.command()
def name(self) -> str: def tag(token: Optional[str] = None):
return "tag" """Crea un nuevo tag para el último merge."""
ensure_initialized()
def description(self) -> str: envs = flowconfig["flow.branches"].split(",")
return """Crea un nuevo tag para el último merge.""" target = Git.get_current_branch()
def run(self, args: Namespace = Namespace()): if target not in envs:
self.ensure_initialized() raise GitFlowError("Solo se pueden taggear commits en ramas principales.")
envs = self.flowconfig["flow.branches"].split(",") branch = Git(
target = Git.get_current_branch() "show", 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 = get_next_tag_from_commits(commits, target)
if target not in envs: if not next_tag:
raise GitFlowError("Solo se pueden taggear commits en ramas principales.") info(
"Los cambios realizados no implican un salto de versión, se mantiene la anterior."
)
return
branch = Git("show", self.get_last_merge_commit() + "^2", patch=False, format="%h").firstline() token, ci = get_token_and_ci(token)
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: if not token:
self.info( raise GitFlowError("No hay token disponible")
"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 = Changelog()
changelog.update(next_tag, base, branch) changelog.update(next_tag, base, branch)
Git("add", Changelog.FILENAME).exec(print="Agregando nuevo CHANGELOG.md") Git("add", Changelog.FILENAME).exec(print="Agregando nuevo CHANGELOG.md")
Git("commit", message=Changelog.COMMIT_MESSAGE).exec(print="Creando commit") Git("commit", message=Changelog.COMMIT_MESSAGE).exec(print="Creando commit")
tag_commit = Git("show", "HEAD", patch=False, format="%h").firstline() tag_commit = Git("show", "HEAD", patch=False, format="%h").firstline()
if ci: if ci:
Git("push").exec(print="Subiendo commit al remoto para taggearlo") Git("push").exec(print="Subiendo commit al remoto para taggearlo")
self.success(self.get_remote_api(token).create_tag(next_tag, tag_commit)) success(get_remote_api(token).create_tag(next_tag, tag_commit))
else:
Git("tag", next_tag, tag_commit).exec(print="Creando tag localmente")
def get_token_and_ci(self, args: Namespace): Git("tag", next_tag, tag_commit).exec(print="Creando tag localmente")
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_token_and_ci(token: Optional[str]):
try:
return (ensure_repository_token(), False)
except GitFlowError:
return (token, True)
def get_last_merge_commit(self):
commit = Git(
"log", first_parent=True, merges=True, max_count=1, format="%h"
).firstline()
if not commit: def get_last_merge_commit():
raise GitFlowError("No hay merges a taggear.") commit = Git(
"log", first_parent=True, merges=True, max_count=1, format="%h"
).firstline()
return commit if not commit:
raise GitFlowError("No hay merges a taggear.")
def check_not_tagged(self, commit: str): return commit
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: def check_not_tagged(commit: str):
increment = SEMVER_SKIP tag = Git("describe", commit, tags=True, exact_match=True).firstline(check=False)
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}" if tag:
raise GitFlowError("El commit a taggear ya tiene tag: " + tag)
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: def get_next_tag_from_commits(commits: list[str], branch: str) -> str | None:
major += 1 increment = SEMVER_SKIP
minor = 0 for commit in commits:
patch = 0 commit_type = commit[: commit.index(":")]
elif increment == SEMVER_MINOR: increment = max(increment, COMMIT_TYPE_INCREMENT.get(commit_type, SEMVER_SKIP))
minor += 1
patch = 0
elif increment == SEMVER_PATCH:
patch += 1
return None if increment == SEMVER_SKIP else f"v{major}.{minor}.{patch}{suffix}" 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
elif increment == SEMVER_SKIP:
suffix = "-r" + Git("rev-list", "HEAD", count=True).firstline()
return None if increment == SEMVER_SKIP else f"v{major}.{minor}.{patch}{suffix}"
+15 -19
View File
@@ -1,16 +1,9 @@
import subprocess import subprocess
from git_flow import GitFlowError from git_flow import FLOWCONFIG_FILENAME
from git_flow.io import * 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 Git:
class Status: class Status:
worktree: str worktree: str
@@ -44,7 +37,7 @@ class Git:
@staticmethod @staticmethod
def flow_config(*args, **kwargs): def flow_config(*args, **kwargs):
return Git("config", *args, file=FLOWCONFIG_FILE, **kwargs) return Git("config", *args, file=FLOWCONFIG_FILENAME, **kwargs)
@staticmethod @staticmethod
def get_current_branch(): def get_current_branch():
@@ -88,7 +81,9 @@ class Git:
except subprocess.CalledProcessError: except subprocess.CalledProcessError:
return None return None
def __init__(self, subcommand: str, *args: str, **kwargs: str | int | bool | list[str] | None) -> None: def __init__(
self, subcommand: str, *args: str, **kwargs: str | int | bool | list[str] | None
) -> None:
self.command = ["git", subcommand] self.command = ["git", subcommand]
for option, value in kwargs.items(): for option, value in kwargs.items():
@@ -133,7 +128,7 @@ class Git:
def exec(self, **kwargs): def exec(self, **kwargs):
self._run(**kwargs) self._run(**kwargs)
def _get(self, print: str|None = None, check: bool = True): def _get(self, print: str | None = None, check: bool = True):
process = subprocess.run(self.command, capture_output=True, text=True) process = subprocess.run(self.command, capture_output=True, text=True)
if print is not None: if print is not None:
@@ -144,9 +139,10 @@ class Git:
return process return process
def _run(self, print: str | None = None, check: bool = True):
def _run(self, print: str|None = None, check: bool = True): process = subprocess.run(
process = subprocess.run(self.command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) self.command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
)
if print is not None: if print is not None:
self._print_process("", "", title=print) self._print_process("", "", title=print)
@@ -168,12 +164,12 @@ class Git:
command.append(arg) command.append(arg)
title = title or "Ejecutando" text = f"[yellow]$ {" ".join(command)}[/yellow]"
print(COLOR_YELLOW + "| " + title + COLOR_RESET)
print(COLOR_YELLOW + "| $ " + " ".join(command) + COLOR_RESET)
for line in stdout.splitlines(): for line in stdout.splitlines():
print(COLOR_YELLOW + "| " + COLOR_RESET + "[out] " + line) text += "\n\\[out] " + line
for line in stderr.splitlines(): for line in stderr.splitlines():
print(COLOR_YELLOW + "| " + COLOR_RED + "[err] " + line + COLOR_RESET) text += "\n[red]\\[err][/red] " + line
panel(title or "Ejecutando", text)
+41 -42
View File
@@ -1,56 +1,55 @@
import sys from rich.console import Console
from rich.prompt import Prompt, Confirm
from rich.panel import Panel
from git_flow import COLOR_BLACK_BOLD, COLOR_BLUE, COLOR_GREEN, COLOR_RED, COLOR_RESET, COLOR_YELLOW console = Console(highlight=False)
def confirm(prompt, default: bool = True):
user_input = input(prompt + (" [Y/n]: " if default else " [y/N]: "))
return default if len(user_input) == 0 else user_input.startswith("y")
def choose(prompt: str, options: list[str]): def success(msg: str):
print(prompt) console.print(f":white_check_mark: [green]{msg}[/green]")
for i, option in enumerate(options):
print(f"\t{i+1}. {option}")
selection = None def warning(msg: str):
console.print(f":warning: [yellow]{msg}[/yellow]")
while selection is None:
user_input = input(f"Seleccione una opción [1-{len(options)}] o escribala: ")
if user_input.isdigit(): def error(msg: str):
user_input = int(user_input) console.print(f":x: [red]{msg}[/red]")
if 1 <= user_input and user_input <= len(options):
selection = options[user_input - 1]
if not confirm( def info(msg: str):
f"Seleccionó la opción {user_input} ({selection}), ¿es correcto?" console.print(f":information: [blue]{msg}[/blue]")
):
selection = None
else: def panel(title: str, text: str):
print_error(f"La opción {user_input} está fuera del rango permitido.") console.print(Panel(text, title=title, expand=False, title_align="left"))
elif user_input in options:
selection = user_input
def prompt(
prompt: str,
default: str | None = None,
persistent: bool = False,
strip: bool = True,
) -> str:
onetime = not persistent
while onetime or persistent:
result = Prompt.ask(prompt)
result = result.strip() if strip else result
if result:
return result
elif default is not None:
return default
elif persistent:
error("Debe ingresar un valor no vacío.")
else: else:
print_error(f"La opción '{user_input}' es inválida.") return result
return selection
def print_error(message: str): def confirm(question: str, default: bool = True):
print(f"{COLOR_RED}[err] {message}{COLOR_RESET}", file=sys.stderr) return Confirm.ask(question, default=default)
def print_warning(message: str): def choice(prompt: str, options: list[str]):
print(f"{COLOR_YELLOW}[wrn] {message}{COLOR_RESET}") return Prompt.ask(prompt, choices=options)
def print_success(message: str):
print(f"{COLOR_GREEN}[ok!] {message}{COLOR_RESET}")
def print_info(message: str):
print(f"{COLOR_BLUE}[inf] {message}{COLOR_RESET}")
def print_debug(message: str):
print(f"{COLOR_BLACK_BOLD}[dbg] {message}{COLOR_RESET}")
+15 -73
View File
@@ -1,90 +1,32 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import locale import typer
from argparse import Namespace, ArgumentParser
from git_flow import GitFlowError from git_flow import GitFlowError
from git_flow.command.base import Command from git_flow.command.base import *
from git_flow.command.branch import BranchCommand from git_flow.command import init, new, commit, merge, tag, release, branch
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" REPOSITORY_TOKEN_PATH = ".repository-token"
LOCALE = "es_AR.UTF-8" LOCALE = "es_AR.UTF-8"
class GitFlowCommand(Command): app = typer.Typer()
commands: tuple[Command, ...] app.add_typer(init.app)
app.add_typer(new.app)
def __init__(self, *args: Command) -> None: app.add_typer(commit.app)
self.commands = args app.add_typer(merge.app)
app.add_typer(tag.app)
def name(self) -> str: app.add_typer(release.app)
return "git-flow" app.add_typer(branch.app)
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(): def main():
try: try:
locale.setlocale(locale.LC_ALL, LOCALE) app()
except locale.Error as e:
print_warning(f"No se pudo configurar el locale '{LOCALE}': {e}")
command = GitFlowCommand(
InitCommand(),
NewCommand(),
CommitCommand(),
MergeCommand(),
TagCommand(),
ReleaseCommand(),
BranchCommand()
)
try:
command.run()
except GitFlowError as e: except GitFlowError as e:
command.error(str(e)) error(str(e))
except Exception as e: except Exception as e:
command.error("Ocurrió un error inesperado: " + str(e)) error("Ocurrió un error inesperado: " + str(e))
except KeyboardInterrupt as e: except KeyboardInterrupt as e:
print() print()
command.error("Ejecución abortada") error("Ejecución abortada")
return
if __name__ == "__main__":
main()
Generated
+181
View File
@@ -0,0 +1,181 @@
version = 1
revision = 3
requires-python = ">=3.14"
[[package]]
name = "annotated-doc"
version = "0.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
]
[[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/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 = "click"
version = "8.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "git-flow-envs"
source = { editable = "." }
dependencies = [
{ name = "requests" },
{ name = "typer" },
]
[package.metadata]
requires-dist = [
{ name = "requests" },
{ name = "typer" },
]
[[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 = "markdown-it-py"
version = "4.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[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 = "rich"
version = "15.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
]
[[package]]
name = "shellingham"
version = "1.5.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
]
[[package]]
name = "typer"
version = "0.25.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "click" },
{ name = "rich" },
{ name = "shellingham" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e4/51/9aed62104cea109b820bbd6c14245af756112017d309da813ef107d42e7e/typer-0.25.1.tar.gz", hash = "sha256:9616eb8853a09ffeabab1698952f33c6f29ffdbceb4eaeecf571880e8d7664cc", size = 122276, upload-time = "2026-04-30T19:32:16.964Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl", hash = "sha256:75caa44ed46a03fb2dab8808753ffacdbfea88495e74c85a28c5eefcf5f39c89", size = 58409, upload-time = "2026-04-30T19:32:18.271Z" },
]
[[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" },
]