Compare commits

...
29 Commits
Author SHA1 Message Date
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
14 changed files with 416 additions and 550 deletions
+1
View File
@@ -3,3 +3,4 @@
**/__pycache__/
build/
**/*.egg-info/
dist/
+53
View File
@@ -1,3 +1,56 @@
## 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)
+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 API Token para poder crear los PRs automáticamente.
El mismo se puede crear desde: *"Atlassian Account Settings" > "Security" > "Create and Manage API
Tokens" > "Create API Tokens with scopes"*. El token necesita el scope `write:pullrequest:bitbucket`
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`.
+1
View File
@@ -6,6 +6,7 @@ BRANCH_TYPES = [
"bugfix",
"hotfix",
"chore",
"docs",
]
COMMIT_TYPES = [
+21 -3
View File
@@ -1,6 +1,5 @@
from argparse import Namespace, ArgumentParser
from abc import ABC, abstractmethod
import os
from os.path import isfile
from git_flow import (
@@ -31,6 +30,8 @@ TYPE_INFO = 3
class Command(ABC):
flowconfig: dict[str, str]
@abstractmethod
def name(self) -> str:
pass
@@ -43,7 +44,7 @@ class Command(ABC):
def run(self, args: Namespace = Namespace()):
pass
def __init__(self) -> None:
def init(self):
self.flowconfig = (
Git.get_config(FLOWCONFIG_FILE) if isfile(FLOWCONFIG_FILE) else {}
)
@@ -148,7 +149,7 @@ class Command(ABC):
raise GitFlowError("Ejecución cancelada.")
def ensure_repository_token(self):
if not os.path.isfile(REPOSITORY_TOKEN_FILENAME):
if not isfile(REPOSITORY_TOKEN_FILENAME):
raise GitFlowError(
"No existe un token para el repositorio en " + REPOSITORY_TOKEN_FILENAME
)
@@ -156,6 +157,23 @@ class Command(ABC):
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(" ")
+79
View File
@@ -0,0 +1,79 @@
from argparse import ArgumentParser, Namespace
from git_flow import GitFlowError
from git_flow.command.base import COLOR_GREEN, COLOR_RED, COLOR_RESET, 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. Puede ser vacio para listar ramas de todos los entornos, un entorno válido, o 'trash' para listar las ramas eliminadas.",
)
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 args.environment is None:
self.show_all_branches()
elif args.environment == "trash":
self.show_trash_branches()
elif args.environment in self.environments:
self.show_env_branches(args.environment)
else:
raise GitFlowError(f"'{args.environment}' no es un entorno válido.")
def show_trash_branches(self):
branches = self.get_trash_branches()
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)
trash = len(self.get_trash_branches())
print("\nTrash: " + str(trash) + " rama" + ("" if trash == 1 else "s"))
def show_env_branches(self, environment: str):
is_first_env = environment == self.environments[0]
branches = (
Git(
"for-each-ref",
"refs/heads/",
exclude=["refs/heads/trash/", "refs/heads/release/"],
format=BRANCH_FORMAT,
).lines()
if is_first_env
else Git(
"for-each-ref",
"refs/heads/release/" + environment + "/",
format=BRANCH_FORMAT,
).lines()
)
for branch in branches:
prefix = (COLOR_GREEN + "*") if branch == self.current_branch else " "
print(prefix + " " + branch + COLOR_RESET)
+9 -9
View File
@@ -18,8 +18,8 @@ class CommitCommand(Command):
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 .`)?"):
Git("add", ".").exec()
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."
@@ -33,19 +33,19 @@ class CommitCommand(Command):
if branch.startswith("wip/"):
original_branch = branch.removeprefix("wip/")
Git("commit", m=message).exec()
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()
Git("merge", branch, squash=True).exec()
Git("commit", m=message).exec()
Git("branch", branch, "trash/" + branch + "/" + suffix, move=True).exec()
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:
if commit_type == "wip":
Git("switch", "wip/" + branch, create=True).exec()
Git("switch", "wip/" + branch, create=True).exec(print="Creando nueva rama WIP")
Git("commit", m=message).exec()
Git("commit", m=message).exec(print="Creando commit")
def _has_files_staged(self):
status = Git.status()
+13 -12
View File
@@ -45,13 +45,10 @@ class InitCommand(Command):
Git("init").exec()
def _ensure_not_already_initialized(self):
if os.path.isfile(FLOWCONFIG_FILE):
flowconfig = Git.get_config(FLOWCONFIG_FILE)
if flowconfig["flow.initialized"] == "true":
raise GitFlowError(
"El repositorio ya fue inicializado para usar git-flow"
)
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")
@@ -79,7 +76,7 @@ class InitCommand(Command):
remote = None
if self.confirm("¿Configurar repositorio remoto?"):
remotes = Git("remote").lines()
remotes = Git("remote").lines(print="Listando remotos disponibles")
if not remotes:
self.info("No tiene ningún repositorio remoto, se creará uno.")
@@ -89,7 +86,7 @@ class InitCommand(Command):
persistent=True,
)
remote = "origin"
Git("remote", "add", remote, url).exec()
Git("remote", "add", remote, url).exec(print="Agregando remoto")
elif len(remotes) == 1:
remote = remotes[0]
else:
@@ -98,7 +95,7 @@ class InitCommand(Command):
return remote
def _ensure_all_flow_branches_exist(self, branches: list[str], remote: str|None):
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)):
@@ -109,7 +106,11 @@ class InitCommand(Command):
for branch in branches:
if branch not in existing_branches:
Git("branch", branch, target_branch).exec()
Git("branch", branch, target_branch).exec(
print="Creando rama inexistente"
)
if remote:
Git("push", remote, branch, set_upstream=True).exec()
Git("push", remote, branch, set_upstream=True).exec(
print="Creando rama en remoto"
)
+17 -28
View File
@@ -27,44 +27,30 @@ class MergeCommand(Command):
self.run_local(base, branch, target)
def run_remote(self, remote: str, token: str, base: str, branch: str, target: str):
self.check_pending_changes(True)
self.ensure_clean_worktree(True)
Git("switch", target).exec()
Git("pull").exec()
Git("switch", "-").exec()
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(base)
Git("push", remote, branch, set_upstream=True).exec()
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, base, branch, target)
def run_local(self, base: str, branch: str, target: str):
self.check_pending_changes(False)
self.ensure_clean_worktree(False)
self.check_merge_conflicts(target)
self.show_commits_to_merge(base)
if self.confirm(f"Mergear rama '{target}' <= '{branch}'?"):
Git("switch", target).exec()
Git("merge", branch, ff=False).exec()
def check_pending_changes(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")
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, base):
commits = Git("log", base + "..", format="%s").lines()
@@ -78,10 +64,13 @@ class MergeCommand(Command):
print("- " + commit)
def check_merge_conflicts(self, target: str):
merge_conflicts = Git("merge", target, ff=False, commit=False).code() != 0
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:
Git("merge", abort=True).exec()
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"""
+1 -1
View File
@@ -22,7 +22,7 @@ class NewCommand(Command):
new_branch = self._get_unique_branch_name(branch_type)
if self.confirm(f"¿Crear rama '{new_branch}' sobre la rama actual?"):
Git("switch", new_branch, create=True).exec()
Git("switch", new_branch, create=True).exec(print="Creando nueva rama")
def _get_unique_branch_name(self, branch_type: str) -> str:
self.info(
+67
View File
@@ -0,0 +1,67 @@
from argparse import 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 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}"
)
if has_remote:
Git("pull").exec(print="Sincronizando cambios de la rama")
Git("switch", next_env).exec(print="Cambiando al siguiente entorno")
Git("pull").exec(print="Sincronizando cambios del entorno")
Git("switch", "-").exec(print="Volviendo a la rama original")
base = Git.get_first_fork_point(branch, env)
commits = int(Git("rev-list", base + "..", count=True).firstline())
if commits > 1 and self.confirm(
"Desea reemplazar los {commits} commits de la rama por uno solo?"
):
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"
)
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("commit", m=message).exec(print="Creando commit único")
else:
Git("switch", next_branch, create=True).exec(print="Creando rama release")
Git("rebase", base, next_branch, onto=next_env).exec(
print="Moviendo cambios hacia el siguiente ambiente"
)
+11 -19
View File
@@ -28,11 +28,7 @@ class TagCommand(Command):
if target not in envs:
raise GitFlowError("Solo se pueden taggear commits en ramas principales.")
merge_commit = self.get_last_merge_commit()
self.check_not_tagged(merge_commit)
branch = Git("show", merge_commit + "^2", patch=False, format="%h").firstline()
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)
@@ -44,18 +40,18 @@ class TagCommand(Command):
return
(token, ci) = self.get_token_and_ci(args)
if ci:
message = self.get_remote_api(token).create_tag(next_tag, merge_commit)
changelog = Changelog()
self.success(message)
changelog.update(next_tag, base, branch)
Git("add", Changelog.FILENAME).exec()
Git("commit", message=Changelog.COMMIT_MESSAGE).exec()
Git("push").exec()
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))
else:
Git("tag", next_tag, merge_commit).exec()
Git("tag", next_tag, tag_commit).exec(print="Creando tag localmente")
def get_token_and_ci(self, args: Namespace):
try:
@@ -79,14 +75,10 @@ class TagCommand(Command):
return commit
def check_not_tagged(self, commit: str):
tag = (
Git("describe", commit, tags=True, exact_match=True)
.without_checking()
.firstline()
)
tag = Git("describe", commit, tags=True, exact_match=True).firstline(check=False)
if tag:
raise GitFlowError("El ultimo merge ya tiene tag: " + 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
+34 -27
View File
@@ -22,7 +22,6 @@ class Git:
self.file = line[3:].strip("\n")
command: list[str]
check_returncode: bool = True
@staticmethod
def get_config(file: str | None = None):
@@ -60,7 +59,7 @@ class Git:
@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):
@@ -90,7 +89,7 @@ class Git:
def is_repository() -> bool:
return Git("rev-parse", is_inside_work_tree=True).code() == 0
def __init__(self, subcommand: str, *args: str, **kwargs: str | int | bool | None) -> 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():
@@ -104,6 +103,9 @@ class Git:
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)
@@ -115,44 +117,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:
@@ -164,10 +169,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)
+15 -446
View File
@@ -1,26 +1,23 @@
#!/usr/bin/env python3
import os
import sys
import datetime
import requests
import locale
from argparse import Namespace, ArgumentParser
from git_flow import GitFlowError
from git_flow.changelog import Changelog
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.git import BUMP_VERSION, Git
from git_flow.io import *
REPOSITORY_TOKEN_PATH=".repository-token"
LOCALE = 'es_AR.UTF-8'
REPOSITORY_TOKEN_PATH = ".repository-token"
LOCALE = "es_AR.UTF-8"
class GitFlowCommand(Command):
commands: tuple[Command, ...]
@@ -32,14 +29,17 @@ class GitFlowCommand(Command):
return "git-flow"
def description(self) -> str:
return """Git flow es un workflow automatizado siguiendo conventional commits, semver, y
deploys aislados a distintos entornos."""
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)
subparsers = parser.add_subparsers(
title="comandos", dest="command", required=True
)
for command in self.commands:
subparser = subparsers.add_parser(command.name())
subparser = subparsers.add_parser(
command.name(), description=command.description()
)
command.setup_parser(subparser)
return parser
@@ -54,6 +54,7 @@ class GitFlowCommand(Command):
for command in self.commands:
if command.name() == args.command:
command.init()
return command.run(args)
@@ -69,6 +70,8 @@ def main():
CommitCommand(),
MergeCommand(),
TagCommand(),
ReleaseCommand(),
BranchCommand()
)
try:
command.run()
@@ -79,440 +82,6 @@ def main():
return
args = sys.argv
if len(args) < 2:
print_error("missing command")
return
command = args[1]
try:
if command == "tag":
tag_command()
elif command == "release":
release_command()
elif command == "help":
help_command()
else:
help_command(command)
except Exception as e:
print_error(f"Ocurrio un error al ejecutar el comando: {e}")
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 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)
merged_commit = Git("show", "HEAD^2", patch=False, format="%H").firstline()
if update_changelog(merged_commit, branch):
Git("add", Changelog.FILENAME).exec()
Git("commit", message=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 == BUMP_VERSION:
return False
else:
content = generate_changelog_entry(branch, target)
if os.path.isfile(Changelog.FILENAME):
with open(Changelog.FILENAME, "r") as changelog:
content = content + changelog.read()
with open(Changelog.FILENAME, "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 == 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()