Compare commits

...
18 Commits
Author SHA1 Message Date
jt 6e760f7866 Merge pull request 'feature: show commit lists in merge and rebase in a table' (#66) from feature/display-commits-in-table into main
Publish to PyPI / Bump-Build-Publish (push) Successful in 18s
Reviewed-on: #66
2026-09-26 20:40:22 +00:00
jt 67eb6fb6d1 feature: show commit lists in merge and rebase in a table 2026-09-26 17:38:05 -03:00
jt 8ba5f8f5da Merge pull request 'feature: add verbose flag, improve command output, improve error display' (#64) from feature/improve-verbose-output into main
Publish to PyPI / Bump-Build-Publish (push) Successful in 45s
Reviewed-on: #64
2026-09-26 20:19:41 +00:00
jt 839f71962f feature: add verbose flag, improve command output, improve error display 2026-09-26 17:18:34 -03:00
jt 26ac95e5cf Merge pull request 'docs: update pipeline examples, installation guide, add badges' (#63) from docs/update-readme into main
Publish to PyPI / Bump-Build-Publish (push) Failing after 23s
Reviewed-on: #63
2026-09-25 12:54:24 +00:00
Jonathan Teran 8a28ce3a7e docs: update pipeline examples, installation guide, add badges 2026-09-25 09:53:44 -03:00
jt 3d65356f7a Merge pull request 'feature: when merging environments, promote tag from previous env to next env' (#62) from feature/promote-tag-when-merging-environments into main
Publish to PyPI / Bump-Build-Publish (push) Successful in 40s
Reviewed-on: #62
2026-09-24 15:07:02 +00:00
Jonathan Teran 44fa8bc1d6 feature: when merging environments, promote tag from previous env to next env 2026-09-24 12:06:19 -03:00
jt 631c507064 Merge pull request 'feature: add branch clean command' (#61) from feature/add-branch-clean-command into main
Publish to PyPI / Bump-Build-Publish (push) Successful in 41s
Reviewed-on: #61
2026-09-23 00:41:19 +00:00
jt b20c7e0cb5 feature: include author email in report, generate report filename 2026-09-22 21:39:48 -03:00
jt 6a4f6c9804 feature: add branch clean subcommand 2026-09-22 01:32:10 -03:00
jt 5ece118c05 refactor: move branch command to branch ls subcommand 2026-09-22 01:20:33 -03:00
jt d383fa4003 Merge pull request 'docs: remove skip step logic, fail if no new tag' (#58) from docs/update-readme into main
Publish to PyPI / Bump-Build-Publish (push) Failing after 9s
Reviewed-on: #58
2026-09-21 19:27:21 +00:00
jt 3a1220f257 docs: remove skip step logic, fail if no new tag 2026-09-21 16:26:35 -03:00
jt 83dc348fff Merge pull request 'docs: cleanup pipeline steps' (#57) from docs/update-readme into main
Publish to PyPI / Bump-Build-Publish (push) Failing after 8s
Reviewed-on: #57
2026-09-21 19:24:23 +00:00
jt 5f65df64e4 docs: cleanup pipeline steps 2026-09-21 16:24:04 -03:00
jt e4a633e2e2 Merge pull request 'docs: update readme to reflect new tag command' (#56) from docs/update-readme into main
Publish to PyPI / Bump-Build-Publish (push) Failing after 22s
Reviewed-on: #56
2026-09-21 19:22:21 +00:00
jt ae81260e38 docs: update readme to reflect new tag command 2026-09-21 16:22:01 -03:00
18 changed files with 455 additions and 129 deletions
+2 -18
View File
@@ -13,37 +13,21 @@ jobs:
with: with:
fetch-depth: 10 fetch-depth: 10
- name: Configure git
run: |
git config --global user.name "gitea-actions"
git config --global user.email "actions@git.jonathanteran.dev"
git config --global --add safe.directory "$GITHUB_WORKSPACE"
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v6 uses: actions/setup-python@v6
with: with:
python-version-file: ".python-version" python-version-file: ".python-version"
- name: Install uv
uses: astral-sh/setup-uv@v8.3.2
- name: Install git-flow and bump version - name: Install git-flow and bump version
id: version id: version
run: | run: |
pip install git-flow-envs=="${{ vars.GITFLOWENVS_VERSION }}" pip install git-flow-envs=="${{ vars.GITFLOWENVS_VERSION }}"
git-flow tag --token="${{ secrets.GITEA_TOKEN }}" git-flow tag --token="${{ secrets.GITEA_TOKEN }}"
RESULT="$?"
if [ "$RESULT" -eq 0 ]; then - name: Install uv
echo "New tag available." uses: astral-sh/setup-uv@v8.3.2
echo "should_build=true" >> $GITHUB_OUTPUT
else
echo "No new tag available, skipping next step."
echo "should_build=false" >> $GITHUB_OUTPUT
fi
- name: Build and publish - name: Build and publish
if: steps.version.outputs.should_build == 'true'
run: | run: |
uv build uv build
uv publish --token="${{ secrets.PYPI_TOKEN }}" uv publish --token="${{ secrets.PYPI_TOKEN }}"
+58 -34
View File
@@ -1,5 +1,8 @@
# Git Flow # Git Flow
[![PyPI Version](https://img.shields.io/pypi/v/git-flow-envs)](https://pypi.org/project/git-flow-envs/)
![Python Version](https://img.shields.io/python/required-version-toml?tomlFilePath=https://git.jonathanteran.dev/jt/git-flow/raw/branch/main/pyproject.toml)
Herramienta para establecer un workflow siguiendo [Conventional Commits Herramienta para establecer un workflow siguiendo [Conventional Commits
(CC)](https://www.conventionalcommits.org), [Conventional Branch (CC)](https://www.conventionalcommits.org), [Conventional Branch
(CB)](https://conventional-branch.github.io/), y [SemVer](https://semver.org/). (CB)](https://conventional-branch.github.io/), y [SemVer](https://semver.org/).
@@ -9,27 +12,12 @@ derivar versiones automaticamente de los merges sobre ramas principales.
## Instalación ## Instalación
Para instalar Git Flow, se sugiere utilizar [pipx](https://github.com/pypa/pipx) (seguir la Se puede instalar con `pip` ejecutando el siguiente comando: `pip install git-flow-envs`. Sin
documentación para instalarlo). Una vez instalado `pipx`, simplemente clonamos el repositorio en una embargo, se sugiere utilizar [pipx](https://github.com/pypa/pipx) (seguir la documentación para
carpeta local, y lo instalamos: instalarlo), ya que aisla las dependencias de la herramienta en un `venv`. Una vez instalado,
simplemente ejecutamos `pipx install git-flow-envs`
```sh Luego podemos verificar que se haya instalado correctamente ejecutando: `git-flow --help`
cd ~/Downloads
git clone ssh://git@git.jonathanteran.dev:59169/jt/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 ## Configuración
@@ -60,16 +48,22 @@ El comando solicitará 2 opciones:
## Utilización ## Utilización
Git Flow cuenta con los siguientes comandos para guiar el workflow (ver `git-flow <command> -h` para Git Flow cuenta con los siguientes comandos para guiar el workflow (ver `git-flow <command> --help` para
más información): más información):
- `init`: Inicializa el repositorio para utilizar git-flow (ramas principales y remoto). - `init`: Inicializa el repositorio para utilizar git-flow (ramas principales y remoto).
- `new`: Crea una nueva rama siguiendo CB. - `new`: Crea una nueva rama siguiendo CB.
- `commit`: Crea un commit siguiendo CC. - `commit`: Crea un commit siguiendo CC.
- `merge`: Mergea la rama actual a una de las ramas principales. - `merge`: Mergea la rama actual a uno de los entornos.
- `tag`: Crea un tag sobre el ultimo merge siguiendo SemVer. - `tag`: Crea un tag sobre el ultimo merge siguiendo SemVer (ver [Tag](#tag)).
- `release`: Prepara la rama actual para pasarse al siguiente entorno configurado. - `release`: Prepara la rama actual para pasarse al siguiente entorno configurado.
- `branch`: Lista las ramas del repositorio, agrupadas por entorno objetivo. - `branch ls`: Lista las ramas del repositorio, agrupadas por entorno objetivo.
- `branch clean`: Elimina las ramas alcanzables desde HEAD (locales por defecto, o remotas con
`--remote`), dejando un reporte CSV (`branch-clean-report.csv`) con nombre, commit, autor y
fecha de cada rama eliminada.
Por defecto solo se muestra cada paso que se ejecuta. Con `git-flow --verbose <command>` (o `-v`) se
muestra además la salida de cada comando git y el traceback completo de los errores.
El workflow para el cual se penso la herramienta es el siguiente: El workflow para el cual se penso la herramienta es el siguiente:
@@ -88,9 +82,35 @@ El workflow para el cual se penso la herramienta es el siguiente:
`feature/my-new-feature`, se crea la rama `release/test/feature/my-new-feature` sobre `test` que `feature/my-new-feature`, se crea la rama `release/test/feature/my-new-feature` sobre `test` que
tiene los cambios de la rama original. tiene los cambios de la rama original.
En cualquier momento se puede usar `git-flow branch` para ver en qué entorno está cada rama, y cuáles En cualquier momento se puede usar `git-flow branch ls` para ver en qué entorno está cada rama, y cuáles
están todavía en progreso (`wip/`) o descartadas (`trash/`). están todavía en progreso (`wip/`) o descartadas (`trash/`).
### Tag
`git-flow tag [--target=<RAMA>] [--token=<TOKEN>] [--update-changelog]`
- `--target`: rama principal sobre la que se taggea. Por defecto, la rama actual. Debe ser una de las
ramas principales configuradas.
- `--token`: Access Token del remoto. Si se pasa, se asume que corre en un pipeline: los commits y el
último tag se obtienen por API del remoto y el tag se crea en el remoto (y también localmente). Si
no, se usa `.repository-token` y todo se resuelve localmente.
- `--update-changelog`: genera la entrada correspondiente en `CHANGELOG.md` y la commitea antes de
taggear (en un pipeline, además hace push del commit).
Comportamiento:
- El tag se crea sobre el último merge (first-parent) de la rama. Si ese merge ya tiene un tag, no se
hace nada.
- La versión se calcula a partir de los tipos de commit (CC) del último merge. Si ningún commit
implica un salto de versión, se avisa y el comando termina con código de salida 1.
- Con más de un entorno configurado, cada rama versiona por separado con el nombre del entorno como
sufijo (por ejemplo, `v1.2.0-prod`). Con un solo entorno no se agrega sufijo.
- Si el merge viene del entorno anterior (por ejemplo, `dev` a `test`), no se calcula una nueva
versión: se promueve la del entorno anterior cambiando el sufijo (si `dev` está en `v1.2.0-dev` y
`test` en `v1.0.0-test`, el nuevo tag es `v1.2.0-test`), sin importar los tipos de commit. Si ese
tag ya existe en el destino, el comando falla. En un pipeline, el merge se detecta por la rama
origen del PR; localmente, porque el merge trae tags del entorno anterior que el destino no tenía.
## Remotos soportados ## Remotos soportados
Cada remoto necesita un Access Token con permisos para crear Pull/Merge Requests y tags. El token se Cada remoto necesita un Access Token con permisos para crear Pull/Merge Requests y tags. El token se
@@ -124,12 +144,11 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 10
- uses: astral-sh/setup-uv@v3 - uses: astral-sh/setup-uv@v3
- run: uv run git-flow tag --token=${{ secrets.GIT_FLOW_TOKEN }} - run: uv run git-flow tag --token=${{ secrets.GIT_FLOW_TOKEN }}
- run: uv build - run: uv build
- run: uv publish --token=${{ secrets.PYPI_TOKEN }} - run: uv publish --token=${{ secrets.PYPI_TOKEN }}
``` ```
@@ -148,11 +167,14 @@ Ejemplo de pipeline (GitLab CI) para taggear automáticamente en cada push a una
# .gitlab-ci.yml # .gitlab-ci.yml
tag: tag:
image: python:3.14 image: python:3.14
variables:
GIT_DEPTH: 10
rules: rules:
- if: $CI_COMMIT_BRANCH == "main" - if: $CI_COMMIT_BRANCH == "main"
script: script:
- pip install uv - pip install uv
- uv run git-flow tag --token=$GIT_FLOW_TOKEN # GitLab hace checkout en detached HEAD, por lo que la rama se indica explícitamente
- uv run git-flow tag --target=$CI_COMMIT_BRANCH --token=$GIT_FLOW_TOKEN
- uv build - uv build
- uv publish --token=$PYPI_TOKEN - uv publish --token=$PYPI_TOKEN
``` ```
@@ -174,6 +196,9 @@ ver el archivo `bitbucket-pipelines.yml` como ejemplo:
# bitbucket-pipelines.yml # bitbucket-pipelines.yml
image: python:3.14 image: python:3.14
clone:
depth: 10
pipelines: pipelines:
branches: branches:
'main': 'main':
@@ -216,19 +241,18 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 10
- uses: astral-sh/setup-uv@v3 - uses: astral-sh/setup-uv@v3
- run: uv run git-flow tag --token=${{ secrets.GIT_FLOW_TOKEN }} - run: uv run git-flow tag --token=${{ secrets.GIT_FLOW_TOKEN }}
- run: uv build - run: uv build
- run: uv publish --token=${{ secrets.PYPI_TOKEN }} - run: uv publish --token=${{ secrets.PYPI_TOKEN }}
``` ```
## Taggeo automático por pipeline ## Taggeo automático por pipeline
En todos los casos, `git-flow tag` detecta que se está ejecutando desde un pipeline (no encuentra En todos los casos, al pasar `--token=<TOKEN>` `git-flow tag` asume que se está ejecutando desde un
`.repository-token` en el repositorio) y usa el token pasado por `--token=<TOKEN>` tanto para crear pipeline: obtiene los commits del último PR / MR y el último tag por API del remoto, y usa el token
el tag en el remoto como para pushear el commit del `CHANGELOG.md` generado, en vez de crear el tag tanto para crear el tag en el remoto como para pushear el commit del `CHANGELOG.md` generado (con
únicamente en forma local como sucede al ejecutarlo manualmente sin remoto configurado. `--update-changelog`), en vez de resolver todo en forma local.
+2
View File
@@ -0,0 +1,2 @@
name,commit sha,author,date
worktree-kind-watching-hearth,e8c0c0b777d31fa94cd3f01fbf9c68b165d77015,Jonathan Teran Carballo,2026-09-13T23:18:47-03:00
1 name commit sha author date
2 worktree-kind-watching-hearth e8c0c0b777d31fa94cd3f01fbf9c68b165d77015 Jonathan Teran Carballo 2026-09-13T23:18:47-03:00
+51
View File
@@ -1,8 +1,11 @@
import re
import time
from os.path import isfile from os.path import isfile
import questionary import questionary
import rich import rich
import rich.panel import rich.panel
from rich.table import Table
from git_flow import ( from git_flow import (
BRANCH_TYPES, BRANCH_TYPES,
@@ -162,6 +165,54 @@ def get_commit_message(commit_types: list[str] = COMMIT_TYPES) -> str:
return commit_type + ": " + commit_message return commit_type + ": " + commit_message
# <tipo>[(alcance)][!]: <mensaje>, para no confundir "Merge pull request 'feature: ...'" con un tipo
COMMIT_SUBJECT_RE = re.compile(r"^(\w+)(?:\([^)]*\))?!?:\s*(.*)$")
def show_commits(title: str, revision_range: str):
"""Muestra en una tabla los commits del rango (fecha relativa, tipo y mensaje).
Si no hay commits aborta, ya que no hay nada para mergear."""
# fecha de autor: la de commit cambia al hacer rebase (por ejemplo, en release)
lines = Git("log", revision_range, format="%at%x09%s").lines()
if not lines:
raise GitFlowError("No hay cambios a mergear.")
table = Table(title=title, title_justify="left", title_style="bold")
table.add_column("Fecha", style="dim", no_wrap=True)
table.add_column("Tipo", style="cyan", no_wrap=True)
table.add_column("Mensaje")
now = time.time()
for line in lines:
timestamp, subject = line.split("\t", 1)
match = COMMIT_SUBJECT_RE.match(subject)
commit_type, message = match.groups() if match else ("-", subject)
table.add_row(format_time_ago(now - int(timestamp)), commit_type, message)
console.print(table)
def format_time_ago(seconds: float) -> str:
units = [
(365 * 24 * 3600, "año", "años"),
(30 * 24 * 3600, "mes", "meses"),
(7 * 24 * 3600, "semana", "semanas"),
(24 * 3600, "día", "días"),
(3600, "hora", "horas"),
(60, "minuto", "minutos"),
]
for size, singular, plural in units:
amount = int(seconds // size)
if amount >= 1:
return f"hace {amount} {singular if amount == 1 else plural}"
return "hace un momento"
def io_error(message: str, title: str | None = None): def io_error(message: str, title: str | None = None):
_io_status(message, "✖ Error: ", "red", title) _io_status(message, "✖ Error: ", "red", title)
+7
View File
@@ -0,0 +1,7 @@
import typer
from git_flow.command.branch import clean, ls
app = typer.Typer(name="branch")
app.add_typer(ls.app)
app.add_typer(clean.app)
+124
View File
@@ -0,0 +1,124 @@
import csv
import datetime
from dataclasses import dataclass
from typing import Annotated
import typer
from git_flow import GitFlowError
from git_flow.command import base
from git_flow.git import Git
REPORT_FORMAT = "%(refname:short)\t%(objectname)\t%(author)\t%(authordate:iso-strict)"
REPORT_FILENAME = "branch-clean-report.csv"
app = typer.Typer()
@dataclass
class BranchInfo:
name: str
sha: str
author: str
date: str
@app.command()
def clean(
remote: Annotated[
bool,
typer.Option(
help="Elimina las ramas remotas alcanzables desde HEAD, en vez de las locales"
),
] = False,
):
"""Elimina las ramas alcanzables desde HEAD, dejando un reporte CSV de las ramas eliminadas"""
base.ensure_initialized()
branches = get_remote_branches() if remote else get_local_branches()
if not branches:
base.io_info("No hay ramas alcanzables desde HEAD para eliminar.")
return
base.io_info(
"\n".join(f"- {branch.name}" for branch in branches),
title=f"Ramas a eliminar ({'remoto' if remote else 'local'})",
)
if not base.io_confirm(f"¿Eliminar {len(branches)} rama(s)?", False):
raise GitFlowError("Ejecución abortada")
report = write_report(branches)
for branch in branches:
delete_branch(branch, remote)
base.io_success(f"Se eliminaron {len(branches)} rama(s). Reporte generado en {report}")
def get_local_branches() -> list[BranchInfo]:
current_branch = Git.get_current_branch()
branches = get_merged_branches("refs/heads/")
return [b for b in branches if b.name != current_branch and b.name not in base.environments]
def get_remote_branches() -> list[BranchInfo]:
if "flow.remote" not in base.flowconfig:
raise GitFlowError("El repositorio no tiene configurado un remoto.")
remote = base.flowconfig["flow.remote"]
current_tracking = Git.get_tracking_branch(Git.get_current_branch())
branches = get_merged_branches(f"refs/remotes/{remote}/")
result = []
for branch in branches:
if branch.name == current_tracking or branch.name == f"{remote}/HEAD":
continue
name = branch.name.removeprefix(f"{remote}/")
if name in base.environments:
continue
result.append(BranchInfo(name, branch.sha, branch.author, branch.date))
return result
def get_merged_branches(pattern: str) -> list[BranchInfo]:
lines = Git("for-each-ref", pattern, merged="HEAD", format=REPORT_FORMAT).lines()
return [parse_branch(line) for line in lines]
def parse_branch(line: str) -> BranchInfo:
name, sha, author, date = line.split("\t")
return BranchInfo(name, sha, author, date)
def write_report(branches: list[BranchInfo]) -> str:
now = datetime.datetime.now().strftime("%Y%m%d%H%M%S")
report = "branch-clean-report-" + now + ".csv"
with open(report, "w", newline="") as f:
writer = csv.writer(f)
writer.writerow(["name", "commit", "author", "date"])
for branch in branches:
writer.writerow([branch.name, branch.sha, branch.author, branch.date])
return report
def delete_branch(branch: BranchInfo, remote: bool):
if remote:
remote_name = base.flowconfig["flow.remote"]
Git("push", remote_name, branch.name, delete=True).exec(
print=f"Eliminando rama remota '{branch.name}'"
)
else:
Git("branch", branch.name, d=True).exec(
print=f"Eliminando rama local '{branch.name}'"
)
@@ -3,12 +3,12 @@ from typing import Annotated
import typer import typer
from rich.console import Console from rich.console import Console
import git_flow.command.base as base
from git_flow import ( from git_flow import (
TRASH_BRANCH_PREFIX, TRASH_BRANCH_PREFIX,
WIP_BRANCH_PREFIX, WIP_BRANCH_PREFIX,
GitFlowError, GitFlowError,
) )
from git_flow.command import base
from git_flow.git import Git from git_flow.git import Git
BRANCH_FORMAT = "%(refname:short)" BRANCH_FORMAT = "%(refname:short)"
@@ -18,7 +18,7 @@ console = Console()
@app.command() @app.command()
def branch( def ls(
environment: Annotated[ environment: Annotated[
str | None, str | None,
typer.Argument( typer.Argument(
+1 -9
View File
@@ -71,15 +71,7 @@ def run_local(branch: str, target: str):
def show_commits_to_merge(target): def show_commits_to_merge(target):
commits = Git("log", target + "..", format="%s").lines() base.show_commits("Cambios a mergear", target + "..")
if not commits:
raise GitFlowError("No hay cambios a mergear.")
print("Cambios a mergear:")
for commit in commits:
print("- " + commit)
def check_merge_conflicts(target: str): def check_merge_conflicts(target: str):
+1 -9
View File
@@ -32,15 +32,7 @@ def release():
) )
fork_point = Git.get_first_fork_point(branch, env) fork_point = Git.get_first_fork_point(branch, env)
commits = Git("log", fork_point + "..", format="%s").lines() base.show_commits("Cambios a pasar al próximo entorno", fork_point + "..")
if not commits:
raise GitFlowError("No hay cambios a mergear.")
print("Cambios a pasar al proximo entorno:")
for commit in commits:
print("- " + commit)
if has_remote and Git.get_tracking_branch(next_env): if has_remote and Git.get_tracking_branch(next_env):
Git("switch", next_env).exec(print="Cambiando a la rama objetivo") Git("switch", next_env).exec(print="Cambiando a la rama objetivo")
+33 -7
View File
@@ -37,9 +37,6 @@ def tag(target: str | None = None, token: str | None = None, update_changelog: b
base.io_info(f"El último merge ya tiene un tag: {merge_commit_tag}") base.io_info(f"El último merge ya tiene un tag: {merge_commit_tag}")
return return
# Con varios entornos cada uno versiona por separado (v1.0.0-prod), con uno solo no hace falta
suffix = f"-{target}" if len(base.environments) > 1 else ""
token, ci = get_token_and_ci(token) token, ci = get_token_and_ci(token)
if ci and not token: if ci and not token:
@@ -48,20 +45,49 @@ def tag(target: str | None = None, token: str | None = None, update_changelog: b
# Con varios entornos cada uno versiona por separado (v1.0.0-prod), con uno solo no hace falta # Con varios entornos cada uno versiona por separado (v1.0.0-prod), con uno solo no hace falta
suffix = f"-{target}" if len(base.environments) > 1 else "" suffix = f"-{target}" if len(base.environments) > 1 else ""
if ci: # Un merge desde el entorno anterior (por ejemplo, dev a test) promueve su versión tal cual
target_index = base.environments.index(target)
source_env = base.environments[target_index - 1] if target_index > 0 else None
source_suffix = f"-{source_env}"
promoted_tag = None
api = base.get_remote_api(token) if ci else None
if api:
# Commits se obtienen por API del ultimo PR / MR mergeado # Commits se obtienen por API del ultimo PR / MR mergeado
commits = base.get_remote_api(token).get_merge_commits(merge_commit) commits = api.get_merge_commits(merge_commit)
commits = [c for c in commits if not c.is_merge] commits = [c for c in commits if not c.is_merge]
# Último tag se obtiene por API filtrando por sufijo # Último tag se obtiene por API filtrando por sufijo
current_tag = base.get_remote_api(token).get_latest_tag(suffix) current_tag = api.get_latest_tag(suffix)
if source_env and api.get_merge_source_branch(merge_commit) == source_env:
promoted_tag = api.get_latest_tag(source_suffix)
else:
branch = Git("show", merge_commit + "^2", patch=False, format="%H").firstline()
promoted_tag = Git.get_promoted_tag(merge_commit, source_suffix) if source_env else None
if promoted_tag:
# Todos los cambios que el entorno anterior trae al destino
commits = Git.get_commits(merge_commit + "^1", branch, first_parent=False)
else: else:
# Commits se obtienen del último merge, listando hasta el primer forkpoint # Commits se obtienen del último merge, listando hasta el primer forkpoint
branch = Git("show", merge_commit + "^2", patch=False, format="%H").firstline()
fork_point = Git.get_first_fork_point(branch, target) fork_point = Git.get_first_fork_point(branch, target)
commits = Git.get_commits(fork_point, branch) commits = Git.get_commits(fork_point, branch)
# Último tag se obtiene localmente # Último tag se obtiene localmente
current_tag = Git.get_current_tag(suffix) current_tag = Git.get_current_tag(suffix)
if promoted_tag:
next_tag = promoted_tag.removesuffix(source_suffix) + suffix
base.io_info(
f"Merge desde {source_env}: se promueve la versión {promoted_tag} a {target}."
)
# Puede existir si el destino ya llegó a esa versión por otro lado (por ejemplo, un release)
existing = api.list_tags(suffix) if api else Git("tag", next_tag, list=True).lines()
if next_tag in existing:
raise GitFlowError(f"El tag {next_tag} ya existe en {target}.")
else:
next_tag = get_next_tag_from_commits( next_tag = get_next_tag_from_commits(
[c.subject for c in commits], suffix, current_tag [c.subject for c in commits], suffix, current_tag
) )
+51 -12
View File
@@ -1,6 +1,10 @@
import subprocess import subprocess
from dataclasses import dataclass from dataclasses import dataclass
from rich.markup import escape
from rich.panel import Panel
from rich.text import Text
from git_flow import FLOWCONFIG_FILENAME from git_flow import FLOWCONFIG_FILENAME
from git_flow.io import * from git_flow.io import *
@@ -73,6 +77,21 @@ class Git:
exclude=None if suffix else "*-*", exclude=None if suffix else "*-*",
).firstline(check=False, print="Obteniendo tag actual") ).firstline(check=False, print="Obteniendo tag actual")
@staticmethod
def get_promoted_tag(merge_commit: str, suffix: str) -> str | None:
"""Si el merge trae tags con el sufijo indicado que la rama destino no tenía (por ejemplo,
un merge de dev a test trae tags *-dev nuevos), devuelve el más alto de ellos."""
tags = Git(
"tag",
"v[0-9]*.[0-9]*.[0-9]*" + suffix,
list=True,
merged=merge_commit + "^2",
no_merged=merge_commit + "^1",
sort="-v:refname",
).lines()
return tags[0] if tags else None
@staticmethod @staticmethod
def get_first_fork_point(branch: str, env: str): def get_first_fork_point(branch: str, env: str):
# Lista de commits alcanzables por el entorno (por ejemplo, dev), # Lista de commits alcanzables por el entorno (por ejemplo, dev),
@@ -84,10 +103,14 @@ class Git:
return Git("show", env_commits[-1] + "^", patch=False, format="%h").firstline() return Git("show", env_commits[-1] + "^", patch=False, format="%h").firstline()
@staticmethod @staticmethod
def get_commits(base: str, branch: str) -> list[Commit]: def get_commits(base: str, branch: str, first_parent: bool = True) -> list[Commit]:
"""Commits propios de la rama (sin merges), del más nuevo al más viejo.""" """Commits propios de la rama (sin merges), del más nuevo al más viejo."""
lines = Git( lines = Git(
"log", base + ".." + branch, first_parent=True, merges=False, format="%h%x09%ae%x09%s" "log",
base + ".." + branch,
first_parent=first_parent or None,
merges=False,
format="%h%x09%ae%x09%s",
).lines() ).lines()
commits = [] commits = []
@@ -161,10 +184,13 @@ class Git:
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, check=False)
if print is not None: # Sin --verbose solo se muestra el paso; si falla, la salida la muestra main()
self._print_process(process.stdout, process.stderr, print) if print is not None and settings.verbose:
self._print_process(process, print)
elif print is not None:
console.print(Text.assemble(("› ", "cyan"), print))
if check: if check:
process.check_returncode() process.check_returncode()
@@ -174,7 +200,7 @@ class Git:
def _run(self, print: str | None = None, check: bool = True): def _run(self, print: str | None = None, check: bool = True):
return self._get(print, check) return self._get(print, check)
def _print_process(self, stdout: str, stderr: str, title: str): def _print_process(self, process: subprocess.CompletedProcess, title: str | None):
command = [] command = []
for arg in self.command: for arg in self.command:
@@ -186,12 +212,25 @@ class Git:
command.append(arg) command.append(arg)
text = f"[green]$ {' '.join(command)}[/green]" ok = process.returncode == 0
# git escribe mensajes informativos en stderr, así que solo se resalta si falló
stderr_style = "dim" if ok else "red"
text = Text.assemble(("$ ", "dim"), (" ".join(command), "bold yellow"))
for line in stdout.splitlines(): for line in process.stdout.splitlines():
text += "\n" + line text.append("\n" + line)
for line in stderr.splitlines(): for line in process.stderr.splitlines():
text += "\n[red]" + line + "[/red]" text.append("\n" + line, stderr_style)
panel(title or "Ejecutando", text) console.print(
Panel(
text,
title=f"[bold]{escape(title or 'Ejecutando')}[/bold]",
title_align="left",
subtitle=None if ok else f"código de salida {process.returncode}",
subtitle_align="right",
border_style="bright_black" if ok else "red",
expand=False,
)
)
+10 -1
View File
@@ -1,10 +1,19 @@
from rich.console import Console from rich.console import Console
from rich.prompt import Prompt, Confirm
from rich.panel import Panel from rich.panel import Panel
from rich.prompt import Confirm, Prompt
console = Console(highlight=False) console = Console(highlight=False)
class Settings:
"""Opciones globales de ejecución, configuradas desde la línea de comandos."""
verbose: bool = False
settings = Settings()
def success(msg: str): def success(msg: str):
console.print(f":white_check_mark: [green]{msg}[/green]") console.print(f":white_check_mark: [green]{msg}[/green]")
+50 -6
View File
@@ -1,12 +1,16 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import traceback from subprocess import CalledProcessError, SubprocessError
from subprocess import SubprocessError from typing import Annotated
import click
import typer import typer
from rich.markup import escape
from rich.traceback import Traceback
from git_flow import GitFlowError from git_flow import GitFlowError
from git_flow.command import base, branch, commit, init, merge, new, release, tag from git_flow.command import base, branch, commit, init, merge, new, release, tag
from git_flow.io import console, settings
REPOSITORY_TOKEN_PATH = ".repository-token" REPOSITORY_TOKEN_PATH = ".repository-token"
LOCALE = "es_AR.UTF-8" LOCALE = "es_AR.UTF-8"
@@ -22,15 +26,55 @@ app.add_typer(release.app)
app.add_typer(branch.app) app.add_typer(branch.app)
@app.callback()
def options(
verbose: Annotated[
bool,
typer.Option(
"--verbose",
"-v",
help="Muestra la salida de los comandos git y el traceback de los errores",
),
] = False,
):
settings.verbose = verbose
def main(): def main():
try: try:
app() app()
print() print()
except GitFlowError as e: except GitFlowError as e:
lines = [str(e), ""] _print_error(escape(str(e)), "Git Flow", e)
lines += traceback.format_exception(e) except CalledProcessError as e:
base.io_error("".join(lines), title="Git Flow") _print_error(_format_process_error(e), "Comando", e)
except SubprocessError as e: except SubprocessError as e:
base.io_error(str(e), title="Comando") _print_error(escape(str(e)), "Comando", e)
except KeyboardInterrupt: except KeyboardInterrupt:
base.io_error("Ejecución abortada") base.io_error("Ejecución abortada")
def _format_process_error(e: CalledProcessError) -> str:
command = " ".join(e.cmd) if isinstance(e.cmd, list) else str(e.cmd)
message = f"[bold]$ {escape(command)}[/bold]\nTerminó con código de salida {e.returncode}"
for output in (e.stdout, e.stderr):
if isinstance(output, str) and output.strip():
message += "\n\n" + escape(output.strip())
return message
def _print_error(message: str, title: str, e: BaseException):
if settings.verbose:
base.io_error(message, title=title)
console.print(
Traceback.from_exception(
type(e), e, e.__traceback__, suppress=[click, typer]
)
)
else:
base.io_error(
message + "\n\n[dim]Ejecutar con `git-flow --verbose <comando>` para ver el detalle[/dim]",
title=title,
)
+5
View File
@@ -74,6 +74,11 @@ class RemoteAPI(ABC):
viejo), sin depender del historial local.""" viejo), sin depender del historial local."""
pass pass
@abstractmethod
def get_merge_source_branch(self, merge_commit: str) -> str:
"""Nombre de la rama origen del PR que generó el merge commit."""
pass
@abstractmethod @abstractmethod
def list_tags(self, suffix: str = "") -> list[str]: def list_tags(self, suffix: str = "") -> list[str]:
"""Nombres de los tags del repositorio, sin orden garantizado. `suffix` es solo una pista """Nombres de los tags del repositorio, sin orden garantizado. `suffix` es solo una pista
+13 -6
View File
@@ -48,12 +48,7 @@ class BitbucketRemoteAPI(RemoteAPI):
raise GitFlowError("Ocurrió un error al crear el tag: " + response.text) raise GitFlowError("Ocurrió un error al crear el tag: " + response.text)
def get_merge_commits(self, merge_commit: str) -> list[Commit]: def get_merge_commits(self, merge_commit: str) -> list[Commit]:
prs = self.get_json(self.get_endpoint(f"commit/{merge_commit}/pullrequests")).json() pr = self._get_merge_pull_request(merge_commit)
pr = next((p for p in prs["values"] if p["state"] == "MERGED"), None)
if not pr:
raise GitFlowError(f"No se encontró un PR para el commit {merge_commit}")
commits: list[Commit] = [] commits: list[Commit] = []
url: str | None = self.get_endpoint(f"pullrequests/{pr['id']}/commits") url: str | None = self.get_endpoint(f"pullrequests/{pr['id']}/commits")
@@ -77,6 +72,18 @@ class BitbucketRemoteAPI(RemoteAPI):
return commits return commits
def get_merge_source_branch(self, merge_commit: str) -> str:
return self._get_merge_pull_request(merge_commit)["source"]["branch"]["name"]
def _get_merge_pull_request(self, merge_commit: str) -> dict:
prs = self.get_json(self.get_endpoint(f"commit/{merge_commit}/pullrequests")).json()
pr = next((p for p in prs["values"] if p["state"] == "MERGED"), None)
if not pr:
raise GitFlowError(f"No se encontró un PR para el commit {merge_commit}")
return pr
def list_tags(self, suffix: str = "") -> list[str]: def list_tags(self, suffix: str = "") -> list[str]:
names: list[str] = [] names: list[str] = []
url: str | None = self.get_endpoint("refs/tags") url: str | None = self.get_endpoint("refs/tags")
+7 -1
View File
@@ -46,7 +46,7 @@ class GiteaRemoteAPI(RemoteAPI):
raise GitFlowError("Ocurrió un error al crear el tag: " + response.text) raise GitFlowError("Ocurrió un error al crear el tag: " + response.text)
def get_merge_commits(self, merge_commit: str) -> list[Commit]: def get_merge_commits(self, merge_commit: str) -> list[Commit]:
pr = self.get_json(self.get_endpoint(f"/commits/{merge_commit}/pull")).json() pr = self._get_merge_pull_request(merge_commit)
url = self.get_endpoint(f"/pulls/{pr['number']}/commits") url = self.get_endpoint(f"/pulls/{pr['number']}/commits")
items: list = [] items: list = []
page = 1 page = 1
@@ -74,6 +74,12 @@ class GiteaRemoteAPI(RemoteAPI):
for c in reversed(items) for c in reversed(items)
] ]
def get_merge_source_branch(self, merge_commit: str) -> str:
return self._get_merge_pull_request(merge_commit)["head"]["ref"]
def _get_merge_pull_request(self, merge_commit: str) -> dict:
return self.get_json(self.get_endpoint(f"/commits/{merge_commit}/pull")).json()
def list_tags(self, suffix: str = "") -> list[str]: def list_tags(self, suffix: str = "") -> list[str]:
tags = self.get_all_pages(self.get_endpoint("/tags"), 50, size_param="limit") tags = self.get_all_pages(self.get_endpoint("/tags"), 50, size_param="limit")
+13 -6
View File
@@ -44,12 +44,7 @@ class GithubRemoteAPI(RemoteAPI):
raise GitFlowError("Ocurrió un error al crear el tag: " + response.text) raise GitFlowError("Ocurrió un error al crear el tag: " + response.text)
def get_merge_commits(self, merge_commit: str) -> list[Commit]: def get_merge_commits(self, merge_commit: str) -> list[Commit]:
prs = self.get_json(self.get_endpoint(f"/commits/{merge_commit}/pulls")).json() pr = self._get_merge_pull_request(merge_commit)
pr = next((p for p in prs if p.get("merge_commit_sha") == merge_commit), None)
if not pr:
raise GitFlowError(f"No se encontró un PR para el commit {merge_commit}")
items = self.get_all_pages(self.get_endpoint(f"/pulls/{pr['number']}/commits"), 100) items = self.get_all_pages(self.get_endpoint(f"/pulls/{pr['number']}/commits"), 100)
# La API los devuelve del más viejo al más nuevo # La API los devuelve del más viejo al más nuevo
@@ -63,6 +58,18 @@ class GithubRemoteAPI(RemoteAPI):
for c in reversed(items) for c in reversed(items)
] ]
def get_merge_source_branch(self, merge_commit: str) -> str:
return self._get_merge_pull_request(merge_commit)["head"]["ref"]
def _get_merge_pull_request(self, merge_commit: str) -> dict:
prs = self.get_json(self.get_endpoint(f"/commits/{merge_commit}/pulls")).json()
pr = next((p for p in prs if p.get("merge_commit_sha") == merge_commit), None)
if not pr:
raise GitFlowError(f"No se encontró un PR para el commit {merge_commit}")
return pr
def list_tags(self, suffix: str = "") -> list[str]: def list_tags(self, suffix: str = "") -> list[str]:
return [t["name"] for t in self.get_all_pages(self.get_endpoint("/tags"), 100)] return [t["name"] for t in self.get_all_pages(self.get_endpoint("/tags"), 100)]
+15 -8
View File
@@ -49,14 +49,7 @@ class GitlabRemoteAPI(RemoteAPI):
raise GitFlowError("Ocurrió un error al crear el tag: " + response.text) raise GitFlowError("Ocurrió un error al crear el tag: " + response.text)
def get_merge_commits(self, merge_commit: str) -> list[Commit]: def get_merge_commits(self, merge_commit: str) -> list[Commit]:
mrs = self.get_json( mr = self._get_merge_request(merge_commit)
self.get_endpoint(f"/repository/commits/{merge_commit}/merge_requests")
).json()
mr = next((m for m in mrs if m.get("merge_commit_sha") == merge_commit), None)
if not mr:
raise GitFlowError(f"No se encontró un MR para el commit {merge_commit}")
items = self.get_all_pages(self.get_endpoint(f"/merge_requests/{mr['iid']}/commits"), 100) items = self.get_all_pages(self.get_endpoint(f"/merge_requests/{mr['iid']}/commits"), 100)
# GitLab ya los devuelve del más nuevo al más viejo # GitLab ya los devuelve del más nuevo al más viejo
@@ -70,6 +63,20 @@ class GitlabRemoteAPI(RemoteAPI):
for c in items for c in items
] ]
def get_merge_source_branch(self, merge_commit: str) -> str:
return self._get_merge_request(merge_commit)["source_branch"]
def _get_merge_request(self, merge_commit: str) -> dict:
mrs = self.get_json(
self.get_endpoint(f"/repository/commits/{merge_commit}/merge_requests")
).json()
mr = next((m for m in mrs if m.get("merge_commit_sha") == merge_commit), None)
if not mr:
raise GitFlowError(f"No se encontró un MR para el commit {merge_commit}")
return mr
def list_tags(self, suffix: str = "") -> list[str]: def list_tags(self, suffix: str = "") -> list[str]:
# "search" soporta "$" para anclar al final del nombre # "search" soporta "$" para anclar al final del nombre
params = {"search": suffix + "$"} if suffix else None params = {"search": suffix + "$"} if suffix else None