Compare commits

..
2 Commits
27 changed files with 285 additions and 1274 deletions
-33
View File
@@ -1,33 +0,0 @@
name: Publish to PyPI
on:
push:
branches:
- main
jobs:
Bump-Build-Publish:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
with:
fetch-depth: 10
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version-file: ".python-version"
- name: Install git-flow and bump version
id: version
run: |
pip install git-flow-envs=="${{ vars.GITFLOWENVS_VERSION }}"
git-flow tag --token="${{ secrets.GITEA_TOKEN }}"
- name: Install uv
uses: astral-sh/setup-uv@v8.3.2
- name: Build and publish
run: |
uv build
uv publish --token="${{ secrets.PYPI_TOKEN }}"
+38
View File
@@ -0,0 +1,38 @@
name: Publish to PyPI
on:
push:
branches:
- main
jobs:
Publish-to-PyPI:
runs-on: ubuntu-latest
steps:
- name: Check out repository code
uses: actions/checkout@v7
with:
fetch-depth: 0
fetch-tags: true
- 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"
uses: actions/setup-python@v6
with:
python-version-file: ".python-version"
- name: Install uv
uses: astral-sh/setup-uv@v8.3.2
- name: Bump version
run: |
uv run git-flow tag --token="${{ secrets.GITEA_TOKEN }}"
- name: Build and publish
run: |
uv build
uv publish --token="${{ secrets.PYPI_TOKEN }}"
+1 -1
View File
@@ -1 +1 @@
3.10
3.14
-125
View File
@@ -1,128 +1,3 @@
## v1.23.0 - 2026-09-21
- **feature**: add fetch depth [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (68cec91)
---
## v1.22.3 - 2026-09-21
- **bugfix**: tag locally for workflow to run [[jteran](mailto:jteran@renatre.org.ar)] (24a40c9)
---
## v1.22.2 - 2026-09-21
- **bugfix**: fix workflow pipeline [[jteran](mailto:jteran@renatre.org.ar)] (82c78c7)
---
## v1.22.1 - 2026-09-21
- **refactor**: update remote tag fetching behaviour so it uses api instead of shallow clone [[jteran](mailto:jteran@renatre.org.ar)] (22ba8f5)
---
## v1.22.0 - 2026-09-21
- **feature**: tag from ci fetches commits from remote api [[jteran](mailto:jteran@renatre.org.ar)] (26ab81b)
- **bugfix**: fix parameters for changelog generate_content [[jteran](mailto:jteran@renatre.org.ar)] (c50688b)
---
## v1.21.3 - 2026-09-18
- **bugfix**: muevo titulo de error al panel, agrego repositorio a gitlab error [[jteran](mailto:jteran@renatre.org.ar)] (ce1ab99)
---
## v1.21.2 - 2026-09-18
- **bugfix**: agrego informacion de traceback para errores de GitFlow [[jteran](mailto:jteran@renatre.org.ar)] (8a4cf6e)
---
## v1.21.1 - 2026-09-17
- **bugfix**: apply release prefix and ticket detection to single-commit PR titles [[jteran](mailto:jteran@renatre.org.ar)] (e0200e3)
---
## v1.21.0 - 2026-09-17
- **feature**: downgrade python version for better compatibility [[jteran](mailto:jteran@renatre.org.ar)] (55172bd)
---
## v1.20.2 - 2026-09-15
- **bugfix**: include group path in repo for gitlab support [[jteran](mailto:jteran@renatre.org.ar)] (3e44854)
---
## v1.20.1 - 2026-09-14
- **refactor**: cambiar incremento de commits refactor y perf [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (c035a88)
- **refactor**: eliminar preguntas innecesarias de release y merge [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (0c31c74)
---
## v1.20.0 - 2026-09-13
- **feature**: add gitlab remote support [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (748d420)
---
## v1.19.0 - 2026-07-31
- **style**: format with ruff [[jteran](mailto:jteran@renatre.org.ar)] (11a48a9)
- **feature**: parse git remote url with giturlparse [[jteran](mailto:jteran@renatre.org.ar)] (f4adb43)
---
## v1.18.0 - 2026-07-29
- **bugfix**: change checkout version to v7 [[jteran](mailto:jteran@renatre.org.ar)] (c58c4ce)
- **bugfix**: move git fetch to separate line [[jteran](mailto:jteran@renatre.org.ar)] (64fc890)
- **bugfix**: change actions/checkout to v4 and add step to fetch tags [[jteran](mailto:jteran@renatre.org.ar)] (dc79ffa)
- **bugfix**: change actions/checkout version [[jteran](mailto:jteran@renatre.org.ar)] (c45f065)
- **bugfix**: add fetch-tags: true [[jteran](mailto:jteran@renatre.org.ar)] (adcb531)
- **bugfix**: remove checkout parameters [[jteran](mailto:jteran@renatre.org.ar)] (2be9b50)
- **feature**: show commit count and list tags on workflow [[jteran](mailto:jteran@renatre.org.ar)] (f05582b)
---
## v0.1.0-main - 2026-07-29
- **bugfix**: change actions/checkout to v4 and add step to fetch tags [[jteran](mailto:jteran@renatre.org.ar)] (dc79ffa)
- **bugfix**: change actions/checkout version [[jteran](mailto:jteran@renatre.org.ar)] (c45f065)
- **bugfix**: add fetch-tags: true [[jteran](mailto:jteran@renatre.org.ar)] (adcb531)
- **bugfix**: remove checkout parameters [[jteran](mailto:jteran@renatre.org.ar)] (2be9b50)
- **feature**: show commit count and list tags on workflow [[jteran](mailto:jteran@renatre.org.ar)] (f05582b)
---
## v0.1.0-main - 2026-07-29
- **feature**: show commit count and list tags on workflow [[jteran](mailto:jteran@renatre.org.ar)] (f05582b)
---
## v0.0.1-main - 2026-07-18
- **bugfix**: add fetch tags to checkout step [[jonathan.nerat](mailto:jonathan.nerat@gmail.com)] (f9fb5a6)
+35 -200
View File
@@ -1,8 +1,5 @@
# 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
(CC)](https://www.conventionalcommits.org), [Conventional Branch
(CB)](https://conventional-branch.github.io/), y [SemVer](https://semver.org/).
@@ -12,12 +9,27 @@ derivar versiones automaticamente de los merges sobre ramas principales.
## Instalación
Se puede instalar con `pip` ejecutando el siguiente comando: `pip install git-flow-envs`. Sin
embargo, se sugiere utilizar [pipx](https://github.com/pypa/pipx) (seguir la documentación para
instalarlo), ya que aisla las dependencias de la herramienta en un `venv`. Una vez instalado,
simplemente ejecutamos `pipx install git-flow-envs`
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:
Luego podemos verificar que se haya instalado correctamente ejecutando: `git-flow --help`
```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
@@ -34,36 +46,27 @@ El comando solicitará 2 opciones:
- **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
- [x] Github
- [x] Gitea
- [x] Gitlab
- [x] Bitbucket
- [x] Github
- [x] Gitea
La configuración del remoto requiere tener un Access Token para poder crear los PRs y tags
automáticamente. Ver la sección [Remotos soportados](#remotos-soportados) para las instrucciones
de cómo generar el token en cada proveedor.
La configuración del remoto requiere tener un Access Token para poder crear los PRs automáticamente.
El mismo se puede crear desde el repositorio al que se quiere dar acceso: *"Repository settings" >
"Access Tokens" > "Create access token"*. El token necesita permiso para *Write* en *Pull Requests*.
Una vez generado, el token se debe guardar dentro del repositorio a configurar, en el archivo
Luego se debe guardar el token dentro del repositorio a configurar, en el archivo
`.repository-token`. De no especificarse, los merges y tags se harán de forma local.
## Utilización
Git Flow cuenta con los siguientes comandos para guiar el workflow (ver `git-flow <command> --help` para
Git Flow cuenta con 5 comandos principales para guiar el workflow (ver `git-flow <command> -h` para
más información):
- `init`: Inicializa el repositorio para utilizar git-flow (ramas principales y remoto).
- `new`: Crea una nueva rama siguiendo CB.
- `commit`: Crea un commit siguiendo CC.
- `merge`: Mergea la rama actual a uno de los entornos.
- `tag`: Crea un tag sobre el ultimo merge siguiendo SemVer (ver [Tag](#tag)).
- `release`: Prepara la rama actual para pasarse al siguiente entorno configurado.
- `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.
- `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:
@@ -82,177 +85,9 @@ 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
tiene los cambios de la rama original.
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/`).
### 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
Cada remoto necesita un Access Token con permisos para crear Pull/Merge Requests y tags. El token se
guarda en el archivo `.repository-token` en la raíz del repositorio (una única línea con el valor del
token), o se pasa por parámetro con `--token=<TOKEN>` al comando `git-flow tag` (útil para no
commitear el token y usarlo solo desde un pipeline mediante una variable secreta).
### Github
1. Ir a *Settings* del repositorio (o de la organización) > *Developer settings* > *Personal access
tokens* > *Fine-grained tokens* > *Generate new token*.
2. Seleccionar el repositorio sobre el que se va a usar `git-flow`.
3. En *Repository permissions*, otorgar:
- **Contents**: Read and write (necesario para crear tags).
- **Pull requests**: Read and write (necesario para crear PRs).
4. Generar el token y guardarlo en `.repository-token`.
Ejemplo de pipeline (GitHub Actions) para taggear automáticamente en cada push a una rama principal:
```yaml
# .github/workflows/tag.yml
name: Tag and publish
on:
push:
branches: [main]
jobs:
tag:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 10
- uses: astral-sh/setup-uv@v3
- run: uv run git-flow tag --token=${{ secrets.GIT_FLOW_TOKEN }}
- run: uv build
- run: uv publish --token=${{ secrets.PYPI_TOKEN }}
```
### Gitlab
1. Ir a *Settings* > *Access Tokens* del proyecto (o *Group access tokens* si se quiere usar a nivel
grupo).
2. Crear un token con rol **Developer** o superior, y los scopes:
- **api** (o al menos `write_repository` para tags y `api` para Merge Requests).
3. Generar el token y guardarlo en `.repository-token`.
Ejemplo de pipeline (GitLab CI) para taggear automáticamente en cada push a una rama principal:
```yaml
# .gitlab-ci.yml
tag:
image: python:3.14
variables:
GIT_DEPTH: 10
rules:
- if: $CI_COMMIT_BRANCH == "main"
script:
- pip install uv
# 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 publish --token=$PYPI_TOKEN
```
El token debe guardarse como variable protegida y enmascarada (`GIT_FLOW_TOKEN`) en *Settings* >
*CI/CD* > *Variables*.
### Bitbucket
1. Ir a *Repository settings* > *Access Tokens* > *Create access token* (o *Workspace settings* >
*Access Tokens* para un token a nivel workspace).
2. Otorgar permiso de **Write** en *Pull requests* y *Repositories* (necesario para crear tags).
3. Generar el token y guardarlo en `.repository-token`.
Este mismo repositorio usa Git Flow para el taggeo automático usando Bitbucket Pipelines, se puede
ver el archivo `bitbucket-pipelines.yml` como ejemplo:
```yaml
# bitbucket-pipelines.yml
image: python:3.14
clone:
depth: 10
pipelines:
branches:
'main':
- step:
name: "Tag and publish package"
caches:
- pip
script:
- pip install uv
- uv run git-flow tag --token=$BEARER
- uv build
- uv publish --token=$PYPI_TOKEN
```
El token se configura como variable segura del repositorio (`BEARER`) en *Repository settings* >
*Repository variables*.
### Gitea
1. Ir a *Settings* del usuario (o de la organización) > *Applications* > *Manage Access Tokens* >
*Generate New Token*.
2. Otorgar permiso de escritura sobre:
- **repository** (necesario para crear tags).
- **issue** (necesario para crear PRs, ya que Gitea expone los PRs bajo el mismo scope).
3. Generar el token y guardarlo en `.repository-token`.
Ejemplo de pipeline (Gitea Actions) para taggear automáticamente en cada push a una rama principal:
```yaml
# .gitea/workflows/tag.yml
name: Tag and publish
on:
push:
branches: [main]
jobs:
tag:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 10
- uses: astral-sh/setup-uv@v3
- run: uv run git-flow tag --token=${{ secrets.GIT_FLOW_TOKEN }}
- run: uv build
- run: uv publish --token=${{ secrets.PYPI_TOKEN }}
```
## Taggeo automático por pipeline
En todos los casos, al pasar `--token=<TOKEN>` `git-flow tag` asume que se está ejecutando desde un
pipeline: obtiene los commits del último PR / MR y el último tag por API del remoto, y usa el token
tanto para crear el tag en el remoto como para pushear el commit del `CHANGELOG.md` generado (con
`--update-changelog`), en vez de resolver todo en forma local.
É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 -1
View File
@@ -1,4 +1,4 @@
image: python:3.10
image: python:3.14
pipelines:
branches:
-2
View File
@@ -1,2 +0,0 @@
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
+2 -2
View File
@@ -6,9 +6,9 @@ build-backend = "setuptools.build_meta"
name = "git-flow-envs"
description = "Git workflow automation to follow best practices when working with multiple environments"
dynamic = ["version"]
requires-python = ">= 3.10"
requires-python = ">= 3.14"
dependencies = [
"requests", "typer", "rich", "questionary", "giturlparse"
"requests", "typer", "rich", "questionary"
]
authors = [
{name = "Alan Facundo Biglieri", email = "abiglieri@renatre.org.ar"},
+5 -8
View File
@@ -36,21 +36,18 @@ COMMIT_TYPE_INCREMENT = {
"feature": SEMVER_MINOR,
"bugfix": SEMVER_PATCH,
"hotfix": SEMVER_PATCH,
"refactor": SEMVER_PATCH,
"perf": SEMVER_PATCH,
"refactor": SEMVER_SKIP,
"perf": SEMVER_SKIP,
"docs": SEMVER_SKIP,
"style": SEMVER_SKIP,
"test": SEMVER_SKIP,
}
SUPPORTED_REMOTE_URL_SCHEMAS = [
"ssh",
"http",
"https"
SUPPORTED_REMOTE_APIS = [
"bitbucket.org",
"github.com",
]
REPOSITORY_TOKEN_FILENAME = ".repository-token"
FLOWCONFIG_FILENAME = ".flowconfig"
FLOWCONFIG_VERSION = 2
+13 -10
View File
@@ -1,15 +1,15 @@
from datetime import datetime
from os.path import isfile
from git_flow.git import Commit
from git_flow.git import Git
class Changelog:
FILENAME = "CHANGELOG.md"
COMMIT_MESSAGE = "chore: bump version and update CHANGELOG.md [skip ci]"
def update(self, title: str, commits: list[Commit]):
self._prepend(self.generate_entry(title, commits))
def update(self, title: str, base: str, branch: str):
self._prepend(self.generate_entry(title, base, branch))
def generate_header(self, title):
now = datetime.now()
@@ -18,13 +18,16 @@ class Changelog:
"""
def generate_content(self, commits: list[Commit]):
def generate_content(self, base: str, branch: str):
commits = Git(
"log", base + ".." + branch, first_parent=True, format="%h", merges=False
).lines()
content = ""
for commit in commits:
message = commit.subject
email = commit.email
username = email[: email.index("@")] if "@" in email else email
message = Git("show", commit, patch=False, format="%s").firstline()
email = Git("show", commit, patch=False, format="%ae").firstline()
username = email[: email.index("@")]
description = message
if ":" in message:
@@ -33,7 +36,7 @@ class Changelog:
commit_message = message[index + 1 :]
description = f"**{commit_type}**: {commit_message}"
content += f"- {description} [[{username}](mailto:{email})] ({commit.sha})\n"
content += f"- {description} [[{username}](mailto:{email})] ({commit})\n"
return content
@@ -44,10 +47,10 @@ class Changelog:
"""
def generate_entry(self, title: str, commits: list[Commit]):
def generate_entry(self, title: str, base: str, branch: str):
return (
self.generate_header(title)
+ self.generate_content(commits)
+ self.generate_content(base, branch)
+ self.generate_footer()
)
+17 -73
View File
@@ -1,29 +1,24 @@
import re
import time
from os.path import isfile
import questionary
import rich
import rich.panel
from rich.table import Table
from git_flow import (
BRANCH_TYPES,
COMMIT_TYPES,
FLOWCONFIG_FILENAME,
FLOWCONFIG_VERSION,
REPOSITORY_TOKEN_FILENAME,
FLOWCONFIG_VERSION,
COMMIT_TYPES,
GitFlowError,
)
from git_flow.git import Git
from git_flow.io import *
from git_flow.remote.base import RemoteAPI
from git_flow.remote.bitbucket import BitbucketRemoteAPI
from git_flow.remote.gitea import GiteaRemoteAPI
from git_flow.remote.github import GithubRemoteAPI
from git_flow.remote.gitlab import GitlabRemoteAPI
from git_flow.remote.gitea import GiteaRemoteAPI
from git_flow.io import *
from os.path import isfile
flowconfig = Git.get_config(FLOWCONFIG_FILENAME) if isfile(FLOWCONFIG_FILENAME) else {}
from typing import Optional
import rich
import rich.panel
import questionary
flowconfig = Git.get_config()
environments = (
flowconfig["flow.branches"].split(",") if "flow.branches" in flowconfig else []
)
@@ -105,7 +100,7 @@ def ensure_clean_worktree(has_remote: bool):
if not status:
return
not_empty = any(s.index != " " or s.worktree != " " for s in status)
not_empty = any(map(lambda s: s.index != " " or s.worktree != " ", status))
if not_empty:
if not has_remote:
@@ -127,9 +122,6 @@ def get_remote_api(token: str) -> RemoteAPI:
if remote_type == "gitea":
remote_schema = "https://" if schema == "ssh://" else schema
return GiteaRemoteAPI(remote_schema + host, repository, token)
elif remote_type == "gitlab":
remote_schema = "https://" if schema == "ssh://" else schema
return GitlabRemoteAPI(remote_schema + host, repository, token)
elif remote_type == "bitbucket":
return BitbucketRemoteAPI(repository, token)
elif remote_type == "github":
@@ -165,71 +157,23 @@ def get_commit_message(commit_types: list[str] = COMMIT_TYPES) -> str:
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: Optional[str] = None):
_io_status(message, "✖ Error: ", "red", title)
def io_warning(message: str, title: str | None = None):
def io_warning(message: str, title: Optional[str] = None):
_io_status(message, "⚠ Warning: ", "yellow", title)
def io_info(message: str, title: str | None = None):
def io_info(message: str, title: Optional[str] = None):
_io_status(message, "ℹ Info: ", "blue", title)
def io_success(message: str, title: str | None = None):
def io_success(message: str, title: Optional[str] = None):
_io_status(message, "✔ Success: ", "green", title)
def _io_status(message: str, prefix: str, style: str, title: str | None = None):
def _io_status(message: str, prefix: str, style: str, title: Optional[str] = None):
if title:
rich.print(
rich.panel.Panel(
@@ -3,12 +3,12 @@ from typing import Annotated
import typer
from rich.console import Console
import git_flow.command.base as base
from git_flow import (
TRASH_BRANCH_PREFIX,
WIP_BRANCH_PREFIX,
GitFlowError,
)
from git_flow.command import base
from git_flow.git import Git
BRANCH_FORMAT = "%(refname:short)"
@@ -18,7 +18,7 @@ console = Console()
@app.command()
def ls(
def branch(
environment: Annotated[
str | None,
typer.Argument(
-7
View File
@@ -1,7 +0,0 @@
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
@@ -1,124 +0,0 @@
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 -5
View File
@@ -1,7 +1,7 @@
import typer
import git_flow.command.base as base
from git_flow import FLOWCONFIG_FILENAME, FLOWCONFIG_VERSION, GitFlowError
from git_flow import FLOWCONFIG_VERSION, GitFlowError
from git_flow.git import Git
app = typer.Typer()
@@ -27,9 +27,7 @@ def init():
flowconfig["flow.remote"] = remote
flowconfig["flow.remote-type"] = remote_type
Git.set_config(flowconfig, FLOWCONFIG_FILENAME)
Git("add", FLOWCONFIG_FILENAME).exec()
Git("commit", message="feature: initialize git-flow").exec()
Git.set_config(flowconfig)
_ensure_all_flow_branches_exist(branches, remote)
@@ -101,7 +99,7 @@ uno nuevo.""",
remote = base.io_choice("Remoto", remotes)
remote_type = base.io_choice(
"Tipo de remoto:", ["bitbucket", "github", "gitea", "gitlab"]
"Tipo de remoto:", ["bitbucket", "github", "gitea"]
)
return (remote, remote_type)
+32 -31
View File
@@ -1,5 +1,3 @@
from typing import Optional
import typer
import git_flow.command.base as base
@@ -71,7 +69,15 @@ def run_local(branch: str, target: str):
def show_commits_to_merge(target):
base.show_commits("Cambios a mergear", target + "..")
commits = Git("log", target + "..", format="%s").lines()
if not commits:
raise GitFlowError("No hay cambios a mergear.")
print("Cambios a mergear:")
for commit in commits:
print("- " + commit)
def check_merge_conflicts(target: str):
@@ -96,61 +102,56 @@ def check_merge_conflicts(target: str):
def create_pull_request(token: str, branch: str, target: str):
changelog = Changelog()
commits = Git("log", target + "..", format="%s").lines()
title = _get_pr_title(branch, commits)
title = commits[0] if len(commits) == 1 else _get_pr_title_from_branch(branch)
base.panel("Título del PR", title)
if base.io_confirm("¿Desea cambiar el título del PR?", False):
title = base.io_prompt("Título del PR", persistent=True)
commits = Git.get_commits(target, branch)
message = base.get_remote_api(token).create_pull_request(
branch,
target,
title,
changelog.generate_content(commits),
changelog.generate_content(target, branch),
branch not in base.environments,
)
base.io_success(message, "Rama creada")
if base.io_confirm("¿Desea cambiar a la rama objetivo y bajar los cambios?"):
Git("switch", target).exec(print="Cambiando a rama objetivo")
Git("pull").exec(print="Obteniendo cambios")
def _get_pr_title(branch: str, commits: list[str]) -> str:
def _get_pr_title_from_branch(branch: str) -> str:
if branch in base.environments:
target = base.environments[base.environments.index(branch) + 1]
return f"Sincronización de entorno {branch} a {target}"
components = branch.split("/") # <type>/<desc> or release/<env>/<type>/<desc>
release_prefix = f"[Pasaje a {components[1]}]" if len(components) == 4 else None
branch_type = components[-2]
branch_ticket, branch_desc = _extract_ticket_from_branch(components[-1])
branch_desc = components[-1]
# when there's a single commit, use it as the PR description instead of
# deriving one from the branch name, but keep the release prefix / ticket
if len(commits) == 1:
return " ".join(filter(None, [release_prefix, branch_ticket, commits[0]]))
return " ".join(
filter(
None,
[
release_prefix,
branch_type + ":",
branch_ticket,
branch_desc.replace("-", " "),
],
)
)
def _extract_ticket_from_branch(branch_desc: str) -> tuple[Optional[str], str]:
"""detect if branch description has ticket as prefix: <desc> = ABC-123-my-branch-name"""
# detect if branch description has ticket as prefix: <desc> = ABC-123-my-branch-name
branch_ticket = None
first_dash = branch_desc.find("-")
second_dash = branch_desc.find("-", first_dash + 1)
if first_dash >= 0 and second_dash >= 0:
maybe_ticket = branch_desc[:second_dash]
if base.is_valid_ticket(maybe_ticket):
return maybe_ticket, branch_desc[second_dash + 1 :]
branch_ticket = maybe_ticket
branch_desc = branch_desc[second_dash + 1 :]
return None, branch_desc
return " ".join(
filter(
None,
[
f"[Pasaje a {components[1]}]" if len(components) == 4 else None,
branch_type + ":",
branch_ticket,
branch_desc.replace("-", " "),
],
)
)
+45 -7
View File
@@ -1,14 +1,16 @@
from typing import Optional
import typer
import git_flow.command.base as base
from git_flow import GitFlowError
from git_flow import COMMIT_TYPES, GitFlowError
from git_flow.git import Git
app = typer.Typer()
@app.command()
def release():
def release(group: Optional[str] = None):
"""Realiza el merge de la rama, o crea el PR para hacerlo si tiene un remoto configurado"""
base.ensure_initialized()
@@ -32,19 +34,55 @@ def release():
)
fork_point = Git.get_first_fork_point(branch, env)
base.show_commits("Cambios a pasar al próximo entorno", fork_point + "..")
commits = Git("log", fork_point + "..", format="%s").lines()
if has_remote and Git.get_tracking_branch(next_env):
Git("switch", next_env).exec(print="Cambiando a la rama objetivo")
if not commits:
raise GitFlowError("No hay cambios a mergear.")
print("Cambios a pasar al proximo entorno:")
for commit in commits:
print("- " + commit)
grouping = group is not None
if not group:
if base.io_confirm("¿Desea agrupar este release con otra rama?", False):
grouping = True
group = base.io_choice(
"Grupo release: ", Git.get_branches("release/" + next_env + "/")
)
else:
group = next_env
if has_remote and Git.get_tracking_branch(group):
Git("switch", group).exec(print="Cambiando a la rama objetivo")
Git("pull").exec(print="Sincronizando cambios la rama objetivo")
Git("switch", "-").exec(print="Volviendo a la rama original")
Git("switch", next_branch, create=True).exec(print="Creando rama release")
if len(commits) > 1 and base.io_confirm(
f"¿Desea reemplazar los {len(commits)} commits de la rama por uno solo?", False
):
base.io_info("Debe ingresar el mensaje del commit a crear.")
message = base.get_commit_message(COMMIT_TYPES[:-1])
status = Git("rebase", fork_point, next_branch, onto=next_env).code(
Git("switch", next_branch, fork_point, create=True).exec(
print="Creando rama release en base"
)
Git("merge", branch, squash=True).exec(print="Squasheando commits en uno solo")
Git("commit", m=message).exec(print="Creando commit único")
else:
Git("switch", next_branch, create=True).exec(print="Creando rama release")
status = Git("rebase", fork_point, next_branch, onto=group).code(
print="Moviendo cambios hacia el siguiente ambiente"
)
if status:
Git("rebase", abort=True).exec(print="Deshaciendo cambios por conflictos")
raise GitFlowError("Error al realizar pasaje de cambios a rama objetivo")
elif grouping:
Git("switch", group).exec(print="Cambiando a rama objetivo")
Git("merge", next_branch, ff_only=True).exec(
print="Mergeando cambios de la nueva rama"
)
+52 -91
View File
@@ -1,5 +1,8 @@
from typing import Optional
import typer
import git_flow.command.base as base
from git_flow import (
COMMIT_TYPE_INCREMENT,
SEMVER_MAJOR,
@@ -9,133 +12,89 @@ from git_flow import (
GitFlowError,
)
from git_flow.changelog import Changelog
from git_flow.command import base
from git_flow.git import Git
app = typer.Typer()
@app.command()
def tag(target: str | None = None, token: str | None = None, update_changelog: bool = False):
def tag(token: Optional[str] = None):
"""Crea un nuevo tag para el último merge."""
base.ensure_initialized()
target = target or Git.get_current_branch()
target = Git.get_current_branch()
if target not in base.environments:
raise GitFlowError("Solo se pueden taggear commits en ramas principales.")
merge_commit = Git(
"log", first_parent=True, merges=True, max_count=1, format="%H"
branch = Git(
"show", get_last_merge_commit() + "^2", patch=False, format="%h"
).firstline()
fork_point = Git.get_first_fork_point(branch, target)
commits = Git("log", fork_point + ".." + branch, format="%s").lines()
next_tag = get_next_tag_from_commits(commits, target)
if not merge_commit:
raise GitFlowError("No hay merges a taggear.")
merge_commit_tag = Git("tag", "--points-at", merge_commit).firstline()
if merge_commit_tag:
base.io_info(f"El último merge ya tiene un tag: {merge_commit_tag}")
if not next_tag:
base.io_info(
"Los cambios realizados no implican un salto de versión, se mantiene la anterior."
)
return
token, ci = get_token_and_ci(token)
if ci and not token:
if not token:
raise GitFlowError("No hay token disponible")
# 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 ""
changelog = Changelog()
# 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 = api.get_merge_commits(merge_commit)
commits = [c for c in commits if not c.is_merge]
# Último tag se obtiene por API filtrando por sufijo
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:
# Commits se obtienen del último merge, listando hasta el primer forkpoint
fork_point = Git.get_first_fork_point(branch, target)
commits = Git.get_commits(fork_point, branch)
# Último tag se obtiene localmente
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(
[c.subject for c in commits], suffix, current_tag
)
if not next_tag:
base.io_warning(
"Los cambios realizados no implican un salto de versión, se mantiene la anterior."
)
raise typer.Exit(code=1)
if update_changelog:
changelog = Changelog()
changelog.update(next_tag, commits)
Git("add", Changelog.FILENAME).exec(print="Agregando nuevo CHANGELOG.md")
Git("commit", message=Changelog.COMMIT_MESSAGE).exec(print="Creando commit")
if ci:
Git("push").exec()
tag_commit = Git("show", "HEAD", patch=False, format="%H").firstline()
changelog.update(next_tag, fork_point, branch)
Git("add", Changelog.FILENAME).exec(print="Agregando nuevo CHANGELOG.md")
Git("commit", message=Changelog.COMMIT_MESSAGE).exec(print="Creando commit")
tag_commit = Git("show", "HEAD", patch=False, format="%h").firstline()
if ci:
Git("push").exec(print="Subiendo commit al remoto para taggearlo")
base.io_success(base.get_remote_api(token).create_tag(next_tag, tag_commit))
Git("tag", next_tag, tag_commit).exec(print="Creando tag localmente")
def get_token_and_ci(token: str | None):
if token:
return (token, True)
else:
def get_token_and_ci(token: Optional[str]):
try:
return (base.ensure_repository_token(), False)
except GitFlowError:
return (token, True)
def get_next_tag_from_commits(
commits: list[str], suffix: str, current_tag: str | None
) -> str | None:
def get_last_merge_commit():
commit = Git(
"log", first_parent=True, merges=True, max_count=1, format="%h"
).firstline()
if not commit:
raise GitFlowError("No hay merges a taggear.")
return commit
def check_not_tagged(commit: str):
tag = Git("describe", commit, tags=True, exact_match=True).firstline(check=False)
if tag:
raise GitFlowError("El commit a taggear ya tiene tag: " + tag)
def get_next_tag_from_commits(commits: list[str], branch: str) -> str | None:
increment = SEMVER_SKIP
for commit in commits:
commit_type = commit.partition(":")[0]
commit_type = commit[: commit.index(":")]
increment = max(increment, COMMIT_TYPE_INCREMENT.get(commit_type, SEMVER_SKIP))
if increment == SEMVER_SKIP:
return None
tag = Git.get_current_tag() or f"v0.0.0-{branch}"
version = (current_tag or "v0.0.0" + suffix).removeprefix("v").removesuffix(suffix)
[major, minor, patch] = map(int, version.split("."))
until_dash = tag.index("-") if "-" in tag else None
suffix = tag[until_dash:] if until_dash is not None else ""
[major, minor, patch] = map(int, tag[1:until_dash].split("."))
if increment == SEMVER_MAJOR:
major += 1
@@ -146,5 +105,7 @@ def get_next_tag_from_commits(
patch = 0
elif increment == SEMVER_PATCH:
patch += 1
elif increment == SEMVER_SKIP:
suffix = "-r" + Git("rev-list", "HEAD", count=True).firstline()
return f"v{major}.{minor}.{patch}{suffix}"
return None if increment == SEMVER_SKIP else f"v{major}.{minor}.{patch}{suffix}"
+13 -89
View File
@@ -1,24 +1,8 @@
import subprocess
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.io import *
@dataclass
class Commit:
"""Datos de un commit necesarios para versionar y generar el changelog."""
sha: str
subject: str
email: str
is_merge: bool = False
class Git:
class Status:
worktree: str
@@ -48,11 +32,7 @@ class Git:
@staticmethod
def set_config(config: dict[str, str], file: str | None = None):
for key, value in config.items():
Git("config", key, value, file=file).exec()
@staticmethod
def flow_config(*args, **kwargs):
return Git("config", *args, file=FLOWCONFIG_FILENAME, **kwargs)
Git("config", key, value, file=file).exec(print="Updating config")
@staticmethod
def get_current_branch():
@@ -67,30 +47,8 @@ class Git:
return list(map(Git.Status, Git("status", porcelain=True).lines(False)))
@staticmethod
def get_current_tag(suffix: str = ""):
"""Último tag SemVer alcanzable desde HEAD, con el sufijo exacto indicado."""
return Git(
"describe",
abbrev="0",
tags=True,
match="v[0-9]*.[0-9]*.[0-9]*" + suffix,
exclude=None if suffix else "*-*",
).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
def get_current_tag():
return Git("describe", abbrev="0", tags=True).firstline(check=False, print="Obteniendo tag actual")
@staticmethod
def get_first_fork_point(branch: str, env: str):
@@ -102,24 +60,6 @@ class Git:
return Git("show", env_commits[-1] + "^", patch=False, format="%h").firstline()
@staticmethod
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."""
lines = Git(
"log",
base + ".." + branch,
first_parent=first_parent or None,
merges=False,
format="%h%x09%ae%x09%s",
).lines()
commits = []
for line in lines:
[sha, email, subject] = line.split("\t", 2)
commits.append(Commit(sha, subject, email))
return commits
@staticmethod
def _get_references(kind: str):
prefix = "refs/" + kind
@@ -184,13 +124,10 @@ class Git:
self._run(**kwargs)
def _get(self, print: str | None = None, check: bool = True):
process = subprocess.run(self.command, capture_output=True, text=True, check=False)
process = subprocess.run(self.command, capture_output=True, text=True)
# Sin --verbose solo se muestra el paso; si falla, la salida la muestra main()
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 print is not None:
self._print_process(process.stdout, process.stderr, print)
if check:
process.check_returncode()
@@ -200,7 +137,7 @@ class Git:
def _run(self, print: str | None = None, check: bool = True):
return self._get(print, check)
def _print_process(self, process: subprocess.CompletedProcess, title: str | None):
def _print_process(self, stdout: str, stderr: str, title: str):
command = []
for arg in self.command:
@@ -212,25 +149,12 @@ class Git:
command.append(arg)
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"))
text = f"[green]$ {" ".join(command)}[/green]"
for line in process.stdout.splitlines():
text.append("\n" + line)
for line in stdout.splitlines():
text += "\n" + line
for line in process.stderr.splitlines():
text.append("\n" + line, stderr_style)
for line in stderr.splitlines():
text += "\n[red]" + line + "[/red]"
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,
)
)
panel(title or "Ejecutando", text)
+1 -10
View File
@@ -1,19 +1,10 @@
from rich.console import Console
from rich.prompt import Prompt, Confirm
from rich.panel import Panel
from rich.prompt import Confirm, Prompt
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):
console.print(f":white_check_mark: [green]{msg}[/green]")
+6 -54
View File
@@ -1,16 +1,10 @@
#!/usr/bin/env python3
from subprocess import CalledProcessError, SubprocessError
from typing import Annotated
import click
import typer
from rich.markup import escape
from rich.traceback import Traceback
from git_flow import GitFlowError
from git_flow.command import base, branch, commit, init, merge, new, release, tag
from git_flow.io import console, settings
import git_flow.command.base as base
from git_flow.command import init, new, commit, merge, tag, release, branch
REPOSITORY_TOKEN_PATH = ".repository-token"
LOCALE = "es_AR.UTF-8"
@@ -26,55 +20,13 @@ app.add_typer(release.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():
try:
app()
print()
except GitFlowError as e:
_print_error(escape(str(e)), "Git Flow", e)
except CalledProcessError as e:
_print_error(_format_process_error(e), "Comando", e)
except SubprocessError as e:
_print_error(escape(str(e)), "Comando", e)
except KeyboardInterrupt:
base.io_error(str(e), title="Git Flow")
except Exception as e:
base.io_error(str(e), title="Inesperado")
except KeyboardInterrupt as e:
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,
)
+18 -99
View File
@@ -1,12 +1,7 @@
from abc import ABC, abstractmethod
import re
import giturlparse
import requests
from git_flow import SUPPORTED_REMOTE_URL_SCHEMAS, GitFlowError
from git_flow.git import Commit, Git
from git_flow import SUPPORTED_REMOTE_APIS, GitFlowError
from git_flow.git import Git
class RemoteAPI(ABC):
@@ -16,42 +11,25 @@ class RemoteAPI(ABC):
@staticmethod
def parse(remote: str):
"""Soportamos 3 tipos de URL: http, https y ssh. Si no se especifica el esquema, asumimos ssh:
Ejemplos:
- http://gitea/user/repo
- https://gitea.com/user/repo.git
- git@bitbucket.org:user/repo
- git.mygiteainstance.com:user/repo.git
- ssh://git@git.mygiteainstance.com:1234/user/repo.git
"""
url = Git("remote", "get-url", remote).firstline()
parsed_url = giturlparse.parse(url) # Validamos que la URL sea válida
if not parsed_url.valid:
if url.startswith("git@"):
at_pos = url.index("@")
colon_pos = url.index(":")
schema = "ssh://"
host = url[at_pos+1:colon_pos]
repository = url[colon_pos+1:]
elif url.startswith("http://") or url.startswith("https://"):
start_pos = url.index("://") + 3
uri_start = url.index("/", start_pos)
schema = url[:start_pos]
host = url[start_pos:uri_start]
repository = url[uri_start+1:]
else:
raise GitFlowError(f"La URL del remoto {remote} es inválida: {url}")
if not parsed_url.protocol in SUPPORTED_REMOTE_URL_SCHEMAS:
raise GitFlowError(
f"La URL del remoto {remote} no tiene un esquema soportado: {url}"
)
# Cuando el host es seteado por pipeline puede ser "http://gitea/OWNER/REPO", en ese caso
# la libreria parsea OWNER="" y REPO="OWNER/REPO"
# En GitLab, los proyectos pueden estar dentro de grupos y subgrupos (ej:
# owner/grupo/subgrupo/repo), que la libreria expone por separado en groups_path
repository = (
"/".join(
filter(
None,
[parsed_url.owner, parsed_url.groups_path, parsed_url.repo],
)
)
.removeprefix("/")
.removesuffix(".git")
)
return [parsed_url.protocol + "://", parsed_url.host, repository]
repository = repository.removesuffix(".git")
return [schema, host, repository]
@abstractmethod
def create_pull_request(
@@ -60,7 +38,7 @@ class RemoteAPI(ABC):
destination: str,
title: str | None = None,
description: str | None = None,
close_source_branch: bool = True,
close_source_branch: bool = True
) -> str:
pass
@@ -68,23 +46,6 @@ class RemoteAPI(ABC):
def create_tag(self, tag: str, commit: str) -> str:
pass
@abstractmethod
def get_merge_commits(self, merge_commit: str) -> list[Commit]:
"""Busca el PR que generó el merge commit y devuelve sus commits (del más nuevo al más
viejo), sin depender del historial local."""
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
def list_tags(self, suffix: str = "") -> list[str]:
"""Nombres de los tags del repositorio, sin orden garantizado. `suffix` es solo una pista
para que la API filtre; el filtrado definitivo se hace en `get_latest_tag`."""
pass
@abstractmethod
def get_endpoint(self, resource: str) -> str:
pass
@@ -95,45 +56,3 @@ class RemoteAPI(ABC):
"Content-Type": "application/json",
"Authorization": "Bearer " + self.token,
}
def get_json(self, url: str, params: dict | None = None):
response = requests.get(url, headers=self.get_headers(), params=params)
if not response.ok:
raise GitFlowError(f"Ocurrió un error al consultar {url}: {response.text}")
return response
def get_latest_tag(self, suffix: str = "") -> str | None:
"""Tag SemVer más alto (vX.Y.Z + suffix exacto), o None si no hay ninguno."""
pattern = re.compile(rf"^v(\d+)\.(\d+)\.(\d+){re.escape(suffix)}$")
versions = {}
for name in self.list_tags(suffix):
match = pattern.match(name)
if match:
versions[tuple(map(int, match.groups()))] = name
return versions[max(versions)] if versions else None
def get_all_pages(
self,
url: str,
page_size: int = 50,
params: dict | None = None,
size_param: str = "per_page",
) -> list:
"""Recorre un endpoint paginado por número de página (page / per_page)."""
items: list = []
page = 1
while True:
query = {**(params or {}), "page": page, size_param: page_size}
batch = self.get_json(url, query).json()
items += batch
if len(batch) < page_size:
return items
page += 1
-56
View File
@@ -1,8 +1,5 @@
import re
import requests
from git_flow import GitFlowError
from git_flow.git import Commit
from git_flow.remote.base import RemoteAPI
@@ -47,58 +44,5 @@ class BitbucketRemoteAPI(RemoteAPI):
else:
raise GitFlowError("Ocurrió un error al crear el tag: " + response.text)
def get_merge_commits(self, merge_commit: str) -> list[Commit]:
pr = self._get_merge_pull_request(merge_commit)
commits: list[Commit] = []
url: str | None = self.get_endpoint(f"pullrequests/{pr['id']}/commits")
# Bitbucket pagina siguiendo el link "next", y devuelve del más nuevo al más viejo
while url:
page = self.get_json(url).json()
for c in page["values"]:
raw = c["author"].get("raw", "")
match = re.search(r"<(.+?)>", raw)
commits.append(
Commit(
c["hash"][:7],
c["message"].splitlines()[0],
match.group(1) if match else raw,
len(c["parents"]) > 1,
)
)
url = page.get("next")
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]:
names: list[str] = []
url: str | None = self.get_endpoint("refs/tags")
params: dict[str, int|str] | None = {"pagelen": 100}
if suffix:
params["q"] = f'name ~ "{suffix}"'
while url:
page = self.get_json(url, params).json()
names += [t["name"] for t in page["values"]]
url = page.get("next")
params = None # "next" ya incluye la query
return names
def get_endpoint(self, resource: str) -> str:
return f"{BitbucketRemoteAPI.API_ENDPOINT}/{self.repository}/{resource}"
-41
View File
@@ -1,6 +1,5 @@
import requests
from git_flow import GitFlowError
from git_flow.git import Commit
from git_flow.remote.base import RemoteAPI
@@ -45,46 +44,6 @@ class GiteaRemoteAPI(RemoteAPI):
else:
raise GitFlowError("Ocurrió un error al crear el tag: " + response.text)
def get_merge_commits(self, merge_commit: str) -> list[Commit]:
pr = self._get_merge_pull_request(merge_commit)
url = self.get_endpoint(f"/pulls/{pr['number']}/commits")
items: list = []
page = 1
# Gitea pagina con page / limit
while True:
batch = self.get_json(
url, {"page": page, "limit": 50, "files": "false", "verification": "false"}
).json()
items += batch
if len(batch) < 50:
break
page += 1
# La API los devuelve del más viejo al más nuevo
return [
Commit(
c["sha"][:7],
c["commit"]["message"].splitlines()[0],
c["commit"]["author"]["email"],
len(c["parents"]) > 1,
)
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]:
tags = self.get_all_pages(self.get_endpoint("/tags"), 50, size_param="limit")
return [t["name"] for t in tags]
def get_endpoint(self, resource: str) -> str:
return f"{self.base_url}/api/v1/repos/{self.repository}{resource}"
-31
View File
@@ -1,6 +1,5 @@
import requests
from git_flow import GitFlowError
from git_flow.git import Commit
from git_flow.remote.base import RemoteAPI
@@ -43,35 +42,5 @@ class GithubRemoteAPI(RemoteAPI):
else:
raise GitFlowError("Ocurrió un error al crear el tag: " + response.text)
def get_merge_commits(self, merge_commit: str) -> list[Commit]:
pr = self._get_merge_pull_request(merge_commit)
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
return [
Commit(
c["sha"][:7],
c["commit"]["message"].splitlines()[0],
c["commit"]["author"]["email"],
len(c["parents"]) > 1,
)
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]:
return [t["name"] for t in self.get_all_pages(self.get_endpoint("/tags"), 100)]
def get_endpoint(self, resource: str) -> str:
return f"{GithubRemoteAPI.API_ENDPOINT}/{self.repository}{resource}"
-96
View File
@@ -1,96 +0,0 @@
from urllib.parse import quote
import requests
from git_flow import GitFlowError
from git_flow.git import Commit
from git_flow.remote.base import RemoteAPI
class GitlabRemoteAPI(RemoteAPI):
def __init__(self, base_url: str, repository: str, token: str) -> None:
super().__init__(repository, token)
self.base_url = base_url
def create_pull_request(
self,
source: str,
destination: str,
title: str | None = None,
description: str | None = None,
close_source_branch: bool = True
) -> str:
response = requests.post(
self.get_endpoint("/merge_requests"),
headers=self.get_headers(),
json={
"title": title,
"description": description,
"source_branch": source,
"target_branch": destination,
"remove_source_branch": close_source_branch,
},
)
if response.ok:
return "PR creado exitosamente: " + response.json()["web_url"]
else:
raise GitFlowError("Ocurrió un error al crear el PR: " + response.text)
def create_tag(self, tag: str, commit: str) -> str:
response = requests.post(
self.get_endpoint("/repository/tags"),
headers=self.get_headers(),
json={"tag_name": tag, "ref": commit},
)
if response.ok:
return "Tag creado exitosamente: " + tag
else:
raise GitFlowError("Ocurrió un error al crear el tag: " + response.text)
def get_merge_commits(self, merge_commit: str) -> list[Commit]:
mr = self._get_merge_request(merge_commit)
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
return [
Commit(
c["short_id"][:7],
c["title"],
c["author_email"],
len(c["parent_ids"]) > 1,
)
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]:
# "search" soporta "$" para anclar al final del nombre
params = {"search": suffix + "$"} if suffix else None
tags = self.get_all_pages(self.get_endpoint("/repository/tags"), 100, params)
return [t["name"] for t in tags]
def get_endpoint(self, resource: str) -> str:
project_id = quote(self.repository, safe="")
return f"{self.base_url}/api/v4/projects/{project_id}{resource}"
def get_headers(self):
return {
"Accept": "application/json",
"Content-Type": "application/json",
"PRIVATE-TOKEN": self.token,
}
Generated
+1 -76
View File
@@ -1,6 +1,6 @@
version = 1
revision = 3
requires-python = ">=3.10"
requires-python = ">=3.14"
[[package]]
name = "annotated-doc"
@@ -26,70 +26,6 @@ version = "3.4.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1f/b8/6d51fc1d52cbd52cd4ccedd5b5b2f0f6a11bbf6765c782298b0f3e808541/charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", size = 209709, upload-time = "2025-10-14T04:40:11.385Z" },
{ url = "https://files.pythonhosted.org/packages/5c/af/1f9d7f7faafe2ddfb6f72a2e07a548a629c61ad510fe60f9630309908fef/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", size = 148814, upload-time = "2025-10-14T04:40:13.135Z" },
{ url = "https://files.pythonhosted.org/packages/79/3d/f2e3ac2bbc056ca0c204298ea4e3d9db9b4afe437812638759db2c976b5f/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", size = 144467, upload-time = "2025-10-14T04:40:14.728Z" },
{ url = "https://files.pythonhosted.org/packages/ec/85/1bf997003815e60d57de7bd972c57dc6950446a3e4ccac43bc3070721856/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", size = 162280, upload-time = "2025-10-14T04:40:16.14Z" },
{ url = "https://files.pythonhosted.org/packages/3e/8e/6aa1952f56b192f54921c436b87f2aaf7c7a7c3d0d1a765547d64fd83c13/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", size = 159454, upload-time = "2025-10-14T04:40:17.567Z" },
{ url = "https://files.pythonhosted.org/packages/36/3b/60cbd1f8e93aa25d1c669c649b7a655b0b5fb4c571858910ea9332678558/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", size = 153609, upload-time = "2025-10-14T04:40:19.08Z" },
{ url = "https://files.pythonhosted.org/packages/64/91/6a13396948b8fd3c4b4fd5bc74d045f5637d78c9675585e8e9fbe5636554/charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", size = 151849, upload-time = "2025-10-14T04:40:20.607Z" },
{ url = "https://files.pythonhosted.org/packages/b7/7a/59482e28b9981d105691e968c544cc0df3b7d6133152fb3dcdc8f135da7a/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", size = 151586, upload-time = "2025-10-14T04:40:21.719Z" },
{ url = "https://files.pythonhosted.org/packages/92/59/f64ef6a1c4bdd2baf892b04cd78792ed8684fbc48d4c2afe467d96b4df57/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", size = 145290, upload-time = "2025-10-14T04:40:23.069Z" },
{ url = "https://files.pythonhosted.org/packages/6b/63/3bf9f279ddfa641ffa1962b0db6a57a9c294361cc2f5fcac997049a00e9c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", size = 163663, upload-time = "2025-10-14T04:40:24.17Z" },
{ url = "https://files.pythonhosted.org/packages/ed/09/c9e38fc8fa9e0849b172b581fd9803bdf6e694041127933934184e19f8c3/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", size = 151964, upload-time = "2025-10-14T04:40:25.368Z" },
{ url = "https://files.pythonhosted.org/packages/d2/d1/d28b747e512d0da79d8b6a1ac18b7ab2ecfd81b2944c4c710e166d8dd09c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", size = 161064, upload-time = "2025-10-14T04:40:26.806Z" },
{ url = "https://files.pythonhosted.org/packages/bb/9a/31d62b611d901c3b9e5500c36aab0ff5eb442043fb3a1c254200d3d397d9/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", size = 155015, upload-time = "2025-10-14T04:40:28.284Z" },
{ url = "https://files.pythonhosted.org/packages/1f/f3/107e008fa2bff0c8b9319584174418e5e5285fef32f79d8ee6a430d0039c/charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", size = 99792, upload-time = "2025-10-14T04:40:29.613Z" },
{ url = "https://files.pythonhosted.org/packages/eb/66/e396e8a408843337d7315bab30dbf106c38966f1819f123257f5520f8a96/charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", size = 107198, upload-time = "2025-10-14T04:40:30.644Z" },
{ url = "https://files.pythonhosted.org/packages/b5/58/01b4f815bf0312704c267f2ccb6e5d42bcc7752340cd487bc9f8c3710597/charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", size = 100262, upload-time = "2025-10-14T04:40:32.108Z" },
{ url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" },
{ url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" },
{ url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" },
{ url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" },
{ url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" },
{ url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" },
{ url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" },
{ url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" },
{ url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" },
{ url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" },
{ url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" },
{ url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" },
{ url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" },
{ url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" },
{ url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" },
{ url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" },
{ url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" },
{ url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" },
{ url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" },
{ url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" },
{ url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" },
{ url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" },
{ url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" },
{ url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" },
{ url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" },
{ url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" },
{ url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" },
{ url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" },
{ url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" },
{ url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" },
{ url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" },
{ url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" },
{ url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" },
{ url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" },
{ url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" },
{ url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" },
{ url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" },
{ url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" },
{ url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" },
{ url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" },
{ url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" },
{ url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" },
{ url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" },
{ url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" },
{ url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" },
{ url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" },
{ url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" },
{ url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" },
{ url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" },
{ url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" },
{ url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" },
@@ -134,7 +70,6 @@ wheels = [
name = "git-flow-envs"
source = { editable = "." }
dependencies = [
{ name = "giturlparse" },
{ name = "questionary" },
{ name = "requests" },
{ name = "rich" },
@@ -143,22 +78,12 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "giturlparse" },
{ name = "questionary" },
{ name = "requests" },
{ name = "rich" },
{ name = "typer" },
]
[[package]]
name = "giturlparse"
version = "0.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/8a/b70b84cc78f9059d627f09560c81a11e8f046570d220a24e169489cad6c9/giturlparse-0.15.0.tar.gz", hash = "sha256:9af3f1fd5c4a0cac94ddb283593635005646393ee0debbe330d1bdff8866bf2c", size = 16138, upload-time = "2026-06-16T07:28:56.021Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/96/147a2771ab655b9353781fb2f95c94eaf1d8576dccc991c1a61d0d355067/giturlparse-0.15.0-py2.py3-none-any.whl", hash = "sha256:76d2e6983b037356ab99b30683e533ac3db96409b68e2163a20fc3aff6446f10", size = 16683, upload-time = "2026-06-16T07:28:55.184Z" },
]
[[package]]
name = "idna"
version = "3.11"