Compare commits

..
6 Commits
9 changed files with 219 additions and 48 deletions
+15 -1
View File
@@ -62,6 +62,9 @@ más información):
`--remote`), dejando un reporte CSV (`branch-clean-report.csv`) con nombre, commit, autor y `--remote`), dejando un reporte CSV (`branch-clean-report.csv`) con nombre, commit, autor y
fecha de cada rama eliminada. 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:
1. Sobre la primer rama principal configurada (por ejemplo, `dev`), se crea una nueva rama con 1. Sobre la primer rama principal configurada (por ejemplo, `dev`), se crea una nueva rama con
@@ -99,7 +102,18 @@ Comportamiento:
- El tag se crea sobre el último merge (first-parent) de la rama. Si ese merge ya tiene un tag, no se - El tag se crea sobre el último merge (first-parent) de la rama. Si ese merge ya tiene un tag, no se
hace nada. hace nada.
- La versión se calcula a partir de los tipos de commit (CC) del último merge. Si ningún commit - 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. implica un salto de versión, se avisa y el comando termina con código de salida 1. Se toma el
mayor incremento entre los commits:
| Tipo de commit | Incremento |
| -------------------------------------------------------------- | ---------- |
| cualquier tipo marcado como breaking (`feature!: ...`) | major |
| `feature` (o `feat`) | minor |
| `bugfix` (o `fix`), `hotfix`, `refactor`, `perf`, `revert` | patch |
| `docs`, `style`, `test`, `build`, `ci`, `chore`, `wip` | ninguno |
| personalizado (opción `otro` en `git-flow commit`) | ninguno |
`git-flow commit` pregunta si el commit es un breaking change (salvo en commits `wip`).
- Con más de un entorno configurado, cada rama versiona por separado con el nombre del entorno como - 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. 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 - Si el merge viene del entorno anterior (por ejemplo, `dev` a `test`), no se calcula una nueva
+28 -9
View File
@@ -1,15 +1,6 @@
GitFlowError = ValueError GitFlowError = ValueError
BRANCH_TYPES = [ BRANCH_TYPES = [
"feature",
"refactor",
"bugfix",
"hotfix",
"chore",
"docs",
]
COMMIT_TYPES = [
"feature", "feature",
"bugfix", "bugfix",
"hotfix", "hotfix",
@@ -18,6 +9,24 @@ COMMIT_TYPES = [
"docs", "docs",
"style", "style",
"test", "test",
"build",
"ci",
"chore",
]
COMMIT_TYPES = [
"feature",
"bugfix",
"hotfix",
"refactor",
"perf",
"revert",
"docs",
"style",
"test",
"build",
"ci",
"chore",
"wip", "wip",
] ]
@@ -32,15 +41,25 @@ SEMVER_PATCH = 1
# Commits con estos tipos no alteran la versión # Commits con estos tipos no alteran la versión
SEMVER_SKIP = 0 SEMVER_SKIP = 0
# Un commit marcado como breaking change (<tipo>!: <mensaje>) incrementa SEMVER_MAJOR sin importar
# el tipo. Los tipos que no figuran aquí no alteran la versión.
COMMIT_TYPE_INCREMENT = { COMMIT_TYPE_INCREMENT = {
"feature": SEMVER_MINOR, "feature": SEMVER_MINOR,
"bugfix": SEMVER_PATCH, "bugfix": SEMVER_PATCH,
"hotfix": SEMVER_PATCH, "hotfix": SEMVER_PATCH,
"refactor": SEMVER_PATCH, "refactor": SEMVER_PATCH,
"perf": SEMVER_PATCH, "perf": SEMVER_PATCH,
"revert": SEMVER_PATCH,
"docs": SEMVER_SKIP, "docs": SEMVER_SKIP,
"style": SEMVER_SKIP, "style": SEMVER_SKIP,
"test": SEMVER_SKIP, "test": SEMVER_SKIP,
"build": SEMVER_SKIP,
"ci": SEMVER_SKIP,
"chore": SEMVER_SKIP,
"wip": SEMVER_SKIP,
# Alias de Conventional Commits, para commits creados fuera de git-flow
"feat": SEMVER_MINOR,
"fix": SEMVER_PATCH,
} }
SUPPORTED_REMOTE_URL_SCHEMAS = [ SUPPORTED_REMOTE_URL_SCHEMAS = [
+76 -1
View File
@@ -1,11 +1,15 @@
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,
COMMIT_TYPE_INCREMENT,
COMMIT_TYPES, COMMIT_TYPES,
FLOWCONFIG_FILENAME, FLOWCONFIG_FILENAME,
FLOWCONFIG_VERSION, FLOWCONFIG_VERSION,
@@ -152,8 +156,30 @@ def is_valid_ticket(ticket: str) -> bool:
) )
CUSTOM_COMMIT_TYPE_OPTION = "otro"
def get_commit_message(commit_types: list[str] = COMMIT_TYPES) -> str: def get_commit_message(commit_types: list[str] = COMMIT_TYPES) -> str:
commit_type = io_choice("Tipo de commit", commit_types) commit_type = io_choice("Tipo de commit", commit_types + [CUSTOM_COMMIT_TYPE_OPTION])
if commit_type == CUSTOM_COMMIT_TYPE_OPTION:
commit_type = io_prompt(
"Tipo de commit personalizado",
strip=True,
validator=lambda s: re.fullmatch(r"\w+", s.strip()) is not None,
instruction="letras, números o _",
)
if commit_type not in COMMIT_TYPE_INCREMENT:
io_warning(
f"El tipo '{commit_type}' no produce un salto de versión, "
"salvo que se marque como breaking change."
)
# Los commits WIP se squashean en uno final, que es el que puede marcarse como breaking
if commit_type != "wip" and io_confirm("¿Es un breaking change?", False):
commit_type += "!"
commit_message = io_prompt( commit_message = io_prompt(
"Mensaje de commit", "Mensaje de commit",
validator=lambda s: 0 < len(s) and len(s) < 100, validator=lambda s: 0 < len(s) and len(s) < 100,
@@ -162,6 +188,55 @@ 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, breaking, message = match.groups() if match else ("-", None, subject)
commit_type += breaking or ""
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)
+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")
+8 -2
View File
@@ -128,8 +128,14 @@ def get_next_tag_from_commits(
) -> str | None: ) -> str | None:
increment = SEMVER_SKIP increment = SEMVER_SKIP
for commit in commits: for commit in commits:
commit_type = commit.partition(":")[0] match = base.COMMIT_SUBJECT_RE.match(commit)
increment = max(increment, COMMIT_TYPE_INCREMENT.get(commit_type, SEMVER_SKIP))
if not match:
continue
commit_type, breaking, _ = match.groups()
commit_increment = SEMVER_MAJOR if breaking else COMMIT_TYPE_INCREMENT.get(commit_type)
increment = max(increment, commit_increment or SEMVER_SKIP)
if increment == SEMVER_SKIP: if increment == SEMVER_SKIP:
return None return None
+30 -10
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 *
@@ -180,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()
@@ -193,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:
@@ -205,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,
)